Implement toolbar customization for both windows
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
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user