Build the HistoryProviding seam and the per-board native undo stack
The provider seam 12 promised: HistoryProviding speaks 13's vocabulary — register a HistoryStep (bare 06 phrase plus undo/redo closures returning applied or skipped), canUndo/canRedo, action names, clear — and no UndoManager type appears anywhere in it, proven by a fake that satisfies the seam with counters. The base provider wraps a private UndoManager with groupsByEvent off so coalescing stays the Writer call site's decision; undo re-registers the reversed step from inside the undo, which makes a stale-skipped step vanish for free and the crossing loop fall through to the next. BoardUndoManager adapts the protocol to the responder chain — a stackless UndoManager subclass answering from the provider — so Pro's git provider inherits menu enablement, dynamic titles, and the nil-target toolbar pair by binding the protocol. One stack per board session, born in beginSession, cleared in the close flush; every window over the board answers it through windowWillReturnUndoManager. Headless probes shaped the routing: a real NSTextView's own manager wins natively, but a field editor's does not — BoardUndoRouting answers the per-window text manager while any NSText is first responder, so a search-field typo never crosses a board step. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user