Editor/Prism/Text/TextEditController.cs

Editor-side text editing controller for a code editor. It implements caret movement, selection management, search, clipboard integration, many edit commands (insert, delete, indent/outdent, comment, duplicate, move lines, transform selection), and integrates with an undo stack and optional bracket matching and visual column mapping.

File AccessNetworking
using Editor.Prism.Core;
using System.Text;

namespace Editor.Prism.Text;

/// <summary>A caret movement unit.</summary>
public enum CaretMove
{
	/// <summary>One character left.</summary>
	Left,

	/// <summary>One character right.</summary>
	Right,

	/// <summary>One visual row up.</summary>
	Up,

	/// <summary>One visual row down.</summary>
	Down,

	/// <summary>To the start of the previous word.</summary>
	WordLeft,

	/// <summary>To the start of the next word.</summary>
	WordRight,

	/// <summary>To the first non-whitespace character, then to column zero.</summary>
	LineStart,

	/// <summary>To the end of the line.</summary>
	LineEnd,

	/// <summary>To the start of the document.</summary>
	DocumentStart,

	/// <summary>To the end of the document.</summary>
	DocumentEnd,

	/// <summary>Up one viewport.</summary>
	PageUp,

	/// <summary>Down one viewport.</summary>
	PageDown
}

/// <summary>
/// Every editing command, expressed over a <see cref="TextDocument"/>, a <see cref="SelectionSet"/>
/// and a <see cref="TextUndoStack"/>. The widget owns input; this owns meaning. Multi-caret is not a
/// special case here — every command runs per caret, bottom-up, mapping the remaining carets through
/// each applied change.
/// </summary>
public sealed class TextEditController
{
	sealed class PendingEdit
	{
		public Caret Owner;
		public TextRange Range;
		public string Text = string.Empty;
		public int CaretOffset = -1;
		public int SelectStart = -1;
		public int SelectEnd = -1;
	}

	readonly List<PendingEdit> _pending = new();

	int _clipboardCaretCount;
	bool _clipboardWasLines;

	/// <summary>Creates a controller over a document.</summary>
	public TextEditController( TextDocument document )
	{
		Document = document;
		Selection = new SelectionSet();
		Undo = new TextUndoStack( document ) { SelectionProvider = () => Selection.Capture() };
	}

	/// <summary>Raised after any command that changed the document.</summary>
	public event Action Changed;

	/// <summary>Raised after any command that moved a caret or altered the selection.</summary>
	public event Action CaretChanged;

	/// <summary>The document being edited.</summary>
	public TextDocument Document { get; }

	/// <summary>The live caret set.</summary>
	public SelectionSet Selection { get; }

	/// <summary>Undo history for this document.</summary>
	public TextUndoStack Undo { get; }

	/// <summary>Optional bracket matcher, used for auto-close skip-over and smart newlines.</summary>
	public BracketMatcher Brackets { get; set; }

	/// <summary>When set, every mutating command is a silent no-op. Used by the read-only Code panel.</summary>
	public bool ReadOnly { get; set; }

	/// <summary>Width of one indent level in columns.</summary>
	public int IndentSize { get; set; } = 4;

	/// <summary>Whether indentation inserts a tab character. The Prism house style says yes.</summary>
	public bool UseTabs { get; set; } = true;

	/// <summary>Whether Enter copies the previous line's indentation and opens braces.</summary>
	public bool AutoIndent { get; set; } = true;

	/// <summary>Whether typing an opening bracket or quote inserts its partner.</summary>
	public bool AutoCloseBrackets { get; set; } = true;

	/// <summary>Whether typing a bracket or quote with text selected wraps the selection instead of replacing it.</summary>
	public bool SurroundSelection { get; set; } = true;

	/// <summary>Whether typing replaces the character under the caret.</summary>
	public bool Overwrite { get; set; }

	/// <summary>The line comment token for the current language.</summary>
	public string LineComment { get; set; } = "//";

	/// <summary>The block comment opener for the current language.</summary>
	public string BlockCommentStart { get; set; } = "/*";

	/// <summary>The block comment terminator for the current language.</summary>
	public string BlockCommentEnd { get; set; } = "*/";

	/// <summary>One indent level as literal text.</summary>
	public string IndentUnit => UseTabs ? "\t" : new string( ' ', Math.Max( 1, IndentSize ) );

	/// <summary>Maps a position to its visual column. Supplied by the widget so tabs expand correctly.</summary>
	public Func<TextPosition, int> VisualColumnOf { get; set; }

	/// <summary>Maps a line and visual column back to a position. Supplied by the widget.</summary>
	public Func<int, int, TextPosition> PositionFromVisualColumn { get; set; }

	/// <summary>Steps a line index by one visual row, skipping folded lines. Supplied by the widget.</summary>
	public Func<int, int, int> StepLine { get; set; }

	/// <summary>The primary caret's position.</summary>
	public TextPosition CaretPosition => Selection.Primary.Position;

	/// <summary>Whether anything is selected.</summary>
	public bool HasSelection => Selection.HasSelection;

	// ---- text queries -----------------------------------------------------

	/// <summary>The selected text of every caret, joined by newlines in document order.</summary>
	public string GetSelectedText()
	{
		Selection.Normalize();

		if ( Selection.Count == 1 )
			return Document.GetText( Selection.Primary.Selection );

		var builder = new StringBuilder();

		for ( var i = 0; i < Selection.Count; i++ )
		{
			if ( i > 0 )
				builder.Append( Document.NewLine );

			builder.Append( Document.GetText( Selection[i].Selection ) );
		}

		return builder.ToString();
	}

	/// <summary>Character class used for word movement and double-click selection.</summary>
	public static bool IsWordChar( char c ) => char.IsLetterOrDigit( c ) || c == '_';

