Editor/HotCodeEditor/CodeView/TextBuffer.cs
using System;
using System.Collections.Generic;
using System.Text;

/// <summary>
/// A position in a <see cref="TextBuffer"/>: zero-based line and character index within that line.
/// </summary>
public readonly record struct TextPos( int Line, int Col ) : IComparable<TextPos>
{
	public int CompareTo( TextPos other ) => Line != other.Line ? Line.CompareTo( other.Line ) : Col.CompareTo( other.Col );

	public static bool operator <( TextPos a, TextPos b ) => a.CompareTo( b ) < 0;
	public static bool operator >( TextPos a, TextPos b ) => a.CompareTo( b ) > 0;
	public static bool operator <=( TextPos a, TextPos b ) => a.CompareTo( b ) <= 0;
	public static bool operator >=( TextPos a, TextPos b ) => a.CompareTo( b ) >= 0;

	public static TextPos Min( TextPos a, TextPos b ) => a <= b ? a : b;
	public static TextPos Max( TextPos a, TextPos b ) => a >= b ? a : b;
}

/// <summary>
/// Plain text stored as a list of lines. Knows nothing about carets, undo or drawing.
/// </summary>
public class TextBuffer
{
	public List<string> Lines { get; } = new() { "" };

	/// <summary>
	/// Line ending used when joining lines back into text. Detected from the loaded text.
	/// </summary>
	public string LineEnding { get; set; } = "\n";

	public int LineCount => Lines.Count;

	public string Text
	{
		get => string.Join( LineEnding, Lines );
		set
		{
			value ??= "";
			LineEnding = value.Contains( "\r\n" ) ? "\r\n" : "\n";
			Lines.Clear();
			Lines.AddRange( Normalize( value ).Split( '\n' ) );
		}
	}

	/// <summary>
	/// Converts any line endings to \n.
	/// </summary>
	public static string Normalize( string text ) => text.Replace( "\r\n", "\n" ).Replace( '\r', '\n' );

	public TextPos Clamp( TextPos p )
	{
		var line = Math.Clamp( p.Line, 0, Lines.Count - 1 );
		return new TextPos( line, Math.Clamp( p.Col, 0, Lines[line].Length ) );
	}

	public TextPos End => new( Lines.Count - 1, Lines[^1].Length );

	/// <summary>
	/// Where text inserted at <paramref name="start"/> would end.
	/// </summary>
	public static TextPos EndOf( TextPos start, string text )
	{
		var lastNewline = text.LastIndexOf( '\n' );
		if ( lastNewline < 0 )
			return new TextPos( start.Line, start.Col + text.Length );

		var newlines = 0;
		foreach ( var c in text ) if ( c == '\n' ) newlines++;
		return new TextPos( start.Line + newlines, text.Length - lastNewline - 1 );
	}

	public string GetText( TextPos a, TextPos b )
	{
		var start = Clamp( TextPos.Min( a, b ) );
		var end = Clamp( TextPos.Max( a, b ) );

		if ( start.Line == end.Line )
			return Lines[start.Line].Substring( start.Col, end.Col - start.Col );

		var sb = new StringBuilder();
		sb.Append( Lines[start.Line], start.Col, Lines[start.Line].Length - start.Col );
		for ( int i = start.Line + 1; i < end.Line; i++ )
			sb.Append( '\n' ).Append( Lines[i] );
		sb.Append( '\n' ).Append( Lines[end.Line], 0, end.Col );
		return sb.ToString();
	}

	public void Delete( TextPos a, TextPos b )
	{
		var start = Clamp( TextPos.Min( a, b ) );
		var end = Clamp( TextPos.Max( a, b ) );
		if ( start == end ) return;

		var head = Lines[start.Line][..start.Col];
		var tail = Lines[end.Line][end.Col..];
		Lines.RemoveRange( start.Line + 1, end.Line - start.Line );
		Lines[start.Line] = head + tail;
	}

	/// <summary>
	/// Inserts text (which may contain newlines) and returns the position just after it.
	/// </summary>
	public TextPos Insert( TextPos at, string text )
	{
		at = Clamp( at );
		text = Normalize( text );
		if ( text.Length == 0 ) return at;

		var line = Lines[at.Line];
		var head = line[..at.Col];
		var tail = line[at.Col..];
		var parts = text.Split( '\n' );

		if ( parts.Length == 1 )
		{
			Lines[at.Line] = head + text + tail;
			return new TextPos( at.Line, at.Col + text.Length );
		}

		Lines[at.Line] = head + parts[0];
		for ( int i = 1; i < parts.Length - 1; i++ )
			Lines.Insert( at.Line + i, parts[i] );
		Lines.Insert( at.Line + parts.Length - 1, parts[^1] + tail );

		return new TextPos( at.Line + parts.Length - 1, parts[^1].Length );
	}
}