diff --git a/DESIGN/04-interactions.md b/DESIGN/04-interactions.md index 3003d56..ad0cac3 100644 --- a/DESIGN/04-interactions.md +++ b/DESIGN/04-interactions.md @@ -36,7 +36,7 @@ Selection, drag & drop, keyboard, clipboard, search. This is where the old app s ### Grammar (fixed keys — deliberately not remappable) -- **Arrows**: spatial card navigation (nearest card in the direction, across interior grid columns and lanes); with a lane selected, ←/→ move lane selection; ⇧-arrow extends — except **⇧↑/⇧↓ in the lane domain, which are inert** (settled: there is nothing above the lane domain and no vertical range within it); selection scrolls into view; all grammar keys inert while a title editor is focused, and menu dispatch narrows to the text domain (focused-editor rule below). +- **Arrows**: spatial card navigation (nearest card in the direction, across interior grid columns and lanes) — **except ←/→ crossing lanes, which preserve the origin's ordinal** (settled 2026-08-09; the *sticky ordinal*): a run of lateral presses remembers the 1-based position the card it started from held in its lane's logical order, counting the cards the board is showing (search filter respected), and each hop lands on `min(that ordinal, the target lane's count)` in the next lane that is showing cards — collapsed and query-emptied lanes hopped over as everywhere else. So a 10-3-10 walk goes 8th → 3rd (clamped) → 8th, which no rule over drawn rectangles can do: after the clamp, "8 cards down" is no longer on screen to be measured. The ordinal is **transient per-window state** (`TransientBoardState.lateralOrdinal`), never persisted, and **any non-lateral selection change resets it to the actual new position** — ↑/↓ steps, ⌥-jumps, clicks, the rubber band, ⌫'s successor, and programmatic re-selection after a reload. A wide lane's interior masonry columns keep their spatial step and do not participate: stickiness is lane-granular, and the ordinal is the lane's logical order whichever column drew the card. With a lane selected, ←/→ move lane selection; ⇧-arrow extends — except **⇧↑/⇧↓ in the lane domain, which are inert** (settled: there is nothing above the lane domain and no vertical range within it); selection scrolls into view; all grammar keys inert while a title editor is focused, and menu dispatch narrows to the text domain (focused-editor rule below). - **⌥-arrows jump**: ⌥↑/⌥↓ to the current lane's first/last card; ⌥←/⌥→ to the first/last lane. **The horizontal jumps land on a card** (settled — ⌥↑ is the keyboard's one entry to lane selection, so ⌥←/⌥→ never select the lane itself): the first card of the first/last *non-empty* lane, scanning inward past empty lanes; ⌥→ prefers the shown non-empty trash — its first entry — per the last-container rule (The trash below). **⌥↑ escalates into the lane domain** (settled — the keyboard's one entry to lane selection): with the lane's first card already selected, ⌥↑ selects the *lane* itself — up in the hierarchy sense, the same key one press deeper; with a lane selected, ↓ (or ⌥↓) descends back into its cards at the first (last) card, and ⌥↑ is inert. **An empty selection seeds at the first lane's first card** on any plain arrow (deterministic origin; the ⌥-jumps behave as specified regardless) — two ⌥↑ presses from nothing reach the lane domain. - **Return** on a selected lane: creates a card at its bottom, editor focused; Return commits and re-selects the lane (next Return = next card); ⌘↩ commits and opens the card window. Abandoned placeholders (Escape, empty commit, click-away) are discarded — creating-then-abandoning never leaves an empty card behind (untitled cards exist only when made deliberately, e.g. by an external writer or by clearing an existing title). The placeholder is store-transient overlay state — the named exception to 02-architecture.md's one-way flow; nothing exists on disk until the title commits. - **Inline rename tracks its target by UUID, and vanishing discards it** (the placeholder and card-window kin rules — 02-architecture.md — applied to the third inline editor): a foreign *move* mid-rename is invisible — the editor follows the UUID and the commit writes the title wherever the card now lives; a target that is trashed, deleted, or gone at commit time discards the editor and its keystrokes silently (entering the trash is a vanish from the board; nothing is ever written into a vanished folder). A write that fails *after* a valid commit is the ordinary one-shot write-failure banner. VoiceOver announces the vanished target per 10-accessibility.md's recovery rule. diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index e28a893..57fc45a 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -4799,15 +4799,23 @@ public final class BoardStore: HealHost { /// /// - Parameter defaultsSoleMember: forwarded verbatim — see `TransientBoardState.select`, whose /// sole-member default the rubber band opts out of. + /// - Parameter lateralOrdinal: forwarded verbatim — see `TransientBoardState.lateralOrdinal`, + /// whose `nil` default is what makes every caller here a reset of the ←/→ run. public func select( _ ids: Set, in container: ItemContainer, anchor: ItemID? = nil, head: ItemID? = nil, - defaultsSoleMember: Bool = true + defaultsSoleMember: Bool = true, + lateralOrdinal: Int? = nil ) { transient.select( - ids, in: container, anchor: anchor, head: head, defaultsSoleMember: defaultsSoleMember + ids, + in: container, + anchor: anchor, + head: head, + defaultsSoleMember: defaultsSoleMember, + lateralOrdinal: lateralOrdinal ) transient.noteActiveLane(Self.lane(holding: ids, in: snapshot)) } diff --git a/Kanban/LiveStore/TransientBoardState.swift b/Kanban/LiveStore/TransientBoardState.swift index bea540c..589337b 100644 --- a/Kanban/LiveStore/TransientBoardState.swift +++ b/Kanban/LiveStore/TransientBoardState.swift @@ -282,6 +282,29 @@ public final class TransientBoardState { /// by `resolve(against:)` under the same universe rule. public private(set) var selectionHead: ItemID? + /// **What position in its lane a run of ←/→ is holding on to** — the sticky ordinal (ruled + /// 2026-08-09), 1-based over the cards the board is *showing*, `nil` when no lateral run is in + /// flight. + /// + /// The third memory of a gesture, and the one that exists because a *screen* cannot remember it: + /// stepping from a 10-card lane's 8th card into a 3-card lane clamps to its 3rd, and every rule + /// that reads drawn rectangles — `NavigationMath.nearest` included — then has only "3rd card" + /// to step back out with. The number the user is walking at survives here instead, unclamped, so + /// the hop back lands on the 8th again (`NavigationMath.lateralHop`). + /// + /// **`nil` is a reset, and every existing caller gets one for free**: `select` takes it as a + /// defaulted parameter and stores it verbatim, so a click, a marquee, a ↑/↓ step, an ⌥-jump, a + /// ⌫ successor, a lane-domain arrow and the reload's focus recovery all end the run without any + /// of them naming it. Only the lateral hop passes a value. Reading `nil` on the next hop is not a + /// missing answer either — it means "start a run here", which captures the cursor's *actual* + /// position, which is the ruling's "resets to the actual new position" arrived at lazily. + /// + /// **An ordinal rather than a card**, deliberately: the thing being preserved is a position in a + /// list, and the lists on either side of a hop are different lanes with different lengths. It + /// therefore references no item, which is why `resolve(against:)` has only one thing to say about + /// it — see there. + public private(set) var lateralOrdinal: Int? + /// The items a drag is carrying — **empty when no drag is in flight**, which is what "no drag" /// means here rather than a separate flag. /// @@ -406,26 +429,33 @@ public final class TransientBoardState { /// `resolve(against:)` on the next reload is what keeps the set honest over time. /// /// - Parameter defaultsSoleMember: whether a `nil` anchor or head falls back to a sole member. + /// - Parameter lateralOrdinal: the sticky ordinal a run of ←/→ is carrying, or `nil` — which is + /// what **every** caller but the lateral hop passes, and is why "a non-lateral selection change + /// resets it" needs no enumeration of the gestures anywhere. See `lateralOrdinal`. public func select( _ ids: Set, in container: ItemContainer, anchor: ItemID? = nil, head: ItemID? = nil, - defaultsSoleMember: Bool = true + defaultsSoleMember: Bool = true, + lateralOrdinal: Int? = nil ) { selection = ItemReferenceSet(ids: ids, container: container) let sole = defaultsSoleMember && ids.count == 1 ? ids.first : nil selectionAnchor = anchor ?? sole selectionHead = head ?? sole + self.lateralOrdinal = lateralOrdinal } /// Selects nothing — Escape's last step outward (04-interactions.md ▸ Grammar). The anchor and /// the head go with it: an empty selection has no origin to range from and no cursor to step - /// from — which is exactly the state the arrows' seed rule answers. + /// from — which is exactly the state the arrows' seed rule answers. The sticky ordinal goes for + /// the same reason: there is no run left to be in the middle of. public func clearSelection() { selection = .empty selectionAnchor = nil selectionHead = nil + lateralOrdinal = nil } /// Records that `laneID` is where the user is working — a lane selected, or created into. @@ -681,6 +711,13 @@ public final class TransientBoardState { if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil } if let head = selectionHead, !universe.contains(head) { selectionHead = nil } } + // **The sticky ordinal never outlives the head it was counted from** — the one thing a reload + // has to say about a value that references no item. A run of ←/→ whose cursor vanished has + // nothing left to be a run *of*, and the next arrow re-derives its head from the selection's + // last member, which is a position the run never named. A reload that leaves the head standing + // deliberately leaves the run standing too: an agent filing a card mid-walk is not the user + // changing their mind. + if selectionHead == nil { lateralOrdinal = nil } constrainToSearch(in: snapshot, commentMatches: commentMatches) } @@ -726,6 +763,9 @@ public final class TransientBoardState { selection = selection.constrained(to: universe) if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil } if let head = selectionHead, !universe.contains(head) { selectionHead = nil } + // A hidden cursor ends the lateral run, for `resolve`'s reason: the ordinal counts what the + // user can see, and a run stepping from a card the filter took away counts from nowhere. + if selectionHead == nil { lateralOrdinal = nil } } /// The placeholder's two discard rules, as a pure function of the placeholder and the snapshot. diff --git a/Kanban/UI/Board/BoardView.swift b/Kanban/UI/Board/BoardView.swift index 17146fc..329d9f4 100644 --- a/Kanban/UI/Board/BoardView.swift +++ b/Kanban/UI/Board/BoardView.swift @@ -1131,19 +1131,67 @@ struct BoardView: View { /// /// Every registered target is a candidate, which is also how the hidden trash stays invisible: /// a column that is not drawn registers nothing. + /// + /// **←/→ across lanes are the one exception, and they answer from the sticky ordinal instead** + /// (ruled 2026-08-09; `NavigationMath.lateralHop`): a lane hop lands on the position the run is + /// holding, clamped to the target lane, so 10-3-10 walks out and back to the same card. The three + /// branches below are ordered by how specific they are — + /// + /// 1. **A same-lane neighbour wins first**, which is a wide lane's interior masonry column. That + /// is a column hop, not a lane hop, so it keeps its geometry *and* leaves the run's ordinal + /// exactly as it found it — "interior masonry position does not participate". + /// 2. **Otherwise the lanes decide**, and the ordinal picks the card in the lane they name. The + /// collapsed and query-emptied lanes are skipped inside `lateralHop`, on `firstCard`'s rule + /// rather than by the accident of registering no frames. + /// 3. **With no lane that way, geometry has the last word** — which is how `→` off the last lane + /// still reaches the shown trash, and how the trash's own ←/→ stay spatial (an origin over + /// there is not in `.board` at all, so branch 2 never sees it). private func step(_ direction: NavigationMath.Direction, from head: ItemID) -> KeyPress.Result { - guard let origin = marqueeTargets.targets[head], - let nextID = NavigationMath.nearest( - from: origin.frame, - direction: direction, - among: marqueeTargets.all - ), - let next = marqueeTargets.targets[nextID] - else { return .handled } + guard let origin = marqueeTargets.targets[head] else { return .handled } + let geometric = NavigationMath.nearest( + from: origin.frame, + direction: direction, + among: marqueeTargets.all + ) + + if direction == .left || direction == .right, origin.container == .board { + let sticky = store.transient.lateralOrdinal + if let geometric, let home = lane(holding: head), lane(holding: geometric) == home { + store.select( + [geometric], in: .board, anchor: geometric, head: geometric, lateralOrdinal: sticky + ) + return .handled + } + if let hop = NavigationMath.lateralHop( + from: head, + direction, + scanning: boardLanes, + filter: store.searchFilter, + sticky: sticky + ) { + store.select( + [hop.target], + in: .board, + anchor: hop.target, + head: hop.target, + lateralOrdinal: hop.ordinal + ) + return .handled + } + } + + guard let nextID = geometric, let next = marqueeTargets.targets[nextID] else { return .handled } replaceSelection(with: next.id, in: next.container) return .handled } + /// The live lane `card` sits in, or `nil` for anything the board is not showing as a card — a + /// trash row included, which is what makes the same-lane test above reject a crossing candidate + /// without a second container check. + private func lane(holding card: ItemID) -> ItemID? { + store.snapshot.lanes.first { $0.cards.contains { $0.id == card } }?.id + } + /// **⇧-arrow extends, and stops at the container boundary** (04 ▸ The trash: "⇧-arrow extension /// still stops at the container boundary") — a ⇧-arrow whose next step would cross from live /// cards into the trash, or back, is simply inert. diff --git a/Kanban/UI/Board/NavigationMath.swift b/Kanban/UI/Board/NavigationMath.swift index 3651d2e..1031d09 100644 --- a/Kanban/UI/Board/NavigationMath.swift +++ b/Kanban/UI/Board/NavigationMath.swift @@ -116,6 +116,83 @@ public enum NavigationMath { } return nil } + + // MARK: The sticky ordinal + + /// Where a lateral hop lands, and the ordinal it carries onward — `lateralHop`'s answer. + public struct LateralHop: Sendable, Equatable { + + /// The card to select. + public let target: ItemID + + /// The ordinal the *sequence* is holding — the origin's, **unclamped**, so a walk through a + /// short lane and out the other side remembers where it started rather than where it was + /// squeezed to. `TransientBoardState.lateralOrdinal` stores exactly this. + public let ordinal: Int + + public init(target: ItemID, ordinal: Int) { + self.target = target + self.ordinal = ordinal + } + } + + /// **←/→ preserve the origin's position in its lane** (ruled 2026-08-09): a lateral hop lands on + /// the `min(sticky, count)`-th card of the adjacent lane, counting the cards the board is + /// *showing*, so the 10-3-10 walk in the card's title round-trips — 8th → 3rd (clamped) → 8th. + /// + /// Geometry cannot do this and the failure is not a bug in the score: `nearest` picks the frame + /// closest to the origin's height, and after a clamp into a short lane the origin's height *is* + /// the short lane's 3rd card. Every candidate rule over drawn rectangles loses the same + /// information, because the information — "the user was 8 cards down" — stopped being on screen. + /// So the ordinal is remembered instead (`TransientBoardState.lateralOrdinal`), and this function + /// is the only thing that reads it. + /// + /// **The lanes it walks are the showing ones**, which is `firstCard`'s rule with one addition, + /// and the three skips are the same three: a collapsed lane draws no card faces, a lane the query + /// emptied shows none either, and an empty lane has none to show. A hop is *one* showing lane + /// over — never "the next lane, unless it is folded, in which case two" spelled at a call site. + /// + /// **Ordinals are lane-granular and read the logical order** (10-accessibility.md's rule, and the + /// one `SortMath` moves along): a wide lane's masonry columns are a rendering, so the 8th card is + /// the 8th in `order` whichever interior column it was laid into. The *interior* step keeps its + /// geometry — `BoardView.step` takes a same-lane neighbour before it ever asks this — because a + /// lane hop and a column hop are different gestures wearing one key. + /// + /// - Parameters: + /// - origin: the card the cursor is on. Not a frame: this rule is about lanes and lists, and + /// taking a rectangle would invite the geometry back in. + /// - sticky: the ordinal the sequence is already carrying, or `nil` to **start** one here — + /// which captures the origin's own ordinal, and is what makes "the first hop of a sequence" + /// need no flag of its own. + /// - Returns: `nil` when the direction is vertical, when `origin` is not in a showing lane, or + /// when there is no showing lane that way — the last of which is `BoardView.step`'s cue to fall + /// back to geometry, so `→` still reaches the shown trash off the last lane. + public static func lateralHop( + from origin: ItemID, + _ direction: Direction, + scanning lanes: some Sequence, + filter: SearchFilter = .inactive, + sticky: Int? = nil + ) -> LateralHop? { + guard direction == .left || direction == .right else { return nil } + + let showing: [[ItemID]] = lanes.compactMap { lane in + guard !LaneLayoutMath.isCollapsed(lane) else { return nil } + let ids = lane.cards.filter { filter.matches($0) }.map(\.id) + return ids.isEmpty ? nil : ids + } + + guard let home = showing.firstIndex(where: { $0.contains(origin) }), + let position = showing[home].firstIndex(of: origin) + else { return nil } + + let carried = sticky ?? (position + 1) + let next = home + (direction == .left ? -1 : 1) + guard showing.indices.contains(next) else { return nil } + + let landing = showing[next] + return LateralHop(target: landing[min(carried, landing.count) - 1], ordinal: carried) + } } // MARK: - Within-lane sort diff --git a/KanbanTests/KeyboardGrammarTests.swift b/KanbanTests/KeyboardGrammarTests.swift index f4532ef..e20e6a4 100644 --- a/KanbanTests/KeyboardGrammarTests.swift +++ b/KanbanTests/KeyboardGrammarTests.swift @@ -24,6 +24,9 @@ import Testing private enum More { static let card5 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" static let card6 = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" + /// A card an agent files mid-navigation — a reload that changes the board without disturbing the + /// cursor, which the sticky ordinal's survival rule needs. + static let filed = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" } private let lane1 = ItemID(rawValue: Ident.lane1) @@ -226,6 +229,369 @@ struct NavigationMathTests { } } +// MARK: - The sticky ordinal + +/// A UUID-shaped folder name for the walk board — `(lane, position)`, with position `0` naming the +/// lane itself, so ten cards in a lane cost no ten literals. +private func name(_ lane: Int, _ position: Int) -> String { + String(format: "%08x-0000-4000-8000-%012x", lane, position) +} + +private func walkID(_ lane: Int, _ position: Int) -> ItemID { + ItemID(rawValue: name(lane, position)) +} + +/// **The card's own board**: ten cards, three, ten. The middle lane is what a walk has to be clamped +/// by, and the third is what proves the clamp was not remembered. +/// +/// The third lane is **two units wide**, so its cards lay out across interior masonry columns — the +/// case the ruling settles by saying interior position "does not participate". Nothing here can see +/// that: the hop takes no frames, which is the enforcement. +/// +/// Every card's body carries two tokens the filter suites query for: `even`/`odd` by position, and +/// `keeplane` on the outer lanes only — so one query narrows all three lanes and another empties the +/// middle one entirely. +@MainActor +private func makeWalk() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.board() + for (lane, count) in [(1, 10), (2, 3), (3, 10)] { + try fixture.lane( + name(lane, 0), + order: "\(lane * 1024)", + title: "Lane \(lane)", + width: lane == 3 ? 2 : nil + ) + for position in 1...count { + try fixture.card( + name(lane, position), + in: name(lane, 0), + order: "\(position * 1024)", + title: "L\(lane)C\(position)", + body: "\(position.isMultiple(of: 2) ? "even" : "odd") \(lane == 2 ? "" : "keeplane")" + ) + } + } + return fixture +} + +@Suite("NavigationMath ▸ the sticky ordinal") +@MainActor +struct LateralHopTests { + + @Test("10-3-10: the 8th card walks out through the 3rd and back to the 8th") + func roundTrip() throws { + let fixture = try makeWalk() + defer { fixture.tearDown() } + let lanes = try fixture.snapshot().lanes + + // Out: clamped to the short lane's last card, and still carrying the 8 it started with — + // the clamp is a landing, never a new position to remember. + let out = try #require(NavigationMath.lateralHop(from: walkID(1, 8), .right, scanning: lanes)) + #expect(out.target == walkID(2, 3)) + #expect(out.ordinal == 8) + + // Onward: the wide lane's 8th in *logical* order, whichever masonry column drew it. + let onward = try #require( + NavigationMath.lateralHop(from: out.target, .right, scanning: lanes, sticky: out.ordinal) + ) + #expect(onward.target == walkID(3, 8)) + #expect(onward.ordinal == 8) + + // And back the way it came, which is the card's title in full. + let returning = try #require( + NavigationMath.lateralHop(from: onward.target, .left, scanning: lanes, sticky: onward.ordinal) + ) + #expect(returning.target == walkID(2, 3)) + let home = try #require( + NavigationMath.lateralHop(from: returning.target, .left, scanning: lanes, sticky: returning.ordinal) + ) + #expect(home.target == walkID(1, 8), "the 8th again — the whole point") + } + + @Test("Without a run in flight the hop starts one at the origin's own position") + func noStickyStartsARun() throws { + let fixture = try makeWalk() + defer { fixture.tearDown() } + let lanes = try fixture.snapshot().lanes + + // This is what a *reset* leaves behind, and it is also the failure the sticky exists to fix: + // starting a fresh run from the clamped 3rd card lands on the third, not the eighth. + let fresh = try #require(NavigationMath.lateralHop(from: walkID(2, 3), .right, scanning: lanes)) + #expect(fresh.target == walkID(3, 3)) + #expect(fresh.ordinal == 3) + + let first = try #require(NavigationMath.lateralHop(from: walkID(1, 4), .right, scanning: lanes)) + #expect(first.ordinal == 4, "the origin's 1-based position in its lane") + } + + @Test("The clamp is the target lane's count, and a lane of one takes everything") + func clampsToTheShortLane() throws { + let fixture = try makeWalk() + defer { fixture.tearDown() } + let lanes = try fixture.snapshot().lanes + + for ordinal in 3...10 { + let hop = try #require( + NavigationMath.lateralHop(from: walkID(1, ordinal), .right, scanning: lanes, sticky: ordinal) + ) + #expect(hop.target == walkID(2, 3), "everything past the 3rd lands on the 3rd") + #expect(hop.ordinal == ordinal) + } + // Under the *filter* the middle lane shows one card, and the clamp follows what is showing + // rather than what is on disk. + let narrowed = try #require( + NavigationMath.lateralHop( + from: walkID(1, 8), .right, scanning: lanes, filter: SearchFilter(query: "even"), sticky: 4 + ) + ) + #expect(narrowed.target == walkID(2, 2)) + } + + @Test("The ordinal counts the cards the query left showing, not the cards on disk") + func countsTheFilteredLane() throws { + let fixture = try makeWalk() + defer { fixture.tearDown() } + let lanes = try fixture.snapshot().lanes + let evens = SearchFilter(query: "even") + + // Lane 1 shows 2,4,6,8,10 — so card 8 is the *4th* thing the user can see, and starting a run + // there carries 4 rather than 8. + let out = try #require( + NavigationMath.lateralHop(from: walkID(1, 8), .right, scanning: lanes, filter: evens) + ) + #expect(out.ordinal == 4) + #expect(out.target == walkID(2, 2), "the middle lane shows one card; 4 clamps onto it") + + let onward = try #require( + NavigationMath.lateralHop( + from: out.target, .right, scanning: lanes, filter: evens, sticky: out.ordinal + ) + ) + #expect(onward.target == walkID(3, 8), "the 4th showing card of the far lane is card 8") + } + + @Test("A lane the query emptied is hopped straight over, exactly as an absent one would be") + func skipsAQueryEmptiedLane() throws { + let fixture = try makeWalk() + defer { fixture.tearDown() } + let lanes = try fixture.snapshot().lanes + // `keeplane` is on lanes 1 and 3 only: the middle lane shows nothing, so it is not a + // destination and one press crosses it. + let hop = try #require( + NavigationMath.lateralHop( + from: walkID(1, 8), .right, scanning: lanes, filter: SearchFilter(query: "keeplane") + ) + ) + #expect(hop.target == walkID(3, 8)) + #expect(hop.ordinal == 8) + } + + @Test("A collapsed lane is hopped over too, and its cards are never a landing") + func skipsACollapsedLane() throws { + let fixture = try makeWalk() + defer { fixture.tearDown() } + // 03-board-ui.md § Lane ▸ Collapsed lanes: the slim strip lays out no card faces, so a hop + // that landed in there would select something drawn nowhere. + try fixture.item(name(2, 0), "---\nschema: 1\ntitle: Lane 2\norder: 2048\ncollapsed: true\n---\n\n") + let lanes = try fixture.snapshot().lanes + + let hop = try #require(NavigationMath.lateralHop(from: walkID(1, 8), .right, scanning: lanes)) + #expect(hop.target == walkID(3, 8), "one press, straight past the strip") + #expect(hop.ordinal == 8) + + // And back: the strip is no more a destination from the right than from the left. + let back = try #require( + NavigationMath.lateralHop(from: hop.target, .left, scanning: lanes, sticky: hop.ordinal) + ) + #expect(back.target == walkID(1, 8)) + + // Folding the *last* lane leaves nothing to the right at all — the handler's cue to fall back + // to geometry, which is how the shown trash stays reachable by →. + try fixture.item(name(3, 0), "---\nschema: 1\ntitle: Lane 3\norder: 3072\ncollapsed: true\n---\n\n") + #expect( + NavigationMath.lateralHop(from: walkID(1, 8), .right, scanning: try fixture.snapshot().lanes) == nil + ) + } + + @Test("The ends, the vertical directions and an origin the board is not showing are all nil") + func refusals() throws { + let fixture = try makeWalk() + defer { fixture.tearDown() } + let lanes = try fixture.snapshot().lanes + + #expect(NavigationMath.lateralHop(from: walkID(1, 1), .left, scanning: lanes) == nil) + #expect(NavigationMath.lateralHop(from: walkID(3, 1), .right, scanning: lanes) == nil) + #expect( + NavigationMath.lateralHop(from: walkID(1, 8), .down, scanning: lanes) == nil, + "↑/↓ are not a lateral run and must never consume the ordinal" + ) + #expect(NavigationMath.lateralHop(from: walkID(1, 8), .up, scanning: lanes) == nil) + #expect(NavigationMath.lateralHop(from: card1, .right, scanning: lanes) == nil, "not on this board") + #expect( + NavigationMath.lateralHop( + from: walkID(1, 1), .right, scanning: lanes, filter: SearchFilter(query: "even") + ) == nil, + "an origin the query hid is in no showing lane, so there is no position to carry" + ) + } +} + +// MARK: - The sticky ordinal's resets + +@Suite("TransientBoardState ▸ the lateral run's resets") +@MainActor +struct LateralOrdinalResetTests { + + @Test("Only a lateral hop stores one — every other selection change is a reset") + func selectResets() { + let state = TransientBoardState() + + state.select([card1], in: .board, anchor: card1, head: card1, lateralOrdinal: 8) + #expect(state.lateralOrdinal == 8) + + // Verbatim `BoardView.replaceSelection`, which is what ↑/↓, the ⌥-jumps and the lane domain + // all land through — no ordinal passed, so the run ends. + state.select([card2], in: .board, anchor: card2, head: card2) + #expect(state.lateralOrdinal == nil, "a vertical step ends the run") + + state.select([card1], in: .board, anchor: card1, head: card1, lateralOrdinal: 8) + state.select([card1, card2], in: .board, anchor: card1, head: card2) + #expect(state.lateralOrdinal == nil, "so does a ⇧-arrow's extension") + + state.select([card1], in: .board, anchor: card1, head: card1, lateralOrdinal: 8) + state.select([card1, card2], in: .board, anchor: nil, head: nil, defaultsSoleMember: false) + #expect(state.lateralOrdinal == nil, "and so does a rubber band") + + state.select([card1], in: .board, anchor: card1, head: card1, lateralOrdinal: 8) + state.clearSelection() + #expect(state.lateralOrdinal == nil, "Escape leaves no run to be in the middle of") + } + + @Test("A click ends the run, whatever it selects") + func clickResets() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.select([card1], in: .board, anchor: card1, head: card1, lateralOrdinal: 8) + store.click(SelectionTarget(id: card3, kind: .card, container: .board), modifier: .plain) + #expect(store.selection.ids == [card3]) + #expect(store.transient.lateralOrdinal == nil) + + // A ⌘-click's toggle and a ⇧-click's range go through the same door, so they reset too. + store.select([card1], in: .board, anchor: card1, head: card1, lateralOrdinal: 8) + store.click(SelectionTarget(id: card3, kind: .card, container: .board), modifier: .shift) + #expect(store.transient.lateralOrdinal == nil) + } + + @Test("A ⌫ successor ends it, because a run's cursor was just deleted out from under it") + func deleteResets() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.select([card2], in: .board, anchor: card2, head: card2, lateralOrdinal: 8) + store.deleteSelection() + #expect(store.selection.ids == [card3]) + #expect(store.transient.lateralOrdinal == nil) + } + + @Test("A reload keeps the run while the cursor stands, and drops it when the cursor goes") + func reloadKeepsThenDrops() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.select([card1], in: .board, anchor: card1, head: card1, lateralOrdinal: 8) + + // An agent filing a card is not the user changing their mind: the head still names a card, so + // the run survives untouched. + try fixture.item("\(Ident.lane1)/\(More.filed)", Item.rich(order: "5120", title: "Filed")) + store.handleWatcherEvent(.treeChanged(.foreign)) + await store.awaitQuiescence() + #expect(store.transient.selectionHead == card1) + #expect(store.transient.lateralOrdinal == 8) + + // The cursor's own card vanishing is the other case, and the ordinal goes with it. What lands + // afterwards is 10-accessibility.md's vanishing-focus recovery (`SelectionHeadTests` pins it): + // the selection had emptied, so focus falls to the card's lane and takes the head with it — + // through `select`, which is exactly the door that ends a run. + try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)")) + store.handleWatcherEvent(.treeChanged(.foreign)) + await store.awaitQuiescence() + #expect(store.transient.selectionHead == lane1) + #expect(store.transient.lateralOrdinal == nil) + } + + @Test("A vanished cursor takes the ordinal with it, before any recovery gets a say") + func resolveDropsTheOrdinalWithTheHead() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let state = TransientBoardState() + + state.select([card1, card2], in: .board, anchor: card1, head: card2, lateralOrdinal: 8) + + // The head's card goes and the selection does not: so it is the head rule doing this, not + // `clearSelection` sweeping everything out on the way past. + try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)")) + state.resolve(against: try load(fixture)) + + #expect(state.selection.ids == [card1]) + #expect(state.selectionHead == nil) + #expect(state.lateralOrdinal == nil) + } + + @Test("A query that hides the cursor ends the run, because the ordinal counts what is showing") + func hidingTheCursorResets() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.select([card1], in: .board, anchor: card1, head: card1, lateralOrdinal: 8) + store.searchQuery = "Second" + + #expect(store.selection.isEmpty) + #expect(store.transient.selectionHead == nil) + #expect(store.transient.lateralOrdinal == nil) + } + + @Test("The store's walk: hop, store, hop again — 10-3-10 through the selection itself") + func theWalkThroughTheStore() throws { + let fixture = try makeWalk() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + // Exactly what `BoardView.step` does on a lane hop, three presses running. + func press(_ direction: NavigationMath.Direction) throws { + let head = try #require(store.transient.selectionHead) + let hop = try #require( + NavigationMath.lateralHop( + from: head, + direction, + scanning: store.snapshot.lanes, + filter: store.searchFilter, + sticky: store.transient.lateralOrdinal + ) + ) + store.select( + [hop.target], in: .board, anchor: hop.target, head: hop.target, lateralOrdinal: hop.ordinal + ) + } + + store.select([walkID(1, 8)], in: .board) + #expect(store.transient.lateralOrdinal == nil, "a fresh selection is not a run") + + try press(.right) + #expect(store.selection.ids == [walkID(2, 3)]) + try press(.right) + #expect(store.selection.ids == [walkID(3, 8)]) + try press(.left) + try press(.left) + #expect(store.selection.ids == [walkID(1, 8)], "back where the walk began") + } +} + // MARK: - The registry the arrows and the band read /// The registry is the one piece of the marquee/arrow pair that is *written* from a view — every card