Files
lanework/Kanban/UI/Board/BoardToolbar.swift
T
rzen 5e6417e749 The board search item becomes the stock NSSearchToolbarItem — the field grows on focus
The toolbar's search field now lives in AppKit's own NSSearchToolbarItem:
em(17) is the focused width (preferredWidthForSearchField), the resting
width is the item's own, and ⌘F becomes beginSearchInteraction — one call
that expands and focuses. Escape's empty-field exit collapses the field as
the keyboard leaves. resignsFirstResponderWithCancel is off so the staged
Escape survives the cancel button. The item ships its own live overflow row
and an above-.high visibility priority, so both custom-view workarounds
retire. The palette copy stays inert through an enablement round-trip the
item's searchField setter would otherwise overwrite.

The centering half of the card is blocked on a Design ruling (03 settles
"trailing") and is not built; the two-homes width question is filed.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 11:16:58 -04:00

147 lines
7.2 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 "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, 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,
// 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) -> 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
}
}