Files
lanework/Kanban/App/WindowToolbar.swift
rzen 5e6417e749 The board search item becomes the stock NSSearchToolbarItem — the field grows on focus
The toolbar's search field now lives in AppKit's own NSSearchToolbarItem:
em(17) is the focused width (preferredWidthForSearchField), the resting
width is the item's own, and ⌘F becomes beginSearchInteraction — one call
that expands and focuses. Escape's empty-field exit collapses the field as
the keyboard leaves. resignsFirstResponderWithCancel is off so the staged
Escape survives the cancel button. The item ships its own live overflow row
and an above-.high visibility priority, so both custom-view workarounds
retire. The palette copy stays inert through an enablement round-trip the
item's searchField setter would otherwise overwrite.

The centering half of the card is blocked on a Design ruling (03 settles
"trailing") and is not built; the two-homes width question is filed.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-08-01 11:16:58 -04:00

499 lines
23 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)
/// 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
)
}
/// 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: 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: 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, .searchField: 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
)
}
}
/// 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
}
/// 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?()
}
}