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
+205 -2
View File
@@ -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.
+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
+25 -1
View File
@@ -313,7 +313,23 @@ struct LaneView: View {
/// The card stack. Its empty space is a click target in its own right (04 Selection): one
/// click selects the lane or, when it is already the selection, clears it; a double click
/// creates a card at the bottom with its title editor focused.
///
/// **"Selection scrolls into view"** (04-interactions.md Grammar): the reader watches the
/// navigation head the cursor the arrows move, not the whole selection and scrolls only when
/// the head names a card *this* lane renders, so exactly one lane responds to any one press.
/// Deliberately unwrapped by `withAnimation`: 03-board-ui.md § Motion has selection follow
/// "whatever transaction is active rather than easing on its own".
private var cardStack: some View {
ScrollViewReader { proxy in
scrollableCards
.onChange(of: store.transient.selectionHead) { _, head in
guard let head, let card = renderedCards.first(where: { $0.id == head }) else { return }
proxy.scrollTo(LaneSlot.identity(of: card.id))
}
}
}
private var scrollableCards: some View {
ScrollView(.vertical) {
// Cards stay standard width whatever the lane spans: at a slot width of
// `units × standard + (units - 1) × gap`, `MasonryLayout` divides back into exactly
@@ -340,6 +356,10 @@ struct LaneView: View {
// decided upstream at the reload for the real cards (`Motion.reloadAnimates`),
// at the gesture for the placeholder, which touches no disk.
.transition(Motion.cardTransition(reduced: reduceMotion))
// The scroll target. `ForEach` already carries this identity, but `scrollTo`
// resolves against an explicit `.id`, and it goes outermost so the transition
// above stays inside the identified view rather than around it.
.id(slot.id)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
@@ -447,12 +467,16 @@ private enum LaneSlot: Identifiable {
var id: String {
switch self {
case let .card(card): "card:\(card.id.rawValue)"
case let .card(card): Self.identity(of: card.id)
// Constant, because there is only ever one placeholder in one lane at a time and it must
// keep its identity and therefore its keyboard focus while the user types.
case .placeholder: "placeholder"
}
}
/// A card slot's id, spelled once so the scroll-into-view call and the slot itself cannot
/// disagree about what `scrollTo` is looking for.
static func identity(of card: ItemID) -> String { "card:\(card.rawValue)" }
}
// MARK: - Card face
+156
View File
@@ -0,0 +1,156 @@
import CoreGraphics
// MARK: - Spatial navigation
/// The arrows' geometry "nearest card in the direction, across interior grid columns and lanes"
/// (04-interactions.md Grammar), as a pure function of the drawn frames (`NavigationMathTests`).
///
/// **It reads the marquee's registry, deliberately.** The frames come from `MarqueeTargetRegistry`,
/// which the views populate with what they actually drew so the keyboard and the rubber band
/// answer "where is that card" from one set of rectangles, and geometry can never disagree with
/// hit-testing. A second derivation off the masonry's arithmetic would be a second answer, and one
/// that a lane resize, a reorder in flight or a foreign reload could falsify.
///
/// **It is also how the hidden trash stays invisible for free**: a hidden column registers nothing,
/// so there is nothing here to filter out 04's "hidden, it is invisible to every gesture" needs no
/// code of its own.
///
/// Pure and `CoreGraphics`-only, for `SelectionGrammar`'s reason: the branches become lines of test
/// rather than gestures to drive, and the four arrow handlers stay thin over it.
public enum NavigationMath {
public enum Direction: Sendable, Equatable {
case up
case down
case left
case right
}
/// The nearest target in `direction` from `origin`, or `nil` when the direction has no candidate.
///
/// The rule, in three parts:
///
/// - **Strictly beyond, along the primary axis.** A candidate's centre must sit at least 1pt
/// past the origin's centre in the direction travelled. The tolerance is what excludes the
/// origin itself and what keeps a card sharing a row (or a column) with the origin from
/// counting as "above" it because of a sub-pixel layout difference.
/// - **Orthogonal drift costs double.** The score is the primary-axis centre distance plus twice
/// the orthogonal one, so a card straight ahead beats a nearer one off to the side which is
/// what makes walk down a masonry column rather than wandering across it, and / cross to
/// the neighbouring lane at the same height.
/// - **Ties are broken by position, then identity** (`MarqueeMath.isAbove`), so identical input
/// picks identically twice.
///
/// - Parameter predicate: which targets are eligible the -arrow's same-side restriction, and
/// nothing else so far. A plain arrow passes everything, because "plain arrows still walk
/// across" the live/trash boundary (04 The trash).
public static func nearest(
from origin: CGRect,
direction: Direction,
among targets: [MarqueeTarget],
where predicate: (MarqueeTarget) -> Bool = { _ in true }
) -> ItemID? {
/// Below this, a candidate is level with the origin rather than beyond it.
let threshold: CGFloat = 1
var best: MarqueeTarget?
var bestScore = CGFloat.infinity
for candidate in targets where predicate(candidate) {
let primary: CGFloat
let orthogonal: CGFloat
switch direction {
case .up:
primary = origin.midY - candidate.frame.midY
orthogonal = abs(candidate.frame.midX - origin.midX)
case .down:
primary = candidate.frame.midY - origin.midY
orthogonal = abs(candidate.frame.midX - origin.midX)
case .left:
primary = origin.midX - candidate.frame.midX
orthogonal = abs(candidate.frame.midY - origin.midY)
case .right:
primary = candidate.frame.midX - origin.midX
orthogonal = abs(candidate.frame.midY - origin.midY)
}
guard primary >= threshold else { continue }
let score = primary + 2 * orthogonal
if score < bestScore {
best = candidate
bestScore = score
} else if score == bestScore, let current = best, MarqueeMath.isAbove(candidate, current) {
best = candidate
}
}
return best?.id
}
}
// MARK: - Within-lane sort
/// /'s arithmetic "the selected card(s) move one position within the lane logical
/// `order`, across interior masonry columns" (04-interactions.md The map), as a pure permutation
/// of the lane's rendered card ids (`NavigationMathTests`).
///
/// **Logical order, never geometry.** The masonry's columns are a rendering; the thing being moved
/// is the `order` ladder, which is also 10-accessibility.md's logical-order rule. So this function
/// never sees a frame it is given the lane's ids top-to-bottom and hands back the same ids in a
/// new order, and `BoardStore.sortSelection` turns that into the minimum set of `order` rewrites.
public enum SortMath {
public enum Direction: Sendable, Equatable {
case up
case down
}
/// The lane's ids after one press, or `nil` for a no-op.
///
/// Two behaviours, and which one fires depends only on whether the selection is already
/// contiguous:
///
/// - **Non-contiguous gathers, and only gathers.** "A non-contiguous multi-selection gathers on
/// the first press: the cards collect into a contiguous block anchored at the first selected
/// card (first = lowest logical order; the rest follow in preserved relative order), and
/// subsequent presses move the block one position." The gather is therefore direction-blind
/// the press that gathers does not also step, which is what makes the second press's meaning
/// unambiguous.
/// - **Contiguous steps one position**, hopping the single unselected sibling above (or below)
/// the block, so the block travels as a unit. At the ladder's end there is nothing to hop, and
/// the answer is `nil`.
///
/// `nil` rather than "the input unchanged" so the menu item's `disabled` state and the store's
/// write path read the *same* answer `LaneWidthCommands`' rule, and for its reason.
///
/// Ids in `selected` that are not in `ordered` are ignored: a selection the next reload will
/// drop must not decide what a press does now.
public static func reordered(
_ ordered: [ItemID],
moving selected: Set<ItemID>,
_ direction: Direction
) -> [ItemID]? {
let doomed = ordered.indices.filter { selected.contains(ordered[$0]) }
guard let first = doomed.first, let last = doomed.last else { return nil }
let block = doomed.map { ordered[$0] }
// Contiguity is a property of the positions, not of the count: N members spanning exactly N
// slots is the block that steps; anything wider gathers first.
guard doomed.count == last - first + 1 else {
var others = ordered.filter { !selected.contains($0) }
// Everything before the first selected card is unselected by definition, so the block's
// landing index among the survivors *is* that first index "anchored at the first
// selected card".
others.insert(contentsOf: block, at: first)
return others
}
switch direction {
case .up:
guard first > 0 else { return nil }
return Array(ordered[..<(first - 1)]) + block + [ordered[first - 1]] + Array(ordered[(last + 1)...])
case .down:
guard last + 1 < ordered.count else { return nil }
return Array(ordered[..<first]) + [ordered[last + 1]] + block + Array(ordered[(last + 2)...])
}
}
}
+7 -3
View File
@@ -68,9 +68,13 @@ struct MarqueeControl {
if ids.isEmpty {
store.clearSelection()
} else {
// No anchor: a band names no click to range from, so a -click after one acts
// plain (`TransientBoardState.selectionAnchor`).
store.select(ids, liveness: session.side, anchor: nil)
// Neither cursor: a band names no click to range from and no item to arrow from,
// so a -click after one acts plain and an arrow re-derives a position from the
// set's last member (`TransientBoardState.selectionAnchor`, `selectionHead`).
// Both are spelled out rather than defaulted, because `select`'s sole-member
// default would otherwise pick one up the moment a band happened to sweep
// exactly one card.
store.select(ids, liveness: session.side, anchor: nil, head: nil)
}
}
.onEnded { _ in session.end() }
+30 -7
View File
@@ -93,12 +93,13 @@ final class TrashDragSession {
///
/// ### What is still a later card's
///
/// The **search filter** ("shown, it participates in the filter like any lane") and the **keyboard**
/// grammar arrow walks into and out of the column, -arrows that go inert at both the liveness and
/// the kind boundary, C copy-out are still owed. The *pointer* grammar is here: a row's click
/// runs the same `SelectionGrammar` the board does, and the column's empty space rubber-bands on the
/// trashed side. So is the drop, with the target lane highlighted and the source row dimmed in
/// place; the drag's replica is not.
/// The **search filter** ("shown, it participates in the filter like any lane") and **C copy-out**
/// are still owed. The *pointer* grammar is here: a row's click runs the same `SelectionGrammar` the
/// board does, and the column's empty space rubber-bands on the trashed side. So is the drop, with
/// the target lane highlighted and the source row dimmed in place; the drag's replica is not. The
/// **keyboard** reaches the column entirely through the frames the rows register arrow walks in
/// and out, -arrows inert at both the liveness and the kind boundary so nothing in this file
/// implements it beyond keeping every row drawn and registered (see `rows`).
struct TrashLaneView: View {
let store: BoardStore
@@ -205,9 +206,29 @@ struct TrashLaneView: View {
// MARK: - Rows
/// The rows, scrollable, with the navigation head kept in view.
///
/// **"Selection scrolls into view"** (04-interactions.md Grammar), watching the head rather
/// than the whole selection so exactly one column responds to any one arrow `LaneView`'s rule,
/// on the trash side.
private var rows: some View {
ScrollViewReader { proxy in
scrollableRows
.onChange(of: store.transient.selectionHead) { _, head in
guard let head, entries.contains(where: { $0.id == head }) else { return }
proxy.scrollTo(head)
}
}
}
private var scrollableRows: some View {
ScrollView(.vertical) {
LazyVStack(alignment: .leading, spacing: rowSpacing) {
// **A plain `VStack`, deliberately not lazy.** Every row must keep its drawn frame
// registered in `MarqueeTargetRegistry` the rubber band sweeps those frames and the
// arrows navigate by them (`NavigationMath`) and a lazy stack only builds the rows it
// has scrolled to, so an unbuilt row is invisible to both. The constraint is affordable
// because a trash is small: it holds one board's tombstones, and Empty Trash exists.
VStack(alignment: .leading, spacing: rowSpacing) {
ForEach(entries) { entry in
TrashEntryRow(
store: store,
@@ -222,6 +243,8 @@ struct TrashLaneView: View {
// that pair should read alike from either side of the strip. The transaction is
// the reload's, like the lanes' (`Motion.reloadAnimates`).
.transition(Motion.cardTransition(reduced: reduceMotion))
// The scroll target `LaneView`'s rule, and outermost for its reason.
.id(entry.id)
}
}
.frame(maxWidth: .infinity, alignment: .topLeading)