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:
2026-07-27 19:32:30 -04:00
parent 2e229735b1
commit 4035ba7986
13 changed files with 1582 additions and 75 deletions
+98 -14
View File
@@ -64,24 +64,31 @@ public enum ClickModifier: Sendable, Equatable {
/// trash row) share one answer instead of four near-copies of it.
public enum SelectionGrammar {
/// What a click leaves behind: the new selection, and the anchor a subsequent -click would
/// range from.
/// What a click leaves behind: the new selection, the anchor a subsequent -click would range
/// from, and the head a subsequent arrow would step from.
///
/// The anchor is carried *out* rather than mutated in place because it is not derivable from the
/// selection a -range replaces the whole set and deliberately leaves the anchor where it was,
/// so "the last plain or click" is a memory of a gesture and only the gesture can update it
/// (`TransientBoardState.selectionAnchor`).
///
/// **The head is always the clicked item**, in every branch below the -branch included, where
/// the anchor deliberately stays put. That asymmetry is the definition of the two:
/// `TransientBoardState.selectionHead` is where the *next* step starts, and a -click moves it
/// exactly as a plain one does.
public struct Outcome: Sendable, Equatable {
public var selection: ItemReferenceSet
public var anchor: ItemID?
public var head: ItemID?
public init(selection: ItemReferenceSet, anchor: ItemID?) {
public init(selection: ItemReferenceSet, anchor: ItemID?, head: ItemID? = nil) {
self.selection = selection
self.anchor = anchor
self.head = head
}
/// Nothing selected and nothing to range from the toggle-off outcomes.
static let cleared = Outcome(selection: .empty, anchor: nil)
/// Nothing selected, nothing to range from, nowhere to step from the toggle-off outcomes.
static let cleared = Outcome(selection: .empty, anchor: nil, head: nil)
}
/// The grammar, one call.
@@ -132,7 +139,8 @@ public enum SelectionGrammar {
}
return Outcome(
selection: ItemReferenceSet(ids: [target.id], liveness: target.side),
anchor: target.id
anchor: target.id,
head: target.id
)
}
@@ -165,7 +173,8 @@ public enum SelectionGrammar {
}
return Outcome(
selection: ItemReferenceSet(ids: ids, liveness: target.side),
anchor: target.id
anchor: target.id,
head: target.id
)
}
@@ -184,20 +193,43 @@ public enum SelectionGrammar {
anchor: ItemID?,
snapshot: BoardModel
) -> Outcome {
let list = order(of: target.kind, on: target.side, in: snapshot)
guard let anchor,
let from = list.firstIndex(of: anchor),
let to = list.firstIndex(of: target.id)
let span = range(from: anchor, to: target.id, kind: target.kind, on: target.side, in: snapshot)
else {
return plain(target, selection: selection, togglesOnRepeat: false)
}
let span = from <= to ? list[from...to] : list[to...from]
return Outcome(
selection: ItemReferenceSet(ids: Set(span), liveness: target.side),
anchor: anchor
selection: ItemReferenceSet(ids: span, liveness: target.side),
anchor: anchor,
head: target.id
)
}
/// The ids between two items in one order list, inclusive **the span both extension gestures
/// select**, -click and -arrow alike.
///
/// It is a function rather than a branch inside `shift` because the keyboard needs the identical
/// answer: "a -arrow extends" (04-interactions.md Grammar) means exactly the range a -click
/// to the same item would produce, and two implementations of one span is two chances for the
/// pointer and the keyboard to disagree about what a range is.
///
/// **`nil` means the two do not share a list**, which folds the vanished endpoint, the nil
/// anchor's caller-side absence, and every axis crossing into one test a list is exactly one
/// (side, kind) pair. The callers differ on what they do with that: a click degrades to a plain
/// click (it names an unambiguous target), while a -arrow goes inert (its next step is
/// ambiguous).
public static func range(
from: ItemID,
to: ItemID,
kind: SelectionKind,
on side: Liveness,
in snapshot: BoardModel
) -> Set<ItemID>? {
let list = order(of: kind, on: side, in: snapshot)
guard let start = list.firstIndex(of: from), let end = list.firstIndex(of: to) else { return nil }
return Set(start <= end ? list[start...end] : list[end...start])
}
// MARK: - The order lists
/// The list a -range walks for one (side, kind) pair **the single place a "what's on the
@@ -284,6 +316,55 @@ public enum SelectionGrammar {
}
return nil
}
// MARK: - Successor on delete
/// What selects after tombstoning `ids` 04-interactions.md The map's Finder-style
/// successor sibling, as a pure function of the **pre-write** snapshot.
///
/// > Selection moves to the deleted item's successor sibling, Finder-style (next card in the
/// > lane, next lane on the board; the last sibling's predecessor otherwise; empty container =
/// > nothing selected) repeated walks down a lane.
///
/// Three decisions the wording implies and this states:
///
/// - **The container is the *last* deleted item's**, in flatten order the same "last member"
/// the N target rule and paste anchoring already share. A selection spanning lanes therefore
/// lands in the rightmost/bottom-most one, which is where the user was working.
/// - **The survivor search is forward first, then backward**: the first surviving sibling *after*
/// the last deleted position, else the last surviving sibling *before* the first deleted one.
/// Forward is what makes repeated walk down a lane rather than bouncing.
/// - **`nil` is a legitimate answer** an emptied container selects nothing, and the caller
/// clears.
///
/// **Deliberate deletes only.** External vanishing never picks a successor (02-architecture.md's
/// reload-survival rule: "the selection just shrinks"), which is why this is called by
/// `BoardStore.delete` and by nothing on the reload path.
public static func successor(afterDeleting ids: Set<ItemID>, in snapshot: BoardModel) -> ItemID? {
guard !ids.isEmpty else { return nil }
let selection = ItemReferenceSet(ids: ids, liveness: .live)
guard let kind = kind(of: selection, in: snapshot) else { return nil }
let container: [ItemID]
switch kind {
case .lane:
container = liveLanes(in: snapshot)
case .card:
// The last selected card in flatten order names the lane; its lane's rendered cards are
// the container the successor is drawn from.
guard let last = liveCards(in: snapshot).last(where: { ids.contains($0) }),
let lane = snapshot.lanes.first(where: { lane in
!lane.isDeleted && lane.cards.contains { $0.id == last && !$0.isDeleted }
})
else { return nil }
container = lane.cards.filter { !$0.isDeleted }.map(\.id)
}
let doomed = container.indices.filter { ids.contains(container[$0]) }
guard let first = doomed.first, let last = doomed.last else { return nil }
if let after = container[(last + 1)...].first(where: { !ids.contains($0) }) { return after }
return container[..<first].last { !ids.contains($0) }
}
}
// MARK: - The rubber band
@@ -344,7 +425,10 @@ public enum MarqueeMath {
///
/// Total rather than merely correct-for-a-column: two rows sharing a top edge must still order
/// the same way twice, or the topmost-kind rule would pick differently on identical input.
private static func isAbove(_ lhs: MarqueeTarget, _ rhs: MarqueeTarget) -> Bool {
///
/// Shared with `NavigationMath`, which breaks its score ties with it for the same reason: two
/// candidates that a metric cannot separate must still be separated the same way twice.
static func isAbove(_ lhs: MarqueeTarget, _ rhs: MarqueeTarget) -> Bool {
if lhs.frame.minY != rhs.frame.minY { return lhs.frame.minY < rhs.frame.minY }
if lhs.frame.minX != rhs.frame.minX { return lhs.frame.minX < rhs.frame.minX }
return lhs.id.rawValue < rhs.id.rawValue