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 are the responder chain's, exactly as the menu's are /// /// The app ships no Undo/Redo rows of its own: those are the standard Edit-menu items, nil-target /// `undo:`/`redo:` resolved up the responder chain (`KanbanApp.menuCommands`). The toolbar items /// carry the same actions with the same nil target, so "matching their menu items" (03) is not a /// predicate written here — it is literally the same validation. Both reach the board window, whose /// `windowWillReturnUndoManager` hands back the session's `BoardUndoManager`, and both therefore /// enable exactly when that board has a step to cross and no read-only lock stands /// (13-native-undo.md ▸ Rules). **Every board has undo in every tier**, so there is no tier-shaped /// disablement to write: 03's parenthetical about boards without undo is 06's *git* substrate, which /// only a Pro subscription binds. /// /// Their labels are the design's one exception to the menu-title rule: `NSUndoManager` rewrites the /// *menu* titles 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`). static func specs( store: BoardStore, search: BoardSearchPresentation, zoom: BoardZoomStore, appearance: AppearanceStore, session: DragSession ) -> [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) } ) ), .staticLabel( "Undo", identifier: .boardUndo, symbol: "arrow.uturn.backward", behavior: .responderAction(NSSelectorFromString("undo:")) ), .staticLabel( "Redo", identifier: .boardRedo, symbol: "arrow.uturn.forward", behavior: .responderAction(NSSelectorFromString("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 ) -> WindowToolbarController { let controller = WindowToolbarController( identifier: identifier, specs: specs(store: store, search: search, zoom: zoom, appearance: appearance, session: session), 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 } }