Editor/Prism/Text/TextSelection.cs

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.

Reflection
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";
}