CardMoveTarget (BoardCommands.swift), LaneMoveTarget's cousin: validates the clicked card's own current lane against the live lane order rather than the board's live selection, refuses a target that reaches outside that lane (no coherent left for a spread), and answers an index — the clicked card's own position in its lane, clamped — for the adjacent live lane in either direction. Trash is never a candidate (not a Lane); a collapsed lane is a fine landing (a fold hides cards, it doesn't close the lane). CardFaceView's Navigation rows now call moveCardAcrossLane(by:), which hands CardMoveTarget's answer straight to BoardStore.moveCards(_:toLane:at:) — the exact call a released drag makes, so rank-minting, the undo step, the watcher echo and the banner all come free. Targeting is Copy/Cut's own widening (targetIDs): the clicked card, or the live selection when the clicked card is a member of it. Enablement stays render-safe the way isSelected/selectedCount already are: three new CardFaceView parameters (hasLeftNeighbor, hasRightNeighbor, selectionSpansLanes) are hoisted once per lane in LaneView.scrollableCards and handed down as compared parameters, never read from inside a card face's own .disabled. Caught and fixed a real regression here during development: an early cut of the multi-lane-spread check answered true for any lane that simply didn't contain the selected card, which flipped a compared parameter for most of the board on an ordinary single-card select and defeated CardFaceView's equality gate wholesale (BoardRenderPerformanceTests.selectionStillRepaints caught it at 151 of 180 card faces). Tests: CardMoveTargetTests (KeyboardGrammarTests.swift) pins the pure predicate — leftmost/rightmost lane, single lane, index clamping, a widened group anchoring on the clicked member rather than its own extent, the multi-lane-spread refusal, and an integration test feeding the answer straight through moveCards. CardFaceViewEquatableTests gains a case pinning the three new compared parameters. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
743 lines
37 KiB
Swift
743 lines
37 KiB
Swift
import Observation
|
|
import SwiftUI
|
|
|
|
// MARK: - The focused board
|
|
|
|
/// The frontmost board window's store, published into the focus system by `BoardWindowHost` so menu
|
|
/// items can act on "the board in front" without the app model keeping a which-window-is-key
|
|
/// register of its own.
|
|
///
|
|
/// `focusedSceneValue` rather than `focusedValue`: the value is the *window's*, not any particular
|
|
/// control's, so it stays available whatever inside the board has keyboard focus — which is what a
|
|
/// menu item validating against the selection needs.
|
|
struct FocusedBoardStoreKey: FocusedValueKey {
|
|
typealias Value = BoardStore
|
|
}
|
|
|
|
extension FocusedValues {
|
|
var boardStore: BoardStore? {
|
|
get { self[FocusedBoardStoreKey.self] }
|
|
set { self[FocusedBoardStoreKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
// 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: (@MainActor (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.
|
|
///
|
|
/// - **The read-only lock** (02-architecture.md § The lock's scope): "every mutating command
|
|
/// disables via menu validation" across every window sharing the store. An item that is going to
|
|
/// be refused should not look available.
|
|
/// - **The focused-editor rule** (04-interactions.md ▸ Grammar, settled): "while an inline title
|
|
/// 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 — `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.
|
|
extension BoardStore {
|
|
var acceptsBoardMutations: Bool {
|
|
!isReadOnly && !isEditingInline
|
|
}
|
|
}
|
|
|
|
/// **The caret-chords rule, as one expression** (04-interactions.md ▸ Grammar, settled):
|
|
///
|
|
/// > Board ▸ Move Left/Move Right ⌘←/⌘→ and the width pair ⌥⌘←/⌥⌘→ disable via menu validation
|
|
/// > whenever *any* text control has keyboard focus — inline title editors, the board search field,
|
|
/// > board-popover fields (rename, git identity, remote), and card-window fields — because an
|
|
/// > enabled menu key equivalent fires before the field ever sees the key, and ⌘←/⌘→ are the
|
|
/// > standard line-start/end caret chords.
|
|
///
|
|
/// Four text surfaces, answered three ways, and only two of them are here:
|
|
///
|
|
/// - **Inline title editors** are `acceptsBoardMutations`', through the focused-editor rule — a
|
|
/// broader lockdown that already covers these two items.
|
|
/// - **The board popover's fields** are covered by disabling while the popover is open at all —
|
|
/// coarser than per-field focus, but it is a configuration surface (04's carve-out) and no lane
|
|
/// move belongs under it. That clause covered **every** configuration field the app had since
|
|
/// 2026-08-07: the 2026-07-31 split moved most of them to a board settings sheet, whose flag this
|
|
/// function read as a third disjunct, and the reversal brought them back under the popover's own
|
|
/// open-at-all rule — branch creation, commit identity, and pro-m2's remote URL and credentials
|
|
/// among them, all in the popover's Git tab at the time. The Git tab itself retired with
|
|
/// app-managed git (`strategy/01-git-excision.md`, 2026-08-08); the popover's open-at-all rule
|
|
/// now guards only `BoardInfoTabView`'s ordinary fields.
|
|
/// - **The search field** is per-focus and exact (`BoardSearchPresentation.isFocused`), which it has
|
|
/// to be: the field's own rule is that board commands *stay enabled* while it holds the keyboard
|
|
/// (04 § Search), so these two are the narrow exception to it and nothing coarser would do.
|
|
/// - **Card-window fields** need nothing: those windows never publish a `boardStore`, so both items
|
|
/// are already scopeless there.
|
|
///
|
|
/// Stated once because the two command groups must not drift: a rule with two implementations is a
|
|
/// rule with two chances to forget a surface.
|
|
@MainActor
|
|
func caretChordsYield(
|
|
boardInfo: BoardInfoPresentation?,
|
|
search: BoardSearchPresentation?
|
|
) -> Bool {
|
|
boardInfo?.isPresented == true || search?.isFocused == true
|
|
}
|
|
|
|
// 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 || store.openCardTarget != nil
|
|
}
|
|
|
|
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 }) {
|
|
store.select([lane], in: .board)
|
|
}
|
|
if let created { open(created) }
|
|
return
|
|
}
|
|
|
|
if let editor = store.transient.renameEditor {
|
|
let target = editor.targetID
|
|
let isCard = BoardStore.boardItem(target, in: store.snapshot)?.cardID != nil
|
|
store.commitRename()
|
|
if isCard { open(target) }
|
|
return
|
|
}
|
|
|
|
if let card = store.openCardTarget { open(card) }
|
|
}
|
|
}
|
|
|
|
// MARK: - The three edit-shaped targets, as pure predicates
|
|
|
|
/// **Everything edit-shaped refuses a trash selection** (04-interactions.md ▸ The trash: "Everything
|
|
/// edit-shaped is disabled on trash selections — Open Card, Rename, Style…"), and each of the three
|
|
/// answers that with one expression used for both its `disabled` state and its action — the
|
|
/// `newCardTarget` idiom, for its reason: two derivations of a rule are two chances to disagree.
|
|
///
|
|
/// They live on the store rather than inside the three menu rows so the grammar can be pinned
|
|
/// without a menu (`SelectionGrammarTests`) — the same reason `TrashModel`'s validation is a pure
|
|
/// function of a snapshot and a selection. A view-private predicate is a rule nobody can test.
|
|
extension BoardStore {
|
|
|
|
/// Board ▸ Open Card's target: the sole selected **board card**, or `nil`. A lane, a
|
|
/// multi-selection and a trash selection all answer `nil` — a card window is tied to one card,
|
|
/// and trash cards don't open ("double-click stops at selection; move it out first" — 03 §
|
|
/// Trash).
|
|
///
|
|
/// Deliberately free of `acceptsBoardMutations`: opening a window is not a mutation, and the
|
|
/// item's own mid-edit branch is the focused-editor rule's one carve-out (`OpenCardCommand`).
|
|
var openCardTarget: ItemID? {
|
|
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
|
|
Self.boardItem(id, in: snapshot)?.cardID != nil
|
|
else { return nil }
|
|
return id
|
|
}
|
|
|
|
/// Board ▸ Rename's target: the sole selected board item, card or lane, with the title to seed
|
|
/// the editor with — or `nil`. A trash selection never enables it, which `ItemReferenceSet`'s
|
|
/// container answers directly.
|
|
var renameTarget: (id: ItemID, title: String?)? {
|
|
guard acceptsBoardMutations else { return nil }
|
|
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
|
|
let item = Self.boardItem(id, in: snapshot)
|
|
else { return nil }
|
|
return (id: id, title: item.title)
|
|
}
|
|
|
|
/// Board ▸ Style…'s target: the selected board items, or **the board itself** when nothing is
|
|
/// selected — "Board window: selected cards or lane; nothing selected = the board".
|
|
///
|
|
/// **A trash selection disables it rather than falling through to the board**: quietly restyling
|
|
/// the board because the user had a trashed card selected would be the silent retarget 03
|
|
/// forbids.
|
|
var boardStyleTarget: StyleTarget? {
|
|
guard acceptsBoardMutations else { return nil }
|
|
guard !selection.isEmpty else { return .board }
|
|
guard selection.container == .board else { return nil }
|
|
// Re-resolved against the snapshot on the way in, so the session starts out holding only
|
|
// items that render — the same universe its own reload rule will hold it to.
|
|
let live = selection.resolved(against: snapshot).ids
|
|
return live.isEmpty ? nil : .items(live)
|
|
}
|
|
}
|
|
|
|
// 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 trash 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
|
|
|
|
/// **The pure predicate behind Board ▸ Move Left / Move Right** — extracted so the menu-bar row
|
|
/// (`MoveLaneCommands`) and the card context menu's Navigation submenu (`CardFaceView`, 2026-08-09 ▸
|
|
/// "redesign context menu for cards") validate off one definition rather than two that could drift.
|
|
///
|
|
/// "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 batches over a multi-lane selection; this rule'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 this
|
|
/// validates on exactly one selected live lane.
|
|
///
|
|
/// **A card id answers `nil` here, on purpose and unconditionally** — "a card id is in no lane
|
|
/// order, so this is also the 'not a lane' test". That is the whole of why the card context menu's
|
|
/// own Navigation rows are a structural mismatch rather than a card-scoped move: this predicate reads
|
|
/// the *board's live selection*, never the clicked card, so a card menu's Move Left/Right can only
|
|
/// ever be live when the selection elsewhere on the board happens to be a sole lane — see
|
|
/// `CardFaceView`'s own note on why it does not even attempt to read this per card.
|
|
///
|
|
/// **Never into the trash** costs nothing: the column is not in the 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.
|
|
enum LaneMoveTarget {
|
|
|
|
/// 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.
|
|
static func destination(
|
|
selection: ItemReferenceSet,
|
|
snapshot: BoardModel,
|
|
delta: Int
|
|
) -> (lane: ItemID, index: Int)? {
|
|
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first
|
|
else { return nil }
|
|
let lanes = SelectionGrammar.lanes(in: 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)
|
|
}
|
|
}
|
|
|
|
/// 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). The
|
|
/// validating predicate is `LaneMoveTarget.destination(selection:snapshot:delta:)`; this struct is the
|
|
/// menu-bar row around it.
|
|
///
|
|
/// **Caret chords yield to any focused text control** (04-interactions.md ▸ Grammar, settled):
|
|
/// ⌘←/⌘→ are the standard line-start/end chords, and an enabled key equivalent fires before a
|
|
/// field ever sees the key. Which surfaces that covers, and how each is answered, is
|
|
/// `caretChordsYield(boardInfo:search:)`'s doc comment — shared verbatim with the
|
|
/// width pair below.
|
|
struct MoveLaneCommands: View {
|
|
|
|
@FocusedValue(\.boardStore) private var store
|
|
@FocusedValue(\.boardInfo) private var boardInfo
|
|
@FocusedValue(\.boardSearch) private var search
|
|
|
|
var body: some View {
|
|
Button("Move Left") {
|
|
move(by: -1)
|
|
}
|
|
.keyboardShortcut(.leftArrow, modifiers: .command)
|
|
.disabled(yieldsCaretChords || destination(-1) == nil)
|
|
|
|
Button("Move Right") {
|
|
move(by: 1)
|
|
}
|
|
.keyboardShortcut(.rightArrow, modifiers: .command)
|
|
.disabled(yieldsCaretChords || destination(1) == nil)
|
|
}
|
|
|
|
private var yieldsCaretChords: Bool {
|
|
caretChordsYield(boardInfo: boardInfo, search: search)
|
|
}
|
|
|
|
private func destination(_ delta: Int) -> (lane: ItemID, index: Int)? {
|
|
guard let store, store.acceptsBoardMutations else { return nil }
|
|
return LaneMoveTarget.destination(selection: store.selection, snapshot: store.snapshot, delta: delta)
|
|
}
|
|
|
|
private func move(by delta: Int) {
|
|
guard let store, let target = destination(delta) else { return }
|
|
store.moveLane(target.lane, toIndex: target.index)
|
|
}
|
|
}
|
|
|
|
// MARK: - Card context menu ▸ Navigation (cross-lane card move)
|
|
|
|
/// **The pure predicate behind the card context menu's Navigation ▸ Move Left / Move Right**
|
|
/// (`CardFaceView`, 2026-08-09 ▸ "Give the card context menu's Navigation rows real card-move
|
|
/// behavior", card 06322636) — `LaneMoveTarget`'s cousin, not its reuse: that predicate answers `nil`
|
|
/// for a card id unconditionally ("a card id is in no lane order" — its own doc comment), because it
|
|
/// validates the **board's live selection** against the *lane* order. This one validates a *card's*
|
|
/// own current lane against the same order, which is the whole difference between "move the selected
|
|
/// lane one slot" and "move this card into the neighbouring lane".
|
|
///
|
|
/// **Targeting is Copy/Cut's own widening** (`CardFaceView.targetIDs`, `.clipboardTarget`): the
|
|
/// clicked card alone, widened to the live selection when the clicked card is a member of it. Passed
|
|
/// in as `targeting` rather than re-derived here — this type never reads `store.selection`, so the
|
|
/// same function serves a render-safe `.disabled` predicate (`CardFaceView`'s hoisted
|
|
/// `hasLeftNeighbor`/`hasRightNeighbor`/`selectionSpansLanes`) and the action underneath it without
|
|
/// either one re-deriving the other's answer.
|
|
///
|
|
/// **Destination is the adjacent *live* lane** — owner ruling: "skip nothing — lanes are lanes; the
|
|
/// trash is not a lane and is never a destination; collapsed lanes ARE valid destinations". That is
|
|
/// exactly `SelectionGrammar.lanes(in:)`'s own list: every lane in `snapshot.lanes`, board order,
|
|
/// unfiltered by fold state, and the trash never in it because it is not a `Lane` at all
|
|
/// (`BoardModel.trash`/`trashedLanes` are separate arrays). Reused rather than re-walked, the same
|
|
/// list `LaneMoveTarget` steps by `delta`.
|
|
///
|
|
/// **Position is index-preserving** — owner ruling: "preserve the moved group's internal order and
|
|
/// land at the clicked card's relative visual index in the destination lane, clamped". The *clicked*
|
|
/// card's index in its own lane's display order (`Lane.cards`, `moveCards`'s own counting space) is
|
|
/// the anchor — not the group's leading or trailing edge — clamped to the destination lane's card
|
|
/// count exactly as `moveCards` clamps any index handed to it (`DropSlotMath.applied`'s convention,
|
|
/// restated at the call site once more so the clamp never depends on `moveCards` catching an
|
|
/// out-of-range value silently). **Internal order** is `moveCards`' own: it walks `snapshot.lanes` in
|
|
/// display order and takes members as it finds them (`BoardStore.draggedCards`), so a `targeting` set
|
|
/// entirely inside one lane always arrives at `moveCards` already in that lane's own order, whatever
|
|
/// order the `Set` iterates in — nothing here has to re-sort it.
|
|
///
|
|
/// **A multi-lane spread answers `nil`** — owner ruling: "no coherent 'left' for a spread". `targeting`
|
|
/// must be non-empty and entirely inside the clicked card's own lane, or this refuses; there is no
|
|
/// widened-but-not-really-this-lane fallback.
|
|
enum CardMoveTarget {
|
|
|
|
/// The live lane one step from `laneID`, board order, or `nil` at a wall or for an id
|
|
/// `snapshot.lanes` no longer holds — the shared half of `hasNeighbor(of:delta:in:)` and
|
|
/// `destination(clicked:targeting:snapshot:delta:)`.
|
|
private static func adjacentLane(of laneID: ItemID, delta: Int, in snapshot: BoardModel) -> ItemID? {
|
|
let lanes = SelectionGrammar.lanes(in: snapshot)
|
|
guard let from = lanes.firstIndex(of: laneID) else { return nil }
|
|
let to = from + delta
|
|
guard lanes.indices.contains(to) else { return nil }
|
|
return lanes[to]
|
|
}
|
|
|
|
/// Whether `laneID` has a live neighbour one step in `delta`'s direction — `hasLeftNeighbor` /
|
|
/// `hasRightNeighbor`'s pure half, meant to be called **once per lane** at the arrangement level
|
|
/// (`LaneView.scrollableCards`) and handed to every card face in it as a compared parameter, the
|
|
/// exact `isSelected`/`selectedCount` pattern: cheap (`O(lane count)`, not `O(board cards)`), and
|
|
/// never read from inside a card face's own `.disabled` (`CardFaceView`'s own render-safety note).
|
|
static func hasNeighbor(of laneID: ItemID, delta: Int, in snapshot: BoardModel) -> Bool {
|
|
adjacentLane(of: laneID, delta: delta, in: snapshot) != nil
|
|
}
|
|
|
|
/// Whether `selectedIDs` — the live board selection, already narrowed to `.board`'s container by
|
|
/// the caller (`LaneView`'s own `selectedIDs` hoist) — has **both** a member inside `laneCardIDs`,
|
|
/// this lane's own cards, **and** a member outside it: a genuine spread, not merely "the selection
|
|
/// exists and this happens to be some other lane". The other half of the enablement fold,
|
|
/// `hasNeighbor`'s twin: also lane-hoisted, also free of any `store.selection`/`store.snapshot`
|
|
/// read of its own (the caller already did the one read this needs).
|
|
///
|
|
/// **The `inLane` guard is load-bearing, not a shortcut.** A selection with no member in this lane
|
|
/// at all must answer `false` here — every card in an untouched lane has no `isSelected` face to
|
|
/// gate in the first place, so the *value* has to stay `false` (and therefore unchanged) whenever
|
|
/// the live selection moves entirely outside this lane, or every face in every other lane on the
|
|
/// board becomes a compared difference on a selection change that has nothing to do with them
|
|
/// (`CardFaceView.==`'s gate would stop suppressing rebuilds it exists to suppress — caught by
|
|
/// `BoardRenderPerformanceTests.selectionStillRepaints`, which asserts a selection change costs a
|
|
/// handful of faces, not the board).
|
|
///
|
|
/// Only matters when the clicked card is itself selected — an unselected clicked card's target is
|
|
/// always `{card.id}` alone, trivially confined — but is cheap enough to hoist unconditionally
|
|
/// rather than gate on `isSelected` a second time.
|
|
static func selectionSpansOtherLanes(_ selectedIDs: Set<ItemID>, laneCardIDs: Set<ItemID>) -> Bool {
|
|
guard !selectedIDs.isEmpty, selectedIDs.contains(where: { laneCardIDs.contains($0) }) else {
|
|
return false
|
|
}
|
|
return selectedIDs.contains { !laneCardIDs.contains($0) }
|
|
}
|
|
|
|
/// Where `targeting` would land one lane over, or `nil` when it cannot move at all: `clicked` is
|
|
/// not a live board card, `targeting` is empty or reaches outside `clicked`'s own lane (the
|
|
/// multi-lane-spread refusal), or that lane has no live neighbour in `delta`'s direction.
|
|
///
|
|
/// The index is `clicked`'s own position in its lane's display order, clamped to the destination
|
|
/// lane's card count — see the type comment's "Position is index-preserving". The caller hands the
|
|
/// answer straight to `BoardStore.moveCards(_:toLane:at:)`, unmodified: rank-minting, the undo
|
|
/// step, the watcher echo and the banner all come from that one call, exactly as they do for a
|
|
/// released drag.
|
|
static func destination(
|
|
clicked: ItemID,
|
|
targeting: Set<ItemID>,
|
|
snapshot: BoardModel,
|
|
delta: Int
|
|
) -> (laneID: ItemID, index: Int)? {
|
|
guard let sourceLane = snapshot.lanes.first(where: { lane in lane.cards.contains { $0.id == clicked } }),
|
|
let clickedIndex = sourceLane.cards.firstIndex(where: { $0.id == clicked }),
|
|
!targeting.isEmpty,
|
|
targeting.allSatisfy({ id in sourceLane.cards.contains { $0.id == id } })
|
|
else { return nil }
|
|
|
|
guard let destinationLaneID = adjacentLane(of: sourceLane.id, delta: delta, in: snapshot)
|
|
else { return nil }
|
|
let destinationCount = snapshot.lanes.first(where: { $0.id == destinationLaneID })?.cards.count ?? 0
|
|
|
|
return (laneID: destinationLaneID, index: min(clickedIndex, destinationCount))
|
|
}
|
|
}
|
|
|
|
// MARK: - Creation items
|
|
|
|
/// File ▸ New Card (⌘N) and File ▸ New Lane (⇧⌘N) — 11-command-nexus.md's two creation rows.
|
|
///
|
|
/// **New Card resolves its target through `NewCardTarget`**, the ⌘N target rule as a pure function,
|
|
/// and uses the *same* answer for its `disabled` state as for its action: a `nil` resolution is the
|
|
/// zero-lane board, where "card creation and card paste have no target — New Card, Return-creation,
|
|
/// and Paste with a card payload disable via menu validation until a lane exists"
|
|
/// (04-interactions.md ▸ The map). Two derivations of that condition would be two chances to
|
|
/// disagree.
|
|
///
|
|
/// **New Lane is enabled whenever the board accepts writes.** It is the way *out* of a zero-lane
|
|
/// board — "New Lane (⇧⌘N) is one way in" — so it can have no selection precondition at all. The
|
|
/// lane it creates is untitled and no editor opens on it; see `BoardStore.createLane`.
|
|
struct BoardCreationCommands: View {
|
|
|
|
@FocusedValue(\.boardStore) private var store
|
|
|
|
var body: some View {
|
|
Button("New Card") {
|
|
store?.beginNewCard()
|
|
}
|
|
.keyboardShortcut("n", modifiers: .command)
|
|
.disabled(store?.newCardTarget == nil)
|
|
|
|
Button("New Lane") {
|
|
store?.createLane()
|
|
}
|
|
.keyboardShortcut("n", modifiers: [.shift, .command])
|
|
.disabled(store?.acceptsBoardMutations != true)
|
|
}
|
|
}
|
|
|
|
extension BoardStore {
|
|
|
|
/// Where ⌘N would file a card, or `nil` when it cannot — a board that refuses writes, an inline
|
|
/// editor holding the keyboard, or a board with no lanes.
|
|
///
|
|
/// On the store rather than private to the menu row because **the toolbar's New Card item is the
|
|
/// same command** (03-board-ui.md ▸ Toolbar: toolbar items mirror menu commands), and a second
|
|
/// derivation of this rule would be a second chance to disagree with it — the same reason the row
|
|
/// itself uses one answer for both its action and its validation.
|
|
var newCardTarget: NewCardTarget.Resolution? {
|
|
guard acceptsBoardMutations else { return nil }
|
|
return NewCardTarget.resolve(
|
|
selection: selection,
|
|
lastActiveLaneID: transient.lastActiveLaneID,
|
|
snapshot: snapshot
|
|
)
|
|
}
|
|
|
|
/// ⌘N's action — opening the new-card placeholder wherever `newCardTarget` says. Shared with the
|
|
/// toolbar item that mirrors the row.
|
|
func beginNewCard() {
|
|
guard let target = newCardTarget else { return }
|
|
transient.beginPlaceholder(inLane: target.laneID, after: target.anchorCardID)
|
|
}
|
|
}
|
|
|
|
// MARK: - Board Info
|
|
|
|
/// File ▸ Board Info (⌘I) — the board popover's keyboard path (11-command-nexus.md; class **C**,
|
|
/// whose "keyboard path is reachability (Board Info ⌘I + Tab-reachable controls), not bindings").
|
|
///
|
|
/// **It toggles**, because the popover's other entry point is a disclosure widget and a shortcut
|
|
/// that could only ever open would leave the surface with no keyboard way out.
|
|
///
|
|
/// Two focused values, both required: the store is what makes this a *board window* item (the
|
|
/// scope 11 gives the row), and the presentation is the window's own popover flag — see
|
|
/// `BoardInfoPresentation` for why the flag is per window rather than per board.
|
|
///
|
|
/// **Validation is scope and nothing else.** Neither the read-only lock nor the focused-editor rule
|
|
/// closes it, unlike every mutating item above: the popover is *configuration* (04-interactions.md
|
|
/// ▸ The map's carve-out), a locked board is exactly when a user wants to read its title and
|
|
/// styling, and the controls inside disable themselves.
|
|
struct BoardInfoCommand: View {
|
|
|
|
@FocusedValue(\.boardStore) private var store
|
|
@FocusedValue(\.boardInfo) private var presentation
|
|
|
|
var body: some View {
|
|
Button("Board Info") {
|
|
presentation?.toggle()
|
|
}
|
|
.keyboardShortcut("i", modifiers: .command)
|
|
.disabled(store == nil || presentation == nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - Copy Link
|
|
|
|
extension BoardStore {
|
|
|
|
/// Board ▸ Copy Link's target: the sole selected **live board card**'s folder URL, or `nil` — "a
|
|
/// link is singular" (design ruling 2026-08-09, card 737a949f "Add an option to card context menu
|
|
/// to copy a link to the card folder"), the same board-card-only shape `openCardTarget` answers
|
|
/// for its own reason: a lane, a multi-selection and a trash selection all disable it.
|
|
///
|
|
/// **Additionally gated on `acceptsBoardMutations`**, unlike `openCardTarget` — the ruling asks
|
|
/// for this in as many words ("disabled … wherever edit-shaped actions already disable"), even
|
|
/// though writing a link to the pasteboard changes nothing on disk. Worth flagging rather than
|
|
/// silently matching: Copy Link could have stayed live under the lock the way Reveal in Finder
|
|
/// does ("not edit-shaped … inspecting a folder before a purge is exactly the errand it exists
|
|
/// for" — `CardFaceView.trashMenu`'s doc), but the ruling states the gate explicitly, so it is
|
|
/// implemented as written rather than re-litigated here (the card's DECISIONS comment flags it).
|
|
var copyLinkTarget: URL? {
|
|
guard acceptsBoardMutations else { return nil }
|
|
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
|
|
Self.boardItem(id, in: snapshot)?.cardID != nil
|
|
else { return nil }
|
|
return ItemPath.resolve([id], in: .board, snapshot: snapshot).first?.folder(under: rootURL)
|
|
}
|
|
|
|
/// Board ▸ Copy Link's action, and the menu-bar row's one write. The context menu's own Copy Link
|
|
/// row (`CardFaceView.copyLink`) does not call this: a context menu names its target by where it
|
|
/// was invoked (`targetIDs`'s standing rule, shared with Style… and Delete), not by the live
|
|
/// selection this property reads.
|
|
func copyCardLink(to pasteboard: FolderLinkPasteboard = SystemFolderLinkPasteboard()) {
|
|
guard let folder = copyLinkTarget else { return }
|
|
pasteboard.write(link: folder)
|
|
}
|
|
}
|
|
|
|
/// Board ▸ Copy Link — no default chord (11-command-nexus.md; design ruling 2026-08-09, card
|
|
/// 737a949f). The menu-bar twin the ruling asks for ("follow the codebase's CURRENT command
|
|
/// conventions … if the every-function-a-menu-item contract still governs, add the item … in the
|
|
/// matching menu"): 11-command-nexus.md's contract is still in force, so this exists beside the card
|
|
/// context menu's own row (`CardFaceView.boardMenu`) rather than instead of it.
|
|
///
|
|
/// One answer (`copyLinkTarget`) for both the row's `disabled` state and its action, `BoardRenameCommand`'s
|
|
/// shape.
|
|
struct CopyLinkCommand: View {
|
|
|
|
@FocusedValue(\.boardStore) private var store
|
|
|
|
var body: some View {
|
|
Button("Copy Link") {
|
|
store?.copyCardLink()
|
|
}
|
|
.disabled(store?.copyLinkTarget == nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - Rename
|
|
|
|
/// Board ▸ Rename — no default chord, deliberately (11-command-nexus.md: "— (cards: Return in
|
|
/// place)"), and remappable like any other item.
|
|
///
|
|
/// It "exists for completeness and remapping" for cards, whose real path is Return, and it is a
|
|
/// **lane's only rename path**: Return on a lane creates a card, so without this item a lane could
|
|
/// never be renamed at all (04-interactions.md ▸ Selection).
|
|
///
|
|
/// Validation is the sole-selected-board-item rule — card or lane, either kind, exactly one. A
|
|
/// trash selection never enables it: "everything edit-shaped is disabled on trash selections"
|
|
/// (04 ▸ The trash), which `ItemReferenceSet`'s container answers directly.
|
|
struct BoardRenameCommand: View {
|
|
|
|
@FocusedValue(\.boardStore) private var store
|
|
|
|
var body: some View {
|
|
Button("Rename") {
|
|
guard let store, let target = store.renameTarget else { return }
|
|
store.transient.beginRename(of: target.id, currentTitle: target.title)
|
|
}
|
|
.disabled(store?.renameTarget == nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - Style
|
|
|
|
/// Board ▸ Style… (⌥⌘S) — the style editor's menu-bar anchor (11-command-nexus.md;
|
|
/// 03-board-ui.md § Styling ▸ Controls).
|
|
///
|
|
/// **Selection-aware, with the board as the empty-selection case**: "Board window: selected cards or
|
|
/// lane; nothing selected = the board". The item does not present anything itself — it opens the
|
|
/// session (`TransientBoardState.beginStyleEditor`) and the board window's anchors decide which
|
|
/// surface hosts the popover, which is what keeps the presentation attached to what is being styled
|
|
/// rather than to the menu bar.
|
|
///
|
|
/// Validation is `acceptsBoardMutations` — the lock and the focused-editor rule, the latter naming
|
|
/// Style in its own list of board-scoped commands (04-interactions.md ▸ Grammar) — plus one rule of
|
|
/// its own: **a trash selection disables it rather than falling through to the board**
|
|
/// (`BoardStore.boardStyleTarget`, where both halves live).
|
|
struct BoardStyleCommand: View {
|
|
|
|
@FocusedValue(\.boardStore) private var store
|
|
|
|
var body: some View {
|
|
Button("Style…") {
|
|
guard let store, let target = store.boardStyleTarget else { return }
|
|
store.transient.beginStyleEditor(for: target)
|
|
}
|
|
.keyboardShortcut("s", modifiers: [.option, .command])
|
|
.disabled(store?.boardStyleTarget == nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - Lane width items
|
|
|
|
/// Increase / Decrease Lane Width — **the width stepper's keyboard face** (03-board-ui.md § Lane,
|
|
/// 11-command-nexus.md), and therefore the *re-divide* mechanism: each step re-divides the existing
|
|
/// window width across the new unit total, compressing the siblings. They never touch the window's
|
|
/// size — window-growing behaviour belongs to the right-edge drag alone — and they are uncapped, so
|
|
/// widths beyond what the screen can fit stay reachable here even though the drag hard-stops.
|
|
///
|
|
/// **They batch over a multi-lane selection** (03-board-ui.md § Lane, settled — the styling
|
|
/// precedent): each selected lane steps one unit, one gesture, one commit
|
|
/// (`BoardStore.stepLaneWidths`); the context-menu stepper stays single-lane by nature. A card
|
|
/// selection, a trash-side selection and an empty one all disable them. Decrease additionally
|
|
/// disables when **every** selected lane is at the one-unit floor — a mixed batch stays live, its
|
|
/// floor members simply holding (the style batch's silent skip).
|
|
///
|
|
/// **⌥⌘←/⌥⌘→ yield to any focused text control** (04-interactions.md ▸ Grammar's caret-chords
|
|
/// rule) — see `MoveLaneCommands`, whose ⌘←/⌘→ carry the same obligation and whose doc comment
|
|
/// carries the mechanism.
|
|
struct LaneWidthCommands: View {
|
|
|
|
@FocusedValue(\.boardStore) private var store
|
|
@FocusedValue(\.boardInfo) private var boardInfo
|
|
@FocusedValue(\.boardSearch) private var search
|
|
|
|
var body: some View {
|
|
Button("Increase Lane Width") {
|
|
step(by: 1)
|
|
}
|
|
.keyboardShortcut(.rightArrow, modifiers: [.option, .command])
|
|
.disabled(yieldsCaretChords || selectedLanes.isEmpty)
|
|
|
|
Button("Decrease Lane Width") {
|
|
step(by: -1)
|
|
}
|
|
.keyboardShortcut(.leftArrow, modifiers: [.option, .command])
|
|
.disabled(yieldsCaretChords || !canDecrease)
|
|
}
|
|
|
|
private var yieldsCaretChords: Bool {
|
|
caretChordsYield(boardInfo: boardInfo, search: search)
|
|
}
|
|
|
|
/// The selected live lanes, in snapshot order — the batch, and the items' validation.
|
|
///
|
|
/// The lock and the open-editor rule are folded in through `acceptsBoardMutations` rather than
|
|
/// left for the write to refuse: an item that is going to fail should not look available.
|
|
private var selectedLanes: [Lane] {
|
|
guard let store, store.acceptsBoardMutations else { return [] }
|
|
let selection = store.selection
|
|
guard selection.container == .board, !selection.isEmpty else { return [] }
|
|
return store.snapshot.lanes.filter { selection.ids.contains($0.id) }
|
|
}
|
|
|
|
/// `width` is ≥ 1 (03-board-ui.md § Lane), and an item whose only outcome is a no-op reads
|
|
/// better disabled than dead — which for a batch means *some* member must have room to shrink.
|
|
private var canDecrease: Bool {
|
|
selectedLanes.contains { LaneLayoutMath.displayUnits(of: $0) > 1 }
|
|
}
|
|
|
|
private func step(by delta: Int) {
|
|
guard let store else { return }
|
|
let lanes = selectedLanes
|
|
guard !lanes.isEmpty else { return }
|
|
store.stepLaneWidths(Set(lanes.map(\.id)), by: delta)
|
|
}
|
|
}
|