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: ((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 four 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. /// - **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 || 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. /// /// **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) } /// 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. /// /// **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") { guard let store, let target = newCardTarget else { return } store.transient.beginPlaceholder(inLane: target.laneID, after: target.anchorCardID) } .keyboardShortcut("n", modifiers: .command) .disabled(newCardTarget == nil) Button("New Lane") { store?.createLane() } .keyboardShortcut("n", modifiers: [.shift, .command]) .disabled(store?.acceptsBoardMutations != true) } /// Where ⌘N would file a card, or `nil` when it cannot — no focused board, a board that refuses /// writes, an inline editor holding the keyboard, or a board with no lanes. private var newCardTarget: NewCardTarget.Resolution? { guard let store, store.acceptsBoardMutations else { return nil } return NewCardTarget.resolve( selection: store.selection, lastActiveLaneID: store.transient.lastActiveLaneID, snapshot: store.snapshot ) } } // 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: - 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-live-item rule — card or lane, either kind, exactly one. A /// tombstoned selection never enables it: "everything edit-shaped is disabled on tombstoned /// selections" (04 ▸ The trash), which `ItemReferenceSet`'s liveness side answers directly. struct BoardRenameCommand: View { @FocusedValue(\.boardStore) private var store var body: some View { Button("Rename") { guard let store, let target = renameTarget else { return } store.transient.beginRename(of: target.id, currentTitle: target.title) } .disabled(renameTarget == nil) } private var renameTarget: (id: ItemID, title: String?)? { 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, let item = BoardStore.liveItem(id, in: store.snapshot) else { return nil } return (id: id, title: item.title) } } // 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 tombstoned selection disables it rather than falling through to the board.** /// Everything edit-shaped is disabled on tombstoned selections (04 ▸ The trash), and quietly /// restyling the board because the user had a trashed card selected would be the silent retarget /// 03 forbids. struct BoardStyleCommand: View { @FocusedValue(\.boardStore) private var store var body: some View { Button("Style…") { guard let store, let target = styleTarget else { return } store.transient.beginStyleEditor(for: target) } .keyboardShortcut("s", modifiers: [.option, .command]) .disabled(styleTarget == nil) } private var styleTarget: StyleTarget? { guard let store, store.acceptsBoardMutations else { return nil } let selection = store.selection guard !selection.isEmpty else { return .board } guard selection.liveness == .live 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: store.snapshot).ids return live.isEmpty ? nil : .items(live) } } // 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.liveness == .live, !selection.isEmpty else { return [] } return store.snapshot.lanes.filter { selection.ids.contains($0.id) && !$0.isDeleted } } /// `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) } }