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") } // MARK: - The board window's toolbar /// The board window's toolbar (03-board-ui.md ▸ Toolbar). /// /// ### The default is one item, and the catalog is five more /// /// "**Board window default: the search field, nothing else** — trailing, the one default item; the /// titlebar stays clean." The flexible space ahead of it is what "trailing" means to `NSToolbar`. /// /// "**Catalog** (available via Customize): New Card, New Lane, 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 base board has undo**, so there is no edition-shaped /// disablement to write: 03's parenthetical about boards without undo is 06's *git* substrate, which /// only Pro 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, nothing else — trailing, the one default item." static let defaultItems: [NSToolbarItem.Identifier] = [.flexibleSpace, .boardSearch] static func specs(store: BoardStore, search: BoardSearchPresentation) -> [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() } ) ), .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) } ) ), .staticLabel( "Search", identifier: .boardSearch, symbol: nil, behavior: .control(width: 220) { [weak store] willBeInserted in guard willBeInserted, let store else { return BoardSearchFieldController.makePaletteField() } return BoardSearchFieldController.makeField( store: store, presentation: search, home: .toolbar ) } ), ] } /// 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) -> WindowToolbarController { let controller = WindowToolbarController( identifier: identifier, specs: specs(store: store, search: search), defaults: defaultItems ) controller.onInstalledItemsChanged = { [weak search] identifiers in search?.isInstalledInToolbar = identifiers.contains(.boardSearch) } return controller } }