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. /// /// Five text surfaces, answered four ways, and only three 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 board settings sheet's fields** are the popover's rule for the popover's reason, and the /// surface the 2026-07-31 split moved most of those fields *to* (branch name, commit identity, and /// pro-m2's remote URL and credentials): the sheet is the other half of 04's configuration /// carve-out, and a lane move under a modal settings surface is not a gesture that exists. /// - **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?, boardSettings: BoardSettingsPresentation?, search: BoardSearchPresentation? ) -> Bool { boardInfo?.isPresented == true || boardSettings?.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 /// 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 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. /// /// **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:boardSettings: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(\.boardSettings) private var boardSettings @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, boardSettings: boardSettings, 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.container == .board, selection.ids.count == 1, let id = selection.ids.first else { return nil } let lanes = SelectionGrammar.lanes(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") { 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: - Board Settings /// Board ▸ Board Settings… — no default chord (11-command-nexus.md: "— (no default)"), and the board /// settings sheet's second door, the popover's row being the first (03-board-ui.md ▸ Board settings /// sheet). /// /// **"Remappable" asks nothing of this file.** The remapping mechanism is macOS's own — System /// Settings ▸ Keyboard ▸ App Shortcuts, keyed on the menu item's *title* (04-interactions.md ▸ /// Configurable bindings) — so all a chordless row owes it is a stable title, which the Nexus fixes. /// A `Button` with no `keyboardShortcut` is therefore the whole implementation, exactly as File ▸ /// Save as Template and Board ▸ Rename are. /// /// ### It opens; it never toggles /// /// Board Info ⌘I toggles because a popover reached by a chord would otherwise have no keyboard way /// out. A sheet has one built in (Done, and Escape through it), and the menu is behind the sheet /// while it is up — so a toggling row would be a second exit nobody can reach. /// /// ### Validation: scope, then reachability — and never the lock /// /// The row stays **visible and disabled** where the sheet cannot exist (`BoardSettingsAvailability`, /// which carries the reasoning): the free tier, and a Pro board nested inside someone else's /// repository. That is standard menu validation, and it is the deliberate asymmetry with the popover /// row, which is *absent* there instead — a menu is an inventory of the app, a popover section is a /// description of this board. /// /// The read-only lock does not close it, for Board Info's reason: a settings sheet is *configuration* /// (04-interactions.md ▸ The map's carve-out), a locked board is exactly when a user may want to read /// its git setup, and the controls inside disable themselves in place (03 ▸ Board settings sheet). struct BoardSettingsCommand: View { @FocusedValue(\.boardStore) private var store @FocusedValue(\.boardSettings) private var presentation var body: some View { Button("Board Settings…") { presentation?.present() } .disabled(store == nil || presentation?.isReachable != true) } } // 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(\.boardSettings) private var boardSettings @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, boardSettings: boardSettings, 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) } }