	/// <summary>The word range around a position, or an empty range when the position is not on a word.</summary>
	public TextRange WordRangeAt( TextPosition position )
	{
		var clamped = Document.Clamp( position );
		var text = Document.GetLine( clamped.Line );

		if ( text.Length == 0 )
			return TextRange.At( clamped );

		var start = Math.Clamp( clamped.Column, 0, text.Length );
		var end = start;

		if ( start >= text.Length || !IsWordChar( text[start] ) )
		{
			if ( start > 0 && IsWordChar( text[start - 1] ) )
			{
				start--;
				end = start + 1;
			}
			else
			{
				// Select the run of identical non-word characters.
				if ( start >= text.Length )
					return TextRange.At( clamped );

				var c = text[start];
				end = start;

				while ( end < text.Length && text[end] == c )
					end++;

				while ( start > 0 && text[start - 1] == c )
					start--;

				return new TextRange( new TextPosition( clamped.Line, start ), new TextPosition( clamped.Line, end ) );
			}
		}

		while ( start > 0 && IsWordChar( text[start - 1] ) )
			start--;

		while ( end < text.Length && IsWordChar( text[end] ) )
			end++;

		return new TextRange( new TextPosition( clamped.Line, start ), new TextPosition( clamped.Line, end ) );
	}

	/// <summary>The word at a position, or an empty string.</summary>
	public string WordAt( TextPosition position ) => Document.GetText( WordRangeAt( position ) );

	/// <summary>The identifier immediately before a position, which is what completion filters on.</summary>
	public string WordPrefixBefore( TextPosition position )
	{
		var clamped = Document.Clamp( position );
		var text = Document.GetLine( clamped.Line );
		var end = Math.Clamp( clamped.Column, 0, text.Length );
		var start = end;

		while ( start > 0 && IsWordChar( text[start - 1] ) )
			start--;

		return text.Substring( start, end - start );
	}

	/// <summary>The leading whitespace of a line.</summary>
	public string IndentOf( int line )
	{
		var text = Document.GetLine( line );
		var index = 0;

		while ( index < text.Length && (text[index] == ' ' || text[index] == '\t') )
			index++;

		return text[..index];
	}

	// ---- caret movement ---------------------------------------------------

	/// <summary>Moves every caret. <paramref name="extend"/> keeps the anchors, growing the selection.</summary>
	public void Move( CaretMove move, bool extend, int pageRows = 20 )
	{
		var vertical = move is CaretMove.Up or CaretMove.Down or CaretMove.PageUp or CaretMove.PageDown;

		for ( var i = 0; i < Selection.Count; i++ )
		{
			var caret = Selection[i];
			var from = caret.Position;

			if ( !extend && caret.HasSelection && move is CaretMove.Left or CaretMove.Right )
			{
				caret.MoveTo( move == CaretMove.Left ? caret.Selection.Start : caret.Selection.End, false );
				caret.DesiredColumn = -1;
				continue;
			}

			var target = Resolve( caret, move, from, pageRows );
			caret.MoveTo( Document.Clamp( target ), extend );

			if ( !vertical )
				caret.DesiredColumn = -1;
		}

		Selection.Normalize();
		RaiseCaretChanged();
	}

	TextPosition Resolve( Caret caret, CaretMove move, TextPosition from, int pageRows )
	{
		switch ( move )
		{
			case CaretMove.Left:
				if ( from.Column > 0 )
					return new TextPosition( from.Line, from.Column - 1 );

				return from.Line > 0
					? new TextPosition( from.Line - 1, Document.GetLineLength( from.Line - 1 ) )
					: from;

			case CaretMove.Right:
				if ( from.Column < Document.GetLineLength( from.Line ) )
					return new TextPosition( from.Line, from.Column + 1 );

				return from.Line < Document.LineCount - 1 ? new TextPosition( from.Line + 1, 0 ) : from;

			case CaretMove.Up:
				return Vertical( caret, from, -1 );

			case CaretMove.Down:
				return Vertical( caret, from, 1 );

			case CaretMove.PageUp:
				return Vertical( caret, from, -Math.Max( 1, pageRows ) );

			case CaretMove.PageDown:
				return Vertical( caret, from, Math.Max( 1, pageRows ) );

			case CaretMove.WordLeft:
				return PreviousWordBoundary( from );

			case CaretMove.WordRight:
				return NextWordBoundary( from );

			case CaretMove.LineStart:
			{
				var indent = IndentOf( from.Line ).Length;
				return new TextPosition( from.Line, from.Column == indent ? 0 : indent );
			}

			case CaretMove.LineEnd:
				return new TextPosition( from.Line, Document.GetLineLength( from.Line ) );

			case CaretMove.DocumentStart:
				return TextPosition.Zero;

			case CaretMove.DocumentEnd:
				return Document.EndPosition;

			default:
				return from;
		}
	}

	TextPosition Vertical( Caret caret, TextPosition from, int rows )
	{
		var desired = caret.DesiredColumn;

		if ( desired < 0 )
		{
			desired = VisualColumnOf is null ? from.Column : VisualColumnOf( from );
			caret.DesiredColumn = desired;
		}

		var line = from.Line;
		var step = Math.Sign( rows );

		for ( var i = 0; i < Math.Abs( rows ); i++ )
		{
			var next = StepLine is null ? line + step : StepLine( line, step );

			if ( next == line )
				break;

			line = next;
		}

		line = Math.Clamp( line, 0, Document.LineCount - 1 );

		if ( PositionFromVisualColumn is not null )
			return PositionFromVisualColumn( line, desired );

		return new TextPosition( line, Math.Min( desired, Document.GetLineLength( line ) ) );
	}

	TextPosition PreviousWordBoundary( TextPosition from )
	{
		if ( from.Column == 0 )
			return from.Line > 0 ? new TextPosition( from.Line - 1, Document.GetLineLength( from.Line - 1 ) ) : from;

		var text = Document.GetLine( from.Line );
		var index = Math.Clamp( from.Column, 0, text.Length );

		while ( index > 0 && char.IsWhiteSpace( text[index - 1] ) )
			index--;

		if ( index == 0 )
			return new TextPosition( from.Line, 0 );

		if ( IsWordChar( text[index - 1] ) )
		{
			while ( index > 0 && IsWordChar( text[index - 1] ) )
				index--;
		}
		else
		{
			while ( index > 0 && !IsWordChar( text[index - 1] ) && !char.IsWhiteSpace( text[index - 1] ) )
				index--;
		}

		return new TextPosition( from.Line, index );
	}

