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. /// /// 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 } } // 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[..