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
+536
View File
@@ -0,0 +1,536 @@
import CoreGraphics
import Foundation
import Testing
@testable import Kanban
/// The keyboard grammar's pure halves 04-interactions.md Grammar's spatial navigation
/// (`NavigationMath`), The map's within-lane sort (`SortMath`) and its successor-on-delete rule
/// (`SelectionGrammar.successor`), plus the navigation head the arrows step from
/// (`TransientBoardState.selectionHead`).
///
/// The arrow *handlers* are deliberately absent: they are dispatch over these functions and a
/// registry of drawn frames, so everything with a rule in it is here and the views hold nothing that
/// could be asserted without a window.
///
/// The board-level suites drive a **real `BoardStore` over a real temp tree** and read the result
/// back off disk, the write suites' rule a sort is only correct if the bytes say so.
/// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`.
// MARK: - Identities
/// Two more card identities than `Ident` offers: the successor rule needs a *second* multi-card lane
/// to prove it reads the last selected member's lane rather than the first's.
private enum More {
static let card5 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
static let card6 = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
}
private let lane1 = ItemID(rawValue: Ident.lane1)
private let lane2 = ItemID(rawValue: Ident.lane2)
private let lane3 = ItemID(rawValue: Ident.lane3)
private let card1 = ItemID(rawValue: Ident.card1)
private let card2 = ItemID(rawValue: Ident.card2)
private let card3 = ItemID(rawValue: Ident.card3)
private let card4 = ItemID(rawValue: Ident.card4)
private let card5 = ItemID(rawValue: More.card5)
private let card6 = ItemID(rawValue: More.card6)
/// Four cards in one lane, two in the next, and an empty third the shapes every rule below needs:
/// a block with room on both sides, a second container to be redirected into, and a lane that
/// contributes nothing to card navigation.
@MainActor
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third"))
try fixture.item("\(Ident.lane1)/\(Ident.card4)", Item.rich(order: "4096", title: "Fourth"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane2)/\(More.card5)", Item.rich(order: "1024", title: "Fifth"))
try fixture.item("\(Ident.lane2)/\(More.card6)", Item.rich(order: "2048", title: "Sixth"))
try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done"))
return fixture
}
private func load(_ fixture: WriterFixture) throws -> BoardModel {
try BoardLoader.load(boardRoot: fixture.root).model
}
/// The ids a lane renders, top to bottom, as they are **on disk right now**.
private func cardOrder(_ laneID: String, in fixture: WriterFixture) throws -> [ItemID] {
let model = try load(fixture)
let lane = try #require(model.lanes.first { $0.id.rawValue == laneID })
return lane.cards.filter { !$0.isDeleted }.map(\.id)
}
// MARK: - Frames
/// A drawn frame, with the two axes the score reads spelled out at the call site.
private func target(
_ id: ItemID,
x: CGFloat,
y: CGFloat,
width: CGFloat = 100,
height: CGFloat = 100,
kind: SelectionKind = .card,
side: Liveness = .live
) -> MarqueeTarget {
MarqueeTarget(id: id, kind: kind, side: side, frame: CGRect(x: x, y: y, width: width, height: height))
}
/// A two-by-two grid: `card1` `card3` on the top row, `card2` `card4` beneath them the smallest
/// board shape with an interior column *and* a lane boundary to cross.
private let grid: [MarqueeTarget] = [
target(card1, x: 0, y: 0),
target(card2, x: 0, y: 120),
target(card3, x: 120, y: 0),
target(card4, x: 120, y: 120)
]
private let originFrame = CGRect(x: 0, y: 0, width: 100, height: 100)
// MARK: - NavigationMath
@Suite("NavigationMath ▸ nearest in the direction")
struct NavigationMathTests {
@Test("Each direction picks its own neighbour")
func fourDirections() {
#expect(NavigationMath.nearest(from: grid[0].frame, direction: .down, among: grid) == card2)
#expect(NavigationMath.nearest(from: grid[1].frame, direction: .up, among: grid) == card1)
#expect(NavigationMath.nearest(from: grid[0].frame, direction: .right, among: grid) == card3)
#expect(NavigationMath.nearest(from: grid[2].frame, direction: .left, among: grid) == card1)
}
@Test("A card straight ahead beats a nearer one off to the side — orthogonal drift costs double")
func orthogonalDriftIsPenalised() {
// Straight down at 100pt of primary distance (score 100) versus 40pt down but 200pt across
// (score 40 + 400). Without the penalty the second would win and would wander out of the
// column instead of walking it (04-interactions.md Grammar).
let straight = target(card2, x: 0, y: 100)
let sideways = target(card3, x: 400, y: 40)
#expect(NavigationMath.nearest(from: originFrame, direction: .down, among: [straight, sideways]) == card2)
#expect(NavigationMath.nearest(from: originFrame, direction: .down, among: [sideways, straight]) == card2)
}
@Test("A tie is broken by position, then identity — and the input order never decides")
func tiesAreDeterministic() {
// Both score 50 + 2 × 50: same primary distance, same drift, opposite sides.
let right = target(card2, x: 50, y: 50)
let left = target(card3, x: -50, y: 50)
#expect(NavigationMath.nearest(from: originFrame, direction: .down, among: [right, left]) == card3)
#expect(
NavigationMath.nearest(from: originFrame, direction: .down, among: [left, right]) == card3,
"reversing the candidate list must not change the answer"
)
// Same frame twice: position cannot separate them, so identity does.
let low = target(ItemID(rawValue: "aaaa"), x: 0, y: 200)
let high = target(ItemID(rawValue: "zzzz"), x: 0, y: 200)
#expect(NavigationMath.nearest(from: originFrame, direction: .down, among: [high, low])?.rawValue == "aaaa")
#expect(NavigationMath.nearest(from: originFrame, direction: .down, among: [low, high])?.rawValue == "aaaa")
}
@Test("Nothing beyond the origin in that direction is nil, and the origin never picks itself")
func noCandidate() {
#expect(NavigationMath.nearest(from: grid[0].frame, direction: .up, among: grid) == nil)
#expect(NavigationMath.nearest(from: grid[0].frame, direction: .left, among: grid) == nil)
#expect(NavigationMath.nearest(from: originFrame, direction: .down, among: []) == nil)
// A candidate level with the origin is not beyond it: the 1pt threshold excludes the origin
// itself and its exact row-mates.
#expect(NavigationMath.nearest(from: originFrame, direction: .down, among: [target(card2, x: 300, y: 0)]) == nil)
}
@Test("The predicate is the ⇧-arrow's restriction — the trash side is simply not a candidate")
func predicateRestrictsCandidates() {
let trashed = target(card2, x: 0, y: 100, side: .trashed)
let live = target(card3, x: 0, y: 400)
let all = [trashed, live]
#expect(
NavigationMath.nearest(from: originFrame, direction: .down, among: all) == card2,
"a plain arrow walks across the boundary"
)
#expect(
NavigationMath.nearest(from: originFrame, direction: .down, among: all, where: { $0.side == .live }) == card3
)
}
}
// MARK: - SortMath
@Suite("SortMath ▸ within-lane sort")
struct SortMathTests {
private let ordered = [card1, card2, card3, card4]
@Test("A contiguous block steps one position, hopping its neighbour")
func stepsOnePosition() {
#expect(SortMath.reordered(ordered, moving: [card3], .up) == [card1, card3, card2, card4])
#expect(SortMath.reordered(ordered, moving: [card2], .down) == [card1, card3, card2, card4])
#expect(SortMath.reordered(ordered, moving: [card2, card3], .up) == [card2, card3, card1, card4])
#expect(SortMath.reordered(ordered, moving: [card2, card3], .down) == [card1, card4, card2, card3])
}
@Test("A non-contiguous selection gathers behind its first card, relative order preserved")
func gathersOnTheFirstPress() {
// "Anchored at the first selected card (first = lowest logical order; the rest follow in
// preserved relative order)" and the press that gathers does not also step, which is why
// both directions give the same answer.
#expect(SortMath.reordered(ordered, moving: [card2, card4], .up) == [card1, card2, card4, card3])
#expect(SortMath.reordered(ordered, moving: [card2, card4], .down) == [card1, card2, card4, card3])
#expect(SortMath.reordered(ordered, moving: [card1, card3], .up) == [card1, card3, card2, card4])
#expect(
SortMath.reordered(ordered, moving: [card1, card4], .down) == [card1, card4, card2, card3],
"the unselected cards keep their relative order around the block"
)
}
@Test("At the ladder's end, and with nothing to move, the answer is nil rather than a no-op write")
func edgesAndEmptyAreNil() {
#expect(SortMath.reordered(ordered, moving: [card1], .up) == nil)
#expect(SortMath.reordered(ordered, moving: [card4], .down) == nil)
#expect(SortMath.reordered(ordered, moving: [card1, card2], .up) == nil)
#expect(SortMath.reordered(ordered, moving: Set(ordered), .up) == nil)
#expect(SortMath.reordered(ordered, moving: Set(ordered), .down) == nil)
#expect(SortMath.reordered(ordered, moving: [], .up) == nil)
#expect(SortMath.reordered([card1], moving: [card1], .down) == nil, "a lane of one has nowhere to go")
#expect(
SortMath.reordered(ordered, moving: [card5], .up) == nil,
"ids the lane does not render are ignored, so a stale selection moves nothing"
)
}
}
// MARK: - The successor rule
@MainActor
@Suite("SelectionGrammar ▸ successor on delete")
struct SuccessorTests {
@Test("The next card in the lane, so repeated ⌫ walks down it")
func nextCardInTheLane() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
#expect(SelectionGrammar.successor(afterDeleting: [card2], in: snapshot) == card3)
#expect(SelectionGrammar.successor(afterDeleting: [card1], in: snapshot) == card2)
#expect(
SelectionGrammar.successor(afterDeleting: [card1, card2], in: snapshot) == card3,
"a block's successor is the first survivor after its last member"
)
}
@Test("The last sibling falls back to its predecessor")
func predecessorFallback() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
#expect(SelectionGrammar.successor(afterDeleting: [card4], in: snapshot) == card3)
#expect(SelectionGrammar.successor(afterDeleting: [card3, card4], in: snapshot) == card2)
}
@Test("A survivor between the members is found forwards first")
func forwardSearchWinsOverBackward() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
// Doomed at positions 0 and 2: forward from the last one finds card4, which is what makes
// repeated keep moving down rather than bouncing back up the lane.
#expect(SelectionGrammar.successor(afterDeleting: [card1, card3], in: snapshot) == card4)
}
@Test("An emptied container selects nothing")
func emptiedContainerIsNil() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
#expect(SelectionGrammar.successor(afterDeleting: [card1, card2, card3, card4], in: snapshot) == nil)
#expect(SelectionGrammar.successor(afterDeleting: [], in: snapshot) == nil)
#expect(
SelectionGrammar.successor(afterDeleting: [ItemID(rawValue: "nobody")], in: snapshot) == nil,
"ids naming nothing name no container either"
)
}
@Test("A cross-lane selection is answered in its last member's lane, in flatten order")
func crossLaneUsesTheLastMembersLane() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
// card5 is later than card2 in flatten order (lane `order`, then card `order`), so the
// container is lane2 the same "last member" anchor N and paste already share.
#expect(SelectionGrammar.successor(afterDeleting: [card2, card5], in: snapshot) == card6)
}
@Test("Lanes follow the same rule in the live lane order")
func laneSuccessors() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
#expect(SelectionGrammar.successor(afterDeleting: [lane1], in: snapshot) == lane2)
#expect(SelectionGrammar.successor(afterDeleting: [lane3], in: snapshot) == lane2, "the last lane's predecessor")
#expect(SelectionGrammar.successor(afterDeleting: [lane1, lane2, lane3], in: snapshot) == nil)
}
@Test("⌫ selects the successor immediately, before the reload echoes the tombstone back")
func deleteSelectsTheSuccessor() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card2], liveness: .live)
store.deleteSelection()
#expect(store.selection.ids == [card3])
#expect(store.transient.selectionAnchor == card3, "the successor is a legitimate range origin")
#expect(store.transient.selectionHead == card3, "and the place the next arrow steps from")
// Repeated walks down the lane the whole point of the rule. The store's snapshot has not
// reloaded, so card2 is still in it and card3's successor is card4.
store.deleteSelection()
#expect(store.selection.ids == [card4])
}
@Test("An emptied lane clears the selection instead of inventing one")
func deleteClearsWhenNothingSurvives() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card5, card6], liveness: .live)
store.deleteSelection()
#expect(store.selection.isEmpty)
#expect(store.transient.selectionHead == nil)
}
}
// MARK: - The navigation head
@MainActor
@Suite("TransientBoardState ▸ the navigation head")
struct SelectionHeadTests {
@Test("A sole member is its own head; any other count leaves none")
func headDefaults() throws {
let state = TransientBoardState()
state.select([card1], liveness: .live)
#expect(state.selectionHead == card1)
state.select([card1, card2], liveness: .live)
#expect(state.selectionHead == nil, "a set with no gesture behind it names no cursor")
state.select([card1, card2], liveness: .live, anchor: card1, head: card2)
#expect(state.selectionAnchor == card1)
#expect(state.selectionHead == card2, "an explicit head is kept whatever the count")
state.clearSelection()
#expect(state.selectionHead == nil)
#expect(state.selectionAnchor == nil)
}
@Test("A ⇧-gesture moves the head and leaves the anchor — that asymmetry is why both exist")
func shiftMovesOnlyTheHead() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try load(fixture)
let outcome = SelectionGrammar.click(
SelectionTarget(id: card3, kind: .card, side: .live),
modifier: .shift,
selection: ItemReferenceSet(ids: [card1], liveness: .live),
anchor: card1,
snapshot: snapshot
)
#expect(outcome.selection.ids == [card1, card2, card3])
#expect(outcome.anchor == card1, "the range origin stays put")
#expect(outcome.head == card3, "the cursor walks to what was clicked")
}
@Test("A vanished head is dropped by the reload, like every other item reference")
func resolveDropsAVanishedHead() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card1, card2], liveness: .live, anchor: card1, head: card2)
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)"))
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
#expect(store.selection.ids == [card1])
#expect(store.transient.selectionHead == nil)
#expect(store.transient.selectionAnchor == card1, "the anchor survived — it is still in the tree")
}
@Test("A liveness flip is a vanish for the head too")
func resolveDropsALivenessFlippedHead() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card1], liveness: .live)
#expect(store.transient.selectionHead == card1)
try fixture.item("\(Ident.lane1)/\(Ident.card1)", """
---
schema: 1
title: First
order: 1024
deleted: 2026-03-03T09:00:00Z
---
First body.
""")
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
#expect(store.selection.isEmpty)
#expect(store.transient.selectionHead == nil)
}
}
// MARK: - The sort's write
@MainActor
@Suite("BoardStore ▸ sortSelection")
struct SortWriteTests {
@Test("A step rewrites the two cards that swapped and nothing else")
func stepWritesTheMinimum() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let untouchedFirst = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
let untouchedFourth = try fixture.indexText("\(Ident.lane1)/\(Ident.card4)")
store.select([card3], liveness: .live)
store.sortSelection(.up)
#expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card3, card2, card4])
#expect(
try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") == untouchedFirst,
"a card whose position did not change keeps its bytes — no stamp, no commit"
)
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card4)") == untouchedFourth)
#expect(store.selection.ids == [card3], "the ids all survive, so the selection is left alone")
#expect(store.banners.oneShots.isEmpty)
}
@Test("A gather collects the block behind its first card, on disk")
func gatherWrites() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card2, card4], liveness: .live)
store.sortSelection(.up)
#expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card2, card4, card3])
}
@Test("A step down moves the block past its following sibling")
func stepDownWrites() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card1, card2], liveness: .live)
store.sortSelection(.down)
#expect(try cardOrder(Ident.lane1, in: fixture) == [card3, card1, card2, card4])
}
@Test("Duplicate ranks are compacted first, because a permutation cannot outrank a name tie-break")
func duplicateOrdersRenumberFirst() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
// Two cards sharing one rank: display order falls to the folder-name tie-break
// (`Ranks.isOrderedForDisplay`), which card1's `5555` wins over card2's `6666`.
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "1024", title: "Second"))
let store = try BoardStore(rootURL: fixture.root)
#expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card2])
store.select([card2], liveness: .live)
store.sortSelection(.up)
#expect(try cardOrder(Ident.lane1, in: fixture) == [card2, card1])
#expect(store.banners.oneShots.isEmpty)
}
@Test("The plan refuses every case the design calls inert")
func planRefusals() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
#expect(store.sortPlan(.up) == nil, "nothing selected")
store.select([lane1], liveness: .live)
#expect(store.sortPlan(.up) == nil, "a lane selection — ⌥⌘↑/⌥⌘↓ are inert on lanes")
store.select([card2, card5], liveness: .live)
#expect(store.sortPlan(.up) == nil, "a card selection spanning lanes — cards never change lanes by ⌘-arrow")
store.select([card1], liveness: .trashed)
#expect(store.sortPlan(.up) == nil, "a tombstoned selection")
store.select([card1], liveness: .live)
#expect(store.sortPlan(.up) == nil, "already at the top")
#expect(store.sortPlan(.down) != nil, "but the other direction is live")
}
}
// MARK: - The lane move's index convention
@MainActor
@Suite("BoardStore ▸ moveLane's one-slot convention")
struct MoveLaneConventionTests {
/// The index `MoveLaneCommands` passes: the lane's display position among the live lanes, plus
/// or minus one. `moveLane` counts that position **with the moved lane already removed**, which
/// is exactly what makes `from ± 1` one slot and is easy enough to get backwards that it is
/// pinned here rather than left to the drag path's coverage.
@Test("from 1 moves one slot left, from + 1 moves one slot right")
func oneSlotEachWay() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let lanes = SelectionGrammar.liveLanes(in: store.snapshot)
#expect(lanes == [lane1, lane2, lane3])
let from = try #require(lanes.firstIndex(of: lane2))
store.moveLane(lane2, toIndex: from - 1)
#expect(try load(fixture).lanes.map(\.id) == [lane2, lane1, lane3])
}
@Test("A step right hops exactly one lane, never to the end")
func stepRightHopsOne() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let lanes = SelectionGrammar.liveLanes(in: store.snapshot)
let from = try #require(lanes.firstIndex(of: lane1))
store.moveLane(lane1, toIndex: from + 1)
#expect(try load(fixture).lanes.map(\.id) == [lane2, lane1, lane3])
}
}