	TextPosition NextWordBoundary( TextPosition from )
	{
		var text = Document.GetLine( from.Line );
		var index = Math.Clamp( from.Column, 0, text.Length );

		if ( index >= text.Length )
			return from.Line < Document.LineCount - 1 ? new TextPosition( from.Line + 1, 0 ) : from;

		if ( IsWordChar( text[index] ) )
		{
			while ( index < text.Length && IsWordChar( text[index] ) )
				index++;
		}
		else if ( !char.IsWhiteSpace( text[index] ) )
		{
			while ( index < text.Length && !IsWordChar( text[index] ) && !char.IsWhiteSpace( text[index] ) )
				index++;
		}

		while ( index < text.Length && char.IsWhiteSpace( text[index] ) )
			index++;

		return new TextPosition( from.Line, index );
	}

	/// <summary>Places a single caret, optionally extending the existing selection.</summary>
	public void SetCaret( TextPosition position, bool extend )
	{
		var clamped = Document.Clamp( position );

		if ( extend )
		{
			var primary = Selection.Primary;
			primary.MoveTo( clamped, true );
			primary.DesiredColumn = -1;
		}
		else
		{
			Selection.SetSingle( clamped );
		}

		Selection.Normalize();
		RaiseCaretChanged();
	}

	/// <summary>Adds a caret at a position, or removes it when one is already there.</summary>
	public void ToggleCaret( TextPosition position )
	{
		var clamped = Document.Clamp( position );

		for ( var i = 0; i < Selection.Count; i++ )
		{
			var caret = Selection[i];

			if ( !caret.HasSelection && caret.Position == clamped && Selection.Count > 1 )
			{
				Selection.Remove( caret );
				RaiseCaretChanged();
				return;
			}
		}

		Selection.Add( clamped );
		Selection.Normalize();
		RaiseCaretChanged();
	}

	/// <summary>Selects everything.</summary>
	public void SelectAll()
	{
		Selection.SetSingle( Document.EndPosition, TextPosition.Zero );
		RaiseCaretChanged();
	}

	/// <summary>Selects the word at a position.</summary>
	public void SelectWordAt( TextPosition position, bool add = false )
	{
		var range = WordRangeAt( position );

		if ( add )
			Selection.Add( range.End, range.Start );
		else
			Selection.SetSingle( range.End, range.Start );

		Selection.Normalize();
		RaiseCaretChanged();
	}

	/// <summary>Selects a whole line, including its terminator when one follows.</summary>
	public void SelectLineAt( int line, bool add = false )
	{
		line = Math.Clamp( line, 0, Document.LineCount - 1 );

		var start = new TextPosition( line, 0 );
		var end = line < Document.LineCount - 1
			? new TextPosition( line + 1, 0 )
			: new TextPosition( line, Document.GetLineLength( line ) );

		if ( add )
			Selection.Add( end, start );
		else
			Selection.SetSingle( end, start );

		Selection.Normalize();
		RaiseCaretChanged();
	}

	/// <summary>Grows every selection to cover whole lines.</summary>
	public void ExpandSelectionToLines()
	{
		for ( var i = 0; i < Selection.Count; i++ )
		{
			var caret = Selection[i];
			var range = caret.Selection;
			var last = range.End.Column == 0 && range.End.Line > range.Start.Line ? range.End.Line - 1 : range.End.Line;

			var start = new TextPosition( range.Start.Line, 0 );
			var end = last < Document.LineCount - 1
				? new TextPosition( last + 1, 0 )
				: new TextPosition( last, Document.GetLineLength( last ) );

			caret.Anchor = start;
			caret.Position = end;
		}

		Selection.Normalize();
		RaiseCaretChanged();
	}

	/// <summary>Adds a caret one visual row above or below every existing caret.</summary>
	public void AddCaretVertically( int direction )
	{
		var additions = new List<TextPosition>();

		for ( var i = 0; i < Selection.Count; i++ )
		{
			var caret = Selection[i];
			var line = StepLine is null ? caret.Position.Line + direction : StepLine( caret.Position.Line, direction );

			if ( line == caret.Position.Line || line < 0 || line >= Document.LineCount )
				continue;

			var column = VisualColumnOf is null ? caret.Position.Column : VisualColumnOf( caret.Position );
			var target = PositionFromVisualColumn is null
				? new TextPosition( line, Math.Min( column, Document.GetLineLength( line ) ) )
				: PositionFromVisualColumn( line, column );

			additions.Add( target );
		}

		for ( var i = 0; i < additions.Count; i++ )
			Selection.Add( additions[i] );

		Selection.Normalize();
		RaiseCaretChanged();
	}

	/// <summary>
	/// Selects the word under the caret, or adds a caret at the next occurrence of the current
	/// selection. The Ctrl+D of every modern editor.
	/// </summary>
	public bool AddNextOccurrence()
	{
		var primary = Selection.Primary;

		if ( !primary.HasSelection )
		{
			SelectWordAt( primary.Position );
			return true;
		}

		var needle = Document.GetText( primary.Selection );

		if ( string.IsNullOrEmpty( needle ) || needle.Contains( '\n' ) )
			return false;

		var after = primary.Selection.End;

		for ( var i = 0; i < Selection.Count; i++ )
		{
			if ( Selection[i].Selection.End > after )
				after = Selection[i].Selection.End;
		}

		if ( !TryFindNext( needle, after, true, out var found ) )
			return false;

		Selection.Add( found.End, found.Start );
		Selection.Normalize();
		RaiseCaretChanged();
		return true;
	}

