The undo command surface rebuilds — app-owned rows and explicit toolbar targets over FocusedValues
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
This commit is contained in:
@@ -64,10 +64,6 @@ struct ToolbarItemSpec {
|
||||
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
|
||||
@@ -88,8 +84,8 @@ struct ToolbarItemSpec {
|
||||
/// `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.
|
||||
/// 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?,
|
||||
@@ -126,14 +122,14 @@ struct ToolbarItemSpec {
|
||||
|
||||
// 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.
|
||||
/// 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 .responderAction, .searchField, .picker: true
|
||||
case .searchField, .picker: true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +137,7 @@ struct ToolbarItemSpec {
|
||||
var isOn: Bool? {
|
||||
switch behavior {
|
||||
case let .toggle(_, isOn, _): isOn()
|
||||
case .button, .responderAction, .searchField, .picker: nil
|
||||
case .button, .searchField, .picker: nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +148,7 @@ struct ToolbarItemSpec {
|
||||
switch behavior {
|
||||
case let .button(_, perform): perform()
|
||||
case let .toggle(_, isOn, setOn): setOn(!isOn())
|
||||
case .responderAction, .searchField, .picker: break
|
||||
case .searchField, .picker: break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,7 +161,9 @@ struct ToolbarItemSpec {
|
||||
/// ### 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:
|
||||
/// 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
|
||||
@@ -173,12 +171,16 @@ struct ToolbarItemSpec {
|
||||
/// 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.
|
||||
/// - ~~**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
|
||||
@@ -304,8 +306,6 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
|
||||
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,
|
||||
@@ -333,19 +333,6 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user