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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)...])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user