Editor/HotCodeEditor/CodeView/UndoHistory.cs
using System;
using System.Collections.Generic;
/// <summary>
/// One change to a <see cref="TextBuffer"/>: at <see cref="Start"/>, <see cref="Removed"/> was replaced by <see cref="Inserted"/>.
/// </summary>
public readonly record struct BufferEdit( TextPos Start, string Removed, string Inserted );
/// <summary>
/// What kind of edit this was, which decides whether it can merge with the one before it.
/// </summary>
public enum EditKind
{
/// <summary>Paste, cut, indent, new line... always its own undo step.</summary>
Other,
Typing,
Deleting
}
/// <summary>
/// One undo step. Usually a single edit, but typing a word, backspacing, or a "replace all"
/// becomes one group.
/// </summary>
public class UndoGroup
{
public int Id { get; init; }
public EditKind Kind { get; init; }
public List<BufferEdit> Edits { get; } = new();
public TextPos CaretBefore { get; init; }
public TextPos? AnchorBefore { get; init; }
public TextPos CaretAfter { get; set; }
public TextPos? AnchorAfter { get; set; }
public double Time { get; set; }
}
/// <summary>
/// Undo/redo stacks with editor-style merging:
/// <list type="bullet">
/// <item>typing merges per word ("hello world" undoes as "world", then "hello ")</item>
/// <item>backspace / delete runs merge</item>
/// <item>moving the caret, saving, pausing, or doing anything else starts a new step</item>
/// </list>
/// Also tracks the save point, so undoing back to the saved text counts as unmodified.
/// </summary>
public class UndoHistory
{
/// <summary>
/// A pause longer than this starts a new undo step.
/// </summary>
public const double MergeTimeout = 3.0;
const int MaxSteps = 1000;
private readonly List<UndoGroup> _undo = new();
private readonly List<UndoGroup> _redo = new();
private int _nextId = 1;
private int _savedId;
private bool _breakNext = true;
private UndoGroup _transaction;
private int _transactionDepth;
/// <summary>
/// Id of the text as it is now; 0 is the text as loaded.
/// </summary>
public int CurrentId => _undo.Count > 0 ? _undo[^1].Id : 0;
/// <summary>
/// Differs from what was last loaded or saved.
/// </summary>
public bool IsModified => CurrentId != _savedId;
public bool CanUndo => _undo.Count > 0;
public bool CanRedo => _redo.Count > 0;
public void Clear()
{
_undo.Clear();
_redo.Clear();
_savedId = 0;
_breakNext = true;
}
public void MarkSaved()
{
_savedId = CurrentId;
_breakNext = true;
}
/// <summary>
/// The next edit starts a new undo step (call when the caret moves, etc).
/// </summary>
public void Break() => _breakNext = true;
/// <summary>
/// Everything recorded until the matching <see cref="EndTransaction"/> becomes one undo step.
/// </summary>
public void BeginTransaction( TextPos caret, TextPos? anchor, double now )
{
if ( _transactionDepth++ > 0 ) return;
_transaction = new UndoGroup { Id = _nextId++, Kind = EditKind.Other, CaretBefore = caret, AnchorBefore = anchor, CaretAfter = caret, Time = now };
}
public void EndTransaction( TextPos caret, TextPos? anchor )
{
if ( _transactionDepth == 0 || --_transactionDepth > 0 ) return;
var group = _transaction;
_transaction = null;
if ( group.Edits.Count == 0 ) return;
group.CaretAfter = caret;
group.AnchorAfter = anchor;
Push( group );
_breakNext = true;
}
public void Record( BufferEdit edit, EditKind kind, TextPos caretBefore, TextPos? anchorBefore, TextPos caretAfter, double now )
{
if ( _transaction is not null )
{
_transaction.Edits.Add( edit );
_transaction.CaretAfter = caretAfter;
return;
}
var top = _undo.Count > 0 ? _undo[^1] : null;
if ( top is not null && CanMerge( top, edit, kind, now ) )
{
top.Edits.Add( edit );
top.CaretAfter = caretAfter;
top.AnchorAfter = null;
top.Time = now;
_redo.Clear();
return;
}
var group = new UndoGroup { Id = _nextId++, Kind = kind, CaretBefore = caretBefore, AnchorBefore = anchorBefore, CaretAfter = caretAfter, Time = now };
group.Edits.Add( edit );
Push( group );
// Typing and deleting stay open for merging; anything else stands alone
_breakNext = kind == EditKind.Other;
}
private void Push( UndoGroup group )
{
_undo.Add( group );
_redo.Clear();
if ( _undo.Count > MaxSteps ) _undo.RemoveAt( 0 );
}
private bool CanMerge( UndoGroup top, BufferEdit edit, EditKind kind, double now )
{
if ( _breakNext || kind == EditKind.Other || top.Kind != kind ) return false;
if ( now - top.Time > MergeTimeout ) return false;
// Never merge into the saved state, or undo would step past it
if ( top.Id == _savedId ) return false;
var last = top.Edits[^1];
if ( kind == EditKind.Typing )
{
if ( edit.Removed.Length != 0 || edit.Inserted.Contains( '\n' ) ) return false;
if ( edit.Start != TextBuffer.EndOf( last.Start, last.Inserted ) ) return false;
// Start a new step at each new word: "hello |world"
var lastChar = last.Inserted.Length > 0 ? last.Inserted[^1] : ' ';
return !(char.IsWhiteSpace( lastChar ) && !char.IsWhiteSpace( edit.Inserted[0] ));
}
if ( kind == EditKind.Deleting )
{
if ( edit.Inserted.Length != 0 || edit.Removed.Contains( '\n' ) ) return false;
var isBackspace = TextBuffer.EndOf( edit.Start, edit.Removed ) == last.Start;
var isForwardDelete = edit.Start == last.Start;
return isBackspace || isForwardDelete;
}
return false;
}
/// <summary>
/// Reverts the last step. Returns it so the caller can restore the caret, or null if there's nothing to undo.
/// </summary>
public UndoGroup Undo( TextBuffer buffer )
{
if ( _undo.Count == 0 ) return null;
var group = _undo[^1];
_undo.RemoveAt( _undo.Count - 1 );
for ( int i = group.Edits.Count - 1; i >= 0; i-- )
{
var e = group.Edits[i];
buffer.Delete( e.Start, TextBuffer.EndOf( e.Start, e.Inserted ) );
buffer.Insert( e.Start, e.Removed );
}
_redo.Add( group );
_breakNext = true;
return group;
}
public UndoGroup Redo( TextBuffer buffer )
{
if ( _redo.Count == 0 ) return null;
var group = _redo[^1];
_redo.RemoveAt( _redo.Count - 1 );
foreach ( var e in group.Edits )
{
buffer.Delete( e.Start, TextBuffer.EndOf( e.Start, e.Removed ) );
buffer.Insert( e.Start, e.Inserted );
}
_undo.Add( group );
_breakNext = true;
return group;
}
}