import Foundation // MARK: - NativeHistoryProvider /// The base edition's undo substrate: one `NSUndoManager`-backed stack per board session /// (13-native-undo.md). /// /// ### Why `NSUndoManager` at all, when the steps are ours /// /// Because the *command surface* is the platform's. Edit ▸ Undo and Edit ▸ Redo are the system's own /// nil-target `undo:`/`redo:` rows (11-command-nexus.md), the toolbar pair carries the same actions /// (`BoardToolbar`), and both light up, disable and **retitle** from whatever `UndoManager` the /// focused window hands back — "Undo Move 3 Cards" is `NSUndoManager`'s dynamic retitling, which 13 /// names as the mechanism and 12 records as the one both editions share. Reimplementing the stack /// over two arrays would mean reimplementing that, badly. /// /// It stays *behind* `HistoryProviding` regardless: nothing outside this file learns that base's /// stack is an `NSUndoManager`, which is what lets pro-m1 bind git to the same seam /// (12 ▸ The provider seam). /// /// ### One `register` call is exactly one step /// /// `groupsByEvent` is turned **off** and every registration is wrapped in its own group. The default /// (on) closes a group at the end of the run-loop turn, which would silently fold two gestures that /// happened to land in one event into a single ⌘Z — the opposite of 13's "one gesture, one undo /// step", and the coalescing decision belongs to the Writer call site (a multi-card move registers /// *one* step with a plural title), never to the run loop's timing. /// /// ### Undo flips to redo by re-registering /// /// A step that applies pushes its own mirror image back onto the manager from inside the undo — and /// `NSUndoManager` routes a registration made while it is undoing onto the **redo** stack, and vice /// versa. That single rule gives the whole classic dance (undo → redo → undo …) with no second stack /// of our own, and it is why a *skipped* step leaves nothing behind: it registers nothing, so the /// empty group is discarded and the step is simply gone (13: "popped from the stack ... and ⌘Z falls /// through to the next step"). @MainActor public final class NativeHistoryProvider: HistoryProviding { /// The stack. `private` and never vended: see the type's note. private let manager = UndoManager() /// Set by the step just crossed when it declined to apply, read by the crossing loop below. /// A flag rather than a return value because the manager, not this object, calls the step. private var lastCrossingSkipped = false public init() { // See "One `register` call is exactly one step", above. manager.groupsByEvent = false } // MARK: - HistoryProviding public var canUndo: Bool { manager.canUndo } public var canRedo: Bool { manager.canRedo } /// `NSUndoManager` answers `""` — not `nil` — for a stack with nothing on it *and* for a step /// registered without a name, so the emptiness check is the honest one. public var undoActionName: String? { guard canUndo, !manager.undoActionName.isEmpty else { return nil } return manager.undoActionName } public var redoActionName: String? { guard canRedo, !manager.redoActionName.isEmpty else { return nil } return manager.redoActionName } public func register(_ step: HistoryStep) { push(step) } public func undo() { cross(manager.undo, while: { self.manager.canUndo }) } public func redo() { cross(manager.redo, while: { self.manager.canRedo }) } public func clear() { manager.removeAllActions() } // MARK: - The stack /// Registers `step` as one group of its own, named for the menu. /// /// The target is `self` and the payload rides in the closure, which is `NSUndoManager`'s /// block-based form: the manager references its target **unowned** (its own documented rule), so /// the provider owning the manager that references the provider is not a cycle. private func push(_ step: HistoryStep) { manager.beginUndoGrouping() manager.registerUndo(withTarget: self) { provider in provider.apply(step) } // Inside the group, deliberately: the name belongs to the group being closed, and on the // way back it is what retitles Redo. manager.setActionName(step.name) manager.endUndoGrouping() } /// Runs one step's action and records what happened. private func apply(_ step: HistoryStep) { switch step.undo() { case .applied: // Lands on the opposite stack — see "Undo flips to redo by re-registering". push(step.reversed) case .skipped: lastCrossingSkipped = true } } /// Crosses one step, and keeps going while the steps it crosses decline to apply — 13's /// fall-through: "the step is skipped, not applied ... and ⌘Z falls through to the next step". /// /// The `while` guard is the manager's own emptiness, so a stack of nothing but stale steps /// empties itself and stops rather than spinning. private func cross(_ step: () -> Void, while more: () -> Bool) { repeat { guard more() else { return } lastCrossingSkipped = false step() } while lastCrossingSkipped } }