Build lane chrome — title bar, badge, inline rename
The lane title bar becomes real: leading SF Symbol (hand-written names render leniently, unknown ones fall back to the level default), title or secondary untitled placeholder, a quiet count badge that counts exactly the cards the body renders (so the m5 search filter is followed by construction), and a new-card button. The whole bar is the reorder drag surface — no grip — with click-vs-movement splitting select from drag; a pure proposal function maps the drag to an insertion index and release commits through the Writer's same-parent degenerate reorder, compacting and retrying when midpoint precision runs out. Clicking never edits: inline rename is Return on the sole selected card or Board > Rename for either kind, a third transient editor beside the placeholder that tracks its target by UUID, commits on focus loss, discards silently when the target vanishes, and removes the title key on an empty commit. The new-card placeholder renders at last — the settled Cmd-N target rule (pure, tested) files it after the anchor card, at a selected lane's bottom, or into the last-active lane; Return commits and re-selects the lane, Cmd-Return also opens the card window, and a failed create discards the overlay. New Card / New Lane / Rename land in the menus with focused-editor and read-only validation; rename gets its own WriteOperation case in the banner vocabulary. 59 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
import CoreGraphics
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// `LaneReorderMath` — the lane drag's proposal, as arithmetic.
|
||||
///
|
||||
/// The board these numbers describe: `standard = 100`, `gap = 10`, so a 1× slot is 100pt wide, a 2×
|
||||
/// slot is 210 (two standards plus the interior gap it swallows) and a 3× is 320. The strip's outer
|
||||
/// margin is one gap, so the first slot starts at x = 10.
|
||||
|
||||
private let standard: CGFloat = 100
|
||||
private let gap: CGFloat = 10
|
||||
|
||||
private func proposal(_ units: [Int], dragging index: Int, centre: CGFloat) -> Int {
|
||||
LaneReorderMath.proposedIndex(
|
||||
unitCounts: units,
|
||||
draggedIndex: index,
|
||||
dragCentreX: centre,
|
||||
standard: standard,
|
||||
gap: gap
|
||||
)
|
||||
}
|
||||
|
||||
@Suite("LaneReorderMath")
|
||||
struct LaneReorderMathTests {
|
||||
|
||||
// MARK: Resting geometry
|
||||
|
||||
@Test("A lane's resting centre is its slot's midpoint, gaps and wide lanes counted")
|
||||
func restingCentres() {
|
||||
// Four 1× lanes: slots at [10, 110), [120, 220), [230, 330), [340, 440).
|
||||
let uniform = [1, 1, 1, 1]
|
||||
#expect(LaneReorderMath.centre(ofLaneAt: 0, unitCounts: uniform, standard: standard, gap: gap) == 60)
|
||||
#expect(LaneReorderMath.centre(ofLaneAt: 1, unitCounts: uniform, standard: standard, gap: gap) == 170)
|
||||
#expect(LaneReorderMath.centre(ofLaneAt: 3, unitCounts: uniform, standard: standard, gap: gap) == 390)
|
||||
|
||||
// A 3× lane in the middle: slots at [10, 110), [120, 440), [450, 550).
|
||||
let mixed = [1, 3, 1]
|
||||
#expect(LaneReorderMath.centre(ofLaneAt: 1, unitCounts: mixed, standard: standard, gap: gap) == 280)
|
||||
#expect(LaneReorderMath.centre(ofLaneAt: 2, unitCounts: mixed, standard: standard, gap: gap) == 500)
|
||||
|
||||
// An index past the end yields the position the next slot would start at, rather than
|
||||
// trapping: the drag's lane can vanish between a render and a gesture callback.
|
||||
#expect(LaneReorderMath.centre(ofLaneAt: 9, unitCounts: mixed, standard: standard, gap: gap) == 560)
|
||||
}
|
||||
|
||||
// MARK: The proposal
|
||||
|
||||
@Test("A lane that has not moved proposes its own index")
|
||||
func restingDragProposesNoChange() {
|
||||
// Dragging lane 1 of four: with it removed the remaining centres are 60, 170, 280. Its own
|
||||
// resting centre is 170, which has passed exactly one of them.
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 1, centre: 170) == 1)
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 60) == 0)
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 3, centre: 390) == 3)
|
||||
}
|
||||
|
||||
@Test("The proposal steps once the cursor passes a remaining lane's centre, and not before")
|
||||
func theThresholdIsTheNeighboursCentre() {
|
||||
// Dragging lane 0 out of four. Remaining slots are the other three, laid out from x = 10:
|
||||
// centres 60, 170, 280.
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 59) == 0)
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 60) == 0, "the boundary itself does not step — strictly past")
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 61) == 1)
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 171) == 2)
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 281) == 3)
|
||||
}
|
||||
|
||||
@Test("A far drag in either direction clamps to the ends")
|
||||
func farDragsClampToTheEnds() {
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 2, centre: -5000) == 0)
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 2, centre: 5000) == 3, "the last index with the lane itself removed")
|
||||
#expect(proposal([1, 1, 1], dragging: 0, centre: 5000) == 2)
|
||||
}
|
||||
|
||||
@Test("Width-aware: a wide neighbour has to be crossed, not merely touched")
|
||||
func wideNeighboursDemandRealTravel() {
|
||||
// Lanes [1, 3, 1] with the 1× at index 0 dragged. The remaining pair is the 3× then the 1×:
|
||||
// slots [10, 330) and [340, 440), centres 170 and 390.
|
||||
//
|
||||
// "No reflow until the cursor reaches where the dragged lane would actually land": at 200 the
|
||||
// cursor is well inside the wide lane but has passed its centre, so the step is honest; at
|
||||
// 150 it has not, and proposing a swap there would reorder the board under a cursor still
|
||||
// sitting over the lane it started left of.
|
||||
#expect(proposal([1, 3, 1], dragging: 0, centre: 150) == 0)
|
||||
#expect(proposal([1, 3, 1], dragging: 0, centre: 200) == 1)
|
||||
#expect(proposal([1, 3, 1], dragging: 0, centre: 400) == 2)
|
||||
}
|
||||
|
||||
@Test("The proposal is monotone in the cursor — it never oscillates")
|
||||
func theProposalIsMonotone() {
|
||||
// One threshold per remaining slot, crossed once, is what makes the shadow stable rather
|
||||
// than jittery (04-interactions.md ▸ Drag and drop). Sweeping the whole strip must therefore
|
||||
// produce a non-decreasing sequence.
|
||||
let units = [2, 1, 3, 1, 2]
|
||||
var last = 0
|
||||
for x in stride(from: CGFloat(-200), through: 1200, by: 1) {
|
||||
let next = proposal(units, dragging: 2, centre: x)
|
||||
#expect(next >= last, "the proposal went backwards as the cursor moved right, at x = \(x)")
|
||||
last = next
|
||||
}
|
||||
#expect(last == units.count - 1)
|
||||
}
|
||||
|
||||
@Test("A single-lane board proposes the only index there is")
|
||||
func singleLaneBoard() {
|
||||
#expect(proposal([1], dragging: 0, centre: -900) == 0)
|
||||
#expect(proposal([1], dragging: 0, centre: 900) == 0)
|
||||
}
|
||||
|
||||
@Test("An out-of-range dragged index yields zero rather than trapping")
|
||||
func vanishedLaneDoesNotTrap() {
|
||||
// The lane vanished under the drag; the caller's release-with-no-valid-proposal rule cancels
|
||||
// anyway, so the only contract here is totality.
|
||||
#expect(proposal([1, 1], dragging: 7, centre: 100) == 0)
|
||||
#expect(proposal([], dragging: 0, centre: 100) == 0)
|
||||
}
|
||||
|
||||
// MARK: Applying a proposal
|
||||
|
||||
@Test("Reordering applies the proposal's own index convention")
|
||||
func reorderedAppliesTheConvention() {
|
||||
let lanes = ["a", "b", "c", "d"]
|
||||
|
||||
// `to` counts positions with the item already removed, which is what `proposedIndex`
|
||||
// returns — so `to == from` must be the identity.
|
||||
#expect(LaneReorderMath.reordered(lanes, from: 1, to: 1) == lanes)
|
||||
#expect(LaneReorderMath.reordered(lanes, from: 0, to: 0) == lanes)
|
||||
|
||||
#expect(LaneReorderMath.reordered(lanes, from: 0, to: 1) == ["b", "a", "c", "d"])
|
||||
#expect(LaneReorderMath.reordered(lanes, from: 0, to: 3) == ["b", "c", "d", "a"])
|
||||
#expect(LaneReorderMath.reordered(lanes, from: 3, to: 0) == ["d", "a", "b", "c"])
|
||||
#expect(LaneReorderMath.reordered(lanes, from: 2, to: 1) == ["a", "c", "b", "d"])
|
||||
}
|
||||
|
||||
@Test("Reordering is total: out-of-range indices clamp or pass through")
|
||||
func reorderedIsTotal() {
|
||||
let lanes = ["a", "b", "c"]
|
||||
#expect(LaneReorderMath.reordered(lanes, from: 9, to: 0) == lanes)
|
||||
#expect(LaneReorderMath.reordered(lanes, from: 0, to: 99) == ["b", "c", "a"])
|
||||
#expect(LaneReorderMath.reordered(lanes, from: 2, to: -5) == ["c", "a", "b"])
|
||||
}
|
||||
|
||||
@Test("A dragged lane parked over each slot in turn lands exactly there")
|
||||
func aRoundTripThroughEverySlot() {
|
||||
// The end-to-end claim the two halves compose into: park the dragged lane on top of a
|
||||
// sibling's resting centre and the proposal, applied, puts it in that sibling's place.
|
||||
let units = [1, 2, 1, 3]
|
||||
let lanes = ["a", "b", "c", "d"]
|
||||
let from = 0
|
||||
var remaining = units
|
||||
remaining.remove(at: from)
|
||||
|
||||
for slot in remaining.indices {
|
||||
let centre = LaneReorderMath.centre(ofLaneAt: slot, unitCounts: remaining, standard: standard, gap: gap)
|
||||
// A hair past the centre is what "passed it" means; sitting exactly on it holds.
|
||||
let landed = proposal(units, dragging: from, centre: centre + 1)
|
||||
#expect(landed == slot + 1)
|
||||
#expect(LaneReorderMath.reordered(lanes, from: from, to: landed).firstIndex(of: "a") == slot + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user