	/// <summary>Puts a caret on every occurrence of the current selection or word.</summary>
	public bool SelectAllOccurrences()
	{
		var primary = Selection.Primary;

		if ( !primary.HasSelection )
			SelectWordAt( primary.Position );

		var needle = Document.GetText( Selection.Primary.Selection );

		if ( string.IsNullOrEmpty( needle ) || needle.Contains( '\n' ) )
			return false;

		var found = FindAll( needle );

		if ( found.Count == 0 )
			return false;

		Selection.SetSingle( found[0].End, found[0].Start );

		for ( var i = 1; i < found.Count; i++ )
			Selection.Add( found[i].End, found[i].Start );

		Selection.Normalize();
		RaiseCaretChanged();
		return true;
	}

	/// <summary>Drops every caret but the primary, and collapses its selection when it has one.</summary>
	public bool Escape()
	{
		if ( Selection.Count > 1 )
		{
			Selection.KeepPrimaryOnly();
			RaiseCaretChanged();
			return true;
		}

		if ( Selection.Primary.HasSelection )
		{
			Selection.Primary.Collapse();
			RaiseCaretChanged();
			return true;
		}

		return false;
	}

	/// <summary>Finds the next literal occurrence after a position.</summary>
	public bool TryFindNext( string needle, TextPosition after, bool matchCase, out TextRange range )
	{
		range = TextRange.Empty;

		if ( string.IsNullOrEmpty( needle ) )
			return false;

		var comparison = matchCase ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;

		for ( var pass = 0; pass < 2; pass++ )
		{
			var startLine = pass == 0 ? after.Line : 0;
			var endLine = pass == 0 ? Document.LineCount - 1 : after.Line;

			for ( var line = startLine; line <= endLine && line < Document.LineCount; line++ )
			{
				var text = Document.GetLine( line );
				var from = pass == 0 && line == after.Line ? after.Column : 0;

				if ( from > text.Length )
					continue;

				var index = text.IndexOf( needle, from, comparison );

				if ( index < 0 )
					continue;

				range = new TextRange( new TextPosition( line, index ), new TextPosition( line, index + needle.Length ) );
				return true;
			}
		}

		return false;
	}

	/// <summary>Every literal occurrence in the document.</summary>
	public List<TextRange> FindAll( string needle, bool matchCase = true )
	{
		var result = new List<TextRange>();

		if ( string.IsNullOrEmpty( needle ) )
			return result;

		var comparison = matchCase ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;

		for ( var line = 0; line < Document.LineCount; line++ )
		{
			var text = Document.GetLine( line );
			var index = 0;

			while ( index <= text.Length - needle.Length )
			{
				var found = text.IndexOf( needle, index, comparison );

				if ( found < 0 )
					break;

				result.Add( new TextRange(
					new TextPosition( line, found ),
					new TextPosition( line, found + needle.Length ) ) );

				index = found + Math.Max( 1, needle.Length );
			}
		}

		return result;
	}

	// ---- editing ----------------------------------------------------------

	/// <summary>Types text at every caret, handling auto-close, surround and overwrite.</summary>
	public bool InsertText( string text, bool typing = true )
	{
		if ( ReadOnly || string.IsNullOrEmpty( text ) )
			return false;

		Selection.Normalize();
		_pending.Clear();

		var single = text.Length == 1 ? text[0] : '\0';
		var isAutoClose = AutoCloseBrackets && single != '\0' && BracketMatcher.IsAutoClosePair( single );
		var isClosing = single != '\0' && (BracketMatcher.IsClose( single ) || single == '"' || single == '\'');

		var skippedAll = true;

		for ( var i = 0; i < Selection.Count; i++ )
		{
			var caret = Selection[i];
			var edit = new PendingEdit { Owner = caret, Range = caret.Selection, Text = text };

			if ( caret.HasSelection && isAutoClose && SurroundSelection )
			{
				var close = BracketMatcher.AutoCloseFor( single );
				var inner = Document.GetText( caret.Selection );
				edit.Text = single + inner + close;
				edit.SelectStart = 1;
				edit.SelectEnd = 1 + inner.Length;
				skippedAll = false;
			}
			else if ( !caret.HasSelection && isClosing && SkipsOver( caret.Position, single ) )
			{
				caret.MoveTo( new TextPosition( caret.Position.Line, caret.Position.Column + 1 ), false );
				continue;
			}
			else if ( !caret.HasSelection && isAutoClose && ShouldAutoClose( caret.Position, single ) )
			{
				edit.Text = single.ToString() + BracketMatcher.AutoCloseFor( single );
				edit.CaretOffset = 1;
				skippedAll = false;
			}
			else
			{
				if ( !caret.HasSelection && Overwrite && !text.Contains( '\n' ) && !text.Contains( '\r' ) )
				{
					var length = Document.GetLineLength( caret.Position.Line );
					var end = Math.Min( length, caret.Position.Column + text.Length );
					edit.Range = new TextRange( caret.Position, new TextPosition( caret.Position.Line, end ) );
				}

				skippedAll = false;
			}

			_pending.Add( edit );
		}

		if ( skippedAll && _pending.Count == 0 )
		{
			Selection.Normalize();
			RaiseCaretChanged();
			return true;
		}

		return ApplyEdits( typing ? "Typing" : "Insert", typing );
	}

	bool SkipsOver( TextPosition position, char c )
	{
		var text = Document.GetLine( position.Line );
		return position.Column < text.Length && text[position.Column] == c;
	}

	bool ShouldAutoClose( TextPosition position, char c )
	{
		var text = Document.GetLine( position.Line );

		if ( position.Column < text.Length )
		{
			var next = text[position.Column];

			if ( !char.IsWhiteSpace( next ) && !BracketMatcher.IsClose( next ) && next != ',' && next != ';' )
				return false;
		}

		if ( c == '\'' || c == '"' )
		{
			// Don't pair a quote that is closing an odd count already on the line.
			var count = 0;

			for ( var i = 0; i < Math.Min( position.Column, text.Length ); i++ )
			{
				if ( text[i] == c )
					count++;
			}

			if ( count % 2 == 1 )
				return false;
		}

		return true;
	}

