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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user