import AppKit import Observation import SwiftUI // MARK: - The focused stack /// **The stack the frontmost window's ⌘Z crosses**, published into the focus system by whichever /// host owns it: `BoardWindowHost` publishes its session's manager, `CardWindowHost` the window's own /// (`CardWindowUndo.manager`). /// /// One key serves both levels, and that is the two-level model's own shape rather than a shortcut /// (13-native-undo.md ▸ Rules ▸ two levels, re-ruled 2026-07-31): a card window's stack is the *same* /// face type over the same seam, so a second key would only ask the rows to decide which of two /// answers is in front — which is exactly the question the focus system already answers. Crossing /// between the two levels is therefore impossible by construction here, which is 13 ▸ Undo routing's /// "no fall-through" holding as a fact about the value rather than as a rule someone applies. /// /// `FocusedBoardStoreKey` is the precedent and its note carries over verbatim: `focusedSceneValue` /// rather than `focusedValue`, because the value is the *window's* and not any particular control's, /// so it stays available whatever inside the window holds the keyboard — which is precisely what a /// row that then routes on the first responder needs to read. struct FocusedUndoStackKey: FocusedValueKey { typealias Value = BoardUndoManager } extension FocusedValues { var undoStack: BoardUndoManager? { get { self[FocusedUndoStackKey.self] } set { self[FocusedUndoStackKey.self] = newValue } } } // MARK: - Routing /// **What an Undo/Redo row answers with**, given the focused stack and what holds the keyboard — /// 13-native-undo.md ▸ Undo routing's predicate, relocated from the window delegate to the command /// layer (13 ▸ Rules ▸ the command-surface bullet, re-ruled 2026-08-08; the rule itself is /// unchanged, only where it is enacted). /// /// ### Why the rule had to move /// /// The predicate used to be the platform's to apply: the system's nil-target `undo:`/`redo:` rows /// resolve to `NSWindow`, which reads the manager its delegate hands back /// (`windowWillReturnUndoManager`, still wired in `HostedWindowController` and still the right answer /// wherever AppKit itself asks). On a SwiftUI window that hook is never consulted — `NSWindow` reads /// and **permanently latches** an undo manager of its own during window creation, before any app code /// can install a delegate (diagnosed 2026-08-07 by live probe) — so the whole command surface became /// the app's own, and the routing came with it. /// /// ### Pure, and free of both windows and SwiftUI /// /// Everything a row needs is two references it is handed. The first responder is the one part a test /// cannot conjure from the focus system, so it is a parameter rather than a read of `NSApp` — the /// same posture `BoardUndoRouting` takes one layer down, and the reason that enum's decision is /// reusable here verbatim rather than restated. @MainActor enum UndoCommandRouting { /// The manager a row answers with — the focused stack, or, while a text surface holds the /// keyboard, that responder's own manager. /// /// The text case is where its typing undo actually registered: an editor that vends a manager /// through its delegate (`CardBodySurface`, `CardRawSourceView`) hands back that one, and a field /// editor — which vends none — resolves up the responder chain to the window's latched manager, /// which is exactly where AppKit put its typing undo. Either way the answer is the responder's, /// which is what "a reflexive undo over a typo must never become a board-level restore" /// (13 ▸ Undo routing) means once the app is the one choosing. /// /// `nil` where there is nothing to cross at all: no stack in front and no text surface focused, or /// a text surface whose own manager is `nil` (a view in no window, with no delegate to ask). A row /// over `nil` disables and keeps the bare verb. static func routedManager(stack: BoardUndoManager?, firstResponder: NSResponder?) -> UndoManager? { guard BoardUndoRouting.isTextEditing(firstResponder) else { return stack } guard let text = firstResponder?.undoManager else { return nil } return BoardUndoRouting.undoManager(isTextEditing: true, board: stack, textFallback: text) } // MARK: Titles /// "Undo Move 3 Cards" — composed and localized by the platform over the bare 13-vocabulary /// phrase, so the app never spells the verb (`BoardUndoManager.undoMenuItemTitle`). /// /// The trim is for the nameless case on a manager that is *not* this app's adapter: a plain /// `NSUndoManager` — a text view's, a field editor's — composes `Undo %@` over an empty name and /// leaves the trailing space standing. The bare verb is also the answer for no manager at all, /// which is a row with nothing in front of it rather than a row that lost its name. static func undoTitle(of manager: UndoManager?) -> String { title(manager?.undoMenuItemTitle, bare: "Undo") } static func redoTitle(of manager: UndoManager?) -> String { title(manager?.redoMenuItemTitle, bare: "Redo") } private static func title(_ composed: String?, bare: String) -> String { let trimmed = composed?.trimmingCharacters(in: .whitespaces) ?? "" return trimmed.isEmpty ? bare : trimmed } // MARK: Enablement /// Whether the row is live — the routed manager's own answer, which for this app's adapter is /// "steps, and no read-only lock" (`BoardUndoManager.canUndo`, where 13 ▸ Rules' lock clause /// lives) and for a text manager is the editor's session. `false` with no manager, which is the /// same disablement an empty stack gets: exhausting what the row reaches never reaches anything /// else (13 ▸ Undo routing, no fall-through). static func canUndo(_ manager: UndoManager?) -> Bool { manager?.canUndo == true } static func canRedo(_ manager: UndoManager?) -> Bool { manager?.canRedo == true } } // MARK: - The revalidation ticker /// **What makes a rendered Undo/Redo row notice the world moved.** /// /// The rows read three things that are not SwiftUI state: the routed manager's enablement, its /// composed title, and — through the first responder — which of the two it is routing to at all. /// `BoardUndoManager` re-derives from an `@Observable` provider, so a board or card step lands on the /// row for free; a *text* manager is a plain `NSUndoManager` with no observation in it, and neither /// is `NSApp.keyWindow?.firstResponder`. This is the subscription those two get. /// /// A bumped counter rather than published state: nothing here knows what any row's answer is, only /// that it may have changed, and the rows are cheap to re-derive. Reading `revision` inside a row's /// `body` is what enrolls it. /// /// ### The notifications, and why each one /// /// `NSUndoManagerCheckpoint` with `object: nil` is the load-bearing one: it catches **every** /// manager's stack changes, which is how typing into a field editor retitles the row that is routing /// to it. The did-undo/did-redo pair covers a crossing that changes which direction is live, the /// `NSText` editing pair covers the field editor arriving and leaving, and `NSMenu`'s did-begin- /// tracking is a re-derive a moment before the Edit menu draws itself. /// /// ### The known residual, which is cosmetic /// /// Focus moving in or out of a text surface *without any typing* — clicking into an empty search /// field, tabbing away from an untouched one — bumps nothing, because AppKit posts no editing /// notification until an edit begins. A row rendered across that moment can therefore be titled or /// enabled off the stack it is no longer routing to. Behaviour stays correct regardless: the action /// re-derives the routed manager at fire time and beeps rather than crossing the wrong stack, which /// is 13 ▸ Undo routing's own answer for an exhausted focus. @MainActor @Observable final class UndoCommandTicker { /// One instance, read by both rows: the rows differ in direction, never in when they are stale. static let shared = UndoCommandTicker() /// Bumped, never read for its value — a row reads it to subscribe, and the number itself means /// nothing. private(set) var revision = 0 private var observers: [any NSObjectProtocol] = [] private init() { let names: [Notification.Name] = [ .NSUndoManagerCheckpoint, .NSUndoManagerDidUndoChange, .NSUndoManagerDidRedoChange, NSText.didBeginEditingNotification, NSText.didEndEditingNotification, NSMenu.didBeginTrackingNotification, ] observers = names.map { name in NotificationCenter.default.addObserver(forName: name, object: nil, queue: nil) { [weak self] _ in // All six are posted on the main thread, and the synchronous path is the one that // matters: a menu that has begun tracking is about to draw, so a hop onto the next // turn would re-derive the row after the user is already looking at it. The // asynchronous branch is the promise-keeping half, never the expected one. if Thread.isMainThread { MainActor.assumeIsolated { self?.bump() } } else { Task { @MainActor in self?.bump() } } } } } private func bump() { revision &+= 1 } } // MARK: - The rows /// **Edit ▸ Undo and Edit ▸ Redo, as the app's own rows** (13-native-undo.md ▸ Rules ▸ the /// command-surface bullet, ruled 2026-08-08). /// /// `CommandGroup(replacing: .undoRedo)` takes the system's nil-target pair out of the menu, because /// on a SwiftUI window they are unreachable: `NSWindow` latches an empty undo manager of its own /// during window creation, before `HostedWindowController` can install the delegate that would have /// vended the board's, so `windowWillReturnUndoManager` is never consulted and the rows validate /// against a stack nothing ever registers into. Everything the pair used to get from the platform — /// enablement, the dynamic title, the crossing — the rows below now ask for by name, off the same /// `BoardUndoManager` that surface always meant to be reading. /// /// The alternatives are recorded in 13 and both re-open decisions this app already made: registering /// steps into the latched manager needs an `NSUndoManager` substrate, which `HistoryStepOutcome /// .failed` rules out (`NativeHistoryProvider` ▸ two arrays), and intercepting nil-target `undo:` /// from the responder chain is preempted by `NSWindow` handling the action itself, ahead of its own /// delegate. struct UndoRedoCommands: Commands { var body: some Commands { CommandGroup(replacing: .undoRedo) { UndoMenuRow() RedoMenuRow() } } } /// Edit ▸ Undo (⌘Z). /// /// **The whole row routes, not just its action** (13 ▸ Rules ▸ the command-surface bullet, re-ruled /// 2026-08-08). A row left titled and enabled off the board stack while a text field held the /// keyboard would fail in one of two ways, and both are worse than a re-derived title: enabled, it /// advertises a board step it will not cross once the action routes elsewhere; disabled — because /// the board stack happened to be empty — it *swallows* ⌘Z outright, since a menu item owns its key /// equivalent whether or not it is live and nothing downstream ever sees the chord. Routing title, /// enablement and action together is what keeps 13 ▸ Undo routing's no-fall-through honest from the /// user's side of the menu. private struct UndoMenuRow: View { @FocusedValue(\.undoStack) private var stack var body: some View { // Read for the subscription, never for the value — see `UndoCommandTicker`, which is what // makes a text manager's stack changes and a focus move reach a row that has already // rendered. It has to be read *in* `body`, which is the only scope SwiftUI tracks. let _ = UndoCommandTicker.shared.revision let manager = UndoCommandRouting.routedManager( stack: stack, firstResponder: NSApp.keyWindow?.firstResponder ) Button(UndoCommandRouting.undoTitle(of: manager)) { cross() } .keyboardShortcut("z", modifiers: .command) .disabled(!UndoCommandRouting.canUndo(manager)) } /// **Routed again here**, rather than closing over what the row rendered with: focus and stacks /// both move between a menu's display and its click, and the manager the crossing reaches must be /// the one that holds the keyboard *now*. The beep is 13 ▸ Undo routing's "exhausting a focused /// editor's stack beeps; it never reaches board history", answering the window where the row's /// rendered enablement was a moment stale. private func cross() { let manager = UndoCommandRouting.routedManager( stack: stack, firstResponder: NSApp.keyWindow?.firstResponder ) guard let manager, manager.canUndo else { NSSound.beep() return } manager.undo() } } /// Edit ▸ Redo (⇧⌘Z) — `UndoMenuRow`'s twin in every respect, which is why its reasoning is not /// repeated here. private struct RedoMenuRow: View { @FocusedValue(\.undoStack) private var stack var body: some View { let _ = UndoCommandTicker.shared.revision let manager = UndoCommandRouting.routedManager( stack: stack, firstResponder: NSApp.keyWindow?.firstResponder ) Button(UndoCommandRouting.redoTitle(of: manager)) { cross() } .keyboardShortcut("z", modifiers: [.command, .shift]) .disabled(!UndoCommandRouting.canRedo(manager)) } private func cross() { let manager = UndoCommandRouting.routedManager( stack: stack, firstResponder: NSApp.keyWindow?.firstResponder ) guard let manager, manager.canRedo else { NSSound.beep() return } manager.redo() } }