Editor code for text selection and caret management. Defines immutable TextPosition and TextRange structs, a Caret class, a SelectionSnapshot struct, and a SelectionSet class that manages multiple carets, normalization, box selections, clamping to a TextDocument, and snapshot/restore for undo.
using System.ComponentModel;
namespace Editor.Prism.Text;
/// <summary>
/// A position inside a <see cref="TextDocument"/>: a zero-based line index and a zero-based
/// <b>character</b> index within that line. Character, not visual column — a tab counts as one.
/// Visual columns are a rendering concept and live in <see cref="CodeEditorWidget"/>.
/// </summary>
public readonly record struct TextPosition( int Line, int Column ) : IComparable<TextPosition>
{
/// <summary>The very start of any document.</summary>
public static readonly TextPosition Zero = new( 0, 0 );
/// <summary>Line-major ordering, then column.</summary>
public int CompareTo( TextPosition other )
{
if ( Line != other.Line )
return Line.CompareTo( other.Line );
return Column.CompareTo( other.Column );
}
/// <summary>Whether this position is before <paramref name="b"/>.</summary>
public static bool operator <( TextPosition a, TextPosition b ) => a.CompareTo( b ) < 0;
/// <summary>Whether this position is after <paramref name="b"/>.</summary>
public static bool operator >( TextPosition a, TextPosition b ) => a.CompareTo( b ) > 0;
/// <summary>Whether this position is at or before <paramref name="b"/>.</summary>
public static bool operator <=( TextPosition a, TextPosition b ) => a.CompareTo( b ) <= 0;
/// <summary>Whether this position is at or after <paramref name="b"/>.</summary>
public static bool operator >=( TextPosition a, TextPosition b ) => a.CompareTo( b ) >= 0;
/// <summary>The smaller of two positions.</summary>
public static TextPosition Min( TextPosition a, TextPosition b ) => a <= b ? a : b;
/// <summary>The larger of two positions.</summary>
public static TextPosition Max( TextPosition a, TextPosition b ) => a >= b ? a : b;
/// <summary>A one-based <c>line:column</c> rendering, matching every shader compiler's error format.</summary>
public override string ToString() => $"{Line + 1}:{Column + 1}";
}
/// <summary>An ordered pair of positions. <see cref="Start"/> may be after <see cref="End"/> — use <see cref="Normalized"/> when order matters.</summary>
public readonly record struct TextRange( TextPosition Start, TextPosition End )
{
/// <summary>An empty range at the origin.</summary>
public static readonly TextRange Empty = new( TextPosition.Zero, TextPosition.Zero );
/// <summary>A zero-length range at a single position.</summary>
public static TextRange At( TextPosition position ) => new( position, position );
/// <summary>A normalized range spanning two positions in either order.</summary>
public static TextRange FromPoints( TextPosition a, TextPosition b ) =>
a <= b ? new TextRange( a, b ) : new TextRange( b, a );
/// <summary>A whole line, excluding its terminator.</summary>
public static TextRange Line( int line, int length ) =>
new( new TextPosition( line, 0 ), new TextPosition( line, length ) );
/// <summary>The earlier of the two endpoints.</summary>
public TextPosition Min => Start <= End ? Start : End;
/// <summary>The later of the two endpoints.</summary>
public TextPosition Max => Start >= End ? Start : End;
/// <summary>This range with <see cref="Start"/> guaranteed to be at or before <see cref="End"/>.</summary>
[Hide, Browsable( false ), JsonIgnore]
public TextRange Normalized => Start <= End ? this : new TextRange( End, Start );
/// <summary>Whether the range covers no characters.</summary>
public bool IsEmpty => Start == End;
/// <summary>Whether both endpoints are on the same line.</summary>
public bool IsSingleLine => Start.Line == End.Line;
/// <summary>Number of lines the range touches, always at least one.</summary>
public int LineCount => Math.Abs( End.Line - Start.Line ) + 1;
/// <summary>Whether <paramref name="position"/> lies inside the range. The end is exclusive unless the range is empty.</summary>
public bool Contains( TextPosition position )
{
var min = Min;
var max = Max;
if ( min == max )
return position == min;
return position >= min && position < max;
}
/// <summary>Whether <paramref name="position"/> lies inside the range, treating both ends as inclusive.</summary>
public bool ContainsInclusive( TextPosition position ) => position >= Min && position <= Max;
/// <summary>Whether two ranges share at least one character, or touch when either is empty.</summary>
public bool Intersects( TextRange other )
{
var a = Normalized;
var b = other.Normalized;
if ( a.IsEmpty || b.IsEmpty )
return a.Start <= b.End && b.Start <= a.End;
return a.Start < b.End && b.Start < a.End;
}
/// <summary>Whether two ranges overlap or are exactly adjacent.</summary>
public bool Touches( TextRange other )
{
var a = Normalized;
var b = other.Normalized;
return a.Start <= b.End && b.Start <= a.End;
}
/// <summary>The smallest range covering both.</summary>
public TextRange Union( TextRange other )
{
var a = Normalized;
var b = other.Normalized;
return new TextRange( TextPosition.Min( a.Start, b.Start ), TextPosition.Max( a.End, b.End ) );
}
/// <summary>A readable <c>start-end</c> rendering.</summary>
public override string ToString() => IsEmpty ? Start.ToString() : $"{Min}-{Max}";
}
/// <summary>
/// One caret. The caret sits at <see cref="Position"/>; the selection is everything between
/// <see cref="Anchor"/> and <see cref="Position"/>, in either direction.
/// </summary>
public sealed class Caret
{
/// <summary>Creates a collapsed caret at the origin.</summary>
public Caret()
{
}
/// <summary>Creates a collapsed caret at a position.</summary>
public Caret( TextPosition position )
{
Position = position;
Anchor = position;
}
/// <summary>Creates a caret with a selection.</summary>
public Caret( TextPosition position, TextPosition anchor )
{
Position = position;
Anchor = anchor;
}
/// <summary>Where the caret is drawn and where typing inserts.</summary>
public TextPosition Position { get; set; }
/// <summary>The fixed end of the selection.</summary>
public TextPosition Anchor { get; set; }
/// <summary>
/// Visual column the caret "wants" while moving vertically, so travelling through short lines
/// does not permanently lose the column. Negative means unset.
/// </summary>
public int DesiredColumn { get; set; } = -1;
/// <summary>Whether this is the caret the viewport follows and the one find/replace acts on.</summary>
public bool IsPrimary { get; set; }
/// <summary>Whether anything is selected.</summary>
public bool HasSelection => Position != Anchor;
/// <summary>The selected range, normalized.</summary>
public TextRange Selection => TextRange.FromPoints( Anchor, Position );
/// <summary>Drops the selection, keeping the caret where it is (or moving it to the selection start).</summary>
public void Collapse( bool toStart = false )
{
if ( toStart && HasSelection )
Position = Selection.Start;
Anchor = Position;
}
/// <summary>Moves the caret, optionally keeping the anchor to extend the selection.</summary>
public void MoveTo( TextPosition position, bool extend )
{
Position = position;
if ( !extend )
Anchor = position;
}
/// <summary>An independent copy.</summary>
public Caret Clone() => new( Position, Anchor ) { DesiredColumn = DesiredColumn, IsPrimary = IsPrimary };
/// <summary>Diagnostic rendering.</summary>
public override string ToString() => HasSelection ? $"caret {Position} sel {Selection}" : $"caret {Position}";
}
/// <summary>An immutable copy of a whole multi-caret state, used by undo/redo.</summary>
public readonly struct SelectionSnapshot
{
internal readonly TextPosition[] Positions;
internal readonly TextPosition[] Anchors;
internal readonly int PrimaryIndex;
internal readonly bool Box;
internal SelectionSnapshot( TextPosition[] positions, TextPosition[] anchors, int primaryIndex, bool box )
{
Positions = positions;
Anchors = anchors;
PrimaryIndex = primaryIndex;
Box = box;
}
/// <summary>Whether the snapshot holds anything.</summary>
public bool IsValid => Positions is not null && Positions.Length > 0;
/// <summary>Number of carets captured.</summary>
public int Count => Positions is null ? 0 : Positions.Length;
}
/// <summary>
/// The live set of carets. Always holds at least one; exactly one is the primary.
/// <see cref="Normalize"/> sorts the set and merges carets whose selections overlap, which is what
/// keeps multi-caret editing from producing duplicate insertions.
/// </summary>
public sealed class SelectionSet
{
readonly List<Caret> _carets = new();
/// <summary>Creates a set holding a single collapsed caret at the origin.</summary>
public SelectionSet()
{
_carets.Add( new Caret { IsPrimary = true } );
}
/// <summary>Every caret, ordered by position after the last <see cref="Normalize"/>.</summary>
public IReadOnlyList<Caret> Carets => _carets;
/// <summary>Number of carets.</summary>
public int Count => _carets.Count;
/// <summary>Indexed access.</summary>
public Caret this[int index] => _carets[index];
/// <summary>The caret the viewport follows. Never null.</summary>
public Caret Primary
{
get
{
for ( var i = 0; i < _carets.Count; i++ )
{
if ( _carets[i].IsPrimary )
return _carets[i];
}
if ( _carets.Count == 0 )
_carets.Add( new Caret() );
_carets[^1].IsPrimary = true;
return _carets[^1];
}
}
/// <summary>Whether any caret has a non-empty selection.</summary>
public bool HasSelection
{
get
{
for ( var i = 0; i < _carets.Count; i++ )
{
if ( _carets[i].HasSelection )
return true;
}
return false;
}
}
/// <summary>Whether more than one caret is active.</summary>
public bool IsMultiple => _carets.Count > 1;
/// <summary>Whether the current set came from a rectangular (box) selection drag.</summary>
public bool IsBox { get; private set; }
/// <summary>The anchor corner of the active box selection.</summary>
public TextPosition BoxAnchor { get; private set; }
/// <summary>The moving corner of the active box selection.</summary>
public TextPosition BoxHead { get; private set; }
/// <summary>Every selection range, in document order.</summary>
public IEnumerable<TextRange> Ranges
{
get
{
for ( var i = 0; i < _carets.Count; i++ )
yield return _carets[i].Selection;
}
}
/// <summary>Replaces the whole set with one collapsed caret.</summary>
public Caret SetSingle( TextPosition position )
{
return SetSingle( position, position );
}
/// <summary>Replaces the whole set with one caret and selection.</summary>
public Caret SetSingle( TextPosition position, TextPosition anchor )
{
IsBox = false;
var caret = _carets.Count > 0 ? Primary : new Caret();
caret.Position = position;
caret.Anchor = anchor;
caret.DesiredColumn = -1;
caret.IsPrimary = true;
_carets.Clear();
_carets.Add( caret );
return caret;
}
/// <summary>Adds a caret and makes it primary. Returns the existing caret when one is already there.</summary>
public Caret Add( TextPosition position, TextPosition anchor )
{
IsBox = false;
for ( var i = 0; i < _carets.Count; i++ )
{
if ( _carets[i].Position == position && _carets[i].Anchor == anchor )
{
MakePrimary( _carets[i] );
return _carets[i];
}
}
var caret = new Caret( position, anchor );
_carets.Add( caret );
MakePrimary( caret );
return caret;
}
/// <summary>Adds a collapsed caret.</summary>
public Caret Add( TextPosition position ) => Add( position, position );
/// <summary>Removes a caret. The last caret is never removed.</summary>
public bool Remove( Caret caret )
{
if ( caret is null || _carets.Count <= 1 )
return false;
if ( !_carets.Remove( caret ) )
return false;
if ( caret.IsPrimary )
_carets[^1].IsPrimary = true;
return true;
}
/// <summary>Makes one caret primary and clears the flag on the others.</summary>
public void MakePrimary( Caret caret )
{
for ( var i = 0; i < _carets.Count; i++ )
_carets[i].IsPrimary = ReferenceEquals( _carets[i], caret );
}
/// <summary>Drops every caret except the primary, keeping its selection.</summary>
public void KeepPrimaryOnly()
{
var primary = Primary;
_carets.Clear();
_carets.Add( primary );
IsBox = false;
}
/// <summary>Collapses every selection.</summary>
public void CollapseAll( bool toStart = false )
{
for ( var i = 0; i < _carets.Count; i++ )
_carets[i].Collapse( toStart );
}
/// <summary>Clears the vertical-movement column memory on every caret.</summary>
public void ClearDesiredColumns()
{
for ( var i = 0; i < _carets.Count; i++ )
_carets[i].DesiredColumn = -1;
}
/// <summary>
/// Sorts the carets by position and merges any whose selections overlap or touch. The merged
/// caret keeps the direction of the later one and stays primary if either input was.
/// </summary>
public void Normalize()
{
if ( _carets.Count <= 1 )
{
if ( _carets.Count == 1 )
_carets[0].IsPrimary = true;
return;
}
_carets.Sort( static ( a, b ) =>
{
var c = a.Selection.Start.CompareTo( b.Selection.Start );
return c != 0 ? c : a.Selection.End.CompareTo( b.Selection.End );
} );
var merged = new List<Caret>( _carets.Count );
merged.Add( _carets[0] );
for ( var i = 1; i < _carets.Count; i++ )
{
var previous = merged[^1];
var current = _carets[i];
var a = previous.Selection;
var b = current.Selection;
var overlaps = a.End > b.Start || (a.End == b.Start && (a.IsEmpty || b.IsEmpty));
if ( !overlaps )
{
merged.Add( current );
continue;
}
var union = a.Union( b );
var forward = current.Position >= current.Anchor;
current.Position = forward ? union.End : union.Start;
current.Anchor = forward ? union.Start : union.End;
current.IsPrimary = current.IsPrimary || previous.IsPrimary;
merged[^1] = current;
}
_carets.Clear();
_carets.AddRange( merged );
var hasPrimary = false;
for ( var i = 0; i < _carets.Count; i++ )
{
if ( !_carets[i].IsPrimary )
continue;
if ( hasPrimary )
_carets[i].IsPrimary = false;
hasPrimary = true;
}
if ( !hasPrimary )
_carets[^1].IsPrimary = true;
}
/// <summary>
/// Rebuilds the set as a rectangular selection between two corners: one caret per line, with
/// columns clamped to the length of each line. Columns are character indices.
/// </summary>
public void SetBox( TextDocument document, TextPosition anchor, TextPosition head )
{
if ( document is null )
return;
BoxAnchor = anchor;
BoxHead = head;
var first = Math.Min( anchor.Line, head.Line );
var last = Math.Max( anchor.Line, head.Line );
first = Math.Clamp( first, 0, Math.Max( 0, document.LineCount - 1 ) );
last = Math.Clamp( last, 0, Math.Max( 0, document.LineCount - 1 ) );
_carets.Clear();
for ( var line = first; line <= last; line++ )
{
var length = document.GetLineLength( line );
var a = Math.Clamp( anchor.Column, 0, length );
var p = Math.Clamp( head.Column, 0, length );
_carets.Add( new Caret( new TextPosition( line, p ), new TextPosition( line, a ) ) );
}
if ( _carets.Count == 0 )
_carets.Add( new Caret( head ) );
var primaryLine = Math.Clamp( head.Line - first, 0, _carets.Count - 1 );
MakePrimary( _carets[primaryLine] );
IsBox = true;
}
/// <summary>Captures the whole state so undo can restore it exactly.</summary>
public SelectionSnapshot Capture()
{
var positions = new TextPosition[_carets.Count];
var anchors = new TextPosition[_carets.Count];
var primary = 0;
for ( var i = 0; i < _carets.Count; i++ )
{
positions[i] = _carets[i].Position;
anchors[i] = _carets[i].Anchor;
if ( _carets[i].IsPrimary )
primary = i;
}
return new SelectionSnapshot( positions, anchors, primary, IsBox );
}
/// <summary>Restores a captured state. Does nothing when the snapshot is empty.</summary>
public void Restore( SelectionSnapshot snapshot )
{
if ( !snapshot.IsValid )
return;
_carets.Clear();
for ( var i = 0; i < snapshot.Positions.Length; i++ )
_carets.Add( new Caret( snapshot.Positions[i], snapshot.Anchors[i] ) );
var primary = Math.Clamp( snapshot.PrimaryIndex, 0, _carets.Count - 1 );
MakePrimary( _carets[primary] );
IsBox = snapshot.Box;
}
/// <summary>Clamps every caret into the document, dropping carets that collapse onto each other.</summary>
public void ClampTo( TextDocument document )
{
if ( document is null )
return;
for ( var i = 0; i < _carets.Count; i++ )
{
_carets[i].Position = document.Clamp( _carets[i].Position );
_carets[i].Anchor = document.Clamp( _carets[i].Anchor );
}
Normalize();
}
/// <summary>Diagnostic rendering.</summary>
public override string ToString() => _carets.Count == 1 ? Primary.ToString() : $"{_carets.Count} carets";
}