import AppKit // MARK: - BoardUndoManager /// What AppKit is handed for a board's windows: an `UndoManager` that owns no stack and answers /// every question from the session's `HistoryProviding`. /// /// ### Why an adapter exists at all /// /// The responder chain speaks one currency. `NSWindow` implements `undo:`/`redo:` and validates /// those menu items itself, reading `canUndo`, `canRedo` and `undoMenuItemTitle` off whatever /// `UndoManager` the window's delegate hands back (`windowWillReturnUndoManager(_:)`) — which is /// exactly how the system's Edit ▸ Undo row and the toolbar's nil-target pair (`BoardToolbar`) light /// up, disable and retitle with no code of the app's own. /// /// The seam, though, must not be an `NSUndoManager`: base's stack is one, Pro's is git /// (12-editions.md ▸ The provider seam), and a protocol that vended one could only ever have had a /// single implementation. So the substrate stays behind `HistoryProviding` and *this* object is the /// translation — one per board session, over whichever provider that session was composed with. Pro /// inherits the whole command surface (enablement, dynamic titles, ⌘Z, the toolbar pair) by binding /// its provider and changing nothing here, which is what "a user moving between editions relearns /// nothing" (12) has to mean in code. /// /// ### It deliberately keeps its inherited stack empty /// /// Every question is overridden, so anything that *did* register into this manager by accident (a /// stray AppKit client finding it through the window) would be invisible rather than half-live: it /// could never enable Undo, retitle it, or be crossed by ⌘Z. Nothing registers here today; the /// overrides are what make that safe rather than lucky. /// /// `removeAllActions()` is deliberately **not** forwarded to `HistoryProviding.clear()`: AppKit /// clears a window's undo manager on paths this app does not control, and a card window closing must /// not empty the board's stack (13-native-undo.md ▸ Rules — the stack belongs to the *session*, and /// its one clearing point is that session's teardown). public final class BoardUndoManager: UndoManager { /// The substrate this manager is a face for. Strong: the session owns both, and the manager is /// only ever reachable while the session that made it is alive. private let history: any HistoryProviding public init(history: any HistoryProviding) { self.history = history super.init() } // MARK: Enablement public override var canUndo: Bool { history.canUndo } public override var canRedo: Bool { history.canRedo } // MARK: Crossing public override func undo() { history.undo() } public override func redo() { history.redo() } // MARK: Titles /// `NSUndoManager`'s own vocabulary for "the phrase, without the verb" — `""` when there is /// nothing to cross, which is what its menu-title composition expects. public override var undoActionName: String { history.undoActionName ?? "" } public override var redoActionName: String { history.redoActionName ?? "" } /// "Undo Move 3 Cards" — composed and localized by the platform (`undoMenuTitle(forUndoActionName:)` /// reads the `undo.strings` pattern), so the step vocabulary stays the bare phrase and this app /// never spells the word "Undo" in a title. /// /// The trim is for the nameless case: the pattern is `Undo %@`, so an empty action name would /// otherwise leave a trailing space where `NSUndoManager` itself answers a bare "Undo". public override var undoMenuItemTitle: String { undoMenuTitle(forUndoActionName: undoActionName).trimmingCharacters(in: .whitespaces) } public override var redoMenuItemTitle: String { redoMenuTitle(forUndoActionName: redoActionName).trimmingCharacters(in: .whitespaces) } } // MARK: - BoardUndoRouting /// Which undo a hosted window answers with, given what holds the keyboard — 06-history-undo.md /// ▸ Undo routing, which 12 records as **edition-independent**: "focus decides text-undo vs /// board-undo; only the substrate behind board-undo differs". /// /// ### Most of the rule is AppKit's, and is not here /// /// "While a text-editing surface is focused ... ⌘Z/⇧⌘Z are that editor's own text undo" is the /// platform's own behaviour for a text view that vends a manager: `NSWindow.undo:` crosses the /// *first responder's* `undoManager`, and both of this app's real editors vend their own through the /// text-view delegate (`CardBodySurface`, `CardRawSourceView` — each with an `UndoManager` created /// per editor, emptied when the session ends). Those never reach this file. /// /// ### What is here is the case AppKit gets wrong /// /// A **field editor** — the shared `NSTextView` that appears inside every focused `NSTextField` and /// `NSSearchField` — vends no manager of its own to the window, so `undo:` falls through to the /// window's, and a reflexive ⌘Z over a typo in the search field or the board-rename field would /// cross a *board* step. 06 rules that out by name: "Control-class text fields route the same way /// (settled): the search field ... and the popover's text fields ... own ⌘Z/⇧⌘Z as field-local text /// undo while focused — a reflexive undo over a typo must never become a tree checkout." So the /// window-level answer is the board's stack **only when no text-editing surface holds the /// keyboard**, and a per-window text manager otherwise — which is also exactly what AppKit would /// have created for such a window on its own, so nothing about typing in a field changes. /// /// Pure and free of windows on purpose: the decision is three lines that are invisible until they /// are wrong, and the responder it reads is the only part a test cannot conjure. public enum BoardUndoRouting { /// Whether `responder` is a text-editing surface — every `NSTextView`, field editors included, /// which `NSText` is precisely the ancestor of. An `NSTextField` that has focus without editing /// is *not* one: AppKit installs the field editor the moment editing begins, and until then the /// board is what the keyboard is acting on. public static func isTextEditing(_ responder: NSResponder?) -> Bool { responder is NSText } /// The manager a window's delegate should answer with. /// /// `board` is `nil` for every window that is not showing a board — welcome, the restore /// bootstrap, the template chooser, and a board or card window whose session has already been /// torn down. Those get the text manager too, which is the platform default they had before this /// seam existed. public static func undoManager( isTextEditing: Bool, board: UndoManager?, textFallback: UndoManager ) -> UndoManager { guard !isTextEditing, let board else { return textFallback } return board } }