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
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
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)...])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user