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 the search item, whose field 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 search field in AppKit's own `NSSearchToolbarItem` — the board's search /// (03-board-ui.md ▸ Toolbar). The item owns the field's layout, so `focusedWidth` is a /// preference rather than a constraint: it is the width the field takes *when it has the /// keyboard*, the resting width being the item's own. /// /// `make` is handed `true` when the field 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. `install` runs for that real item alone, and is where a caller wires the things /// that need the *item* rather than the field — expanding it and putting the keyboard in it /// is one call on `NSSearchToolbarItem`, and no field can make it. case searchField( focusedWidth: CGFloat, make: (_ willBeInsertedIntoToolbar: Bool) -> NSSearchField, install: (NSSearchToolbarItem) -> Void ) /// A pull-down of mutually exclusive choices — **Appearance** (03-board-ui.md ▸ Toolbar): an /// `NSMenuToolbarItem`, item image plus indicator, whose menu lists `options` in order. /// `selected()` names the option index carrying the checkmark, read fresh whenever AppKit /// opens the menu rather than polled — the same freshness every other menu row in the app /// gets (`validateMenuItem(_:)`) — and `select(_:)` is a chosen row's whole action. The one /// behavior with no `activate()` of its own: firing lives in the dropdown's rows, not in the /// item itself, the way `responderAction`'s lives in the responder chain rather than here. case picker( options: [(title: String, symbol: String?)], selected: () -> Int?, select: (Int) -> Void ) } /// 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 the search item 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, .searchField, .picker: 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, .searchField, .picker: nil } } /// Firing the item: a button performs, a toggle flips. A no-op for the kinds AppKit drives itself /// or that fire from somewhere other than the item's own primary action (`.picker`'s dropdown /// rows). func activate() { switch behavior { case let .button(_, perform): perform() case let .toggle(_, isOn, setOn): setOn(!isOn()) case .responderAction, .searchField, .picker: 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 enables and disables with the menu rows by construction rather than by agreement, /// reading the board window's `BoardUndoManager` through `NSWindow`'s own validation /// (13-native-undo.md). A SwiftUI `Button` cannot express that. /// - **The search item is AppKit's own `NSSearchToolbarItem`**, hosting 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). The item is where grow-on-focus, the cancel button's staging, and the /// overflow row all live, and it exists only in AppKit; 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 .searchField(focusedWidth, make, install): return makeSearchItem( spec, focusedWidth: focusedWidth, field: make(willBeInsertedIntoToolbar), // The palette's copy is a picture of the item, not a second live one: it claims no // window-scoped wiring, so it is handed none. install: willBeInsertedIntoToolbar ? install : nil ) case let .picker(options, _, _): return makePickerItem(spec, options: options) } } /// 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 in the item AppKit wrote for it. /// /// **No width constraint here.** `NSSearchToolbarItem` manages the field's layout, and its /// header says custom width constraints "should not conflict with" the preferred width — so the /// em-based figure is handed over as a *preference*, which the item applies "whenever it gets /// the keyboard focus". The resting width is the item's own, and grow-on-focus is what that /// pair of facts means on screen. /// /// **Neither the overflow row nor the visibility priority is written here, and both omissions /// are decisions.** The item ships its own `menuFormRepresentation` — a row titled from `label` /// carrying a live AppKit action that widens the window until the field is usable — where a /// custom-view item ships a blank one that has to be replaced; assigning here (`nil` included) /// destroys it. And its `visibilityPriority` already starts one step *above* `.high`, so the /// nudge a custom-view item needs to stay out of the overflow would be a demotion here. private func makeSearchItem( _ spec: ToolbarItemSpec, focusedWidth: CGFloat, field: NSSearchField, install: ((NSSearchToolbarItem) -> Void)? ) -> NSToolbarItem { let item = NSSearchToolbarItem(itemIdentifier: spec.identifier) decorate(item, with: spec) // Configured before assignment, as the item's header asks — with one exception the header // does not name: assignment stamps the *item's* enablement onto the field, so a field the // caller made inert (the customization palette's copy) comes back live. The caller's answer // is the one that counts, so it is put back. let isFieldEnabled = field.isEnabled item.searchField = field field.isEnabled = isFieldEnabled item.preferredWidthForSearchField = focusedWidth // **Escape is staged** (04-interactions.md ▸ Search, settled: "in a non-empty field it // clears the query, focus staying in the field; in an empty field it returns focus to the // board"). AppKit's default is for the cancel button to clear *and* resign, which collapses // the first two steps of that staircase into one — so the field keeps the keyboard, and the // second press is what hands it back (`BoardSearchFieldController`). item.resignsFirstResponderWithCancel = false install?(item) return item } /// A pull-down of mutually exclusive options — **Appearance**, so far the one item of this shape. /// /// `selected`/`select` are deliberately not captured here: every row's action and every row's /// validation re-fetch the spec fresh from `specs[identifier]` (`pickerItemFired(_:)`, /// `validateMenuItem(_:)`), the same indirection `itemFired(_:)` and `toggleFired(_:)` already use /// for their own specs — so a spec rebuilt between two menu presentations is never read stale. private func makePickerItem(_ spec: ToolbarItemSpec, options: [(title: String, symbol: String?)]) -> NSToolbarItem { let item = NSMenuToolbarItem(itemIdentifier: spec.identifier) decorate(item, with: spec) // "Pull-down: item image + indicator" — the item's own glyph draws at rest, the indicator // chevron shows there is a menu, and the rows are what actually name Auto/Light/Dark. item.showsIndicator = true let menu = NSMenu() for (index, option) in options.enumerated() { let menuItem = NSMenuItem( title: option.title, action: #selector(pickerItemFired(_:)), keyEquivalent: "" ) menuItem.target = self // The row's position in `options`, not an identifier of its own — `select(_:)` and // `selected()` both speak in this same index, which is what lets one closure pair stand // for every row rather than one closure per option. menuItem.tag = index menuItem.representedObject = spec.identifier.rawValue if let symbol = option.symbol { menuItem.image = NSImage(systemSymbolName: symbol, accessibilityDescription: option.title) } menu.addItem(menuItem) } item.menu = menu 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() } /// A row in a `.picker` item's own dropdown — **Appearance**'s Auto/Light/Dark, fired straight /// from the menu rather than through `itemFired(_:)`, since the item has no primary action of its /// own (`ToolbarItemSpec.activate()` is a no-op for `.picker`). @objc private func pickerItemFired(_ sender: NSMenuItem) { guard let raw = sender.representedObject as? String, let spec = specs[NSToolbarItem.Identifier(raw)], case let .picker(_, _, select) = spec.behavior else { return } select(sender.tag) 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") — and where a `.picker` row's checkmark goes too, against its own index rather than /// against `isOn` (which answers `nil` for the whole item, having no single on-state to give). @objc func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { guard let raw = menuItem.representedObject as? String, let spec = specs[NSToolbarItem.Identifier(raw)] else { return true } if case let .picker(_, selected, _) = spec.behavior { menuItem.state = selected() == menuItem.tag ? .on : .off 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?() } }