SboxSuperuser/Core/Editing/TextBuffer.cs

Internal text model for an in-editor effect code editor. Stores text, cursor and anchor offsets, handles insert, newline indent, brace close, tab/outdent of selected lines, backspace/delete by char or word, word-wise and vertical movement with a preferred column, and keeps a 200-entry undo/redo stack of text, cursor and anchor snapshots.

namespace SboxSuperuser.Core.Editing;

/// <summary>
/// The text, cursor and selection behind the effect code editor, with undo and redo. Offsets count characters
/// from the start; the selection runs between <see cref="Anchor"/> and <see cref="Cursor"/>. Tabs become four spaces.
/// </summary>
internal sealed class TextBuffer
{
	internal const string Indent = "    ";
	private const int UndoLimit = 200;

	private readonly Stack<Snapshot> _undo = new();
	private readonly Stack<Snapshot> _redo = new();
	private bool _typing;
	private int _preferredColumn = -1;

	internal TextBuffer( string text = "", int maximumLength = 4096 )
	{
		MaximumLength = maximumLength;
		Text = Clean( text );
		if ( Text.Length > MaximumLength ) Text = Text.Substring( 0, MaximumLength );
	}

	internal string Text { get; private set; }
	internal int Cursor { get; private set; }
	internal int Anchor { get; private set; }
	internal int MaximumLength { get; }

	/// <summary>Counts every edit, so views can tell when to recompile.</summary>
	internal int Revision { get; private set; }

	internal bool HasSelection => Cursor != Anchor;
	internal int SelectionStart => Math.Min( Cursor, Anchor );
	internal int SelectionEnd => Math.Max( Cursor, Anchor );
	internal string SelectedText => Text.Substring( SelectionStart, SelectionEnd - SelectionStart );
	internal bool CanUndo => _undo.Count > 0;
	internal bool CanRedo => _redo.Count > 0;

	internal int LineCount => Text.Count( character => character == '\n' ) + 1;

	internal int LineOf( int offset )
	{
		var line = 0;
		for ( var index = 0; index < offset && index < Text.Length; index++ )
			if ( Text[ index ] == '\n' ) line++;
		return line;
	}

	internal int LineStart( int line )
	{
		var offset = 0;
		for ( var current = 0; current < line; current++ )
		{
			var next = Text.IndexOf( '\n', offset );
			if ( next < 0 ) return Text.Length;
			offset = next + 1;
		}
		return offset;
	}

	internal int LineEnd( int line )
	{
		var end = Text.IndexOf( '\n', LineStart( line ) );
		return end < 0 ? Text.Length : end;
	}

	internal string Line( int line ) => Text.Substring( LineStart( line ), LineEnd( line ) - LineStart( line ) );

	internal int ColumnOf( int offset ) => offset - LineStart( LineOf( offset ) );

	internal int OffsetAt( int line, int column )
	{
		line = Math.Clamp( line, 0, LineCount - 1 );
		return LineStart( line ) + Math.Clamp( column, 0, LineEnd( line ) - LineStart( line ) );
	}

	/// <summary>Replaces everything, as one undoable step.</summary>
	internal void SetText( string text )
	{
		Remember();
		Text = Clean( text );
		if ( Text.Length > MaximumLength ) Text = Text.Substring( 0, MaximumLength );
		Cursor = Anchor = Text.Length;
		Changed();
	}

	/// <summary>Types or pastes text over the selection. Returns false when it would pass the length limit.</summary>
	internal bool Insert( string text )
	{
		text = Clean( text );
		if ( text.Length == 0 && !HasSelection ) return true;
		if ( Text.Length - (SelectionEnd - SelectionStart) + text.Length > MaximumLength ) return false;
		var typing = text.Length == 1 && text != "\n" && text != " " && !HasSelection;
		if ( !typing || !_typing ) Remember();
		_typing = typing;
		var start = SelectionStart;
		Text = Text.Remove( start, SelectionEnd - start ).Insert( start, text );
		Cursor = Anchor = start + text.Length;
		Changed( keepTyping: typing );
		return true;
	}

