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