Files
lanework/Kanban/UI/Board/NavigationMath.swift
T
rzen f9f284cac9 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
2026-08-08 23:28:05 -04:00

265 lines
14 KiB
Swift

import CoreGraphics
// MARK: - Spatial navigation
/// The arrows' geometry — "nearest card in the direction, across interior grid columns and lanes"
/// (04-interactions.md ▸ Grammar), as a pure function of the drawn frames (`NavigationMathTests`).
///
/// **It reads the marquee's registry, deliberately.** The frames come from `MarqueeTargetRegistry`,
/// which the views populate with what they actually drew — so the keyboard and the rubber band
/// answer "where is that card" from one set of rectangles, and geometry can never disagree with
/// hit-testing. A second derivation off the masonry's arithmetic would be a second answer, and one
/// that a lane resize, a reorder in flight or a foreign reload could falsify.
///
/// **It is also how the hidden trash stays invisible for free**: a hidden column registers nothing,
/// so there is nothing here to filter out — 04's "hidden, it is invisible to every gesture" needs no
/// code of its own. **A collapsed lane's cards ride the same mechanism** (03-board-ui.md § Lane ▸
/// Collapsed lanes: "cards inside are not rendered"): a lane drawn as a slim strip lays out no card
/// faces, so it registers no frames and `nearest` cannot step into one. The `⌥`-jumps are the half
/// that *does* need code, because they name an absolute destination rather than a neighbour — see
/// `firstCard(scanning:filter:)`.
///
/// Pure and `CoreGraphics`-only, for `SelectionGrammar`'s reason: the branches become lines of test
/// rather than gestures to drive, and the four arrow handlers stay thin over it.
public enum NavigationMath {
public enum Direction: Sendable, Equatable {
case up
case down
case left
case right
}
/// The nearest target in `direction` from `origin`, or `nil` when the direction has no candidate.
///
/// The rule, in three parts:
///
/// - **Strictly beyond, along the primary axis.** A candidate's centre must sit at least 1pt
/// past the origin's centre in the direction travelled. The tolerance is what excludes the
/// origin itself and what keeps a card sharing a row (or a column) with the origin from
/// counting as "above" it because of a sub-pixel layout difference.
/// - **Orthogonal drift costs double.** The score is the primary-axis centre distance plus twice
/// the orthogonal one, so a card straight ahead beats a nearer one off to the side — which is
/// what makes ↓ walk down a masonry column rather than wandering across it, and ← / → cross to
/// the neighbouring lane at the same height.
/// - **Ties are broken by position, then identity** (`MarqueeMath.isAbove`), so identical input
/// picks identically twice.
///
/// - Parameter predicate: which targets are eligible — the ⇧-arrow's same-side restriction, and
/// nothing else so far. A plain arrow passes everything, because "plain arrows still walk
/// across" the live/trash boundary (04 ▸ The trash).
public static func nearest(
from origin: CGRect,
direction: Direction,
among targets: [MarqueeTarget],
where predicate: (MarqueeTarget) -> Bool = { _ in true }
) -> ItemID? {
/// Below this, a candidate is level with the origin rather than beyond it.
let threshold: CGFloat = 1
var best: MarqueeTarget?
var bestScore = CGFloat.infinity
for candidate in targets where predicate(candidate) {
let primary: CGFloat
let orthogonal: CGFloat
switch direction {
case .up:
primary = origin.midY - candidate.frame.midY
orthogonal = abs(candidate.frame.midX - origin.midX)
case .down:
primary = candidate.frame.midY - origin.midY
orthogonal = abs(candidate.frame.midX - origin.midX)
case .left:
primary = origin.midX - candidate.frame.midX
orthogonal = abs(candidate.frame.midY - origin.midY)
case .right:
primary = candidate.frame.midX - origin.midX
orthogonal = abs(candidate.frame.midY - origin.midY)
}
guard primary >= threshold else { continue }
let score = primary + 2 * orthogonal
if score < bestScore {
best = candidate
bestScore = score
} else if score == bestScore, let current = best, MarqueeMath.isAbove(candidate, current) {
best = candidate
}
}
return best?.id
}
/// **The first card the board is actually showing**, scanning `lanes` in the order given — the
/// landing every absolute keyboard destination shares: ⌥←/⌥→'s first/last lane, and the seed an
/// arrow from an empty selection takes (04-interactions.md ▸ Grammar).
///
/// Three ways a lane is scanned past, and they are one rule — *the user cannot see into it*:
///
/// - it holds no cards;
/// - the search query hid all of them (04 § Search — "a jump that landed nowhere because the end
/// lane happens to be empty would be a dead key", and a lane the query emptied is empty to the
/// eye);
/// - it is **collapsed** (03-board-ui.md § Lane ▸ Collapsed lanes): the slim strip draws no card
/// faces at all, so a jump that landed on one would select something rendered nowhere and leave
/// the arrows with no frame to step from. The strip itself stays selectable **as a lane**, which
/// is the lane domain's business and not this scan's.
///
/// Pure, and here rather than in the view for `SortMath`'s reason: the three skips are lines of
/// test instead of a board to drive.
public static func firstCard(
scanning lanes: some Sequence<Lane>,
filter: SearchFilter = .inactive
) -> ItemID? {
for lane in lanes where !LaneLayoutMath.isCollapsed(lane) {
if let card = lane.cards.first(where: { filter.matches($0) }) { return card.id }
}
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
/// ⌥⌘↑/⌥⌘↓'s arithmetic — "the selected card(s) move one position within the lane — logical
/// `order`, across interior masonry columns" (04-interactions.md ▸ The map), as a pure permutation
/// of the lane's rendered card ids (`NavigationMathTests`).
///
/// **Logical order, never geometry.** The masonry's columns are a rendering; the thing being moved
/// is the `order` ladder, which is also 10-accessibility.md's logical-order rule. So this function
/// never sees a frame — it is given the lane's ids top-to-bottom and hands back the same ids in a
/// new order, and `BoardStore.sortSelection` turns that into the minimum set of `order` rewrites.
public enum SortMath {
public enum Direction: Sendable, Equatable {
case up
case down
}
/// The lane's ids after one press, or `nil` for a no-op.
///
/// Two behaviours, and which one fires depends only on whether the selection is already
/// contiguous:
///
/// - **Non-contiguous gathers, and only gathers.** "A non-contiguous multi-selection gathers on
/// the first press: the cards collect into a contiguous block anchored at the first selected
/// card (first = lowest logical order; the rest follow in preserved relative order), and
/// subsequent presses move the block one position." The gather is therefore direction-blind —
/// the press that gathers does not also step, which is what makes the second press's meaning
/// unambiguous.
/// - **Contiguous steps one position**, hopping the single unselected sibling above (or below)
/// the block, so the block travels as a unit. At the ladder's end there is nothing to hop, and
/// the answer is `nil`.
///
/// `nil` rather than "the input unchanged" so the menu item's `disabled` state and the store's
/// write path read the *same* answer — `LaneWidthCommands`' rule, and for its reason.
///
/// Ids in `selected` that are not in `ordered` are ignored: a selection the next reload will
/// drop must not decide what a press does now.
public static func reordered(
_ ordered: [ItemID],
moving selected: Set<ItemID>,
_ direction: Direction
) -> [ItemID]? {
let doomed = ordered.indices.filter { selected.contains(ordered[$0]) }
guard let first = doomed.first, let last = doomed.last else { return nil }
let block = doomed.map { ordered[$0] }
// Contiguity is a property of the positions, not of the count: N members spanning exactly N
// slots is the block that steps; anything wider gathers first.
guard doomed.count == last - first + 1 else {
var others = ordered.filter { !selected.contains($0) }
// Everything before the first selected card is unselected by definition, so the block's
// landing index among the survivors *is* that first index — "anchored at the first
// selected card".
others.insert(contentsOf: block, at: first)
return others
}
switch direction {
case .up:
guard first > 0 else { return nil }
return Array(ordered[..<(first - 1)]) + block + [ordered[first - 1]] + Array(ordered[(last + 1)...])
case .down:
guard last + 1 < ordered.count else { return nil }
return Array(ordered[..<first]) + [ordered[last + 1]] + block + Array(ordered[(last + 2)...])
}
}
}