	/// <summary>A new line that keeps the current indent, one step deeper after an opening brace.</summary>
	internal void NewLine()
	{
		var line = Line( LineOf( Cursor ) );
		var indent = new string( line.TakeWhile( character => character == ' ' ).ToArray() );
		var before = Text.Substring( LineStart( LineOf( Cursor ) ), ColumnOf( Cursor ) ).TrimEnd();
		if ( before.EndsWith( "{" ) ) indent += Indent;
		Insert( "\n" + indent );
	}

	/// <summary>Types a closing brace, first removing one indent step when the line holds only spaces.</summary>
	internal void CloseBrace()
	{
		var line = LineOf( Cursor );
		var before = Text.Substring( LineStart( line ), ColumnOf( Cursor ) );
		if ( !HasSelection && before.Length >= Indent.Length && before.All( character => character == ' ' ) )
		{
			Remember();
			Text = Text.Remove( Cursor - Indent.Length, Indent.Length );
			Cursor = Anchor = Cursor - Indent.Length;
			Changed();
		}
		Insert( "}" );
	}

	/// <summary>Tab: indents every selected line, or inserts spaces up to the next indent stop.</summary>
	internal void Tab( bool outdent )
	{
		if ( outdent || HasSelection && LineOf( SelectionStart ) != LineOf( SelectionEnd ) )
		{
			ShiftLines( outdent );
			return;
		}
		Insert( new string( ' ', Indent.Length - ColumnOf( Cursor ) % Indent.Length ) );
	}

	internal void Backspace( bool word )
	{
		if ( HasSelection ) { Insert( "" ); return; }
		if ( Cursor == 0 ) return;
		var column = ColumnOf( Cursor );
		var before = Text.Substring( Cursor - column, column );
		// Backspace in leading spaces removes a whole indent step.
		var count = word ? Cursor - WordLeft( Cursor )
			: before.Length > 0 && before.All( character => character == ' ' ) ? (column - 1) % Indent.Length + 1 : 1;
		Anchor = Cursor - count;
		Insert( "" );
	}

	internal void Delete( bool word )
	{
		if ( HasSelection ) { Insert( "" ); return; }
		if ( Cursor == Text.Length ) return;
		Anchor = word ? WordRight( Cursor ) : Cursor + 1;
		Insert( "" );
	}

	internal void MoveHorizontal( int direction, bool word, bool select )
	{
		var target = HasSelection && !select
			? (direction < 0 ? SelectionStart : SelectionEnd)
			: word ? (direction < 0 ? WordLeft( Cursor ) : WordRight( Cursor ))
			: Math.Clamp( Cursor + direction, 0, Text.Length );
		MoveTo( target, select );
	}

	internal void MoveVertical( int lines, bool select )
	{
		var column = _preferredColumn >= 0 ? _preferredColumn : ColumnOf( Cursor );
		var line = LineOf( Cursor ) + lines;
		var target = line < 0 ? 0 : line >= LineCount ? Text.Length : OffsetAt( line, column );
		MoveTo( target, select );
		_preferredColumn = column;
	}

	/// <summary>Home jumps to the first non-space letter, or to column 0 when already there.</summary>
	internal void Home( bool select )
	{
		var start = LineStart( LineOf( Cursor ) );
		var firstLetter = start;
		while ( firstLetter < Text.Length && Text[ firstLetter ] == ' ' ) firstLetter++;
		MoveTo( Cursor == firstLetter ? start : firstLetter, select );
	}

	internal void End( bool select ) => MoveTo( LineEnd( LineOf( Cursor ) ), select );

	internal void DocumentEdge( bool end, bool select ) => MoveTo( end ? Text.Length : 0, select );

	internal void MoveTo( int offset, bool select )
	{
		Cursor = Math.Clamp( offset, 0, Text.Length );
		if ( !select ) Anchor = Cursor;
		_typing = false;
		_preferredColumn = -1;
	}

