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, 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, 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, _ 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[..