Edit ▸ Undo/Redo become the app's own replaced rows and the board toolbar pair takes explicit targets, both reading the focused session's BoardUndoManager through FocusedValues.undoStack (board windows publish the session's manager, card windows their own) — the nil-target route died with the SwiftUI window latch, 13-native-undo.md ▸ Rules ▸ command surface, re-ruled 2026-08-08. The rows enact the routing predicate themselves: text focus routes ⌘Z to the first responder's own manager, title and enablement included, re-derived at fire time with a beep for the stale window. NativeHistoryProvider turns @Observable so both surfaces re-derive on stack changes; a checkpoint-notification ticker covers plain text managers. .responderAction leaves ToolbarItemSpec with its only user; windowWillReturnUndoManager stays wired for AppKit's own asks. Live-probed on the fixture board (21/21): the row retitles to "Undo Add Lane" and crosses via real ⌘Z key events, ⇧⌘Z redoes via a window-server chord, the toolbar pair validates and fires, search-field and body-editor ⌘Z stay text undo with board stacks untouched, and a card window crosses its own stack with no fall-through. 2698 unit tests green. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
553 lines
26 KiB
Swift
553 lines
26 KiB
Swift
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)
|
|
/// 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 rather than
|
|
/// in the item's primary action.
|
|
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. The search item and the picker answer `true`: neither has an
|
|
/// enablement of its own — the field is a control rather than a command, and the picker's
|
|
/// choices are always available.
|
|
var isEnabled: Bool {
|
|
switch behavior {
|
|
case let .button(isEnabled, _): isEnabled()
|
|
case let .toggle(isEnabled, _, _): isEnabled()
|
|
case .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, .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 .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 was normative rather than aesthetic. Three
|
|
/// of them still hold; the fourth is kept below because a retired reason is worth more written down
|
|
/// than deleted, and because the three that remain are what the answer now rests on:
|
|
///
|
|
/// - **⌘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.**~~ **Retired 2026-08-08** with the
|
|
/// mechanism it named (13-native-undo.md ▸ Rules ▸ the command-surface bullet, re-ruled that day):
|
|
/// the pair's nil-target `undo:`/`redo:` resolved to `NSWindow`, which reads the manager its
|
|
/// delegate vends — and a SwiftUI window latches an empty manager of its own during creation,
|
|
/// before `HostedWindowController` installs, so that route reached a stack nothing registers into.
|
|
/// The pair now carries an **explicit target** over the focused session's `BoardUndoManager`
|
|
/// (`BoardToolbar`), which is the same object the app's own Edit ▸ Undo/Redo rows read through the
|
|
/// focus system — "matching their menu items" (03) by sharing the manager rather than by sharing a
|
|
/// lookup. Nothing about that needs AppKit; the other three bullets are why this is still an
|
|
/// `NSToolbar`.
|
|
/// - **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 .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
|
|
}
|
|
|
|
/// 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?()
|
|
}
|
|
}
|