	internal void SelectAll()
	{
		Anchor = 0;
		MoveTo( Text.Length, true );
	}

	/// <summary>Selects the word around an offset (double click).</summary>
	internal void SelectWord( int offset )
	{
		offset = Math.Clamp( offset, 0, Text.Length );
		var start = offset;
		var end = offset;
		while ( start > 0 && IsWord( Text[ start - 1 ] ) ) start--;
		while ( end < Text.Length && IsWord( Text[ end ] ) ) end++;
		Anchor = start;
		MoveTo( end, true );
	}

	/// <summary>Removes and returns the selection.</summary>
	internal string Cut()
	{
		var text = SelectedText;
		if ( text.Length > 0 ) Insert( "" );
		return text;
	}

	internal void Undo() => Swap( _undo, _redo );

	internal void Redo() => Swap( _redo, _undo );

	private void ShiftLines( bool outdent )
	{
		var first = LineOf( SelectionStart );
		var last = LineOf( SelectionEnd );
		if ( HasSelection && last > first && ColumnOf( SelectionEnd ) == 0 ) last--;
		var lines = Text.Split( '\n' );
		var changed = false;
		for ( var line = first; line <= last; line++ )
		{
			var before = lines[ line ];
			lines[ line ] = outdent ? before.Substring( Math.Min( Indent.Length, before.TakeWhile( c => c == ' ' ).Count() ) ) : Indent + before;
			changed |= lines[ line ] != before;
		}
		var result = string.Join( "\n", lines );
		if ( !changed || result.Length > MaximumLength ) return;
		Remember();
		Text = result;
		Anchor = LineStart( first );
		Cursor = LineEnd( last );
		Changed();
	}

	private int WordLeft( int offset )
	{
		while ( offset > 0 && char.IsWhiteSpace( Text[ offset - 1 ] ) ) offset--;
		if ( offset > 0 && !IsWord( Text[ offset - 1 ] ) ) return offset - 1;
		while ( offset > 0 && IsWord( Text[ offset - 1 ] ) ) offset--;
		return offset;
	}

	private int WordRight( int offset )
	{
		if ( offset < Text.Length && !IsWord( Text[ offset ] ) && !char.IsWhiteSpace( Text[ offset ] ) ) return offset + 1;
		while ( offset < Text.Length && IsWord( Text[ offset ] ) ) offset++;
		while ( offset < Text.Length && char.IsWhiteSpace( Text[ offset ] ) && Text[ offset ] != '\n' ) offset++;
		return offset;
	}

	private static bool IsWord( char character ) => char.IsLetterOrDigit( character ) || character == '_';

	private void Remember()
	{
		_undo.Push( new Snapshot( Text, Cursor, Anchor ) );
		_redo.Clear();
		if ( _undo.Count <= UndoLimit ) return;
		var kept = _undo.Take( UndoLimit ).Reverse().ToArray();
		_undo.Clear();
		foreach ( var snapshot in kept ) _undo.Push( snapshot );
	}

	private void Swap( Stack<Snapshot> from, Stack<Snapshot> to )
	{
		if ( from.Count == 0 ) return;
		to.Push( new Snapshot( Text, Cursor, Anchor ) );
		var snapshot = from.Pop();
		Text = snapshot.Text;
		Cursor = snapshot.Cursor;
		Anchor = snapshot.Anchor;
		Changed();
	}

	private void Changed( bool keepTyping = false )
	{
		Revision++;
		_typing = keepTyping;
		_preferredColumn = -1;
	}

	private static string Clean( string text )
	{
		var normalized = (text ?? "").Replace( "\r\n", "\n" ).Replace( '\r', '\n' ).Replace( "\t", Indent );
		return new string( normalized.Where( character => character == '\n' || !char.IsControl( character ) ).ToArray() );
	}

	private readonly record struct Snapshot( string Text, int Cursor, int Anchor );
}