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:
@@ -66,6 +66,12 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
|
||||
/// constructor argument.
|
||||
private var titlebarAccessory: NSTitlebarAccessoryViewController?
|
||||
|
||||
/// This window's toolbar, once something has given it one — the board and card windows'
|
||||
/// customizable toolbars (03-board-ui.md ▸ Toolbar). A slot for the accessory's reason: welcome
|
||||
/// and the bootstrap window have none, and the two that do only learn what goes in it after
|
||||
/// their board has loaded.
|
||||
private var toolbarController: WindowToolbarController?
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "window")
|
||||
|
||||
// MARK: Attachment
|
||||
@@ -84,14 +90,18 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
|
||||
// After `onAttach`, so placement has already happened: an accessory handed over before the
|
||||
// window existed is installed here instead, and one handed over later installs immediately.
|
||||
addTitlebarAccessoryIfPossible()
|
||||
applyToolbarIfPossible()
|
||||
}
|
||||
|
||||
/// Puts the previous delegate back, and takes the titlebar accessory back out. Called when the
|
||||
/// hosting view goes away; the delegate half is a no-op if something else has since taken the
|
||||
/// delegate, because stomping a third party's would be the bug this whole file exists to avoid.
|
||||
/// Puts the previous delegate back, and takes the titlebar accessory and toolbar back out.
|
||||
/// Called when the hosting view goes away; the delegate half is a no-op if something else has
|
||||
/// since taken the delegate, because stomping a third party's would be the bug this whole file
|
||||
/// exists to avoid.
|
||||
func detach() {
|
||||
removeTitlebarAccessory()
|
||||
titlebarAccessory = nil
|
||||
removeToolbar()
|
||||
toolbarController = nil
|
||||
guard let window, window.delegate === self else { return }
|
||||
window.delegate = previousDelegate
|
||||
self.window = nil
|
||||
@@ -127,6 +137,37 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
|
||||
window.removeTitlebarAccessoryViewController(at: index)
|
||||
}
|
||||
|
||||
// MARK: Toolbar
|
||||
|
||||
/// Gives this window its toolbar — **once**, `installTitlebarAccessory`'s rule and for its
|
||||
/// reason: a host may configure itself more than once, and a second toolbar would replace the
|
||||
/// first along with the search field the first was hosting.
|
||||
///
|
||||
/// Installing before the window exists is legal — the toolbar is held and goes on at `attach`.
|
||||
func installToolbar(_ controller: WindowToolbarController) {
|
||||
guard toolbarController == nil else { return }
|
||||
toolbarController = controller
|
||||
applyToolbarIfPossible()
|
||||
}
|
||||
|
||||
/// The window is SwiftUI's, and SwiftUI leaves `toolbar` alone for a scene that declares no
|
||||
/// `.toolbar` modifier — which the board and card windows deliberately do not, since their
|
||||
/// toolbars are `NSToolbar`s (see `WindowToolbarController` for why). The identity check is what
|
||||
/// keeps a re-attach from replacing a live toolbar with itself and rebuilding every item.
|
||||
private func applyToolbarIfPossible() {
|
||||
guard let window, let toolbarController, window.toolbar !== toolbarController.toolbar else { return }
|
||||
window.toolbar = toolbarController.toolbar
|
||||
// The first honest answer to "what is installed", now that the toolbar has built its items
|
||||
// from the saved configuration.
|
||||
toolbarController.reportInstalledItems()
|
||||
toolbarController.revalidate()
|
||||
}
|
||||
|
||||
private func removeToolbar() {
|
||||
guard let window, let toolbarController, window.toolbar === toolbarController.toolbar else { return }
|
||||
window.toolbar = nil
|
||||
}
|
||||
|
||||
/// Closes the window for real, after the flush has run. `performClose` rather than `close` so the
|
||||
/// standard path runs — SwiftUI's own delegate gets its callbacks, tabbing behaves — with the
|
||||
/// flag telling our own `windowShouldClose` to stand aside.
|
||||
|
||||
Reference in New Issue
Block a user