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:
@@ -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<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
|
||||
|
||||
Reference in New Issue
Block a user