The command surface was already almost entirely platform machinery — this card proves it and pins it. Headless probes established that NSWindow.validateMenuItem answers enablement AND rewrites the row title from the delegate-supplied manager, so 'Undo Move 3 Cards' flows step phrase to Edit menu with no code of ours; under the lock the rows dim and keep their names, the correct reading of the-stack-survives. The toolbar twins validate through validateUserInterfaceItem, which never touches labels — 03's static-label exception proven rather than asserted — and their specs' enablement abstention is pinned so nobody later adds a second, disagreeing answer. The one link a headless run cannot close is the nil-target key-window resolution itself: standard responder-chain behavior with none of our code in it, left as the manual check. Base-edition 'disabled without undo' scaffolding is reworded away — every base board has undo now. New suites cover the trash's two doors (delete-then-undo byte-identical to Put Back's effect), position-preserving restore of a middle card, and the capstone: five gestures forward, five presses back to the origin board, five forward again, the menu phrase asserted after every press. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
457 lines
20 KiB
Swift
457 lines
20 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 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 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 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?()
|
|
}
|
|
}
|