This document specifies a lightweight, predictable, and extensible undo/redo system for
TextEditorCore / TextEditorBase (and therefore TextBox, TextArea, MaskedInput, NumberBox, etc.).
It also specifies how the system integrates with Search/Replace.
The goal is to provide an implementation blueprint that fits the existing framework constraints:
ITextDocument / ITextSnapshot as the text model.CanUndo, CanRedo, Undo(), Redo(), and ClearUndoHistory().Relevant existing types:
TextEditorBase (src/XenoAtom.Terminal.UI/Controls/TextEditorBase.cs)TextEditorCore (src/XenoAtom.Terminal.UI/Controls/TextEditorCore.cs) (internal)ITextDocument (src/XenoAtom.Terminal.UI/Text/ITextDocument.cs)TextDocument (src/XenoAtom.Terminal.UI/Text/TextDocument.cs)DynamicTextDocument (src/XenoAtom.Terminal.UI/Text/DynamicTextDocument.cs) (bindable Text bridge)SearchReplacePopup + ISearchReplaceTarget (src/XenoAtom.Terminal.UI/Controls/SearchReplacePopup.cs)TextEditorBase.CreateSearchReplaceTarget() (internal)TextEditorCore.ReplaceCurrentSearchMatch(...) / ReplaceAllSearchMatches(...)TextEditorBase APIExpose undo/redo as editor-level behavior (owned by the editor by default):
public bool CanUndo { get; } (bindable)public bool CanRedo { get; } (bindable)public void Undo()public void Redo()public void ClearUndoHistory()public int MaxUndoEntries { get; set; } (bindable, default e.g. 200)public bool EnableUndo { get; set; } (bindable, default true)Notes:
CanUndo/CanRedo MUST update when the stacks change. They should be implemented as bindable properties so UI can react.ClearUndoHistory() MUST clear both stacks and update CanUndo/CanRedo.IDisposable BeginUndoGroup(string? label = null) for advanced callers.v1 can keep the core manager internal but should keep the design compatible with a future document-owned implementation.
For v1, the simplest and safest model is:
TextEditorBase instance owns a TextUndoRedoManager.TextEditorCore MUST go through that manager (or through core helpers that record).If the editor's TextDocument is replaced (TextEditorBase.TextDocument = ...), the undo manager MUST:
TextChangeRepresents a replace operation at a position:
int Positionstring RemovedText (may be empty)string InsertedText (may be empty)The fundamental document operation is Replace(Position, RemovedText.Length, InsertedText).
Undo applies the inverse:
Replace(Position, InsertedText.Length, RemovedText)Redo applies the forward operation again.
EditorStateSnapshotUndo should restore editor state meaningfully. Minimum fields:
int CaretIndexint SelectionAnchor (or -1 when none)int SelectionEnd (or -1 when none)Recommended fields:
int ScrollX, int ScrollY (from ScrollModel)int PreferredColumn (if relevant to vertical caret movement)Restoration rules:
TerminalTextUtility.GetPreviousTextElementIndex / GetNextTextElementIndex).EnsureCaretVisible(...) to make the caret visible (or restore scroll offsets if you store them).UndoEntryAn undo entry contains:
TextChange[] Changes (usually length 1; for Replace All can be N).EditorStateSnapshot BeforeEditorStateSnapshot AfterUndoKind Kind (Typing, Paste, Delete, Replace, ReplaceAll, etc.)DateTime Timestamp (or tick count) for coalescing windowsUndo/redo MUST not create new undo entries while applying changes:
bool _isApplying._isApplying is true:
Changed events.Apply rules:
Changes in the order they were recorded.Changes in reverse order.For multi-change entries (e.g. Replace All), this rule is critical when replacement lengths differ.
Implementation note:
using var _ = document.BeginUpdate(); around applying all changes in an entry to reduce intermediate work.Coalescing should be supported, but conservative:
Typing edits (insertion with no selection) MAY be merged into the previous entry when:
TypingMergeWindow (e.g. 500ms),new.Position == previous.Position + previous.InsertedText.LengthIf merged, the previous entry's InsertedText grows and the After snapshot updates.
Repeated backspace/delete can coalesce similarly when:
These should NOT coalesce by default:
Ctrl+K/U/W) (unless you explicitly decide to treat them as delete coalescing)Some operations apply multiple document changes but should be one undo step:
Provide an internal API such as:
BeginUndoGroup(UndoKind kind, EditorStateSnapshot before)RecordChange(TextChange change) (accumulate)CommitUndoGroup(EditorStateSnapshot after)RollbackUndoGroup() (optional)During an open group:
UndoEntry.Undo history is only valid if the document hasn't changed unexpectedly.
The system MUST detect and handle external edits by invalidating both stacks:
KnownDocumentVersion in the undo manager.document.Version != KnownDocumentVersion, clear undo/redo and set KnownDocumentVersion = document.Version.document.Changed:
Changed event is received while not recording and not applying, treat as external and clear history.Notes:
DynamicTextDocument can change its Version due to a bound Text property changing without raising Changed.
Version checks are therefore required; event-based invalidation alone is insufficient.TextEditorCoreAll document mutation code paths in TextEditorCore MUST route through one helper that:
_document.Replace(...).This prevents missing cases and keeps behavior consistent.
At minimum:
InsertText(...) (typing and paste)Backspace(...), Delete(...)DeleteSelection()Ctrl+K, Ctrl+U, Ctrl+W)ReplaceCurrentSearchMatch(...)ReplaceAllSearchMatches(...) (transaction)Because TextEditorCore.ReplaceAllSearchMatches(...) currently replaces matches from bottom → top,
the undo transaction MUST record changes in the same execution order, and undo MUST apply them in reverse.
Terminal key input differs per terminal and OS:
Ctrl+A..Ctrl+Z) regardless of Shift.Therefore:
Undo:
Ctrl+Z (TerminalChar.CtrlZ)Redo:
Ctrl+R (TerminalChar.CtrlR)Important: the current editor supports Emacs-like kill/yank (Ctrl+K/U/W/Y). In that mode:
Ctrl+Y is already used for yank and MUST remain yank by default.Ctrl+Shift+Z (and could also support Ctrl+R if you want an alternative that's more likely to be emitted).Expose a configuration mechanism for undo/redo gestures in TextEditorBase (or a derived options object),
so applications can choose Windows-like vs Emacs-like behavior.
Add unit tests for:
CanUndo becomes false and history clearsFor time-based coalescing tests:
When introducing a piece-table/rope based document (CodeEditor v2), consider moving undo/redo closer to the document:
The v1 design should keep the entry model (a list of TextChanges + before/after snapshot) compatible with that future direction.