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: - 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 — it is not a menu item yet (m5), and when it is, it is the one /// item that must *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 } } // 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: - 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: - 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. /// /// **Validation is the sole-selected-lane rule.** Both items are enabled only when the focused /// board's selection resolves to exactly one live lane; a card selection, a multi-selection, a /// trash-side selection and an empty one all disable them. Decrease additionally disables at one /// unit, which is the floor. struct LaneWidthCommands: View { @FocusedValue(\.boardStore) private var store var body: some View { Button("Increase Lane Width") { step(by: 1) } .keyboardShortcut(.rightArrow, modifiers: [.option, .command]) .disabled(selectedLane == nil) Button("Decrease Lane Width") { step(by: -1) } .keyboardShortcut(.leftArrow, modifiers: [.option, .command]) .disabled(!canDecrease) } /// The sole selected live lane, or `nil` — the whole of these 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 selectedLane: Lane? { 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 } return store.snapshot.lanes.first { $0.id == id && !$0.isDeleted } } /// A one-unit lane cannot shrink: `width` is ≥ 1 (03-board-ui.md § Lane), and an item whose only /// outcome is a no-op reads better disabled than dead. private var canDecrease: Bool { guard let lane = selectedLane else { return false } return LaneLayoutMath.displayUnits(of: lane) > 1 } private func step(by delta: Int) { guard let store, let lane = selectedLane else { return } store.setLaneWidth(lane.id, units: LaneLayoutMath.displayUnits(of: lane) + delta) } }