Files
lanework/Kanban/UI/Board/NavigationMath.swift
rzen 4035ba7986 Implement the keyboard grammar and full command map
The board's fixed grammar keys and the menu-backed chords of
04-interactions.md § Keyboard, per the Command Nexus inventory:

- Spatial arrow navigation (NavigationMath.nearest over the marquee
  registry's frames — one geometry source), walking across interior
  masonry columns, lanes, and into the shown trash; ⇧-arrows extend via
  the same range function as ⇧-click and go inert at the liveness and
  kind boundaries; ⌥-jumps with the ⌥↑ lane-domain escalation and ↓
  descent; the empty selection seeds at the first lane's first card;
  selection scrolls into view.
- selectionHead — the navigation cursor beside the anchor, set by every
  click, moved by every arrow, dropped by the reload vanish rule.
- Board ▸ Open Card ⌘↩ (the one command enabled mid-edit: commits the
  placeholder or rename and opens), Move Up/Move Down ⌥⌘↑/⌥⌘↓
  (within-lane sort, gather-then-step, rank-permuting writes in one
  bracket), Move Left/Move Right ⌘←/⌘→ (sole lane, one slot, never the
  trash) — all validating and acting off one shared answer.
- Delete now selects the Finder-style successor sibling from the
  pre-write snapshot, so repeated ⌫ walks down a lane; external
  vanishing still only shrinks the selection.
- handleReturn rejects modified Returns; the trash column renders
  eagerly so every row stays registered for navigation and the marquee.

686 unit tests (27 new).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-27 19:32:30 -04:00

157 lines
7.5 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.
///
/// 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<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)...])
}
}
}