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:
2026-07-28 12:57:42 -04:00
parent 40322247e0
commit 06ee59e24b
13 changed files with 1683 additions and 131 deletions
+26 -15
View File
@@ -29,6 +29,9 @@ struct BoardWindowHost: View {
@Environment(AppModel.self) private var appModel
@Environment(\.openWindow) private var openWindow
@Environment(\.dismissWindow) private var dismissWindow
/// The transient search strip's arrival and departure has a reduced variant like every other
/// appearance in the app (10-accessibility.md; `Motion.transientSearchTransition`).
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// The window's own controller `@State` so it outlives body evaluations and so SwiftUI keeps it
/// alive for exactly as long as this window exists.
@@ -86,6 +89,15 @@ struct BoardWindowHost: View {
case let .open(store):
VStack(spacing: 0) {
BannerStripView(rows: store.bannerRows) { store.banners.dismiss($0) }
// **F's fallback**, and only that: the search field's home is the toolbar item
// (`BoardToolbar`), and this strip exists for the window where the user has taken
// that item out "with the field removed from the toolbar, invoking it surfaces the
// field transiently until the search clears" (03-board-ui.md Toolbar). It sits
// directly under the title bar, where the item it stands in for would be.
if boardSearch.isTransient {
BoardSearchBar(store: store, presentation: boardSearch)
.transition(Motion.transientSearchTransition(reduced: reduceMotion))
}
// The window is handed to the board as a closure, not a value: `WindowAccessor`
// attaches after this body first runs, and the lane-resize drag needs the *live*
// window to grow at its right edge (03-board-ui.md § Lane).
@@ -99,21 +111,14 @@ struct BoardWindowHost: View {
search: boardSearch
)
}
// **The board window's toolbar: the search field, nothing else** (03-board-ui.md
// Toolbar, "trailing, the one default item; the titlebar stays clean"). It is a toolbar
// rather than a strip inside the content because that is where 03 puts it, and it hosts
// an `NSSearchField` rather than `.searchable` for the reasons `BoardSearchField`
// records explicit first-responder control, and stock key behaviour.
//
// m6-toolbar: the rest of 03's toolbar story is the customization card's the
// Customize palette, the New Card / New Lane / Undo / Redo / Show Trash catalog, and
// with it F's transient surfacing of a *removed* field. That work replaces this
// declaration with an identified, customizable toolbar; the item itself does not move.
.toolbar {
ToolbarItem(placement: .primaryAction) {
BoardSearchField(store: store, presentation: boardSearch)
.frame(width: 220)
}
// The transient strip's two dismissal inputs (`BoardSearchPresentation
// .transientPersists`): it stays while a query is filtering the board or while the field
// holds the keyboard, and goes when neither is true.
.onChange(of: store.searchQuery) { _, query in
boardSearch.dismissTransientIfCleared(query: query)
}
.onChange(of: boardSearch.isFocused) { _, _ in
boardSearch.dismissTransientIfCleared(query: store.searchQuery)
}
// "The board in front", for the menu items that act on it (`LaneWidthCommands`), and
// beside it the window's own popover flag, which is what File Board Info toggles, its
@@ -267,6 +272,12 @@ struct BoardWindowHost: View {
windowController.installTitlebarAccessory(
boardInfoTitlebarAccessory(store: store, recents: appModel.styleRecents, presentation: boardInfo)
)
// The board's customizable toolbar (03-board-ui.md Toolbar) installed here for the
// accessory's reason exactly: it carries the store, and it is a board window's, not every
// hosted window's. Its search item is the search field's home, and it is what tells
// `boardSearch` whether that home still exists.
windowController.installToolbar(BoardToolbar.controller(store: store, search: boardSearch))
}
// MARK: - Closing