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
+119 -17
View File
@@ -1101,6 +1101,89 @@ public final class BoardStore {
}
}
// MARK: - Within-lane sort
/// The lane and the new card ordering one / press would produce, or `nil` when the press
/// is not available **the menu items' `disabled` condition and the write's guard, as one
/// answer** (`LaneWidthCommands`' rule).
///
/// `nil` covers every refusal the design names in one expression: an empty or tombstoned
/// selection ("/ are inert *on* tombstoned cards"), a lane selection ("with a lane
/// selected / are inert"), a card selection that **spans lanes** ("cards never change
/// lanes by -arrow so / disable when a card selection spans lanes"), and a block
/// already at the end of its lane.
func sortPlan(_ direction: SortMath.Direction) -> (lane: Lane, ordering: [ItemID])? {
let selection = transient.selection
guard selection.liveness == .live,
SelectionGrammar.kind(of: selection, in: snapshot) == .card,
// `nil` here *is* the spans-lanes case: the helper answers only when one lane holds
// the whole set.
let laneID = Self.lane(holding: selection.ids, in: snapshot),
let lane = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted })
else { return nil }
let rendered = lane.cards.filter { !$0.isDeleted }.map(\.id)
guard let ordering = SortMath.reordered(rendered, moving: selection.ids, direction) else { return nil }
return (lane, ordering)
}
/// Board Move Up / Move Down (/) the within-lane sort (04-interactions.md The map).
///
/// **One `performWrite` bracket**, like every other batch here: one gesture, one app-mediated
/// reload, one commit on git boards.
///
/// **The ranks are permuted, not invented.** The lane's existing `order` values, read in display
/// order, are already a sorted ladder of exactly the right length so the new ordering takes
/// them rung for rung and only the cards whose *position* changed are rewritten. A block stepping
/// past one sibling therefore touches the block plus that sibling and nothing else, which is what
/// keeps `modified` (and, later, a git commit) honest about what actually moved.
///
/// The one case that ladder cannot serve is **duplicate `order` values**, where display order is
/// decided by the folder-name tie-break (`Ranks.isOrderedForDisplay`) rather than by the rank
/// permuting equal ranks would write the file and leave the board looking identical. That is the
/// renumber trigger, exactly as an exhausted midpoint is elsewhere: compact the lane, then place
/// against the fresh ladder (`commitPlaceholder`'s and `moveLane`'s pattern).
///
/// The selection, the anchor and the head are deliberately untouched: every id survives, and the
/// cards the user is moving should stay the cards the user is moving.
public func sortSelection(_ direction: SortMath.Direction) {
guard let plan = sortPlan(direction) else { return }
let rendered = plan.lane.cards.filter { !$0.isDeleted }
let laneFolder = rootURL.appendingPathComponent(plan.lane.id.rawValue, isDirectory: true)
let orders = rendered.map(\.order)
let positions = Dictionary(uniqueKeysWithValues: rendered.enumerated().map { ($1.id, $0) })
try? performWrite { () throws(BoardWriteError) -> Void in
var ladder = orders
if !Self.isStrictlyAscending(orders) {
try BoardWriter.renumberVisibleChildren(of: laneFolder)
// The renumber assigns in display order, so the compacted ladder lines up one-for-one
// with `rendered` the same alignment `commitPlaceholder` relies on.
ladder = Ranks.renumbered(count: rendered.count)
}
for (destination, id) in plan.ordering.enumerated() {
guard let origin = positions[id], origin != destination else { continue }
let rank = ladder[destination]
try BoardWriter.updateIndex(
inItemFolder: laneFolder.appendingPathComponent(id.rawValue, isDirectory: true),
// `.reorder(title: nil)`: `updateIndex` enriches it off the document it reads, so
// a failure names the card by its own title.
operation: .reorder(title: nil)
) { document in
document.set(FrontmatterKeys.order, to: .double(rank))
}
}
}
}
/// Whether a lane's ranks separate its cards on their own the condition under which they can
/// be permuted rather than replaced. Ties fall to the folder-name tie-break, which a permutation
/// cannot reach past.
nonisolated static func isStrictlyAscending(_ orders: [Double]) -> Bool {
zip(orders, orders.dropFirst()).allSatisfy { $0 < $1 }
}
// MARK: - The trash
/// Whether physically removing an item on this board destroys the only copy of it and
@@ -1138,22 +1221,34 @@ public final class BoardStore {
/// only, so a selection the next reload will drop writes nothing rather than re-stamping a
/// `deleted:` that is already there. An empty resolution never opens the bracket at all.
///
/// The selection is **cleared**, not moved to a successor. 04-interactions.md The map asks for
/// the Finder-style successor sibling ("repeated walks down a lane"), which needs the
/// navigation order the keyboard grammar defines that is m5's card. Clearing is the honest
/// interim: what was selected renders nowhere now, and the reload's resolve rule would empty the
/// set a moment later anyway.
/// **The selection moves to the successor sibling** 04-interactions.md The map's Finder-style
/// rule ("next card in the lane, next lane on the board; the last sibling's predecessor
/// otherwise; empty container = nothing selected"), whose whole point is that "repeated walks
/// down a lane".
///
/// Two things make that hold. The successor is computed from the **pre-write** snapshot, which is
/// the last one that still knows where the doomed items sat; and it is selected **immediately**,
/// rather than waiting for the reload the tombstone will echo back a second pressed before
/// the watcher rounds the first one back must already have somewhere to land.
///
/// **Deliberate deletes only.** External vanishing never picks a successor (02-architecture.md's
/// reload-survival rule), and neither do `putBack`/`deleteImmediately` the item merely changed
/// sides, or nothing survives on either.
public func delete(_ ids: Set<ItemID>) {
let folders = TrashModel.paths(of: ids, on: .live, in: snapshot).map { $0.folder(under: rootURL) }
guard !folders.isEmpty else { return }
let successor = SelectionGrammar.successor(afterDeleting: ids, in: snapshot)
try? performWrite { () throws(BoardWriteError) -> Void in
for folder in folders {
try BoardWriter.deleteItem(at: folder)
}
}
// m5-keyboard: the successor-selection grammar replaces this line.
clearSelection()
if let successor {
select([successor], liveness: .live, anchor: successor, head: successor)
} else {
clearSelection()
}
}
/// Put Back: removes `deleted:` from every tombstoned item in `ids`, in one bracket
@@ -1314,8 +1409,8 @@ public final class BoardStore {
/// for "the lane that most recently held selection or a creation", and a *card* selection is
/// its lane holding selection just as much as the lane's own header click is so both are
/// noted here, and creation notes itself in `beginPlaceholder`.
public func select(_ ids: Set<ItemID>, liveness: Liveness, anchor: ItemID? = nil) {
transient.select(ids, liveness: liveness, anchor: anchor)
public func select(_ ids: Set<ItemID>, liveness: Liveness, anchor: ItemID? = nil, head: ItemID? = nil) {
transient.select(ids, liveness: liveness, anchor: anchor, head: head)
transient.noteActiveLane(Self.lane(holding: ids, in: snapshot))
}
@@ -1342,10 +1437,16 @@ public final class BoardStore {
clearSelection()
return
}
// The anchor is passed through explicitly: `select`'s default would otherwise re-anchor a
// Both cursors are passed through explicitly: `select`'s default would otherwise re-anchor a
// -range's single-member edge case on the target, and the grammar's answer is the one that
// knows whether this click was an origin or an extension.
select(outcome.selection.ids, liveness: outcome.selection.liveness, anchor: outcome.anchor)
// knows whether this click was an origin or an extension. The head is the clicked item in
// every branch see `SelectionGrammar.Outcome`.
select(
outcome.selection.ids,
liveness: outcome.selection.liveness,
anchor: outcome.anchor,
head: outcome.head
)
}
/// **Select All** "all visible cards on the board" (04-interactions.md The map), with the
@@ -1359,9 +1460,9 @@ public final class BoardStore {
/// foreign Put Back, a purge) falls through to the board rather than selecting the trash
/// wholesale on a guess.
///
/// The anchor **survives if it is still in the set** and is dropped otherwise: Select All is not
/// a click, so it names no new origin, but it has no business discarding one that is still
/// standing inside what it selected.
/// The anchor and the navigation head with it **survives if it is still in the set** and is
/// dropped otherwise: Select All is not a click, so it names no new origin and no new cursor,
/// but it has no business discarding ones that are still standing inside what it selected.
///
// m5-search: "filter-respecting, like every surface" (04 The map). The universe here is
// `SelectionGrammar`'s order lists, which is where the filter threads in one change, and both
@@ -1376,14 +1477,15 @@ public final class BoardStore {
}
/// Select All's storage half: an empty universe clears rather than storing an empty set, and the
/// anchor is kept only while it is still inside what was selected.
/// anchor and head are kept only while they are still inside what was selected.
private func apply(_ ids: Set<ItemID>, on side: Liveness) {
guard !ids.isEmpty else {
clearSelection()
return
}
let anchor = transient.selectionAnchor.flatMap { ids.contains($0) ? $0 : nil }
select(ids, liveness: side, anchor: anchor)
let head = transient.selectionHead.flatMap { ids.contains($0) ? $0 : nil }
select(ids, liveness: side, anchor: anchor, head: head)
}
/// Selects nothing Escape's last step outward (04-interactions.md Grammar).