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
+8
View File
@@ -515,6 +515,14 @@ struct CardWindowHost: View {
// caller just registered against it), and the record id is what both memories are keyed on.
let recordID = appModel.session(for: ref.board)?.recordID
// The window's customizable toolbar Edit Body · Raw Source · Add Attachment, "the
// window's three committed functions" (03-board-ui.md Toolbar; 05-card-window.md
// Window). It carries the three window-scoped handles above rather than a store, which is
// why it is installed here and not at attach: those are this window's, and so is it.
windowController.installToolbar(
CardToolbar.controller(body: bodyPresentation, rawSource: rawSource, attachments: attachments)
)
windowController.onAttach = { window in
if let recordID,
let saved = appModel.boardRegistry.cardWindowFrame(id: recordID, cardID: ref.cardIdentity) {
+44 -3
View File
@@ -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.
+455
View File
@@ -0,0 +1,455 @@
import AppKit
import Observation
// MARK: - The vocabulary
/// **A toolbar item's label is its menu row's title** (03-board-ui.md Toolbar): "Toolbar item
/// labels match their menu-item titles exactly (Show Trash, Edit Body, Raw Source, ), minus any
/// trailing ellipsis (macOS convention: "Add Attachment" labels as Add Attachment) one vocabulary
/// everywhere, and the customize palette self-documents against the menus."
///
/// One function rather than a hand-written second spelling per item, because the failure this rule
/// guards against is drift: a menu row renamed without its toolbar item is two names for one
/// function, and the customize palette is precisely where a user compares the two.
///
/// The **one exception is Undo/Redo**, which is not expressible here and is not meant to be:
/// `NSUndoManager` rewrites their menu titles as the stack changes ("Undo Move Card"), and a toolbar
/// label does not track that so those two items are built from a static label instead of from a
/// menu title (`ToolbarItemSpec.staticLabel`).
enum ToolbarVocabulary {
/// `menuTitle` minus one trailing ellipsis the "" character and the three-period spelling
/// alike, since a title is only ever written one of those two ways and both mean the same thing.
static func label(menuTitle: String) -> String {
var title = Substring(menuTitle)
if title.hasSuffix("") {
title = title.dropLast()
} else if title.hasSuffix("...") {
title = title.dropLast(3)
}
while title.last == " " {
title = title.dropLast()
}
return String(title)
}
}
// MARK: - One item
/// One toolbar item, as a value: its identity, its vocabulary, and what it does.
///
/// **It is a description, not a view.** The item's `NSToolbarItem` is built from this by
/// `WindowToolbarController` twice, in fact, since the customization palette holds its own copy of
/// every item so nothing here may be a control. Keeping it a value is also what makes the two
/// things worth testing testable without a window: the label the vocabulary rule derives, and the
/// predicates the item mirrors from its menu row (03 Toolbar: "every function they host already has
/// a menu item + shortcut ... nothing below is anyone's only path").
///
/// The predicates are **the menu rows' own**, passed in by the catalogs `EditBodyCommand.isEnabled`
/// and `AddAttachmentCommand.isEnabled` are handed here verbatim rather than re-derived, which is the
/// only way "the toolbar mirrors the menu" can stay true of code as well as of prose.
@MainActor
struct ToolbarItemSpec {
let identifier: NSToolbarItem.Identifier
let label: String
/// The SF Symbol the item draws. `nil` for a hosted control, which draws itself.
let symbol: String?
let behavior: Behavior
/// What the item *is*, which is also what kind of `NSToolbarItem` it becomes.
@MainActor
enum Behavior {
/// A push button: an action, and the predicate its menu row validates against.
case button(isEnabled: () -> Bool, perform: () -> Void)
/// A toggle showing on-state Show Trash, Edit Body, Raw Source (03 Toolbar).
case toggle(isEnabled: () -> Bool, isOn: () -> Bool, setOn: (Bool) -> Void)
/// An action sent up the responder chain with no target of our own **Undo and Redo**, which
/// is how their menu rows work too, so "matching their menu items" is one mechanism rather
/// than two (03 Toolbar; 06-history-undo.md).
case responderAction(Selector)
/// A hosted control the board's search field. `make` is handed `true` when the view is
/// bound for the toolbar itself and `false` when it is the customization palette's copy, so
/// only the real one claims window-scoped wiring.
case control(width: CGFloat, make: (_ willBeInsertedIntoToolbar: Bool) -> NSView)
}
/// The vocabulary rule applied: an item that mirrors a menu row takes that row's title, minus a
/// trailing ellipsis.
static func mirroring(
menuTitle: String,
identifier: NSToolbarItem.Identifier,
symbol: String?,
behavior: Behavior
) -> Self {
Self(
identifier: identifier,
label: ToolbarVocabulary.label(menuTitle: menuTitle),
symbol: symbol,
behavior: behavior
)
}
/// The Undo/Redo exception, and the search field (a control, not a command): a label written
/// here because there is no menu title to derive it from.
static func staticLabel(
_ label: String,
identifier: NSToolbarItem.Identifier,
symbol: String?,
behavior: Behavior
) -> Self {
Self(identifier: identifier, label: label, symbol: symbol, behavior: behavior)
}
// MARK: State
/// The item's live enablement. Responder-chain items and hosted controls answer `true`: the
/// first is validated by the chain itself (which is the point of it), and the second has no
/// enablement of its own.
var isEnabled: Bool {
switch behavior {
case let .button(isEnabled, _): isEnabled()
case let .toggle(isEnabled, _, _): isEnabled()
case .responderAction, .control: true
}
}
/// The item's on-state, or `nil` for the items that have none.
var isOn: Bool? {
switch behavior {
case let .toggle(_, isOn, _): isOn()
case .button, .responderAction, .control: nil
}
}
/// Firing the item: a button performs, a toggle flips. A no-op for the two kinds AppKit drives
/// itself.
func activate() {
switch behavior {
case let .button(_, perform): perform()
case let .toggle(_, isOn, setOn): setOn(!isOn())
case .responderAction, .control: break
}
}
}
// MARK: - The toolbar
/// A window's toolbar: **customizable, macOS-native, and never a function's only home**
/// (03-board-ui.md Toolbar).
///
/// ### Why `NSToolbar` rather than SwiftUI's `.toolbar(id:)`
///
/// SwiftUI's customizable toolbar would answer most of 03's clauses, and it was the first choice.
/// Four requirements sent this to AppKit instead, and each is normative rather than aesthetic:
///
/// - **F has to know whether the search item is installed.** "With the field removed from the
/// toolbar, invoking it surfaces the field transiently" (03) a decision that needs to read the
/// toolbar's *current* contents. `NSToolbar` publishes them (`items`, plus the will-add/did-remove
/// delegate callbacks); SwiftUI's toolbar API has no such query, and reaching around it into the
/// `NSToolbar` it happens to own means matching identifiers SwiftUI derives rather than ones this
/// app spells.
/// - **Undo and Redo have to reach the responder chain.** Their menu rows are the system's own
/// nil-target `undo:`/`redo:` and "matching their menu items" (03) is literal here: a toolbar
/// item with the same nil-target action validates and fires through exactly the same lookup, so
/// the pair is disabled today for the same reason the menu rows are, and lights up in m8 with no
/// change here. A SwiftUI `Button` cannot express that.
/// - **The search item hosts a real `NSSearchField`** with explicit first-responder control, settled
/// in m5 for reasons `BoardSearchFieldController` records (F must focus it from a menu item;
/// Escape in an empty field must hand the keyboard back to the strip). A toolbar that already
/// speaks AppKit hosts it directly.
/// - **The window is already proxied.** `HostedWindowController` fronts SwiftUI's window delegate
/// and installs the board's titlebar accessory; a toolbar is the same kind of thing hung on the
/// same window, through the same install-once seam.
///
/// What the platform gives back, and what 03 asks for by name: right-click Customize Toolbar,
/// drag to rearrange, the system overflow, and the icon/text display options
/// `allowsUserCustomization` and `allowsDisplayModeCustomization` below are those two sentences.
/// The user's arrangement persists through `autosavesConfiguration`, keyed on the toolbar's
/// identifier, so it is per *window kind* (every board window shares one arrangement) exactly as
/// Finder's is.
///
/// ### Validation is observed, not polled
///
/// AppKit validates visible items on its own schedule (`NSToolbarItem.autovalidates`), which is tied
/// to user events. The state these items mirror can change without one a foreign reload flipping
/// the read-only lock, a raw-source Apply landing so the controller *observes* every predicate it
/// was given (`withObservationTracking`) and re-validates when any of them would answer differently.
/// The two mechanisms are complementary: AppKit's covers the ordinary case, this covers the case
/// where nothing the user did caused the change.
@MainActor
final class WindowToolbarController: NSObject, NSToolbarDelegate {
let toolbar: NSToolbar
/// The catalog, in palette order every item the user may install (03: "Catalog (available via
/// Customize)").
private let specs: [NSToolbarItem.Identifier: ToolbarItemSpec]
private let catalog: [NSToolbarItem.Identifier]
private let defaults: [NSToolbarItem.Identifier]
/// Called with the identifiers the toolbar currently carries, whenever that set changes the
/// board's search item is the one consumer (`BoardToolbar`), and 03's transient-F clause is the
/// reason it exists.
var onInstalledItemsChanged: (([NSToolbarItem.Identifier]) -> Void)?
/// Set while a report is already scheduled, so a customization that removes and re-adds a dozen
/// items reports once.
private var isReportScheduled = false
init(identifier: String, specs: [ToolbarItemSpec], defaults: [NSToolbarItem.Identifier]) {
toolbar = NSToolbar(identifier: identifier)
catalog = specs.map(\.identifier)
self.specs = Dictionary(uniqueKeysWithValues: specs.map { ($0.identifier, $0) })
self.defaults = defaults
super.init()
toolbar.delegate = self
// 03's three customization sentences, in three lines: the palette and its drag-rearrange,
// the Show Icon and Text / Icon Only / Text Only popup, and the arrangement remembered
// across launches.
toolbar.allowsUserCustomization = true
toolbar.allowsDisplayModeCustomization = true
toolbar.autosavesConfiguration = true
trackValidationState()
}
/// Re-reads every item's state and pushes it into the toolbar. Called by the observation above
/// and worth calling directly after anything that installs the toolbar.
func revalidate() {
toolbar.validateVisibleItems()
}
// MARK: - Installed items
/// Reports the toolbar's current contents, coalesced onto the next turn: the delegate callbacks
/// fire *around* a change rather than after it (`toolbarWillAddItem` runs before the item joins
/// `items`), so the honest answer is only available once the run loop has come back around.
private func scheduleInstalledItemsReport() {
guard !isReportScheduled else { return }
isReportScheduled = true
Task { @MainActor [weak self] in
guard let self else { return }
isReportScheduled = false
onInstalledItemsChanged?(toolbar.items.map(\.itemIdentifier))
}
}
/// Reports the contents now that the toolbar is on a window the initial answer, which no
/// delegate callback provides when the saved configuration happens to be empty.
func reportInstalledItems() {
scheduleInstalledItemsReport()
}
// MARK: - NSToolbarDelegate
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
catalog + [.space, .flexibleSpace]
}
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
defaults
}
func toolbar(
_ toolbar: NSToolbar,
itemForItemIdentifier identifier: NSToolbarItem.Identifier,
willBeInsertedIntoToolbar flag: Bool
) -> NSToolbarItem? {
guard let spec = specs[identifier] else { return nil }
return makeItem(spec, willBeInsertedIntoToolbar: flag)
}
func toolbarWillAddItem(_ notification: Notification) {
scheduleInstalledItemsReport()
}
func toolbarDidRemoveItem(_ notification: Notification) {
scheduleInstalledItemsReport()
}
// MARK: - Item construction
private func makeItem(_ spec: ToolbarItemSpec, willBeInsertedIntoToolbar: Bool) -> NSToolbarItem {
switch spec.behavior {
case .button:
return makeButtonItem(spec)
case .toggle:
return makeToggleItem(spec)
case let .responderAction(selector):
return makeResponderItem(spec, selector: selector)
case let .control(width, make):
return makeControlItem(spec, width: width, view: make(willBeInsertedIntoToolbar))
}
}
/// A plain bordered item, validated against its menu row's predicate.
private func makeButtonItem(_ spec: ToolbarItemSpec) -> NSToolbarItem {
let item = ValidatingToolbarItem(itemIdentifier: spec.identifier)
decorate(item, with: spec)
item.isBordered = true
item.target = self
item.action = #selector(itemFired(_:))
item.onValidate = { [weak item] in
item?.isEnabled = spec.isEnabled
}
return item
}
/// **Undo and Redo**: no target, so AppKit resolves and validates the action up the responder
/// chain the same lookup their menu rows use, which is the whole of "matching their menu
/// items" (03 Toolbar). Deliberately *not* a `ValidatingToolbarItem`: the default validation
/// is precisely the behaviour wanted here.
private func makeResponderItem(_ spec: ToolbarItemSpec, selector: Selector) -> NSToolbarItem {
let item = NSToolbarItem(itemIdentifier: spec.identifier)
decorate(item, with: spec)
item.isBordered = true
item.target = nil
item.action = selector
return item
}
/// A toggle button showing on-state. A hosted `NSButton` rather than a plain item because
/// `NSToolbarItem` has no state of its own, and 03 asks for one explicitly ("Show Trash (toggle
/// state matching the View menu checkmark)", "Edit Body is a single toggle button (on-state in
/// Edit)").
private func makeToggleItem(_ spec: ToolbarItemSpec) -> NSToolbarItem {
let button = NSButton(frame: NSRect(x: 0, y: 0, width: 38, height: 24))
button.setButtonType(.pushOnPushOff)
button.bezelStyle = .toolbar
button.title = ""
button.imagePosition = .imageOnly
if let symbol = spec.symbol {
button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: spec.label)
}
button.identifier = NSUserInterfaceItemIdentifier(spec.identifier.rawValue)
button.target = self
button.action = #selector(toggleFired(_:))
button.state = spec.isOn == true ? .on : .off
// A custom view carries its own accessibility, where a plain item inherits the toolbar
// item's label (10-accessibility.md: every control is named).
button.setAccessibilityLabel(spec.label)
button.toolTip = spec.label
let item = ValidatingToolbarItem(itemIdentifier: spec.identifier)
decorate(item, with: spec)
item.view = button
// A custom-view item is blank in the overflow menu without this, and the overflow is one of
// 03's clauses ("the system overflow").
item.menuFormRepresentation = menuFormRepresentation(for: spec)
item.onValidate = { [weak button] in
button?.isEnabled = spec.isEnabled
button?.state = spec.isOn == true ? .on : .off
}
return item
}
/// The board's search field, hosted. Sized rather than flexible, matching the width the field
/// shipped with in m5.
private func makeControlItem(_ spec: ToolbarItemSpec, width: CGFloat, view: NSView) -> NSToolbarItem {
view.translatesAutoresizingMaskIntoConstraints = false
view.widthAnchor.constraint(equalToConstant: width).isActive = true
let item = NSToolbarItem(itemIdentifier: spec.identifier)
decorate(item, with: spec)
item.view = view
// No menu form representation, deliberately: a search field in the overflow *menu* is a
// field nobody can type into, so AppKit's generated row label, no action, disabled is
// the honest presentation. F covers that window: an installed field that cannot take the
// keyboard falls through to the transient strip (`BoardSearchPresentation.focusField`).
// The high priority keeps the board's one default item out of the overflow to begin with.
item.visibilityPriority = .high
return item
}
/// The three strings every item carries: the toolbar label, the palette label (the same string
/// one vocabulary), and the tooltip, which is what a user of an icon-only toolbar reads.
private func decorate(_ item: NSToolbarItem, with spec: ToolbarItemSpec) {
item.label = spec.label
item.paletteLabel = spec.label
item.toolTip = spec.label
if let symbol = spec.symbol {
item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: spec.label)
}
}
private func menuFormRepresentation(for spec: ToolbarItemSpec) -> NSMenuItem {
let menuItem = NSMenuItem(title: spec.label, action: #selector(menuFired(_:)), keyEquivalent: "")
menuItem.target = self
menuItem.representedObject = spec.identifier.rawValue
return menuItem
}
// MARK: - Actions
@objc private func itemFired(_ sender: NSToolbarItem) {
specs[sender.itemIdentifier]?.activate()
}
@objc private func toggleFired(_ sender: NSButton) {
guard let raw = sender.identifier?.rawValue else { return }
specs[NSToolbarItem.Identifier(raw)]?.activate()
// The state is the model's, never the click's: a toggle whose setter refuses (a validation
// failure keeping source mode open, say) must not look like it succeeded.
revalidate()
}
@objc private func menuFired(_ sender: NSMenuItem) {
guard let raw = sender.representedObject as? String else { return }
specs[NSToolbarItem.Identifier(raw)]?.activate()
revalidate()
}
/// The overflow menu's copy of an item validates like the item itself including the checkmark,
/// which is where a toggle's on-state goes when the menu is its face (03 Toolbar: "the system
/// overflow").
@objc func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
guard let raw = menuItem.representedObject as? String,
let spec = specs[NSToolbarItem.Identifier(raw)]
else { return true }
if let isOn = spec.isOn {
menuItem.state = isOn ? .on : .off
}
return spec.isEnabled
}
// MARK: - Observed validation
/// Re-arms itself on every change: `withObservationTracking` is one-shot by design, so the
/// tracking closure reads every predicate again after each notification and starts a fresh
/// observation over whatever it read this time.
private func trackValidationState() {
withObservationTracking {
for spec in specs.values {
_ = spec.isEnabled
_ = spec.isOn
}
} onChange: { [weak self] in
// The notification arrives *before* the change lands, so the re-read is deferred by a
// turn which is also what puts it back on the main actor.
Task { @MainActor in
guard let self else { return }
self.revalidate()
self.trackValidationState()
}
}
}
}
// MARK: - The validated item
/// An `NSToolbarItem` whose validation is a closure over the predicate its menu row uses.
///
/// `NSToolbarItem`'s own `validate()` does nothing for an item with a custom view and asks the
/// target for `validateToolbarItem:` otherwise; overriding it outright is what lets one mechanism
/// serve both kinds and what lets the two toggle-state reads (enabled, on) happen in the same
/// pass, so an item can never show one of them stale.
final class ValidatingToolbarItem: NSToolbarItem {
var onValidate: (() -> Void)?
override func validate() {
onValidate?()
}
}