	/// <summary>Inserts a newline at every caret, copying indentation and expanding braces.</summary>
	public bool InsertNewLine()
	{
		if ( ReadOnly )
			return false;

		Selection.Normalize();
		_pending.Clear();

		for ( var i = 0; i < Selection.Count; i++ )
		{
			var caret = Selection[i];
			var range = caret.Selection;
			var start = range.Start;

			var indent = AutoIndent ? IndentOf( start.Line ) : string.Empty;
			var lineText = Document.GetLine( start.Line );
			var beforeCaret = lineText[..Math.Min( start.Column, lineText.Length )].TrimEnd();
			var afterCaret = lineText[Math.Min( range.End.Column, lineText.Length )..];

			var opensBlock = AutoIndent && beforeCaret.EndsWith( "{", StringComparison.Ordinal );
			var closesNext = afterCaret.TrimStart().StartsWith( "}", StringComparison.Ordinal );

			var edit = new PendingEdit { Owner = caret, Range = range };

			if ( opensBlock && closesNext )
			{
				edit.Text = "\n" + indent + IndentUnit + "\n" + indent;
				edit.CaretOffset = 1 + indent.Length + IndentUnit.Length;
			}
			else if ( opensBlock )
			{
				edit.Text = "\n" + indent + IndentUnit;
			}
			else
			{
				edit.Text = "\n" + indent;
			}

			_pending.Add( edit );
		}

		return ApplyEdits( "New Line", false );
	}

	/// <summary>Deletes backwards. With <paramref name="word"/> set, deletes the previous word.</summary>
	public bool Backspace( bool word = false )
	{
		if ( ReadOnly )
			return false;

		Selection.Normalize();
		_pending.Clear();

		for ( var i = 0; i < Selection.Count; i++ )
		{
			var caret = Selection[i];

			if ( caret.HasSelection )
			{
				_pending.Add( new PendingEdit { Owner = caret, Range = caret.Selection, Text = string.Empty } );
				continue;
			}

			var position = caret.Position;

			if ( position.Column == 0 )
			{
				if ( position.Line == 0 )
					continue;

				var previousLength = Document.GetLineLength( position.Line - 1 );

				_pending.Add( new PendingEdit
				{
					Owner = caret,
					Range = new TextRange( new TextPosition( position.Line - 1, previousLength ), position ),
					Text = string.Empty
				} );

				continue;
			}

			if ( word )
			{
				_pending.Add( new PendingEdit
				{
					Owner = caret,
					Range = new TextRange( PreviousWordBoundary( position ), position ),
					Text = string.Empty
				} );

				continue;
			}

			var text = Document.GetLine( position.Line );
			var from = position.Column - 1;
			var to = position.Column;

			if ( AutoCloseBrackets && position.Column < text.Length )
			{
				var open = text[position.Column - 1];
				var close = text[position.Column];

				if ( BracketMatcher.IsAutoClosePair( open ) && BracketMatcher.AutoCloseFor( open ) == close )
					to = position.Column + 1;
			}

			if ( !UseTabs && IsAllWhitespace( text, position.Column ) && position.Column % Math.Max( 1, IndentSize ) == 0 )
				from = Math.Max( 0, position.Column - IndentSize );

			_pending.Add( new PendingEdit
			{
				Owner = caret,
				Range = new TextRange( new TextPosition( position.Line, from ), new TextPosition( position.Line, to ) ),
				Text = string.Empty
			} );
		}

		return ApplyEdits( "Delete", true );
	}

	/// <summary>Deletes forwards. With <paramref name="word"/> set, deletes the next word.</summary>
	public bool DeleteForward( bool word = false )
	{
		if ( ReadOnly )
			return false;

		Selection.Normalize();
		_pending.Clear();

		for ( var i = 0; i < Selection.Count; i++ )
		{
			var caret = Selection[i];

			if ( caret.HasSelection )
			{
				_pending.Add( new PendingEdit { Owner = caret, Range = caret.Selection, Text = string.Empty } );
				continue;
			}

			var position = caret.Position;
			var length = Document.GetLineLength( position.Line );

			if ( position.Column >= length )
			{
				if ( position.Line >= Document.LineCount - 1 )
					continue;

				_pending.Add( new PendingEdit
				{
					Owner = caret,
					Range = new TextRange( position, new TextPosition( position.Line + 1, 0 ) ),
					Text = string.Empty
				} );

				continue;
			}

			var end = word ? NextWordBoundary( position ) : new TextPosition( position.Line, position.Column + 1 );

			_pending.Add( new PendingEdit { Owner = caret, Range = new TextRange( position, end ), Text = string.Empty } );
		}

		return ApplyEdits( "Delete", true );
	}

	/// <summary>Deletes every line touched by a selection or caret.</summary>
	public bool DeleteLines()
	{
		if ( ReadOnly )
			return false;

		var runs = LineRuns();

		if ( runs.Count == 0 )
			return false;

		_pending.Clear();

		for ( var i = 0; i < runs.Count; i++ )
		{
			var (first, last) = runs[i];

			TextPosition start;
			TextPosition end;

			if ( last < Document.LineCount - 1 )
			{
				start = new TextPosition( first, 0 );
				end = new TextPosition( last + 1, 0 );
			}
			else if ( first > 0 )
			{
				start = new TextPosition( first - 1, Document.GetLineLength( first - 1 ) );
				end = new TextPosition( last, Document.GetLineLength( last ) );
			}
			else
			{
				start = new TextPosition( first, 0 );
				end = new TextPosition( last, Document.GetLineLength( last ) );
			}

			_pending.Add( new PendingEdit { Range = new TextRange( start, end ), Text = string.Empty } );
		}

		return ApplyEdits( "Delete Line", false );
	}

