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:
@@ -1,3 +1,4 @@
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - The focused board
|
||||
@@ -20,6 +21,42 @@ extension FocusedValues {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The focused board's card opener
|
||||
|
||||
/// How a menu item opens a card window — the board window's own `openCard` closure, published into
|
||||
/// the focus system beside its store.
|
||||
///
|
||||
/// **It exists because a card window's identity is `(board, card)` and only `BoardWindowHost` holds
|
||||
/// the board half** (02-architecture.md § Windows). Board ▸ Open Card has no window of its own to
|
||||
/// derive that from, and the board *view* cannot supply it either — the item is in the menu bar. So
|
||||
/// the closure travels the same route the store, the popover flag and the purge-alert host already
|
||||
/// do.
|
||||
///
|
||||
/// A small reference type rather than a value, for `BoardInfoPresentation`'s reason: it is the
|
||||
/// window's, one per window, and it is filled in after the board has loaded — `@Observable` so the
|
||||
/// menu item's validation notices when it is.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class CardOpener {
|
||||
|
||||
/// `nil` until the window's board has loaded, which is also exactly when Open Card has nothing
|
||||
/// to act on.
|
||||
var open: ((ItemID) -> Void)?
|
||||
|
||||
init() {}
|
||||
}
|
||||
|
||||
struct FocusedCardOpenerKey: FocusedValueKey {
|
||||
typealias Value = CardOpener
|
||||
}
|
||||
|
||||
extension FocusedValues {
|
||||
var cardOpener: CardOpener? {
|
||||
get { self[FocusedCardOpenerKey.self] }
|
||||
set { self[FocusedCardOpenerKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared validation
|
||||
|
||||
/// The two conditions **every** board-mutating menu item disables on, in one place.
|
||||
@@ -31,8 +68,8 @@ extension FocusedValues {
|
||||
/// editor — rename or the new-card placeholder — is focused, board-scoped menu commands (Delete,
|
||||
/// New Card, Paste, Move, Style, …) disable via menu validation" and the keyboard belongs to the
|
||||
/// text domain. The one carve-out the design names is Open Card ⌘↩, which stays enabled to commit
|
||||
/// the edit and open the window — it is not a menu item yet (m5), and when it is, it is the one
|
||||
/// item that must *not* read this property.
|
||||
/// the edit and open the window — `OpenCardCommand` below is therefore the one item that
|
||||
/// deliberately does not read this property.
|
||||
///
|
||||
/// Stated once rather than repeated per item, because the interesting failure mode is an item that
|
||||
/// quietly forgets half of it.
|
||||
@@ -42,6 +79,172 @@ extension BoardStore {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Open Card
|
||||
|
||||
/// Board ▸ Open Card (⌘↩) — 11-command-nexus.md's first Board row, and **the one board command
|
||||
/// enabled mid-edit** (04-interactions.md ▸ Grammar's focused-editor rule).
|
||||
///
|
||||
/// Two contexts, exactly as the Nexus scopes them: "sole selected live card; during an inline title
|
||||
/// edit (placeholder or rename), commits it and opens". So this is the single item that must *not*
|
||||
/// read `acceptsBoardMutations` — the open-editor half of that property is the very state it exists
|
||||
/// to serve. It does not read the read-only lock either: opening a window is not a mutation, and the
|
||||
/// commit path it may run through refuses on its own with the lock's row already standing.
|
||||
///
|
||||
/// The mid-edit branches mirror the pointer twins exactly rather than reimplementing them — the
|
||||
/// placeholder's is `NewCardStubView.commit()` (read the lane, commit, re-select the surviving lane)
|
||||
/// and the rename's is `LaneView`'s `onCommitAndOpen` (a lane rename just commits; only a card has a
|
||||
/// window to open). Both stores' commits no-op against a closed editor, so this item and
|
||||
/// `InlineTitleField`'s own ⌘↩ fallback compose without acting twice.
|
||||
struct OpenCardCommand: View {
|
||||
|
||||
@FocusedValue(\.boardStore) private var store
|
||||
@FocusedValue(\.cardOpener) private var opener
|
||||
|
||||
var body: some View {
|
||||
Button("Open Card") {
|
||||
open()
|
||||
}
|
||||
.keyboardShortcut(.return, modifiers: .command)
|
||||
.disabled(!isEnabled)
|
||||
}
|
||||
|
||||
private var isEnabled: Bool {
|
||||
guard let store, opener?.open != nil else { return false }
|
||||
return store.isEditingInline || soleSelectedCard != nil
|
||||
}
|
||||
|
||||
/// The sole selected **live card**, or `nil`. A lane, a multi-selection and a tombstoned
|
||||
/// selection all answer `nil` — "everything edit-shaped is disabled on tombstoned selections"
|
||||
/// (04 ▸ The trash), and a card window is tied to one card.
|
||||
private var soleSelectedCard: ItemID? {
|
||||
guard let store else { return nil }
|
||||
let selection = store.selection
|
||||
guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first,
|
||||
BoardStore.liveItem(id, in: store.snapshot)?.cardID != nil
|
||||
else { return nil }
|
||||
return id
|
||||
}
|
||||
|
||||
private func open() {
|
||||
guard let store, let open = opener?.open else { return }
|
||||
|
||||
if let placeholder = store.transient.newCardPlaceholder {
|
||||
// The lane is read before the commit, because every discard path clears the overlay that
|
||||
// holds it — and re-checked after, because one of those paths is *the lane vanished*.
|
||||
let lane = placeholder.laneID
|
||||
let created = store.commitPlaceholder()
|
||||
if store.snapshot.lanes.contains(where: { $0.id == lane && !$0.isDeleted }) {
|
||||
store.select([lane], liveness: .live)
|
||||
}
|
||||
if let created { open(created) }
|
||||
return
|
||||
}
|
||||
|
||||
if let editor = store.transient.renameEditor {
|
||||
let target = editor.targetID
|
||||
let isCard = BoardStore.liveItem(target, in: store.snapshot)?.cardID != nil
|
||||
store.commitRename()
|
||||
if isCard { open(target) }
|
||||
return
|
||||
}
|
||||
|
||||
if let card = soleSelectedCard { open(card) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Within-lane sort
|
||||
|
||||
/// Board ▸ Move Up / Move Down (⌥⌘↑/⌥⌘↓) — the within-lane sort (11-command-nexus.md;
|
||||
/// 04-interactions.md ▸ The map, where the chord is settled as the "⌥⌘ modifies" family's vertical
|
||||
/// half alongside the lane-width pair).
|
||||
///
|
||||
/// **Validation and action read one answer** (`BoardStore.sortPlan`), the width pair's rule: the
|
||||
/// items disable on everything the design calls inert — a lane selection, a tombstoned selection, a
|
||||
/// card selection spanning lanes ("cards never change lanes by ⌘-arrow") — and additionally on a
|
||||
/// block already at its lane's end, where the only outcome would be a silent no-op.
|
||||
///
|
||||
/// The direction matters to that answer, which is why each item asks separately: a block at the top
|
||||
/// disables Move Up while Move Down stays live.
|
||||
struct MoveCardCommands: View {
|
||||
|
||||
@FocusedValue(\.boardStore) private var store
|
||||
|
||||
var body: some View {
|
||||
Button("Move Up") {
|
||||
store?.sortSelection(.up)
|
||||
}
|
||||
.keyboardShortcut(.upArrow, modifiers: [.option, .command])
|
||||
.disabled(!canSort(.up))
|
||||
|
||||
Button("Move Down") {
|
||||
store?.sortSelection(.down)
|
||||
}
|
||||
.keyboardShortcut(.downArrow, modifiers: [.option, .command])
|
||||
.disabled(!canSort(.down))
|
||||
}
|
||||
|
||||
private func canSort(_ direction: SortMath.Direction) -> Bool {
|
||||
guard let store, store.acceptsBoardMutations else { return false }
|
||||
return store.sortPlan(direction) != nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lane moves
|
||||
|
||||
/// Board ▸ Move Left / Move Right (⌘←/⌘→) — "Lane selection only (one slot; never into the trash)"
|
||||
/// (11-command-nexus.md), closing 10-accessibility.md's lane-move defect (04 ▸ Accessibility).
|
||||
///
|
||||
/// **Sole lane, deliberately.** The width pair one row below explicitly batches over a multi-lane
|
||||
/// selection; this row's inventory line says "Lane selection only" with no batching clause, and a
|
||||
/// multi-lane move has no single unambiguous meaning ("one slot" for a discontiguous pair is not one
|
||||
/// answer). So the items validate on exactly one selected live lane.
|
||||
///
|
||||
/// **Never into the trash** costs nothing: the quasi-lane is not in the live lane order, so a step
|
||||
/// past the last real lane is simply off the end — which is also the disable rule at the walls,
|
||||
/// following the width stepper's floor style rather than letting the store no-op silently.
|
||||
struct MoveLaneCommands: View {
|
||||
|
||||
@FocusedValue(\.boardStore) private var store
|
||||
|
||||
var body: some View {
|
||||
Button("Move Left") {
|
||||
move(by: -1)
|
||||
}
|
||||
.keyboardShortcut(.leftArrow, modifiers: .command)
|
||||
.disabled(destination(-1) == nil)
|
||||
|
||||
Button("Move Right") {
|
||||
move(by: 1)
|
||||
}
|
||||
.keyboardShortcut(.rightArrow, modifiers: .command)
|
||||
.disabled(destination(1) == nil)
|
||||
}
|
||||
|
||||
/// The sole selected live lane and the display slot one step would put it in — `nil` when there
|
||||
/// is no such lane or it is already at that wall.
|
||||
///
|
||||
/// `from + delta` **is** the index `moveLane` wants: that method counts display positions among
|
||||
/// the live lanes *with the moved lane already removed*, so inserting at `from - 1` puts the lane
|
||||
/// before its old predecessor and at `from + 1` after its old successor — one slot each way. The
|
||||
/// convention is easy to get backwards, which is why it is pinned by a test.
|
||||
private func destination(_ delta: Int) -> (lane: ItemID, index: Int)? {
|
||||
guard let store, store.acceptsBoardMutations else { return nil }
|
||||
let selection = store.selection
|
||||
guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first else { return nil }
|
||||
let lanes = SelectionGrammar.liveLanes(in: store.snapshot)
|
||||
// A card id is in no lane order, so this is also the "not a lane" test.
|
||||
guard let from = lanes.firstIndex(of: id) else { return nil }
|
||||
let to = from + delta
|
||||
guard lanes.indices.contains(to) else { return nil }
|
||||
return (id, to)
|
||||
}
|
||||
|
||||
private func move(by delta: Int) {
|
||||
guard let store, let target = destination(delta) else { return }
|
||||
store.moveLane(target.lane, toIndex: target.index)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Creation items
|
||||
|
||||
/// File ▸ New Card (⌘N) and File ▸ New Lane (⇧⌘N) — 11-command-nexus.md's two creation rows.
|
||||
|
||||
Reference in New Issue
Block a user