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
+334 -7
View File
@@ -23,8 +23,11 @@ import SwiftUI
/// order.
/// - **The rubber band** a drag from any empty surface sweeps a selection (`MarqueeSession`,
/// `MarqueeMath`); the strip owns the session and the target registry, and hands both down.
/// - **The keyboard's narrow slice** Return's create/rename dispatch, Escape's step outward, and
/// Select All.
/// - **The board's fixed grammar keys** (11-command-nexus.md Fixed grammar keys) the four
/// arrows and their / modes, Return's create/rename dispatch, 's tombstone, Escape's step
/// outward, and Select All. The chorded commands are the menu's (`BoardCommands`); everything
/// here is a plain key or a grammar modifier, which is exactly the split 04-interactions.md
/// Configurable bindings draws between what remaps and what does not.
///
/// - **The trash quasi-lane** trailing, one fixed unit, joining and leaving the width division as
/// View Show Trash toggles it (`TrashLaneView`, 03-board-ui.md § Trash).
@@ -155,9 +158,16 @@ struct BoardView: View {
// after every rename and Return silently stops working.
if !editing { isBoardFocused = true }
}
.onKeyPress(.return) { handleReturn() }
.onKeyPress(keys: [.return], phases: .down) { handleReturn($0) }
.onKeyPress(.escape) { handleEscape() }
.onKeyPress(keys: [.delete], phases: .down) { handleDelete($0) }
// **The arrows** (04-interactions.md Grammar), on `.down` *and* `.repeat`: holding an
// arrow must walk the board, and a handler registered for `.down` alone sees the first
// press only.
.onKeyPress(
keys: [.upArrow, .downArrow, .leftArrow, .rightArrow],
phases: [.down, .repeat]
) { handleArrow($0) }
// **Select All** (04-interactions.md The map). Edit Select All is the standard menu
// item and it dispatches `selectAll:` down the responder chain, so the board answers it as a
// responder rather than growing a second menu item with the same title which titles-are-API
@@ -470,13 +480,17 @@ struct BoardView: View {
/// everything else is ignored a multi-card selection is explicitly inert, and a lane's rename
/// path is Board Rename precisely because Return on a lane creates.
///
/// The full keyboard map arrows, -jumps, the escalation, , the moves is **m5's
/// keyboard-grammar card**. This is the creation/rename pair and nothing else.
///
/// Inert while an inline editor is open: "all grammar keys inert while a title editor is
/// focused". The field consumes Return itself, so this guard is belt over braces but the belt
/// matters, because a stray Return reaching here mid-edit would open a *second* editor.
private func handleReturn() -> KeyPress.Result {
private func handleReturn(_ press: KeyPress) -> KeyPress.Result {
// **Plain Return only**, the delete handler's rule for its reason. belongs to Board
// Open Card and AppKit routes it to the menu first but only while that item is *enabled*,
// and a disabled one lets the chord fall through to here. and are nobody's key
// equivalent at all. Neither may open a rename or a placeholder.
guard press.modifiers.intersection([.command, .option, .control, .shift]).isEmpty else {
return .ignored
}
guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
let selection = store.selection
guard selection.liveness == .live,
@@ -538,6 +552,319 @@ struct BoardView: View {
store.clearSelection()
return .handled
}
// MARK: - The arrows
/// What modifier an arrow carried, reduced to the three meanings the grammar gives it the
/// keyboard's `ClickModifier`.
private enum ArrowMode {
/// Plain: spatial navigation, replacing the selection.
case step
/// : extend the range from the anchor.
case extend
/// : jump to an end (04-interactions.md Grammar's "-arrows jump").
case jump
}
/// **The arrow grammar's one door** (04-interactions.md Grammar; 11-command-nexus.md Fixed
/// grammar keys).
///
/// The handlers below are deliberately thin over pure functions `NavigationMath` for the
/// geometry, `SelectionGrammar` for the order lists and the ranges so what is written here is
/// dispatch and nothing else.
///
/// **- and -arrows never mean anything here.** They are menu key equivalents (Move Left/Right,
/// Move Up/Down, the lane width pair) and AppKit routes them to the menu before any view sees
/// them but only while the item is *enabled*, so a disabled Move Right does deliver here.
/// Rejecting every combination but plain, and is what keeps a disabled command from silently
/// becoming a navigation gesture, and a mistyped text chord from moving the selection.
private func handleArrow(_ press: KeyPress) -> KeyPress.Result {
// "All grammar keys inert while a title editor is focused" and the field owns the arrows
// as caret movement, so this guard is load-bearing rather than belt over braces.
guard !store.isEditingInline else { return .ignored }
guard let direction = Self.direction(of: press.key) else { return .ignored }
// Only the four meaningful flags are read: an arrow event also carries `.function` and
// `.numericPad` on macOS, and testing the whole set for emptiness would reject every press.
let modifiers = press.modifiers.intersection([.command, .control, .option, .shift])
let mode: ArrowMode
if modifiers.isEmpty {
mode = .step
} else if modifiers == .shift {
mode = .extend
} else if modifiers == .option {
mode = .jump
} else {
return .ignored
}
guard let origin = arrowOrigin() else { return seed(direction, mode) }
return origin.isLaneDomain
? laneArrow(direction, mode, from: origin.head)
: cardArrow(direction, mode, from: origin.head, on: origin.side)
}
private static func direction(of key: KeyEquivalent) -> NavigationMath.Direction? {
switch key.character {
case KeyEquivalent.upArrow.character: .up
case KeyEquivalent.downArrow.character: .down
case KeyEquivalent.leftArrow.character: .left
case KeyEquivalent.rightArrow.character: .right
default: nil
}
}
/// Where the next arrow steps from, and on which of the board's two levels `nil` when the
/// selection names nothing to step from, which is the seed rule's cue.
///
/// The head is `TransientBoardState.selectionHead` when it is still in the order list, and
/// otherwise the selection's **last member in that list** the same "last in flatten order"
/// anchor the N target rule and paste already share. That fallback is what makes a marquee, a
/// Select All and a foreign reload leave the arrows somewhere sensible without any of them
/// having to name a cursor.
///
/// The **trash's list is both kinds interleaved** (`TrashModel.entries`), because "arrows walk
/// every trash entry in its sorted order card and lane entries alike" (04 The trash). The
/// per-kind lists are the *range*'s business, not the walk's.
private func arrowOrigin() -> (head: ItemID, side: Liveness, isLaneDomain: Bool)? {
let selection = store.selection
guard !selection.isEmpty else { return nil }
let isLaneDomain: Bool
let list: [ItemID]
switch selection.liveness {
case .live:
guard let kind = SelectionGrammar.kind(of: selection, in: store.snapshot) else { return nil }
isLaneDomain = kind == .lane
list = SelectionGrammar.order(of: kind, on: .live, in: store.snapshot)
case .trashed:
isLaneDomain = false
list = TrashModel.entries(of: store.snapshot).map(\.id)
}
if let head = store.transient.selectionHead, list.contains(head) {
return (head, selection.liveness, isLaneDomain)
}
guard let last = list.last(where: { selection.ids.contains($0) }) else { return nil }
return (last, selection.liveness, isLaneDomain)
}
/// **An empty selection seeds at the first lane's first card** (04-interactions.md Grammar)
/// a deterministic origin, so an arrow from nothing always means the same thing.
///
/// / are the exception, and the design states it: "the -jumps behave as specified
/// regardless". Those two name an *absolute* destination and need no origin, so they run
/// unchanged. / are relative to "the current lane", which an empty selection has none of, so
/// they seed like a plain arrow which is exactly what makes "two presses from nothing reach
/// the lane domain" true: the first seeds, the second escalates.
private func seed(_ direction: NavigationMath.Direction, _ mode: ArrowMode) -> KeyPress.Result {
if mode == .jump, direction == .left || direction == .right {
return jumpToEndLane(direction)
}
guard let first = Self.firstCard(scanning: liveLanes) else { return .handled }
replaceSelection(with: first, on: .live)
return .handled
}
// MARK: Card domain
private func cardArrow(
_ direction: NavigationMath.Direction,
_ mode: ArrowMode,
from head: ItemID,
on side: Liveness
) -> KeyPress.Result {
switch mode {
case .step: step(direction, from: head)
case .extend: extend(direction, from: head)
case .jump:
switch direction {
case .left, .right: jumpToEndLane(direction)
case .up, .down: jumpWithinContainer(direction, from: head, on: side)
}
}
}
/// **Nearest card in the direction, across interior grid columns and lanes** and across the
/// live/trash boundary too, since "plain arrows still walk across" (04 The trash).
///
/// Every registered target is a candidate, which is also how the hidden trash stays invisible:
/// a column that is not drawn registers nothing.
private func step(_ direction: NavigationMath.Direction, from head: ItemID) -> KeyPress.Result {
guard let origin = marqueeTargets.targets[head],
let nextID = NavigationMath.nearest(
from: origin.frame,
direction: direction,
among: marqueeTargets.all
),
let next = marqueeTargets.targets[nextID]
else { return .handled }
replaceSelection(with: next.id, on: next.side)
return .handled
}
/// **-arrow extends, and stops at both boundaries** (04 The trash, settled): "a -arrow whose
/// next step would cross from live cards into the trash (or back), or from card entries onto a
/// lane entry within it, is simply inert".
///
/// The *step* that would cross is what goes inert the crossing item is never stepped over in
/// search of a legal one, because that would silently drop the held range for a longer reach
/// than the user asked for. So the nearest neighbour is computed **unrestricted** and then
/// tested: a different side or a different kind means this press does nothing at all.
private func extend(_ direction: NavigationMath.Direction, from head: ItemID) -> KeyPress.Result {
guard let origin = marqueeTargets.targets[head],
let nextID = NavigationMath.nearest(
from: origin.frame,
direction: direction,
among: marqueeTargets.all
),
let next = marqueeTargets.targets[nextID],
next.side == origin.side,
next.kind == origin.kind
else { return .handled }
// An extension with no anchor makes one of where it started the keyboard's equivalent of
// a -click after a marquee, which the grammar degrades to a plain click for the same
// reason: a range needs an origin, and the only honest one is the cursor's own position.
let anchor = store.transient.selectionAnchor ?? head
guard let ids = SelectionGrammar.range(
from: anchor,
to: next.id,
kind: next.kind,
on: next.side,
in: store.snapshot
) else { return .handled }
store.select(ids, liveness: next.side, anchor: anchor, head: next.id)
return .handled
}
/// **/ jump to the current container's first/last card** the lane's, or the trash
/// quasi-lane's when that is where the cursor is.
///
/// ** escalates into the lane domain** (04 Grammar, settled "the keyboard's one entry to
/// lane selection"): with the lane's first card already the sole selection, the next selects
/// the *lane* itself. The trash deliberately never escalates: it "is never selectable as a lane",
/// so a second there is simply inert.
private func jumpWithinContainer(
_ direction: NavigationMath.Direction,
from head: ItemID,
on side: Liveness
) -> KeyPress.Result {
let container: [ItemID]
var lane: ItemID?
switch side {
case .trashed:
container = TrashModel.entries(of: store.snapshot).map(\.id)
case .live:
guard let home = store.snapshot.lanes.first(where: { lane in
!lane.isDeleted && lane.cards.contains { $0.id == head && !$0.isDeleted }
}) else { return .handled }
lane = home.id
container = home.cards.filter { !$0.isDeleted }.map(\.id)
}
guard let target = direction == .up ? container.first : container.last else { return .handled }
if direction == .up, target == head, let lane, store.selection.ids == [head] {
replaceSelection(with: lane, on: .live)
return .handled
}
replaceSelection(with: target, on: side)
return .handled
}
/// **/ to the first/last lane** (04 Grammar) landing, in the card domain, on that lane's
/// first card, since is the one keyboard entry to lane selection.
///
/// ** reaches the shown trash** first (04 The trash: "the shown trash is the last container
/// for card navigation, and jumps to it"); an empty or hidden column is not a destination, so
/// the jump falls through to the last lane. Empty lanes are scanned past in both directions
/// a jump that landed nowhere because the end lane happens to be empty would be a dead key.
private func jumpToEndLane(_ direction: NavigationMath.Direction) -> KeyPress.Result {
if direction == .right, isTrashVisible, let first = TrashModel.entries(of: store.snapshot).first {
replaceSelection(with: first.id, on: .trashed)
return .handled
}
let lanes = liveLanes
let target = direction == .right
? Self.firstCard(scanning: lanes.reversed())
: Self.firstCard(scanning: lanes)
guard let target else { return .handled }
replaceSelection(with: target, on: .live)
return .handled
}
// MARK: Lane domain
/// The arrows with a **lane** selected (04-interactions.md Grammar, The map).
///
/// - / move the lane selection one lane, inert at the ends **and the trash is never reached**
/// ("with a lane selected, / and stop at the last real lane"), which falls out for free
/// from walking the live lane order and nothing else.
/// - / extend that selection from the anchor, the same range a -click would give.
/// - descends back into the lane's cards at the first card, at the last; an empty lane has
/// nothing to descend into.
/// - and are inert: the lane domain is the top of the hierarchy.
/// - / jump to the first/last lane, staying in the lane domain.
private func laneArrow(
_ direction: NavigationMath.Direction,
_ mode: ArrowMode,
from head: ItemID
) -> KeyPress.Result {
let lanes = SelectionGrammar.liveLanes(in: store.snapshot)
guard let index = lanes.firstIndex(of: head) else { return .handled }
switch (direction, mode) {
case (.left, .step), (.right, .step), (.left, .extend), (.right, .extend):
let next = index + (direction == .left ? -1 : 1)
guard lanes.indices.contains(next) else { return .handled }
if mode == .step {
replaceSelection(with: lanes[next], on: .live)
} else {
let anchor = store.transient.selectionAnchor ?? head
guard let ids = SelectionGrammar.range(
from: anchor,
to: lanes[next],
kind: .lane,
on: .live,
in: store.snapshot
) else { return .handled }
store.select(ids, liveness: .live, anchor: anchor, head: lanes[next])
}
case (.left, .jump), (.right, .jump):
guard let target = direction == .left ? lanes.first : lanes.last else { return .handled }
replaceSelection(with: target, on: .live)
case (.down, .step), (.down, .jump):
guard let lane = store.snapshot.lanes.first(where: { $0.id == head && !$0.isDeleted }) else {
return .handled
}
let cards = lane.cards.filter { !$0.isDeleted }
guard let target = mode == .jump ? cards.last : cards.first else { return .handled }
replaceSelection(with: target.id, on: .live)
case (.up, _), (.down, .extend):
// Nothing above the lane domain, and no vertical range within it.
break
}
return .handled
}
// MARK: Shared
/// A jump's and a plain step's shared landing: one item, both cursors on it.
private func replaceSelection(with id: ItemID, on side: Liveness) {
store.select([id], liveness: side, anchor: id, head: id)
}
/// The first rendered card of the first lane that has one the scan every "first/last lane"
/// destination shares, run over the lane order forwards or reversed.
private static func firstCard(scanning lanes: some Sequence<Lane>) -> ItemID? {
for lane in lanes {
if let card = lane.cards.first(where: { !$0.isDeleted }) { return card.id }
}
return nil
}
}
// MARK: - Resize shadow