Text undo stack and related types for the editor. Defines TextEdit (a reversible text edit), UndoScope (RAII-style group scope), and TextUndoStack which groups edits, supports coalescing, record/undo/redo, selection snapshot capture, and limits.
using Editor.Prism.Core;
namespace Editor.Prism.Text;
/// <summary>
/// One reversible edit. The inverse is derived, not stored: the inserted text always occupies
/// <see cref="Range"/><c>.Start</c> to <see cref="InsertedEnd"/> once applied.
/// </summary>
public readonly record struct TextEdit( TextRange Range, string RemovedText, string InsertedText )
{
/// <summary>Where the inserted text ends once this edit has been applied.</summary>
public TextPosition InsertedEnd => Advance( Range.Normalized.Start, InsertedText );
/// <summary>The range the inserted text occupies once this edit has been applied.</summary>
public TextRange InsertedRange => new( Range.Normalized.Start, InsertedEnd );
/// <summary>The edit that undoes this one.</summary>
public TextEdit Invert() => new( InsertedRange, InsertedText, RemovedText );
/// <summary>Whether the edit changes nothing.</summary>
public bool IsEmpty => string.Equals( RemovedText, InsertedText, StringComparison.Ordinal );
/// <summary>Walks a position forward over a block of text.</summary>
public static TextPosition Advance( TextPosition start, string text )
{
if ( string.IsNullOrEmpty( text ) )
return start;
var line = start.Line;
var column = start.Column;
for ( var i = 0; i < text.Length; i++ )
{
var c = text[i];
if ( c == '\r' )
{
if ( i + 1 < text.Length && text[i + 1] == '\n' )
i++;
line++;
column = 0;
}
else if ( c == '\n' )
{
line++;
column = 0;
}
else
{
column++;
}
}
return new TextPosition( line, column );
}
}
/// <summary>The lifetime of one undo group. Disposing seals it.</summary>
public readonly struct UndoScope : IDisposable
{
readonly TextUndoStack _stack;
readonly bool _owner;
internal UndoScope( TextUndoStack stack, bool owner )
{
_stack = stack;
_owner = owner;
}
/// <summary>Seals the group if this scope opened it.</summary>
public void Dispose()
{
if ( _owner )
_stack?.EndGroup();
}
}
/// <summary>
/// Operation-grouped undo for a <see cref="TextDocument"/>. Consecutive edits carrying the same
/// coalescing label within <see cref="CoalesceSeconds"/> join one group, so a burst of typing undoes
/// as a word rather than a keystroke.
/// </summary>
public sealed class TextUndoStack
{
sealed class Group
{
public string Label;
public readonly List<TextEdit> Edits = new();
public SelectionSnapshot Before;
public SelectionSnapshot After;
public bool Coalescing;
public RealTimeSince Since;
}
readonly TextDocument _document;
readonly List<Group> _undo = new();
readonly List<Group> _redo = new();
Group _open;
int _depth;
/// <summary>Creates a stack bound to a document.</summary>
public TextUndoStack( TextDocument document )
{
_document = document;
}
/// <summary>Raised whenever the stack's shape changes, so a History panel can refresh.</summary>
public event Action Changed;
/// <summary>Supplies the current caret state so a group can restore it. Set by the controller.</summary>
public Func<SelectionSnapshot> SelectionProvider { get; set; }
/// <summary>Maximum number of retained groups.</summary>
public int Limit { get; set; } = 512;
/// <summary>How long a coalescing group stays open. Defaults to the Prism-wide 500 ms.</summary>
public float CoalesceSeconds { get; set; } = PrismConstants.UndoCoalesceMs / 1000f;
/// <summary>True while an undo or redo is being applied, so listeners can ignore the resulting edits.</summary>
public bool IsApplying { get; private set; }
/// <summary>Whether there is anything to undo.</summary>
public bool CanUndo => _undo.Count > 0 || (_open is not null && _open.Edits.Count > 0);
/// <summary>Whether there is anything to redo.</summary>
public bool CanRedo => _redo.Count > 0;
/// <summary>The label of the next undo step, or null.</summary>
public string UndoLabel => _undo.Count > 0 ? _undo[^1].Label : _open?.Label;
/// <summary>The label of the next redo step, or null.</summary>
public string RedoLabel => _redo.Count > 0 ? _redo[^1].Label : null;
/// <summary>Labels of every retained undo step, oldest first.</summary>
public IEnumerable<string> UndoLabels
{
get
{
for ( var i = 0; i < _undo.Count; i++ )
yield return _undo[i].Label;
}
}
/// <summary>Number of retained undo steps.</summary>
public int UndoCount => _undo.Count;
/// <summary>Number of retained redo steps.</summary>
public int RedoCount => _redo.Count;
/// <summary>
/// Opens a group. Nested calls join the outer group. When <paramref name="coalesce"/> is set and the
/// previous group carries the same label and is still fresh, the edits join it instead.
/// </summary>
public UndoScope Begin( string label, bool coalesce = false )
{
if ( _depth > 0 )
{
_depth++;
return new UndoScope( this, false );
}
if ( coalesce && _undo.Count > 0 )
{
var top = _undo[^1];
if ( top.Coalescing && top.Label == label && top.Since < CoalesceSeconds )
{
_undo.RemoveAt( _undo.Count - 1 );
_open = top;
_depth = 1;
_redo.Clear();
return new UndoScope( this, true );
}
}
_open = new Group
{
Label = string.IsNullOrEmpty( label ) ? "Edit" : label,
Coalescing = coalesce,
Before = Capture()
};
_depth = 1;
return new UndoScope( this, true );
}
/// <summary>Records an edit into the open group. Ignored while an undo or redo is being applied.</summary>
public void Record( TextEdit edit )
{
if ( IsApplying || edit.IsEmpty )
return;
if ( _open is null )
{
using ( Begin( "Edit" ) )
Record( edit );
return;
}
_open.Edits.Add( edit );
_open.Since = 0;
}
/// <summary>Records the change a <see cref="TextDocument"/> just produced.</summary>
public void Record( TextChange change )
{
Record( new TextEdit( change.Removed, change.RemovedText, change.InsertedText ) );
}
/// <summary>Seals the open group. Called by <see cref="UndoScope.Dispose"/>.</summary>
public void EndGroup()
{
if ( _depth > 1 )
{
_depth--;
return;
}
_depth = 0;
var group = _open;
_open = null;
if ( group is null )
return;
if ( group.Edits.Count == 0 )
return;
group.After = Capture();
group.Since = 0;
_undo.Add( group );
_redo.Clear();
while ( _undo.Count > Limit && _undo.Count > 0 )
_undo.RemoveAt( 0 );
Changed?.Invoke();
}
/// <summary>Reverses the newest group and restores the caret state it began with.</summary>
public bool Undo( SelectionSet selection )
{
if ( _depth > 0 )
EndGroup();
if ( _undo.Count == 0 )
return false;
var group = _undo[^1];
_undo.RemoveAt( _undo.Count - 1 );
IsApplying = true;
try
{
for ( var i = group.Edits.Count - 1; i >= 0; i-- )
{
var edit = group.Edits[i];
_document.Replace( edit.InsertedRange, edit.RemovedText );
}
}
catch ( Exception e )
{
PrismLog.Error( e, "Prism: undo failed" );
}
finally
{
IsApplying = false;
}
selection?.Restore( group.Before );
selection?.ClampTo( _document );
_redo.Add( group );
Changed?.Invoke();
return true;
}
/// <summary>Re-applies the newest undone group and restores the caret state it ended with.</summary>
public bool Redo( SelectionSet selection )
{
if ( _depth > 0 )
EndGroup();
if ( _redo.Count == 0 )
return false;
var group = _redo[^1];
_redo.RemoveAt( _redo.Count - 1 );
IsApplying = true;
try
{
for ( var i = 0; i < group.Edits.Count; i++ )
{
var edit = group.Edits[i];
_document.Replace( edit.Range, edit.InsertedText );
}
}
catch ( Exception e )
{
PrismLog.Error( e, "Prism: redo failed" );
}
finally
{
IsApplying = false;
}
selection?.Restore( group.After );
selection?.ClampTo( _document );
_undo.Add( group );
Changed?.Invoke();
return true;
}
/// <summary>Throws away all history. Used after a reload from disk.</summary>
public void Clear()
{
_undo.Clear();
_redo.Clear();
_open = null;
_depth = 0;
Changed?.Invoke();
}
/// <summary>Prevents the next edit from joining the currently open coalescing group.</summary>
public void BreakCoalescing()
{
if ( _undo.Count > 0 )
_undo[^1].Coalescing = false;
}
SelectionSnapshot Capture()
{
var provider = SelectionProvider;
return provider is null ? default : provider();
}
}