Keyboard ←/→ keep their place — a sticky ordinal that outlives the clamp

Lateral card navigation was pure geometry: the nearest drawn frame in the
direction. That loses the walk in the card's own title — stepping from a
10-card lane's 8th card into a 3-card lane clamps to its 3rd, and coming
back out, "the nearest frame at that height" is the 3rd card's height. The
information the user was walking at stopped being on screen, so no rule
over rectangles could have recovered it.

So it is remembered instead. `TransientBoardState.lateralOrdinal` holds the
1-based position a run of ←/→ started from, counted over the cards the board
is showing, and `NavigationMath.lateralHop` lands each hop on
`min(ordinal, target lane's count)` of the next lane that is showing cards —
collapsed and query-emptied lanes hopped over on `firstCard`'s rule rather
than by the accident of registering no frames. 8th → 3rd → 8th.

Every reset comes from one funnel and needs no enumeration anywhere: the
ordinal is a defaulted `nil` parameter on `select`, 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 by saying nothing. `resolve` adds the one
rule a value referencing no item can need — the ordinal never outlives the
head it was counted from — while a reload that leaves the cursor standing
leaves the run standing too.

A wide lane's interior masonry columns keep their spatial step and carry the
ordinal through untouched: a column hop is not a lane hop, and stickiness is
lane-granular over the logical order. With no lane in the direction the
geometry has the last word, which is how → still reaches the shown trash.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-08 23:28:05 -04:00
parent eb950ee881
commit f9f284cac9
6 changed files with 552 additions and 13 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ Selection, drag & drop, keyboard, clipboard, search. This is where the old app s
### Grammar (fixed keys — deliberately not remappable) ### 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. - **⌥-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. - **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. - **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.
+10 -2
View File
@@ -4799,15 +4799,23 @@ public final class BoardStore: HealHost {
/// ///
/// - Parameter defaultsSoleMember: forwarded verbatim see `TransientBoardState.select`, whose /// - Parameter defaultsSoleMember: forwarded verbatim see `TransientBoardState.select`, whose
/// sole-member default the rubber band opts out of. /// 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( public func select(
_ ids: Set<ItemID>, _ ids: Set<ItemID>,
in container: ItemContainer, in container: ItemContainer,
anchor: ItemID? = nil, anchor: ItemID? = nil,
head: ItemID? = nil, head: ItemID? = nil,
defaultsSoleMember: Bool = true defaultsSoleMember: Bool = true,
lateralOrdinal: Int? = nil
) { ) {
transient.select( 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)) transient.noteActiveLane(Self.lane(holding: ids, in: snapshot))
} }
+42 -2
View File
@@ -282,6 +282,29 @@ public final class TransientBoardState {
/// by `resolve(against:)` under the same universe rule. /// by `resolve(against:)` under the same universe rule.
public private(set) var selectionHead: ItemID? 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" /// 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. /// 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. /// `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 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( public func select(
_ ids: Set<ItemID>, _ ids: Set<ItemID>,
in container: ItemContainer, in container: ItemContainer,
anchor: ItemID? = nil, anchor: ItemID? = nil,
head: ItemID? = nil, head: ItemID? = nil,
defaultsSoleMember: Bool = true defaultsSoleMember: Bool = true,
lateralOrdinal: Int? = nil
) { ) {
selection = ItemReferenceSet(ids: ids, container: container) selection = ItemReferenceSet(ids: ids, container: container)
let sole = defaultsSoleMember && ids.count == 1 ? ids.first : nil let sole = defaultsSoleMember && ids.count == 1 ? ids.first : nil
selectionAnchor = anchor ?? sole selectionAnchor = anchor ?? sole
selectionHead = head ?? sole selectionHead = head ?? sole
self.lateralOrdinal = lateralOrdinal
} }
/// Selects nothing Escape's last step outward (04-interactions.md Grammar). The anchor and /// 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 /// 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() { public func clearSelection() {
selection = .empty selection = .empty
selectionAnchor = nil selectionAnchor = nil
selectionHead = nil selectionHead = nil
lateralOrdinal = nil
} }
/// Records that `laneID` is where the user is working a lane selected, or created into. /// 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 anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil }
if let head = selectionHead, !universe.contains(head) { selectionHead = 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) constrainToSearch(in: snapshot, commentMatches: commentMatches)
} }
@@ -726,6 +763,9 @@ public final class TransientBoardState {
selection = selection.constrained(to: universe) selection = selection.constrained(to: universe)
if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil } if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil }
if let head = selectionHead, !universe.contains(head) { selectionHead = 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. /// The placeholder's two discard rules, as a pure function of the placeholder and the snapshot.
+53 -5
View File
@@ -1131,19 +1131,67 @@ struct BoardView: View {
/// ///
/// Every registered target is a candidate, which is also how the hidden trash stays invisible: /// Every registered target is a candidate, which is also how the hidden trash stays invisible:
/// a column that is not drawn registers nothing. /// 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 { private func step(_ direction: NavigationMath.Direction, from head: ItemID) -> KeyPress.Result {
guard let origin = marqueeTargets.targets[head], guard let origin = marqueeTargets.targets[head] else { return .handled }
let nextID = NavigationMath.nearest( let geometric = NavigationMath.nearest(
from: origin.frame, from: origin.frame,
direction: direction, direction: direction,
among: marqueeTargets.all among: marqueeTargets.all
), )
let next = marqueeTargets.targets[nextID]
else { return .handled } 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) replaceSelection(with: next.id, in: next.container)
return .handled 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 /// **-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 /// still stops at the container boundary") a -arrow whose next step would cross from live
/// cards into the trash, or back, is simply inert. /// cards into the trash, or back, is simply inert.
+77
View File
@@ -116,6 +116,83 @@ public enum NavigationMath {
} }
return nil 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<Lane>,
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 // MARK: - Within-lane sort
+366
View File
@@ -24,6 +24,9 @@ import Testing
private enum More { private enum More {
static let card5 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" static let card5 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
static let card6 = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" 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) 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 // 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 /// The registry is the one piece of the marquee/arrow pair that is *written* from a view every card