Edit ▸ Undo/Redo become the app's own replaced rows and the board toolbar pair takes explicit targets, both reading the focused session's BoardUndoManager through FocusedValues.undoStack (board windows publish the session's manager, card windows their own) — the nil-target route died with the SwiftUI window latch, 13-native-undo.md ▸ Rules ▸ command surface, re-ruled 2026-08-08. The rows enact the routing predicate themselves: text focus routes ⌘Z to the first responder's own manager, title and enablement included, re-derived at fire time with a beep for the stale window. NativeHistoryProvider turns @Observable so both surfaces re-derive on stack changes; a checkpoint-notification ticker covers plain text managers. .responderAction leaves ToolbarItemSpec with its only user; windowWillReturnUndoManager stays wired for AppKit's own asks. Live-probed on the fixture board (21/21): the row retitles to "Undo Add Lane" and crosses via real ⌘Z key events, ⇧⌘Z redoes via a window-server chord, the toolbar pair validates and fires, search-field and body-editor ⌘Z stay text undo with board stacks untouched, and a card window crosses its own stack with no fall-through. 2698 unit tests green. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
280 lines
15 KiB
Swift
280 lines
15 KiB
Swift
import AppKit
|
|
|
|
// MARK: - Identifiers
|
|
|
|
extension NSToolbarItem.Identifier {
|
|
static let boardSearch = Self("board.search")
|
|
static let boardNewCard = Self("board.newCard")
|
|
static let boardNewLane = Self("board.newLane")
|
|
static let boardUndo = Self("board.undo")
|
|
static let boardRedo = Self("board.redo")
|
|
static let boardShowTrash = Self("board.showTrash")
|
|
static let boardZoomIn = Self("board.zoomIn")
|
|
static let boardZoomOut = Self("board.zoomOut")
|
|
static let boardAppearance = Self("board.appearance")
|
|
}
|
|
|
|
// MARK: - The board window's toolbar
|
|
|
|
/// The board window's toolbar (03-board-ui.md ▸ Toolbar).
|
|
///
|
|
/// ### The default is the search field and Appearance, and the catalog is the rest
|
|
///
|
|
/// "**Board window default: the search field and Appearance** — both **centered**, the titlebar's
|
|
/// view-controls cluster." The flexible space ahead of the pair is what keeps them off the leading
|
|
/// edge before `centeredItemIdentifiers` takes over their placement.
|
|
///
|
|
/// "**Catalog** (available via Customize): New Card, New Lane, Zoom In, Zoom Out …, Undo, Redo …,
|
|
/// Show Trash (toggle state matching the View menu checkmark)." Every one of them is the *same command* as its menu row
|
|
/// — the predicates below are the rows' own (`BoardStore.newCardTarget`, `acceptsBoardMutations`),
|
|
/// and the two actions with consequences call the rows' own functions (`beginNewCard`,
|
|
/// `setTrashVisible`) rather than restating them. That is what makes "toolbars are pure enhancement"
|
|
/// true of the code: removing every item removes nothing but a shortcut to a menu row.
|
|
///
|
|
/// "The board popover deliberately has **no toolbar item** — the window-title widget is its
|
|
/// committed home" — so there is no Board Info entry here, and its absence is pinned by a test.
|
|
///
|
|
/// ### Undo and Redo carry the session's own manager, exactly as the menu's rows do
|
|
///
|
|
/// **The command surface is the app's** (13-native-undo.md ▸ Rules ▸ the command-surface bullet,
|
|
/// re-ruled 2026-08-08): Edit ▸ Undo/Redo are the app's own rows reading the focused session's
|
|
/// `BoardUndoManager` through the focus system (`UndoCommands.swift`), and the two items below carry
|
|
/// an explicit target over **that same manager** — the session's, handed in by the window's host
|
|
/// (`BoardWindowHost.configureWindow`). "Matching their menu items" (03) is therefore one *object*
|
|
/// rather than one lookup: enablement is `canUndo`/`canRedo` on both surfaces, so a step landing, an
|
|
/// empty stack, and the read-only lock reach the pair and the rows together and cannot disagree
|
|
/// (`BoardUndoManager` answers all three, and is the only place any of them is decided).
|
|
///
|
|
/// The pair was nil-target `undo:`/`redo:` until that re-ruling, resolving up the responder chain to
|
|
/// the board window's `windowWillReturnUndoManager` — a route a SwiftUI window makes unreachable by
|
|
/// latching an empty undo manager of its own before any delegate of ours installs (diagnosed
|
|
/// 2026-08-07). Nothing about the *design* changed: the same manager, the same predicates, reached
|
|
/// by being handed it instead of by looking it up.
|
|
///
|
|
/// **Every board has undo**, so there is no substrate-shaped disablement to write: 03's
|
|
/// parenthetical about boards without undo was the *git* substrate, which a board once bound by being
|
|
/// in git mode — on any tier since 12-editions.md ▸ PIVOT 2026-08-07, and under a Pro subscription
|
|
/// before it, until app-managed git left the app entirely (`strategy/01-git-excision.md`,
|
|
/// 2026-08-08) and there stopped being a substrate to bind.
|
|
///
|
|
/// **Toolbar clicks are never text-routed.** The menu rows route ⌘Z to the first responder's own
|
|
/// manager while a text surface holds the keyboard (13 ▸ Undo routing); clicking a toolbar button is
|
|
/// not a keystroke aimed at an editor, so these two act on the board stack unconditionally — which is
|
|
/// also the only stack the item can see.
|
|
///
|
|
/// Their labels are the design's one exception to the menu-title rule: the *menu* titles are rewritten
|
|
/// as the stack changes ("Undo Move Card"), which a toolbar label does not track, so these two are
|
|
/// built from static labels (`ToolbarItemSpec.staticLabel`).
|
|
@MainActor
|
|
enum BoardToolbar {
|
|
|
|
/// Shared by every board window, which is what makes the user's arrangement the *app's* rather
|
|
/// than one window's — Finder's behaviour, and the reason the identifier is a constant.
|
|
static let identifier = "dev.rzen.indie.Kanban.board"
|
|
|
|
/// "The search field and Appearance — both centered, immediately after the field
|
|
/// (03-board-ui.md ▸ Toolbar, the view-controls cluster)."
|
|
static let defaultItems: [NSToolbarItem.Identifier] = [.flexibleSpace, .boardSearch, .boardAppearance]
|
|
|
|
/// The Appearance picker's rows, in menu order — the one place index and meaning are joined, so
|
|
/// `specs(...)`'s `selected`/`select` closures and this array can never name two different
|
|
/// orderings of the same three choices.
|
|
private static let appearanceOptions: [AppAppearance?] = [nil, .light, .dark]
|
|
|
|
/// - Parameters:
|
|
/// - zoom: the app-wide zoom level, in the catalog order the palette shows. It is not the
|
|
/// store's, unlike every other predicate here, because the level is not a board's
|
|
/// (03-board-ui.md ▸ Layout — zoom) — and it must be `@Observable` rather than read from
|
|
/// `UserDefaults` at build time, since `WindowToolbarController.trackValidationState` re-arms
|
|
/// observation over each spec's `isEnabled` and a plain scalar would leave Zoom In looking live
|
|
/// at the top rung.
|
|
/// - appearance: the app-wide appearance override, `zoom`'s reason exactly — not the store's,
|
|
/// and `@Observable` so the picker's checkmark, read when its menu opens, is never stale.
|
|
/// - session: the app's drag session, for the same guard the menu rows carry
|
|
/// (`ZoomCommands.isEnabled`).
|
|
/// - undo: **the board session's stack**, for the Undo/Redo pair — the same `BoardUndoManager`
|
|
/// the Edit menu's rows read through the focus system (13-native-undo.md ▸ Rules ▸ the
|
|
/// command-surface bullet, re-ruled 2026-08-08). `nil` for a window whose session has gone,
|
|
/// which the pair reads as an empty stack: disabled, and crossing nothing.
|
|
static func specs(
|
|
store: BoardStore,
|
|
search: BoardSearchPresentation,
|
|
zoom: BoardZoomStore,
|
|
appearance: AppearanceStore,
|
|
session: DragSession,
|
|
undo: BoardUndoManager?
|
|
) -> [ToolbarItemSpec] {
|
|
[
|
|
.mirroring(
|
|
menuTitle: "New Card",
|
|
identifier: .boardNewCard,
|
|
symbol: "doc.badge.plus",
|
|
behavior: .button(
|
|
isEnabled: { [weak store] in store?.newCardTarget != nil },
|
|
perform: { [weak store] in store?.beginNewCard() }
|
|
)
|
|
),
|
|
.mirroring(
|
|
menuTitle: "New Lane",
|
|
identifier: .boardNewLane,
|
|
symbol: "rectangle.stack.badge.plus",
|
|
behavior: .button(
|
|
isEnabled: { [weak store] in store?.acceptsBoardMutations == true },
|
|
perform: { [weak store] in store?.createLane() }
|
|
)
|
|
),
|
|
// The zoom pair — plain buttons, because that is what the menu rows are. There is no
|
|
// percentage readout and no popup: a control that *displays* the level would need a
|
|
// custom view and a new `ToolbarItemSpec.Behavior` case, and the level already has a
|
|
// spoken voice (`AccessibilityPhrases.zoomLevel`) and a visible one (the board itself).
|
|
.mirroring(
|
|
menuTitle: "Zoom In",
|
|
identifier: .boardZoomIn,
|
|
symbol: "plus.magnifyingglass",
|
|
behavior: .button(
|
|
isEnabled: { [weak store, weak zoom, weak session] in
|
|
guard let zoom, let session else { return false }
|
|
return ZoomCommands.isEnabled(store: store, session: session) && zoom.canZoomIn
|
|
},
|
|
perform: { [weak zoom] in zoom?.step(.in) }
|
|
)
|
|
),
|
|
.mirroring(
|
|
menuTitle: "Zoom Out",
|
|
identifier: .boardZoomOut,
|
|
symbol: "minus.magnifyingglass",
|
|
behavior: .button(
|
|
isEnabled: { [weak store, weak zoom, weak session] in
|
|
guard let zoom, let session else { return false }
|
|
return ZoomCommands.isEnabled(store: store, session: session) && zoom.canZoomOut
|
|
},
|
|
perform: { [weak zoom] in zoom?.step(.out) }
|
|
)
|
|
),
|
|
// The one pair whose predicate is not a menu row's *expression* but the menu row's own
|
|
// object: `canUndo`/`canRedo` on the session's manager, which is where the empty stack
|
|
// and the read-only lock are both already decided (`BoardUndoManager`). Weak like every
|
|
// other capture here — production hands these app-lived objects, and a spec must not be
|
|
// what keeps a torn-down session's stack alive.
|
|
.staticLabel(
|
|
"Undo",
|
|
identifier: .boardUndo,
|
|
symbol: "arrow.uturn.backward",
|
|
behavior: .button(
|
|
isEnabled: { [weak undo] in undo?.canUndo == true },
|
|
perform: { [weak undo] in undo?.undo() }
|
|
)
|
|
),
|
|
.staticLabel(
|
|
"Redo",
|
|
identifier: .boardRedo,
|
|
symbol: "arrow.uturn.forward",
|
|
behavior: .button(
|
|
isEnabled: { [weak undo] in undo?.canRedo == true },
|
|
perform: { [weak undo] in undo?.redo() }
|
|
)
|
|
),
|
|
.mirroring(
|
|
menuTitle: "Show Trash",
|
|
identifier: .boardShowTrash,
|
|
symbol: "trash",
|
|
behavior: .toggle(
|
|
isEnabled: { [weak store] in store != nil },
|
|
isOn: { [weak store] in store?.transient.isTrashVisible == true },
|
|
setOn: { [weak store] shown in store?.setTrashVisible(shown) }
|
|
)
|
|
),
|
|
// A pull-down rather than a toggle: Auto/Light/Dark is a three-way exclusive choice, not
|
|
// an on/off bit. The one default (and centered) catalog item beside the field
|
|
// (`defaultItems`, `controller(...)`'s `centeredItemIdentifiers`), always enabled — an
|
|
// appearance override needs no board state, exactly as the View-menu row needs no board
|
|
// window (`AppearanceCommands`).
|
|
.mirroring(
|
|
menuTitle: "Appearance",
|
|
identifier: .boardAppearance,
|
|
symbol: "circle.lefthalf.filled",
|
|
behavior: .picker(
|
|
options: [
|
|
(title: "Auto", symbol: nil),
|
|
(title: "Light", symbol: nil),
|
|
(title: "Dark", symbol: nil),
|
|
],
|
|
selected: { [weak appearance] in
|
|
guard let appearance else { return nil }
|
|
return appearanceOptions.firstIndex(of: appearance.override)
|
|
},
|
|
select: { [weak appearance] index in
|
|
guard appearanceOptions.indices.contains(index) else { return }
|
|
appearance?.setOverride(appearanceOptions[index])
|
|
}
|
|
)
|
|
),
|
|
.staticLabel(
|
|
"Search",
|
|
identifier: .boardSearch,
|
|
symbol: nil,
|
|
// The width in *characters* rather than points (10-accessibility.md ▸ Text scaling:
|
|
// "no fixed point sizes") — the same 17 ems the transient bar's field takes
|
|
// (`BoardSearchBar`). It is the **focused** width here: `NSSearchToolbarItem` grows
|
|
// the field to its preferred width when the keyboard arrives and lets it settle
|
|
// back to the item's natural width when the keyboard leaves, so the two homes match
|
|
// once the user is typing rather than at rest.
|
|
behavior: .searchField(
|
|
focusedWidth: BoardMetrics.em(17, bodyPointSize: BoardMetrics.bodyPointSize),
|
|
make: { [weak store] willBeInserted in
|
|
guard willBeInserted, let store else {
|
|
return BoardSearchFieldController.makePaletteField()
|
|
}
|
|
return BoardSearchFieldController.makeField(
|
|
store: store,
|
|
presentation: search,
|
|
home: .toolbar
|
|
)
|
|
},
|
|
// The toolbar half of the field's home that only the *item* can answer: ⌘F's
|
|
// expand-and-focus, and the collapse Escape's second step asks for.
|
|
install: { item in
|
|
BoardSearchFieldController.adopt(item, presentation: search)
|
|
}
|
|
)
|
|
),
|
|
]
|
|
}
|
|
|
|
/// The window's toolbar, wired to tell the search presentation where its field currently lives —
|
|
/// which is the whole input to ⌘F's transient fallback (03: "with the field removed from the
|
|
/// toolbar, invoking it surfaces the field transiently until the search clears").
|
|
static func controller(
|
|
store: BoardStore,
|
|
search: BoardSearchPresentation,
|
|
zoom: BoardZoomStore,
|
|
appearance: AppearanceStore,
|
|
session: DragSession,
|
|
undo: BoardUndoManager?
|
|
) -> WindowToolbarController {
|
|
let controller = WindowToolbarController(
|
|
identifier: identifier,
|
|
specs: specs(
|
|
store: store,
|
|
search: search,
|
|
zoom: zoom,
|
|
appearance: appearance,
|
|
session: session,
|
|
undo: undo
|
|
),
|
|
defaults: defaultItems
|
|
)
|
|
// Centered against the window, not a flexible-space sandwich (03 ▸ Toolbar's placement
|
|
// grammar, ratified 2026-08-06): `centeredItemIdentifiers` holds as catalog items install
|
|
// and sits outside the autosaved configuration, so it reaches machines that saved an
|
|
// arrangement under the old trailing default. `defaultItems` is untouched. Appearance joined
|
|
// the cluster after search (`defaultItems`'s own order), so the two center together as one
|
|
// group — search first, Appearance beside it — rather than as two independently-placed items.
|
|
controller.toolbar.centeredItemIdentifiers = [.boardSearch, .boardAppearance]
|
|
controller.onInstalledItemsChanged = { [weak search] identifiers in
|
|
search?.isInstalledInToolbar = identifiers.contains(.boardSearch)
|
|
}
|
|
return controller
|
|
}
|
|
}
|