Core · guide
Undo
A control finds an undo manager on the way up rather than owning one — what the seam is, where a manager is installed, why a run of typing is one entry and not one per keystroke, and why a text field that finds nothing deliberately lets ⌘Z go past it.
Edit this page on GitHubDocuments
What it is
IUndoManager is two delegates per edit and four questions:
manager.Register("Typing", () => field.Value = before, () => field.Value = after);CanUndo, CanRedo, Undo(), Redo() — and IsPerforming, which is the one nobody expects to
need. ⚠ Undoing re-runs the code that made the edit, so a registrant that did not check would
record the undo as a new edit and the second ⌘Z would put the text back rather than going further
into the past. The guard lives in the manager, so every implementation answers it and the check is in
one place instead of in every control.
What it is for
CodeBuffer argues, correctly, that a text control must not own an undo stack: undo has to interleave
with everything else an application does — a rename that touched three files, a refactor, a move — and
a stack inside a control can only ever undo typing.
⚠ What that argument does not settle is where a control should look, and until this existed the
answer was nowhere. git grep "IUndo\|UndoManager" returned no hits in the repository, so a
dialog's text box had no ⌘Z in any Vixen application, the editor included.
AppKit resolves it exactly: NSResponder.undoManager walks the chain, so a control finds a manager
rather than owning one. Here that is UiElement.FindUndoManager() — this element, then its ancestors,
then UiDocument.UndoManager.
panel.UndoManager = document.Edits;Everything inside panel now registers with that document's stack rather than with the application's.
Using it
UiApplication puts a UndoManager on the document before UiApplicationOptions.Configure runs, so a
plain dialog text box is undoable in a program that has no documents at all, and an application with
its own stack replaces it in Configure.
⚠ Null is a real answer and is the important case. A field that finds no manager registers nothing and leaves ⌘Z unhandled, so the chord climbs to whatever else was listening. That is what stops a text box from shadowing an application's own Undo for as long as it has the focus — which, for a search field, is most of the time.
The menu half: UndoCommands.Install
A manager nothing can reach from a menu is half a feature — until this existed ⌘Z worked inside a
TextField and Edit ▸ Undo did nothing at all, because no responder anywhere answered edit.undo.
UndoCommands.Install(element) registers the pair on the element that owns a manager, and
UiApplication calls it on the document root beside the default it installs.
⚠ The pair goes where the manager is and deliberately not on the control, which is the same
argument CodeBuffer makes about the stack, arriving from the other end. CommandRoute.Resolve
stops at the first element that registered an id and a refusal from it is final, so a focused field
answering edit.undo would swallow the application's Undo whatever its canExecute said — exactly
what the chord leg in TextField already refuses to do. Installed on the root, the same walk gives
the field what it wanted anyway: ⌘Z climbs out of the field and reaches the very stack the field
recorded into.
Nearest still wins. A panel that owns a document's stack sets UiElement.UndoManager and calls
Install on the same element; the route then means that panel's history while the focus is inside it
and the application's everywhere else, and neither knows the other exists.
⚠ Each run invalidates the document's commands, and so does each edit a control records. Command state is pulled once per raise rather than observed, so undoing the only edit has to grey Undo and un-grey Redo in one breath — without that the menu keeps the enablement it had when it was opened, which reads exactly like Undo being broken.
Coalescing, which is what makes ⌘Z useful
A run of typing is one entry. ⚠ Decided by shape, not by a clock: a wall-clock typing window calibrated on an idle machine is this repository's largest flake source. Two keystrokes are one edit when the second inserted where the first ended, with nothing selected and no line broken. Anything else — a delete, a paste, a caret move, a newline — starts a fresh entry.
Undo restores the selection as well as the value and the caret. An undo of a cut that leaves the user to re-select what came back is an undo that only half happened.
Examples
A document that puts its own stack behind ⌘Z. UiApplication installs a manager before
Configure runs, so replacing it there is the seam an application with real documents uses:
options.Configure = document => document.UndoManager = new CommandStackUndoManager(stack);A field that must not shadow the application's Undo. Registering nothing is the behaviour, not an omission — a search field with no manager leaves ⌘Z unhandled and the chord climbs past it:
search.HostedDocument = null; // no manager found, so no handler registeredWhat is still owed
Editor/Vixen.Editor.Core/CommandStack.cs is an IUndoManager now, so an edit a control
registered and an edit a command made are one history and ⌘Z steps back through both in the order
they happened. ⚠ Register is the opposite of Execute: the edit has already been applied, so it is
recorded and not run — and it is ignored inside a transaction, whose entry is built out of commands
the transaction ran itself.
⚠ What is still owed is the editor's install, and only that. UiApplication installs a
default manager and UndoCommands.Install beside it, so every application built on the desktop host
has both a stack and the two verbs that reach it. The editor is not one of those applications: it
keeps only UiWindowSurface from that host, and nothing sets UiDocument.UndoManager or
UiElement.UndoManager to a document's CommandStack. So a text field in the editor still finds
nothing and still leaves ⌘Z to the editor's global edit.undo. That wants the panel hosting the
active document to set its own UndoManager and call UndoCommands.Install on the same element —
which is a real feature rather than a line, because the active document changes as the user switches
tabs, and the call sites are in Vixen.Editor.App.
⚠ CodeEditor registering nothing is a decision and not the same owed item, which earlier notes
here read the other way round. CodeDocument is already on CodeBuffer.Changed and already turns
each run of typing into a TextEditCommand on the document's CommandStack — so the moment that
stack is installed as the document's manager, a CodeEditor that also registered through
FindUndoManager would record every edit twice: once as the control's entry and once as the
document's command, in one history that ⌘Z would then have to be pressed through twice. The
asymmetry with TextField is the asymmetry between the two controls: a field has a seam nobody is
on, and a code editor has one with a real consumer.
See also
- Text input and the input method — the editing keymap ⌘Z and Ctrl-Y resolve through
- Commands —
edit.undoandedit.redoas ids an application's menu binds Core/Vixen.Ui.Controls.Advanced/CodeBuffer.cs— the argument this seam is the missing half of