Editor/Prism/Text/TextUndoStack.cs

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.

File AccessNetworking
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();
	}
}