NSToolbar through the existing HostedWindowController rather than SwiftUI's toolbar — for reasons that are contract, not taste: 03's transient-search clause is a decision over the toolbar's current contents, which NSToolbar publishes and SwiftUI's API cannot answer; Undo/Redo are the system's nil-target responder-chain actions so the toolbar items validate exactly as the menu rows do (disabled on base boards, alive in m8 unchanged); and the search item hosts the real NSSearchField with explicit first-responder control. Customization is all system furniture — Customize sheet, drag rearrange, display-mode popup, overflow, autosaved per window kind. Board default: the search field alone, trailing; catalog adds New Card, New Lane, Undo, Redo, Show Trash, every action extracted from its menu command so no second predicate exists. Card default: the Edit Body / Raw Source toggles and Add Attachment, mirroring their commands' own predicates live via observation tracking. Removing the search item keeps the promise — ⌘F surfaces the same field as a transient strip under the title bar, persisting until the query clears, and an overflowed item that cannot take the keyboard falls through to the strip too. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
130 lines
5.9 KiB
Swift
130 lines
5.9 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")
|
|
}
|
|
|
|
// 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 "the pair disabled on boards without undo …
|
|
/// matching their menu items" (03) is not a predicate written here — it is the same validation, and
|
|
/// on a base-edition board (no undo stack until m8 wires native undo) both are disabled for the same
|
|
/// reason the menu rows are.
|
|
///
|
|
/// 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
|
|
}
|
|
}
|