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
+56 -8
View File
@@ -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.