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
+24 -7
View File
@@ -44,6 +44,11 @@ struct BoardWindowHost: View {
/// menu-bar items, and a menu item cannot present anything of its own.
@State private var trashConfirmations = TrashConfirmations()
/// How Board Open Card reaches this window's card windows. `@State` for `boardInfo`'s reason,
/// and published the same way: a menu item has no window of its own, and only this view holds
/// the board half of a card window's `(board, card)` identity see `CardOpener`.
@State private var cardOpener = CardOpener()
@State private var phase: Phase = .opening
private enum Phase {
@@ -78,17 +83,12 @@ struct BoardWindowHost: View {
// attaches after this body first runs, and the lane-resize drag needs the *live*
// window to grow at its right edge (03-board-ui.md § Lane).
//
// `openCard` is the host's too, for a different reason: a card window's identity is
// `(board, card)` and only this view holds the board half. `openWindow(value:)` with
// a ref that already has a window focuses it, so "at most one card window per card
// (reopen focuses)" needs no bookkeeping here (02-architecture.md § Windows).
// `openCard` is the host's too, for a different reason see the property below.
BoardView(
store: store,
window: { windowController.window },
confirmations: trashConfirmations,
openCard: { cardID in
openWindow(id: WindowID.card, value: CardWindowRef(board: ref, cardID: cardID))
}
openCard: openCard
)
}
// "The board in front", for the menu items that act on it (`LaneWidthCommands`), and
@@ -100,6 +100,18 @@ struct BoardWindowHost: View {
.focusedSceneValue(\.boardWindowRef, ref)
.focusedSceneValue(\.boardInfo, boardInfo)
.focusedSceneValue(\.trashConfirmations, trashConfirmations)
// Board Open Card's second half the same closure `BoardView` gets, so the menu item
// and the double-click open one window per card by construction.
.focusedSceneValue(\.cardOpener, cardOpener)
}
}
/// Opens a card's window. `openWindow(value:)` with a ref that already has a window focuses it,
/// so "at most one card window per card (reopen focuses)" needs no bookkeeping here
/// (02-architecture.md § Windows).
private var openCard: (ItemID) -> Void {
{ cardID in
openWindow(id: WindowID.card, value: CardWindowRef(board: ref, cardID: cardID))
}
}
@@ -152,6 +164,11 @@ struct BoardWindowHost: View {
/// Wires the window: the saved frame on the way in, frame changes on the way back out, the
/// close interception that makes the flush unavoidable, and the title-bar widget.
private func configureWindow(store: BoardStore, recordID: UUID) {
// Filled in here rather than at declaration because the closure captures `openWindow`, an
// environment action; until the board has loaded there is also nothing for Open Card to act
// on, which is exactly what the item's `nil` check reads.
cardOpener.open = openCard
windowController.onAttach = { window in
guard let saved = appModel.boardRegistry.record(id: recordID)?.windowFrame else { return }
window.setFrame(HostedWindowController.placementOnCurrentScreens(for: saved), display: true)
+8 -2
View File
@@ -166,16 +166,22 @@ struct KanbanApp: App {
ShowTrashCommand()
}
// The Board menu (11-command-nexus.md), in its inventoried order Rename, then Style,
// then the width pair, with Open Card and the Move items still owed. Its items act on the
// The Board menu (11-command-nexus.md), complete and in its inventoried row order Open
// Card, Rename, Style, the card moves, the lane moves, the width pair. Its items act on the
// frontmost board window, which they reach through the focus system rather than through the
// app model see `BoardCommands.swift`, which also owns their validation.
CommandMenu("Board") {
OpenCardCommand()
BoardRenameCommand()
BoardStyleCommand()
Divider()
MoveCardCommands()
MoveLaneCommands()
Divider()
LaneWidthCommands()
}
+119 -17
View File
@@ -1101,6 +1101,89 @@ public final class BoardStore {
}
}
// MARK: - Within-lane sort
/// The lane and the new card ordering one / press would produce, or `nil` when the press
/// is not available **the menu items' `disabled` condition and the write's guard, as one
/// answer** (`LaneWidthCommands`' rule).
///
/// `nil` covers every refusal the design names in one expression: an empty or tombstoned
/// selection ("/ are inert *on* tombstoned cards"), a lane selection ("with a lane
/// selected / are inert"), a card selection that **spans lanes** ("cards never change
/// lanes by -arrow so / disable when a card selection spans lanes"), and a block
/// already at the end of its lane.
func sortPlan(_ direction: SortMath.Direction) -> (lane: Lane, ordering: [ItemID])? {
let selection = transient.selection
guard selection.liveness == .live,
SelectionGrammar.kind(of: selection, in: snapshot) == .card,
// `nil` here *is* the spans-lanes case: the helper answers only when one lane holds
// the whole set.
let laneID = Self.lane(holding: selection.ids, in: snapshot),
let lane = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted })
else { return nil }
let rendered = lane.cards.filter { !$0.isDeleted }.map(\.id)
guard let ordering = SortMath.reordered(rendered, moving: selection.ids, direction) else { return nil }
return (lane, ordering)
}
/// Board Move Up / Move Down (/) the within-lane sort (04-interactions.md The map).
///
/// **One `performWrite` bracket**, like every other batch here: one gesture, one app-mediated
/// reload, one commit on git boards.
///
/// **The ranks are permuted, not invented.** The lane's existing `order` values, read in display
/// order, are already a sorted ladder of exactly the right length so the new ordering takes
/// them rung for rung and only the cards whose *position* changed are rewritten. A block stepping
/// past one sibling therefore touches the block plus that sibling and nothing else, which is what
/// keeps `modified` (and, later, a git commit) honest about what actually moved.
///
/// The one case that ladder cannot serve is **duplicate `order` values**, where display order is
/// decided by the folder-name tie-break (`Ranks.isOrderedForDisplay`) rather than by the rank
/// permuting equal ranks would write the file and leave the board looking identical. That is the
/// renumber trigger, exactly as an exhausted midpoint is elsewhere: compact the lane, then place
/// against the fresh ladder (`commitPlaceholder`'s and `moveLane`'s pattern).
///
/// The selection, the anchor and the head are deliberately untouched: every id survives, and the
/// cards the user is moving should stay the cards the user is moving.
public func sortSelection(_ direction: SortMath.Direction) {
guard let plan = sortPlan(direction) else { return }
let rendered = plan.lane.cards.filter { !$0.isDeleted }
let laneFolder = rootURL.appendingPathComponent(plan.lane.id.rawValue, isDirectory: true)
let orders = rendered.map(\.order)
let positions = Dictionary(uniqueKeysWithValues: rendered.enumerated().map { ($1.id, $0) })
try? performWrite { () throws(BoardWriteError) -> Void in
var ladder = orders
if !Self.isStrictlyAscending(orders) {
try BoardWriter.renumberVisibleChildren(of: laneFolder)
// The renumber assigns in display order, so the compacted ladder lines up one-for-one
// with `rendered` the same alignment `commitPlaceholder` relies on.
ladder = Ranks.renumbered(count: rendered.count)
}
for (destination, id) in plan.ordering.enumerated() {
guard let origin = positions[id], origin != destination else { continue }
let rank = ladder[destination]
try BoardWriter.updateIndex(
inItemFolder: laneFolder.appendingPathComponent(id.rawValue, isDirectory: true),
// `.reorder(title: nil)`: `updateIndex` enriches it off the document it reads, so
// a failure names the card by its own title.
operation: .reorder(title: nil)
) { document in
document.set(FrontmatterKeys.order, to: .double(rank))
}
}
}
}
/// Whether a lane's ranks separate its cards on their own the condition under which they can
/// be permuted rather than replaced. Ties fall to the folder-name tie-break, which a permutation
/// cannot reach past.
nonisolated static func isStrictlyAscending(_ orders: [Double]) -> Bool {
zip(orders, orders.dropFirst()).allSatisfy { $0 < $1 }
}
// MARK: - The trash
/// Whether physically removing an item on this board destroys the only copy of it and
@@ -1138,22 +1221,34 @@ public final class BoardStore {
/// only, so a selection the next reload will drop writes nothing rather than re-stamping a
/// `deleted:` that is already there. An empty resolution never opens the bracket at all.
///
/// The selection is **cleared**, not moved to a successor. 04-interactions.md The map asks for
/// the Finder-style successor sibling ("repeated walks down a lane"), which needs the
/// navigation order the keyboard grammar defines that is m5's card. Clearing is the honest
/// interim: what was selected renders nowhere now, and the reload's resolve rule would empty the
/// set a moment later anyway.
/// **The selection moves to the successor sibling** 04-interactions.md The map's Finder-style
/// rule ("next card in the lane, next lane on the board; the last sibling's predecessor
/// otherwise; empty container = nothing selected"), whose whole point is that "repeated walks
/// down a lane".
///
/// Two things make that hold. The successor is computed from the **pre-write** snapshot, which is
/// the last one that still knows where the doomed items sat; and it is selected **immediately**,
/// rather than waiting for the reload the tombstone will echo back a second pressed before
/// the watcher rounds the first one back must already have somewhere to land.
///
/// **Deliberate deletes only.** External vanishing never picks a successor (02-architecture.md's
/// reload-survival rule), and neither do `putBack`/`deleteImmediately` the item merely changed
/// sides, or nothing survives on either.
public func delete(_ ids: Set<ItemID>) {
let folders = TrashModel.paths(of: ids, on: .live, in: snapshot).map { $0.folder(under: rootURL) }
guard !folders.isEmpty else { return }
let successor = SelectionGrammar.successor(afterDeleting: ids, in: snapshot)
try? performWrite { () throws(BoardWriteError) -> Void in
for folder in folders {
try BoardWriter.deleteItem(at: folder)
}
}
// m5-keyboard: the successor-selection grammar replaces this line.
clearSelection()
if let successor {
select([successor], liveness: .live, anchor: successor, head: successor)
} else {
clearSelection()
}
}
/// Put Back: removes `deleted:` from every tombstoned item in `ids`, in one bracket
@@ -1314,8 +1409,8 @@ public final class BoardStore {
/// for "the lane that most recently held selection or a creation", and a *card* selection is
/// its lane holding selection just as much as the lane's own header click is so both are
/// noted here, and creation notes itself in `beginPlaceholder`.
public func select(_ ids: Set<ItemID>, liveness: Liveness, anchor: ItemID? = nil) {
transient.select(ids, liveness: liveness, anchor: anchor)
public func select(_ ids: Set<ItemID>, liveness: Liveness, anchor: ItemID? = nil, head: ItemID? = nil) {
transient.select(ids, liveness: liveness, anchor: anchor, head: head)
transient.noteActiveLane(Self.lane(holding: ids, in: snapshot))
}
@@ -1342,10 +1437,16 @@ public final class BoardStore {
clearSelection()
return
}
// The anchor is passed through explicitly: `select`'s default would otherwise re-anchor a
// Both cursors are passed through explicitly: `select`'s default would otherwise re-anchor a
// -range's single-member edge case on the target, and the grammar's answer is the one that
// knows whether this click was an origin or an extension.
select(outcome.selection.ids, liveness: outcome.selection.liveness, anchor: outcome.anchor)
// knows whether this click was an origin or an extension. The head is the clicked item in
// every branch see `SelectionGrammar.Outcome`.
select(
outcome.selection.ids,
liveness: outcome.selection.liveness,
anchor: outcome.anchor,
head: outcome.head
)
}
/// **Select All** "all visible cards on the board" (04-interactions.md The map), with the
@@ -1359,9 +1460,9 @@ public final class BoardStore {
/// foreign Put Back, a purge) falls through to the board rather than selecting the trash
/// wholesale on a guess.
///
/// The anchor **survives if it is still in the set** and is dropped otherwise: Select All is not
/// a click, so it names no new origin, but it has no business discarding one that is still
/// standing inside what it selected.
/// The anchor and the navigation head with it **survives if it is still in the set** and is
/// dropped otherwise: Select All is not a click, so it names no new origin and no new cursor,
/// but it has no business discarding ones that are still standing inside what it selected.
///
// m5-search: "filter-respecting, like every surface" (04 The map). The universe here is
// `SelectionGrammar`'s order lists, which is where the filter threads in one change, and both
@@ -1376,14 +1477,15 @@ public final class BoardStore {
}
/// Select All's storage half: an empty universe clears rather than storing an empty set, and the
/// anchor is kept only while it is still inside what was selected.
/// anchor and head are kept only while they are still inside what was selected.
private func apply(_ ids: Set<ItemID>, on side: Liveness) {
guard !ids.isEmpty else {
clearSelection()
return
}
let anchor = transient.selectionAnchor.flatMap { ids.contains($0) ? $0 : nil }
select(ids, liveness: side, anchor: anchor)
let head = transient.selectionHead.flatMap { ids.contains($0) ? $0 : nil }
select(ids, liveness: side, anchor: anchor, head: head)
}
/// Selects nothing Escape's last step outward (04-interactions.md Grammar).
+98 -14
View File
@@ -64,24 +64,31 @@ public enum ClickModifier: Sendable, Equatable {
/// trash row) share one answer instead of four near-copies of it.
public enum SelectionGrammar {
/// What a click leaves behind: the new selection, and the anchor a subsequent -click would
/// range from.
/// What a click leaves behind: the new selection, the anchor a subsequent -click would range
/// from, and the head a subsequent arrow would step from.
///
/// The anchor is carried *out* rather than mutated in place because it is not derivable from the
/// selection a -range replaces the whole set and deliberately leaves the anchor where it was,
/// so "the last plain or click" is a memory of a gesture and only the gesture can update it
/// (`TransientBoardState.selectionAnchor`).
///
/// **The head is always the clicked item**, in every branch below the -branch included, where
/// the anchor deliberately stays put. That asymmetry is the definition of the two:
/// `TransientBoardState.selectionHead` is where the *next* step starts, and a -click moves it
/// exactly as a plain one does.
public struct Outcome: Sendable, Equatable {
public var selection: ItemReferenceSet
public var anchor: ItemID?
public var head: ItemID?
public init(selection: ItemReferenceSet, anchor: ItemID?) {
public init(selection: ItemReferenceSet, anchor: ItemID?, head: ItemID? = nil) {
self.selection = selection
self.anchor = anchor
self.head = head
}
/// Nothing selected and nothing to range from the toggle-off outcomes.
static let cleared = Outcome(selection: .empty, anchor: nil)
/// Nothing selected, nothing to range from, nowhere to step from the toggle-off outcomes.
static let cleared = Outcome(selection: .empty, anchor: nil, head: nil)
}
/// The grammar, one call.
@@ -132,7 +139,8 @@ public enum SelectionGrammar {
}
return Outcome(
selection: ItemReferenceSet(ids: [target.id], liveness: target.side),
anchor: target.id
anchor: target.id,
head: target.id
)
}
@@ -165,7 +173,8 @@ public enum SelectionGrammar {
}
return Outcome(
selection: ItemReferenceSet(ids: ids, liveness: target.side),
anchor: target.id
anchor: target.id,
head: target.id
)
}
@@ -184,20 +193,43 @@ public enum SelectionGrammar {
anchor: ItemID?,
snapshot: BoardModel
) -> Outcome {
let list = order(of: target.kind, on: target.side, in: snapshot)
guard let anchor,
let from = list.firstIndex(of: anchor),
let to = list.firstIndex(of: target.id)
let span = range(from: anchor, to: target.id, kind: target.kind, on: target.side, in: snapshot)
else {
return plain(target, selection: selection, togglesOnRepeat: false)
}
let span = from <= to ? list[from...to] : list[to...from]
return Outcome(
selection: ItemReferenceSet(ids: Set(span), liveness: target.side),
anchor: anchor
selection: ItemReferenceSet(ids: span, liveness: target.side),
anchor: anchor,
head: target.id
)
}
/// The ids between two items in one order list, inclusive **the span both extension gestures
/// select**, -click and -arrow alike.
///
/// It is a function rather than a branch inside `shift` because the keyboard needs the identical
/// answer: "a -arrow extends" (04-interactions.md Grammar) means exactly the range a -click
/// to the same item would produce, and two implementations of one span is two chances for the
/// pointer and the keyboard to disagree about what a range is.
///
/// **`nil` means the two do not share a list**, which folds the vanished endpoint, the nil
/// anchor's caller-side absence, and every axis crossing into one test a list is exactly one
/// (side, kind) pair. The callers differ on what they do with that: a click degrades to a plain
/// click (it names an unambiguous target), while a -arrow goes inert (its next step is
/// ambiguous).
public static func range(
from: ItemID,
to: ItemID,
kind: SelectionKind,
on side: Liveness,
in snapshot: BoardModel
) -> Set<ItemID>? {
let list = order(of: kind, on: side, in: snapshot)
guard let start = list.firstIndex(of: from), let end = list.firstIndex(of: to) else { return nil }
return Set(start <= end ? list[start...end] : list[end...start])
}
// MARK: - The order lists
/// The list a -range walks for one (side, kind) pair **the single place a "what's on the
@@ -284,6 +316,55 @@ public enum SelectionGrammar {
}
return nil
}
// MARK: - Successor on delete
/// What selects after tombstoning `ids` 04-interactions.md The map's Finder-style
/// successor sibling, as a pure function of the **pre-write** snapshot.
///
/// > Selection moves to the deleted item's successor sibling, Finder-style (next card in the
/// > lane, next lane on the board; the last sibling's predecessor otherwise; empty container =
/// > nothing selected) repeated walks down a lane.
///
/// Three decisions the wording implies and this states:
///
/// - **The container is the *last* deleted item's**, in flatten order the same "last member"
/// the N target rule and paste anchoring already share. A selection spanning lanes therefore
/// lands in the rightmost/bottom-most one, which is where the user was working.
/// - **The survivor search is forward first, then backward**: the first surviving sibling *after*
/// the last deleted position, else the last surviving sibling *before* the first deleted one.
/// Forward is what makes repeated walk down a lane rather than bouncing.
/// - **`nil` is a legitimate answer** an emptied container selects nothing, and the caller
/// clears.
///
/// **Deliberate deletes only.** External vanishing never picks a successor (02-architecture.md's
/// reload-survival rule: "the selection just shrinks"), which is why this is called by
/// `BoardStore.delete` and by nothing on the reload path.
public static func successor(afterDeleting ids: Set<ItemID>, in snapshot: BoardModel) -> ItemID? {
guard !ids.isEmpty else { return nil }
let selection = ItemReferenceSet(ids: ids, liveness: .live)
guard let kind = kind(of: selection, in: snapshot) else { return nil }
let container: [ItemID]
switch kind {
case .lane:
container = liveLanes(in: snapshot)
case .card:
// The last selected card in flatten order names the lane; its lane's rendered cards are
// the container the successor is drawn from.
guard let last = liveCards(in: snapshot).last(where: { ids.contains($0) }),
let lane = snapshot.lanes.first(where: { lane in
!lane.isDeleted && lane.cards.contains { $0.id == last && !$0.isDeleted }
})
else { return nil }
container = lane.cards.filter { !$0.isDeleted }.map(\.id)
}
let doomed = container.indices.filter { ids.contains(container[$0]) }
guard let first = doomed.first, let last = doomed.last else { return nil }
if let after = container[(last + 1)...].first(where: { !ids.contains($0) }) { return after }
return container[..<first].last { !ids.contains($0) }
}
}
// MARK: - The rubber band
@@ -344,7 +425,10 @@ public enum MarqueeMath {
///
/// Total rather than merely correct-for-a-column: two rows sharing a top edge must still order
/// the same way twice, or the topmost-kind rule would pick differently on identical input.
private static func isAbove(_ lhs: MarqueeTarget, _ rhs: MarqueeTarget) -> Bool {
///
/// Shared with `NavigationMath`, which breaks its score ties with it for the same reason: two
/// candidates that a metric cannot separate must still be separated the same way twice.
static func isAbove(_ lhs: MarqueeTarget, _ rhs: MarqueeTarget) -> Bool {
if lhs.frame.minY != rhs.frame.minY { return lhs.frame.minY < rhs.frame.minY }
if lhs.frame.minX != rhs.frame.minX { return lhs.frame.minX < rhs.frame.minX }
return lhs.id.rawValue < rhs.id.rawValue
+38 -15
View File
@@ -295,6 +295,19 @@ public final class TransientBoardState {
/// the same rule every other item reference gets.
public private(set) var selectionAnchor: ItemID?
/// **Where the next arrow steps from** the navigation cursor, AppKit's "lead": the item the
/// last click or arrow named (04-interactions.md Grammar's spatial navigation).
///
/// **Distinct from the anchor, and the difference is the whole reason both exist.** A -gesture
/// leaves the anchor exactly where it was that is what makes successive extensions sweep out
/// from one origin while the *head* walks to whatever was just reached, because the next
/// -arrow has to continue from there rather than from the origin. A plain click or arrow moves
/// both; a -click or -arrow moves only this.
///
/// A memory of a gesture like the anchor, on the selection's side by construction, and re-grounded
/// by `resolve(against:)` under the same universe rule.
public private(set) var selectionHead: ItemID?
/// The items a drag is carrying **empty when no drag is in flight**, which is what "no drag"
/// means here rather than a separate flag.
///
@@ -391,27 +404,34 @@ public final class TransientBoardState {
// MARK: - Selection
/// Replaces the selection, and sets the anchor a subsequent -click ranges from.
/// Replaces the selection, and sets the anchor a subsequent -click ranges from and the head a
/// subsequent arrow steps from.
///
/// The grammar itself is `SelectionGrammar`'s pure, testable, and the one funnel every click
/// surface goes through (`BoardStore.click`). This is the storage half, and its only rule of its
/// own is the **anchor default**: `nil` with a sole member anchors on that member, `nil` with
/// any other count anchors on nothing. That makes the two callers that pass nothing behave
/// exactly as they should a one-item selection made by any route is a legitimate range origin,
/// while a marquee or a Select All names no click and so leaves a -click acting plain.
/// own is the **anchor default**, which the head shares: `nil` with a sole member takes that
/// member, `nil` with any other count takes nothing. That makes the two callers that pass
/// nothing behave exactly as they should a one-item selection made by any route is a
/// legitimate range origin *and* a legitimate place to arrow from, while a marquee or a Select
/// All names no gesture and so leaves a -click acting plain and the arrows re-deriving a
/// position from the set's last member.
///
/// Deliberately **not** filtered against the snapshot: a caller selects what it is rendering, and
/// `resolve(against:)` on the next reload is what keeps the set honest over time.
public func select(_ ids: Set<ItemID>, liveness: Liveness, anchor: ItemID? = nil) {
public func select(_ ids: Set<ItemID>, liveness: Liveness, anchor: ItemID? = nil, head: ItemID? = nil) {
selection = ItemReferenceSet(ids: ids, liveness: liveness)
selectionAnchor = anchor ?? (ids.count == 1 ? ids.first : nil)
let sole = ids.count == 1 ? ids.first : nil
selectionAnchor = anchor ?? sole
selectionHead = head ?? sole
}
/// Selects nothing Escape's last step outward (04-interactions.md Grammar). The anchor goes
/// with it: an empty selection has no origin to range from.
/// Selects nothing Escape's last step outward (04-interactions.md Grammar). The anchor and
/// the head go with it: an empty selection has no origin to range from and no cursor to step
/// from which is exactly the state the arrows' seed rule answers.
public func clearSelection() {
selection = .empty
selectionAnchor = nil
selectionHead = nil
}
/// Records that `laneID` is where the user is working a lane selected, or created into.
@@ -597,15 +617,18 @@ public final class TransientBoardState {
if let lane = lastActiveLaneID, !live.contains(lane) {
lastActiveLaneID = nil
}
if let anchor = selectionAnchor {
// The selection's side, because that is the side the anchor lives on by construction
// every route that sets it sets the selection to the same side in the same call. A
// vanished or liveness-flipped anchor is gone, which is the rule every item reference
// here gets: "a flip is a vanish from its side of the boundary".
if selectionAnchor != nil || selectionHead != nil {
// The selection's side, because that is the side both cursors live on by construction
// every route that sets either sets the selection to the same side in the same call. A
// vanished or liveness-flipped cursor is gone, which is the rule every item reference
// here gets: "a flip is a vanish from its side of the boundary". The head then re-derives
// from the selection's last member on the next arrow, which is the same fallback an
// anchorless -arrow already uses.
let universe = selection.liveness == .live
? live
: ItemReferenceSet.idUniverse(of: snapshot, on: selection.liveness)
if !universe.contains(anchor) { selectionAnchor = nil }
if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil }
if let head = selectionHead, !universe.contains(head) { selectionHead = nil }
}
}
+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)