	/// <summary>Indents the selected lines, or inserts one indent level at the caret.</summary>
	public bool Indent()
	{
		if ( ReadOnly )
			return false;

		if ( !SelectionSpansLines() )
			return InsertText( IndentUnit, false );

		var runs = LineRuns();
		_pending.Clear();

		for ( var r = 0; r < runs.Count; r++ )
		{
			var (first, last) = runs[r];

			for ( var line = first; line <= last; line++ )
			{
				if ( Document.GetLineLength( line ) == 0 )
					continue;

				_pending.Add( new PendingEdit
				{
					Range = TextRange.At( new TextPosition( line, 0 ) ),
					Text = IndentUnit
				} );
			}
		}

		return ApplyEdits( "Indent", false );
	}

	/// <summary>Removes one indent level from every selected line.</summary>
	public bool Outdent()
	{
		if ( ReadOnly )
			return false;

		var runs = LineRuns();
		_pending.Clear();

		for ( var r = 0; r < runs.Count; r++ )
		{
			var (first, last) = runs[r];

			for ( var line = first; line <= last; line++ )
			{
				var text = Document.GetLine( line );

				if ( text.Length == 0 )
					continue;

				var remove = 0;

				if ( text[0] == '\t' )
				{
					remove = 1;
				}
				else
				{
					while ( remove < Math.Max( 1, IndentSize ) && remove < text.Length && text[remove] == ' ' )
						remove++;
				}

				if ( remove == 0 )
					continue;

				_pending.Add( new PendingEdit
				{
					Range = new TextRange( new TextPosition( line, 0 ), new TextPosition( line, remove ) ),
					Text = string.Empty
				} );
			}
		}

		return ApplyEdits( "Outdent", false );
	}

	/// <summary>Comments or uncomments every selected line.</summary>
	public bool ToggleLineComment()
	{
		if ( ReadOnly || string.IsNullOrEmpty( LineComment ) )
			return false;

		var runs = LineRuns();

		if ( runs.Count == 0 )
			return false;

		var lines = new List<int>();

		for ( var r = 0; r < runs.Count; r++ )
		{
			for ( var line = runs[r].First; line <= runs[r].Last; line++ )
				lines.Add( line );
		}

		var meaningful = new List<int>();
		var minimumIndent = int.MaxValue;
		var allCommented = true;

		for ( var i = 0; i < lines.Count; i++ )
		{
			var text = Document.GetLine( lines[i] );
			var trimmed = text.TrimStart();

			if ( trimmed.Length == 0 )
				continue;

			meaningful.Add( lines[i] );
			minimumIndent = Math.Min( minimumIndent, text.Length - trimmed.Length );

			if ( !trimmed.StartsWith( LineComment, StringComparison.Ordinal ) )
				allCommented = false;
		}

		if ( meaningful.Count == 0 )
			return false;

		if ( minimumIndent == int.MaxValue )
			minimumIndent = 0;

		_pending.Clear();

		for ( var i = 0; i < meaningful.Count; i++ )
		{
			var line = meaningful[i];
			var text = Document.GetLine( line );

			if ( allCommented )
			{
				var index = text.IndexOf( LineComment, StringComparison.Ordinal );

				if ( index < 0 )
					continue;

				var end = index + LineComment.Length;

				if ( end < text.Length && text[end] == ' ' )
					end++;

				_pending.Add( new PendingEdit
				{
					Range = new TextRange( new TextPosition( line, index ), new TextPosition( line, end ) ),
					Text = string.Empty
				} );
			}
			else
			{
				_pending.Add( new PendingEdit
				{
					Range = TextRange.At( new TextPosition( line, Math.Min( minimumIndent, text.Length ) ) ),
					Text = LineComment + " "
				} );
			}
		}

		return ApplyEdits( allCommented ? "Uncomment" : "Comment", false );
	}

	/// <summary>Wraps or unwraps the selection in a block comment.</summary>
	public bool ToggleBlockComment()
	{
		if ( ReadOnly || string.IsNullOrEmpty( BlockCommentStart ) || string.IsNullOrEmpty( BlockCommentEnd ) )
			return false;

		Selection.Normalize();
		_pending.Clear();

		for ( var i = 0; i < Selection.Count; i++ )
		{
			var caret = Selection[i];
			var range = caret.HasSelection ? caret.Selection : LineRangeOf( caret.Position.Line );
			var text = Document.GetText( range );
			var trimmed = text.Trim();

			if ( trimmed.StartsWith( BlockCommentStart, StringComparison.Ordinal ) &&
			     trimmed.EndsWith( BlockCommentEnd, StringComparison.Ordinal ) &&
			     trimmed.Length >= BlockCommentStart.Length + BlockCommentEnd.Length )
			{
				var inner = trimmed[BlockCommentStart.Length..^BlockCommentEnd.Length];
				_pending.Add( new PendingEdit { Owner = caret, Range = range, Text = inner, SelectStart = 0, SelectEnd = inner.Length } );
			}
			else
			{
				var wrapped = BlockCommentStart + text + BlockCommentEnd;

				_pending.Add( new PendingEdit
				{
					Owner = caret,
					Range = range,
					Text = wrapped,
					SelectStart = 0,
					SelectEnd = wrapped.Length
				} );
			}
		}

		return ApplyEdits( "Block Comment", false );
	}

	/// <summary>Duplicates the selection, or the current line when nothing is selected.</summary>
	public bool DuplicateSelection()
	{
		if ( ReadOnly )
			return false;

		Selection.Normalize();
		_pending.Clear();

		for ( var i = 0; i < Selection.Count; i++ )
		{
			var caret = Selection[i];

			if ( caret.HasSelection )
			{
				var text = Document.GetText( caret.Selection );

				_pending.Add( new PendingEdit
				{
					Owner = caret,
					Range = TextRange.At( caret.Selection.End ),
					Text = text,
					SelectStart = 0,
					SelectEnd = text.Length
				} );

				continue;
			}

			var line = caret.Position.Line;
			var lineText = Document.GetLine( line );

			_pending.Add( new PendingEdit
			{
				Owner = caret,
				Range = TextRange.At( new TextPosition( line, lineText.Length ) ),
				Text = "\n" + lineText,
				CaretOffset = 1 + Math.Min( caret.Position.Column, lineText.Length )
			} );
		}

		return ApplyEdits( "Duplicate", false );
	}

	/// <summary>Moves every selected line up or down one row.</summary>
	public bool MoveLines( int direction )
	{
		if ( ReadOnly || direction == 0 )
			return false;

		direction = Math.Sign( direction );

		var runs = LineRuns();

		if ( runs.Count == 0 )
			return false;

		_pending.Clear();

		for ( var i = 0; i < runs.Count; i++ )
		{
			var (first, last) = runs[i];

			if ( direction < 0 && first == 0 )
				return false;

			if ( direction > 0 && last >= Document.LineCount - 1 )
				return false;

			var body = Document.GetLines( first, last );

			if ( direction < 0 )
			{
				var previous = Document.GetLine( first - 1 );

				_pending.Add( new PendingEdit
				{
					Range = new TextRange(
						new TextPosition( first - 1, 0 ),
						new TextPosition( last, Document.GetLineLength( last ) ) ),
					Text = body + "\n" + previous
				} );
			}
			else
			{
				var next = Document.GetLine( last + 1 );

				_pending.Add( new PendingEdit
				{
					Range = new TextRange(
						new TextPosition( first, 0 ),
						new TextPosition( last + 1, Document.GetLineLength( last + 1 ) ) ),
					Text = next + "\n" + body
				} );
			}
		}

		var before = Selection.Capture();

		if ( !ApplyEdits( "Move Line", false, mapCarets: false ) )
			return false;

		Selection.Restore( before );

		for ( var i = 0; i < Selection.Count; i++ )
		{
			var caret = Selection[i];
			caret.Position = Document.Clamp( new TextPosition( caret.Position.Line + direction, caret.Position.Column ) );
			caret.Anchor = Document.Clamp( new TextPosition( caret.Anchor.Line + direction, caret.Anchor.Column ) );
		}

		Selection.Normalize();
		RaiseCaretChanged();
		return true;
	}

	/// <summary>Applies a transform to every selection, or to the word under the caret.</summary>
	public bool TransformSelection( Func<string, string> transform, string label )
	{
		if ( ReadOnly || transform is null )
			return false;

		Selection.Normalize();
		_pending.Clear();

		for ( var i = 0; i < Selection.Count; i++ )
		{
			var caret = Selection[i];
			var range = caret.HasSelection ? caret.Selection : WordRangeAt( caret.Position );

			if ( range.IsEmpty )
				continue;

			var text = Document.GetText( range );
			var replaced = transform( text ) ?? text;

			_pending.Add( new PendingEdit
			{
				Owner = caret,
				Range = range,
				Text = replaced,
				SelectStart = 0,
				SelectEnd = replaced.Length
			} );
		}

		return ApplyEdits( label, false );
	}

	/// <summary>Removes trailing whitespace from every line.</summary>
	public bool TrimTrailingWhitespace()
	{
		if ( ReadOnly )
			return false;

		_pending.Clear();

		for ( var line = 0; line < Document.LineCount; line++ )
		{
			var text = Document.GetLine( line );
			var end = text.Length;

			while ( end > 0 && (text[end - 1] == ' ' || text[end - 1] == '\t') )
				end--;

			if ( end == text.Length )
				continue;

			_pending.Add( new PendingEdit
			{
				Range = new TextRange( new TextPosition( line, end ), new TextPosition( line, text.Length ) ),
				Text = string.Empty
			} );
		}

		if ( _pending.Count == 0 )
			return false;

		var before = Selection.Capture();
		var applied = ApplyEdits( "Trim Whitespace", false, mapCarets: false );

		if ( applied )
		{
			Selection.Restore( before );
			Selection.ClampTo( Document );
			RaiseCaretChanged();
		}

		return applied;
	}

	/// <summary>Replaces an explicit range, leaving the caret at the end of the replacement.</summary>
	public bool ReplaceRange( TextRange range, string text, string label = "Replace" )
	{
		if ( ReadOnly )
			return false;

		_pending.Clear();
		_pending.Add( new PendingEdit { Owner = Selection.Primary, Range = range, Text = text ?? string.Empty } );
		return ApplyEdits( label, false );
	}

	/// <summary>Replaces many ranges in one undo step. Ranges must not overlap.</summary>
	public bool ReplaceRanges( IReadOnlyList<TextRange> ranges, Func<TextRange, string> replacement, string label = "Replace All" )
	{
		if ( ReadOnly || ranges is null || ranges.Count == 0 || replacement is null )
			return false;

		_pending.Clear();

		for ( var i = 0; i < ranges.Count; i++ )
			_pending.Add( new PendingEdit { Range = ranges[i], Text = replacement( ranges[i] ) ?? string.Empty } );

		return ApplyEdits( label, false );
	}

	// ---- clipboard --------------------------------------------------------

	/// <summary>Copies the selection, or the whole current line when nothing is selected.</summary>
	public bool Copy()
	{
		Selection.Normalize();

		string text;

		if ( !Selection.HasSelection )
		{
			var builder = new StringBuilder();

			for ( var i = 0; i < Selection.Count; i++ )
			{
				if ( i > 0 )
					builder.Append( Document.NewLine );

				builder.Append( Document.GetLine( Selection[i].Position.Line ) );
			}

			text = builder.ToString();
			_clipboardWasLines = true;
		}
		else
		{
			text = GetSelectedText();
			_clipboardWasLines = false;
		}

		_clipboardCaretCount = Selection.Count;

		if ( string.IsNullOrEmpty( text ) )
			return false;

		return PrismLog.Guard( "Prism.Text: clipboard copy", () =>
		{
			EditorUtility.Clipboard.Copy( text );
			return true;
		}, false );
	}

	/// <summary>Copies then deletes.</summary>
	public bool Cut()
	{
		if ( ReadOnly )
			return Copy();

		var hadSelection = Selection.HasSelection;

		if ( !Copy() )
			return false;

		return hadSelection ? Backspace() : DeleteLines();
	}

	/// <summary>
	/// Pastes. When the clipboard was copied from this editor with the same number of carets, one line
	/// is distributed per caret; otherwise the whole payload goes to every caret.
	/// </summary>
	public bool Paste()
	{
		if ( ReadOnly )
			return false;

		var text = PrismLog.Guard( "Prism.Text: clipboard paste", EditorUtility.Clipboard.Paste, string.Empty );

		if ( string.IsNullOrEmpty( text ) )
			return false;

		Selection.Normalize();

		var lines = TextDocument.SplitLines( text );

		if ( Selection.Count > 1 && _clipboardCaretCount == Selection.Count && lines.Count == Selection.Count )
		{
			_pending.Clear();

			for ( var i = 0; i < Selection.Count; i++ )
			{
				_pending.Add( new PendingEdit
				{
					Owner = Selection[i],
					Range = Selection[i].Selection,
					Text = lines[i]
				} );
			}

			return ApplyEdits( "Paste", false );
		}

		if ( _clipboardWasLines && !Selection.HasSelection && Selection.Count == 1 )
		{
			var caret = Selection.Primary;

			_pending.Clear();
			_pending.Add( new PendingEdit
			{
				Owner = caret,
				Range = TextRange.At( new TextPosition( caret.Position.Line, 0 ) ),
				Text = text.TrimEnd( '\r', '\n' ) + "\n",
				CaretOffset = 0
			} );

			return ApplyEdits( "Paste", false );
		}

		return InsertText( text, false );
	}

	// ---- undo -------------------------------------------------------------

	/// <summary>Reverses the newest undo group.</summary>
	public bool PerformUndo()
	{
		if ( ReadOnly )
			return false;

		if ( !Undo.Undo( Selection ) )
			return false;

		RaiseChanged();
		RaiseCaretChanged();
		return true;
	}

	/// <summary>Re-applies the newest undone group.</summary>
	public bool PerformRedo()
	{
		if ( ReadOnly )
			return false;

		if ( !Undo.Redo( Selection ) )
			return false;

		RaiseChanged();
		RaiseCaretChanged();
		return true;
	}

	// ---- internals --------------------------------------------------------

	bool ApplyEdits( string label, bool coalesce, bool mapCarets = true )
	{
		if ( ReadOnly || _pending.Count == 0 )
			return false;

		_pending.Sort( static ( a, b ) => b.Range.Normalized.Start.CompareTo( a.Range.Normalized.Start ) );

		var applied = false;

		using ( Undo.Begin( label, coalesce ) )
		{
			for ( var i = 0; i < _pending.Count; i++ )
			{
				var edit = _pending[i];
				var before = Document.Version;
				var change = Document.Replace( edit.Range, edit.Text );

				if ( Document.Version == before )
					continue;

				applied = true;
				Undo.Record( change );

				if ( !mapCarets )
					continue;

				for ( var c = 0; c < Selection.Count; c++ )
				{
					var caret = Selection[c];
					caret.Position = change.Map( caret.Position );
					caret.Anchor = change.Map( caret.Anchor );
				}

				if ( edit.Owner is null )
					continue;

				var start = change.Removed.Start;

				if ( edit.SelectStart >= 0 && edit.SelectEnd >= 0 )
				{
					edit.Owner.Anchor = TextEdit.Advance( start, Slice( edit.Text, edit.SelectStart ) );
					edit.Owner.Position = TextEdit.Advance( start, Slice( edit.Text, edit.SelectEnd ) );
				}
				else if ( edit.CaretOffset >= 0 )
				{
					var position = TextEdit.Advance( start, Slice( edit.Text, edit.CaretOffset ) );
					edit.Owner.Position = position;
					edit.Owner.Anchor = position;
				}
				else
				{
					edit.Owner.Position = change.InsertedEnd;
					edit.Owner.Anchor = change.InsertedEnd;
				}

				edit.Owner.DesiredColumn = -1;
			}
		}

		_pending.Clear();

		if ( !applied )
			return false;

		if ( mapCarets )
		{
			Selection.ClampTo( Document );
			RaiseCaretChanged();
		}

		RaiseChanged();
		return true;
	}

	static string Slice( string text, int length )
	{
		if ( string.IsNullOrEmpty( text ) || length <= 0 )
			return string.Empty;

		return length >= text.Length ? text : text[..length];
	}

	static bool IsAllWhitespace( string text, int upTo )
	{
		for ( var i = 0; i < Math.Min( upTo, text.Length ); i++ )
		{
			if ( text[i] != ' ' && text[i] != '\t' )
				return false;
		}

		return upTo > 0;
	}

	bool SelectionSpansLines()
	{
		for ( var i = 0; i < Selection.Count; i++ )
		{
			var range = Selection[i].Selection;

			if ( range.Start.Line != range.End.Line )
				return true;
		}

		return false;
	}

	TextRange LineRangeOf( int line ) =>
		new( new TextPosition( line, 0 ), new TextPosition( line, Document.GetLineLength( line ) ) );

	List<(int First, int Last)> LineRuns()
	{
		Selection.Normalize();

		var lines = new List<int>();

		for ( var i = 0; i < Selection.Count; i++ )
		{
			var range = Selection[i].Selection;
			var last = range.End.Line;

			if ( range.End.Column == 0 && range.End.Line > range.Start.Line )
				last--;

			for ( var line = range.Start.Line; line <= last; line++ )
			{
				if ( line >= 0 && line < Document.LineCount )
					lines.Add( line );
			}
		}

		lines.Sort();

		var runs = new List<(int First, int Last)>();

		for ( var i = 0; i < lines.Count; i++ )
		{
			if ( i > 0 && lines[i] == lines[i - 1] )
				continue;

			if ( runs.Count > 0 && lines[i] == runs[^1].Last + 1 )
			{
				runs[^1] = (runs[^1].First, lines[i]);
				continue;
			}

			runs.Add( (lines[i], lines[i]) );
		}

		return runs;
	}

	void RaiseChanged() => Changed?.Invoke();

	void RaiseCaretChanged() => CaretChanged?.Invoke();
}