Editor/Prism/Text/CodeEditorRenderer.cs

Renderer for the code editor widget. It builds and caches a paint-ready representation of each visible line (tab expansion, visual-column mapping, styled runs), and draws the editor UI: background, gutter, line numbers, selections, occurrences, indent guides, tokens, carets, brackets, diagnostics squiggles and fold markers.

Native Interop
using Editor.Prism.Core;
using Editor.Prism.Text.Lexer;
using Editor.Prism.Ui;
using System.Text;

namespace Editor.Prism.Text;

/// <summary>
/// Every pixel of the code editor. Virtualised to the visible rows, one tab expansion per row, and
/// adjacent same-style tokens merged into a single <c>Paint.DrawText</c> call — the monospace advance
/// is measured once by the widget and columns are indexed arithmetically, never measured per token.
/// <para>
/// The gutter is painted last over an opaque background because <c>Paint</c> exposes no clip
/// rectangle, so horizontally scrolled text would otherwise bleed under the line numbers.
/// </para>
/// </summary>
public sealed class CodeEditorRenderer
{
	readonly CodeEditorWidget _editor;
	readonly StringBuilder _expanded = new();
	readonly List<int> _visualOfChar = new();
	readonly List<Vector2> _points = new();
	readonly Dictionary<int, string> _lineNumbers = new();
	readonly Dictionary<int, RowVisual> _rows = new();

	readonly List<int> _runStart = new();
	readonly List<int> _runEnd = new();
	readonly List<TokenKind> _runKind = new();

	static readonly object s_noTokens = new();

	RowVisual _row = RowVisual.Blank;
	TokenKind _fontKind = (TokenKind)(-1);

	/// <summary>Line-number strings kept before the cache is dropped. A gutter shows sixty at a time.</summary>
	const int MaxCachedLineNumbers = 4096;

	/// <summary>Rows kept before the visual cache is dropped. A viewport shows sixty at a time.</summary>
	const int MaxCachedRows = 1024;

	/// <summary>
	/// Widest row that is worth caching. Past this the per-frame cost of building every run — a
	/// substring each — outweighs the saving, so a pathological line falls back to the streaming path,
	/// which culls to the visible columns before it allocates anything.
	/// </summary>
	const int MaxCachedRowWidth = 2000;

	/// <summary>
	/// One row's paint-ready form: the tab-expanded text, the character-to-visual-column map, and the
	/// coloured runs already split and cut.
	/// <para>
	/// <see cref="Raw"/> and <see cref="Tokens"/> are <b>identity</b> keys, not value keys. A line's text
	/// is an immutable <see cref="string"/> the document replaces wholesale when that line is edited, and
	/// its tokens are a <see cref="Token"/> array the incremental lexer replaces wholesale when that line
	/// is re-lexed. So a reference comparison is an exact, allocation-free test of whether this row is
	/// still current — and it is precise per line, which a document-version stamp is not: one keystroke
	/// invalidates one row rather than the whole viewport.
	/// </para>
	/// </summary>
	sealed class RowVisual
	{
		public static readonly RowVisual Blank = new() { Raw = string.Empty, Expanded = string.Empty, IdentityColumns = true };

		public string Raw;
		public object Tokens;
		public int TabSize;

		public string Expanded;

		/// <summary>Visual column of every character index, plus one past the end. Null when identity.</summary>
		public int[] VisualOfChar;

		/// <summary>True when no tab or control character widened anything, so column equals index.</summary>
		public bool IdentityColumns;

		/// <summary>Visual column one past the last character.</summary>
		public int VisualLength;

		/// <summary>Number of runs, or -1 when this row is too wide to cache and must stream.</summary>
		public int RunCount;

		public int[] RunStart;
		public int[] RunEnd;
		public TokenKind[] RunKind;

		/// <summary>Run text, already cut. Null for a run that is blank and therefore draws nothing.</summary>
		public string[] RunText;

		public int VisualAt( int charIndex )
		{
			if ( IdentityColumns )
				return Math.Clamp( charIndex, 0, Raw.Length );

			var index = Math.Clamp( charIndex, 0, VisualOfChar.Length - 1 );
			return VisualOfChar[index];
		}
	}

	/// <summary>Creates a renderer for one editor.</summary>
	public CodeEditorRenderer( CodeEditorWidget editor )
	{
		_editor = editor;
	}

	/// <summary>
	/// Drops the cached row visuals. Only needed when something outside the document changed the way a
	/// row lays out — the tab width, or a language swap that re-tokenises everything.
	/// </summary>
	public void InvalidateRows()
	{
		_rows.Clear();
		_row = RowVisual.Blank;
	}

	/// <summary>The editor being drawn.</summary>
	public CodeEditorWidget Editor => _editor;

	/// <summary>The palette entry for a token class. Every colour comes from <see cref="PrismTheme.Code"/>.</summary>
	public static Color ColorFor( TokenKind kind ) => kind switch
	{
		TokenKind.Keyword => PrismTheme.Code.Keyword,
		TokenKind.ControlKeyword => PrismTheme.Code.ControlKeyword,
		TokenKind.Modifier => PrismTheme.Code.Keyword,
		TokenKind.BuiltinType => PrismTheme.Code.BuiltinType,
		TokenKind.UserType => PrismTheme.Code.UserType,
		TokenKind.Intrinsic => PrismTheme.Code.Intrinsic,
		TokenKind.FunctionName => PrismTheme.Code.FunctionDecl,
		TokenKind.Number => PrismTheme.Code.Number,
		TokenKind.String => PrismTheme.Code.String,
		TokenKind.IncludePath => PrismTheme.Code.String,
		TokenKind.Comment => PrismTheme.Code.Comment,
		TokenKind.DocComment => PrismTheme.Code.Comment,
		TokenKind.Preprocessor => PrismTheme.Code.Preprocessor,
		TokenKind.BlockKeyword => PrismTheme.Code.VfxBlock,
		TokenKind.Annotation => PrismTheme.Code.Annotation,
		TokenKind.Attribute => PrismTheme.Code.Annotation,
		TokenKind.Semantic => PrismTheme.Code.Semantic,
		TokenKind.EngineGlobal => PrismTheme.Code.EngineGlobal,
		TokenKind.Operator => PrismTheme.Code.Operator,
		TokenKind.Punctuation => PrismTheme.Code.Operator,
		TokenKind.Error => PrismTheme.Code.ErrorUnderline,
		_ => PrismTheme.Code.Text
	};

	/// <summary>Whether a token class is drawn bold.</summary>
	public static bool BoldFor( TokenKind kind ) => kind is TokenKind.FunctionName or TokenKind.BlockKeyword;

	/// <summary>Whether a token class is drawn italic.</summary>
	public static bool ItalicFor( TokenKind kind ) => kind is TokenKind.Comment or TokenKind.DocComment;

	/// <summary>Draws the whole widget. Only valid inside the widget's paint scope.</summary>
	public void Render()
	{
		var document = _editor.Document;

		if ( document is null )
			return;

		Paint.Antialiasing = true;
		Paint.TextAntialiasing = true;

		var rect = _editor.LocalRect;

		Paint.ClearPen();
		Paint.SetBrush( PrismTheme.Code.Background );
		Paint.DrawRect( rect );

		_fontKind = (TokenKind)(-1);
		SetFontFor( TokenKind.None );

		var lineHeight = _editor.LineHeight;
		var charWidth = _editor.CharWidth;
		var gutter = _editor.GutterWidth;
		var scrollX = _editor.ScrollX;

		var firstRow = _editor.FirstVisibleRow;
		var lastRow = Math.Min( _editor.VisualRowCount - 1, firstRow + _editor.VisibleRowCount );

		var folding = _editor.Folding;
		var selection = _editor.Selection;
		var occurrences = _editor.Occurrences;

		var bracket = BracketMatch.None;

		if ( _editor.Brackets is not null && !_editor.ReadOnly )
			_editor.Brackets.TryMatchAt( selection.Primary.Position, out bracket );

		DrawRuler( rect, gutter, charWidth, scrollX );

		for ( var row = firstRow; row <= lastRow; row++ )
		{
			var line = folding is null ? row : folding.ToDocument( row );

			if ( line < 0 || line >= document.LineCount )
				continue;

			var y = row * lineHeight - _editor.ScrollY;
			var rowRect = new Rect( 0, y, rect.Width, lineHeight );

			_row = EnsureRow( line );

			DrawCurrentLine( rowRect, line, selection );
			DrawSelection( rowRect, line, gutter, charWidth, scrollX, selection );
			DrawRanges( rowRect, line, gutter, charWidth, scrollX, _editor.SearchMatches, PrismTheme.Accent.WithAlpha( 0.22f ), true );
			DrawRanges( rowRect, line, gutter, charWidth, scrollX, occurrences, PrismTheme.Code.Occurrence, false );
			DrawIndentGuides( rowRect, line, gutter, charWidth, scrollX );
			DrawText( rowRect, gutter, charWidth, scrollX, rect.Width );

			if ( _editor.ShowWhitespace )
				DrawWhitespace( rowRect, line, gutter, charWidth, scrollX );

			DrawBracket( rowRect, line, gutter, charWidth, scrollX, bracket );
			DrawSquiggles( rowRect, line, gutter, charWidth, scrollX );
			DrawFoldedMarker( rowRect, line, gutter, charWidth, scrollX );
			DrawCarets( rowRect, line, gutter, charWidth, scrollX, selection );
		}

		DrawGutter( rect, firstRow, lastRow );
	}

	// ---- helpers ----------------------------------------------------------

	void SetFontFor( TokenKind kind )
	{
		if ( _fontKind == kind )
			return;

		var bold = BoldFor( kind );
		var italic = ItalicFor( kind );

		Paint.SetFont( PrismTheme.MonospaceFamily, _editor.FontSize, bold ? 600 : 400, italic, true );
		_fontKind = kind;
	}

	float ColumnX( int visualColumn, float gutter, float charWidth, float scrollX ) =>
		gutter + visualColumn * charWidth - scrollX;

	int VisualAt( int charIndex ) => _row.VisualAt( charIndex );

	int LineVisualLength => _row.VisualLength;

	// ---- passes -----------------------------------------------------------

	void DrawRuler( Rect rect, float gutter, float charWidth, float scrollX )
	{
		if ( !_editor.ShowRuler || _editor.RulerColumn <= 0 )
			return;

		var x = ColumnX( _editor.RulerColumn, gutter, charWidth, scrollX );

		if ( x < gutter || x > rect.Right )
			return;

		Paint.SetPen( PrismTheme.Code.IndentGuide, 1f );
		Paint.DrawLine( new Vector2( x, 0f ), new Vector2( x, rect.Height ) );
	}

	void DrawCurrentLine( Rect rowRect, int line, SelectionSet selection )
	{
		if ( !_editor.HighlightCurrentLine )
			return;

		var isCaretLine = false;

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

			if ( caret.Position.Line != line || caret.HasSelection )
				continue;

			isCaretLine = true;
			break;
		}

		if ( !isCaretLine )
			return;

		Paint.ClearPen();
		Paint.SetBrush( PrismTheme.Code.CurrentLine );
		Paint.DrawRect( rowRect );
	}

	void DrawSelection( Rect rowRect, int line, float gutter, float charWidth, float scrollX, SelectionSet selection )
	{
		Paint.ClearPen();
		Paint.SetBrush( PrismTheme.Code.Selection );

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

			if ( range.IsEmpty || line < range.Start.Line || line > range.End.Line )
				continue;

			var startVisual = line == range.Start.Line ? VisualAt( range.Start.Column ) : 0;
			var endVisual = line == range.End.Line ? VisualAt( range.End.Column ) : LineVisualLength + 1;

			if ( endVisual <= startVisual && line != range.End.Line )
				endVisual = startVisual + 1;

			if ( endVisual == startVisual )
				continue;

			var x0 = ColumnX( startVisual, gutter, charWidth, scrollX );
			var x1 = ColumnX( endVisual, gutter, charWidth, scrollX );

			if ( x1 < gutter || x0 > rowRect.Right )
				continue;

			Paint.DrawRect( new Rect( Math.Max( x0, gutter ), rowRect.Top, Math.Max( 1f, x1 - Math.Max( x0, gutter ) ), rowRect.Height ) );
		}
	}

	void DrawRanges( Rect rowRect, int line, float gutter, float charWidth, float scrollX,
		IReadOnlyList<TextRange> ranges, Color color, bool outline )
	{
		if ( ranges is null || ranges.Count == 0 )
			return;

		for ( var i = 0; i < ranges.Count; i++ )
		{
			var range = ranges[i].Normalized;

			if ( range.Start.Line != line || range.IsEmpty )
				continue;

			var x0 = ColumnX( VisualAt( range.Start.Column ), gutter, charWidth, scrollX );
			var x1 = ColumnX( VisualAt( range.End.Line == line ? range.End.Column : Document().GetLineLength( line ) ), gutter, charWidth, scrollX );

			if ( x1 <= x0 || x1 < gutter || x0 > rowRect.Right )
				continue;

			var box = new Rect( x0, rowRect.Top + 1f, x1 - x0, rowRect.Height - 2f );

			var isCurrent = outline && i == _editor.CurrentSearchMatch;

			Paint.SetBrush( isCurrent ? color.WithAlpha( Math.Min( 1f, color.a * 2f ) ) : color );
			Paint.SetPen( outline ? PrismTheme.Accent.WithAlpha( isCurrent ? 0.9f : 0.35f ) : PrismTheme.BorderStrong, 1f );
			Paint.DrawRect( box, PrismTheme.RadiusChip * 0.5f );
		}
	}

	void DrawIndentGuides( Rect rowRect, int line, float gutter, float charWidth, float scrollX )
	{
		if ( !_editor.ShowIndentGuides )
			return;

		var document = Document();
		var text = document.GetLine( line );
		var indent = LeadingVisualWidth( text );

		if ( IsBlank( text, 0, text.Length ) )
		{
			var probe = line + 1;
			var limit = Math.Min( document.LineCount - 1, line + 24 );

			while ( probe <= limit && IsBlank( document.GetLine( probe ), 0, int.MaxValue ) )
				probe++;

			if ( probe <= limit )
				indent = LeadingVisualWidth( document.GetLine( probe ) );
		}

		var step = Math.Max( 1, _editor.TabSize );

		Paint.SetPen( PrismTheme.Code.IndentGuide, 1f );

		for ( var column = step; column < indent; column += step )
		{
			var x = MathF.Round( ColumnX( column, gutter, charWidth, scrollX ) ) + 0.5f;

			if ( x < gutter || x > rowRect.Right )
				continue;

			Paint.DrawLine( new Vector2( x, rowRect.Top ), new Vector2( x, rowRect.Bottom ) );
		}
	}

	/// <summary>
	/// The paint-ready form of a line, built on first sight and then reused until that line's text or its
	/// tokens are replaced.
	/// <para>
	/// This is what keeps the paint path free of garbage. Without it every repaint cut one string per
	/// coloured run — around four hundred for a sixty-row viewport — plus one tab-expanded line per row,
	/// and the caret blinks twice a second whether or not anything was typed. With it a keystroke rebuilds
	/// exactly the rows it changed and every other row is drawn from strings that already exist.
	/// </para>
	/// <para>
	/// A line containing no tab and no control character expands to itself, which is most lines in a
	/// space-indented file and every line of a <c>.slang</c> module: it keeps the string the document
	/// already holds and skips the column map entirely, because column then equals character index.
	/// </para>
	/// </summary>
	RowVisual EnsureRow( int line )
	{
		var raw = Document().GetLine( line );
		var tokens = _editor.Lexer is null ? s_noTokens : (object)_editor.Lexer.GetTokens( line );
		var tabSize = Math.Max( 1, _editor.TabSize );

		if ( _rows.TryGetValue( line, out var cached ) &&
			 ReferenceEquals( cached.Raw, raw ) && ReferenceEquals( cached.Tokens, tokens ) &&
			 cached.TabSize == tabSize )
		{
			return cached;
		}

		var visual = new RowVisual { Raw = raw, Tokens = tokens, TabSize = tabSize };

		if ( NeedsExpansion( raw ) )
		{
			_editor.BuildLineLayout( line, _expanded, _visualOfChar );
			visual.Expanded = _expanded.ToString();
			visual.VisualOfChar = _visualOfChar.ToArray();
			visual.VisualLength = visual.VisualOfChar[^1];
		}
		else
		{
			visual.Expanded = raw;
			visual.IdentityColumns = true;
			visual.VisualLength = raw.Length;
		}

		BuildRuns( visual, tokens as IReadOnlyList<Token> );

		if ( _rows.Count >= MaxCachedRows )
			_rows.Clear();

		_rows[line] = visual;
		return visual;
	}

	/// <summary>
	/// Splits a row into runs of one style and cuts each one's text, unless the row is wide enough that
	/// streaming it is cheaper — see <see cref="MaxCachedRowWidth"/>.
	/// </summary>
	void BuildRuns( RowVisual visual, IReadOnlyList<Token> tokens )
	{
		if ( visual.Expanded.Length > MaxCachedRowWidth )
		{
			visual.RunCount = -1;
			return;
		}

		SplitRuns( visual, tokens );

		var count = _runStart.Count;
		visual.RunCount = count;

		if ( count == 0 )
		{
			// A blank line is a good third of a shader, and four empty arrays each is not free.
			visual.RunStart = Array.Empty<int>();
			visual.RunEnd = Array.Empty<int>();
			visual.RunKind = Array.Empty<TokenKind>();
			visual.RunText = Array.Empty<string>();
			return;
		}

		visual.RunStart = new int[count];
		visual.RunEnd = new int[count];
		visual.RunKind = new TokenKind[count];
		visual.RunText = new string[count];

		for ( var i = 0; i < count; i++ )
		{
			var start = _runStart[i];
			var end = _runEnd[i];

			visual.RunStart[i] = start;
			visual.RunEnd[i] = end;
			visual.RunKind[i] = _runKind[i];

			// Whitespace draws nothing, so it must not cost a substring: in indented code that is a good
			// third of every row's runs.
			visual.RunText[i] = IsBlank( visual.Expanded, start, end )
				? null
				: visual.Expanded.Substring( start, end - start );
		}
	}

	/// <summary>
	/// Fills the shared run buffers with this row's runs, in visual columns, merging adjacent tokens that
	/// would be drawn identically. Allocates nothing beyond the amortised growth of the three buffers.
	/// </summary>
	void SplitRuns( RowVisual visual, IReadOnlyList<Token> tokens )
	{
		_runStart.Clear();
		_runEnd.Clear();
		_runKind.Clear();

		var length = visual.Raw.Length;

		if ( length == 0 )
			return;

		var segmentStart = 0;
		var segmentKind = TokenKind.None;
		var pointer = 0;

		var tokenCount = tokens?.Count ?? 0;
		var tokenIndex = 0;

		void Emit( int startChar, int endChar, TokenKind kind )
		{
			var startVisual = Math.Clamp( visual.VisualAt( startChar ), 0, visual.Expanded.Length );
			var endVisual = Math.Clamp( visual.VisualAt( endChar ), startVisual, visual.Expanded.Length );

			if ( endVisual <= startVisual )
				return;

			_runStart.Add( startVisual );
			_runEnd.Add( endVisual );
			_runKind.Add( kind );
		}

		while ( pointer < length )
		{
			var kind = TokenKind.None;
			var end = length;

			if ( tokenIndex < tokenCount )
			{
				var token = tokens[tokenIndex];
				var tokenStart = Math.Clamp( token.Start, 0, length );
				var tokenEnd = Math.Clamp( token.Start + token.Length, tokenStart, length );

				if ( tokenEnd <= pointer )
				{
					tokenIndex++;
					continue;
				}

				if ( tokenStart > pointer )
				{
					end = tokenStart;
				}
				else
				{
					kind = token.Kind;
					end = tokenEnd;
					tokenIndex++;
				}
			}

			if ( end <= pointer )
				end = pointer + 1;

			if ( pointer == segmentStart )
			{
				segmentKind = kind;
			}
			else if ( !SameStyle( kind, segmentKind ) )
			{
				Emit( segmentStart, pointer, segmentKind );
				segmentStart = pointer;
				segmentKind = kind;
			}

			pointer = end;
		}

		if ( pointer > segmentStart )
			Emit( segmentStart, pointer, segmentKind );
	}

	static bool NeedsExpansion( string text )
	{
		for ( var i = 0; i < text.Length; i++ )
		{
			if ( text[i] == '\t' || text[i] < ' ' )
				return true;
		}

		return false;
	}

	/// <summary>True when a half-open character range holds nothing but whitespace.</summary>
	static bool IsBlank( string text, int start, int end )
	{
		for ( var i = start; i < end && i < text.Length; i++ )
		{
			if ( !char.IsWhiteSpace( text[i] ) )
				return false;
		}

		return true;
	}

	int LeadingVisualWidth( string text )
	{
		var column = 0;
		var tab = Math.Max( 1, _editor.TabSize );

		for ( var i = 0; i < text.Length; i++ )
		{
			if ( text[i] == '\t' )
				column += tab - (column % tab);
			else if ( text[i] == ' ' )
				column++;
			else
				break;
		}

		return column;
	}

	void DrawText( Rect rowRect, float gutter, float charWidth, float scrollX, float viewWidth )
	{
		var visual = _row;

		if ( visual.Expanded.Length == 0 )
			return;

		var firstVisibleColumn = Math.Max( 0, (int)((scrollX - charWidth) / Math.Max( 1f, charWidth )) );
		var lastVisibleColumn = firstVisibleColumn + (int)(viewWidth / Math.Max( 1f, charWidth )) + 2;

		if ( visual.RunCount < 0 )
		{
			DrawWideRow( rowRect, visual, gutter, charWidth, scrollX, firstVisibleColumn, lastVisibleColumn );
			return;
		}

		for ( var i = 0; i < visual.RunCount; i++ )
		{
			var text = visual.RunText[i];

			if ( text is null )
				continue;

			var startVisual = visual.RunStart[i];

			if ( visual.RunEnd[i] < firstVisibleColumn || startVisual > lastVisibleColumn )
				continue;

			DrawRun( rowRect, text, startVisual, visual.RunEnd[i], visual.RunKind[i], gutter, charWidth, scrollX );
		}
	}

	/// <summary>
	/// The fallback for a row too wide to cache: split it on the fly and cut only the runs that are
	/// actually on screen, exactly as the renderer did before rows were cached.
	/// </summary>
	void DrawWideRow( Rect rowRect, RowVisual visual, float gutter, float charWidth, float scrollX,
		int firstVisibleColumn, int lastVisibleColumn )
	{
		SplitRuns( visual, visual.Tokens as IReadOnlyList<Token> );

		for ( var i = 0; i < _runStart.Count; i++ )
		{
			var startVisual = _runStart[i];
			var endVisual = _runEnd[i];

			if ( endVisual < firstVisibleColumn || startVisual > lastVisibleColumn )
				continue;

			if ( IsBlank( visual.Expanded, startVisual, endVisual ) )
				continue;

			DrawRun( rowRect, visual.Expanded.Substring( startVisual, endVisual - startVisual ),
				startVisual, endVisual, _runKind[i], gutter, charWidth, scrollX );
		}
	}

	static bool SameStyle( TokenKind a, TokenKind b ) =>
		ColorFor( a ) == ColorFor( b ) && BoldFor( a ) == BoldFor( b ) && ItalicFor( a ) == ItalicFor( b );

	void DrawRun( Rect rowRect, string text, int startVisual, int endVisual, TokenKind kind,
		float gutter, float charWidth, float scrollX )
	{
		var x = ColumnX( startVisual, gutter, charWidth, scrollX );
		var width = (endVisual - startVisual) * charWidth + 2f;

		SetFontFor( kind );
		Paint.SetPen( ColorFor( kind ) );
		Paint.DrawText( new Rect( x, rowRect.Top, width, rowRect.Height ), text, TextFlag.LeftCenter | TextFlag.SingleLine );
	}

	void DrawWhitespace( Rect rowRect, int line, float gutter, float charWidth, float scrollX )
	{
		var text = Document().GetLine( line );

		if ( text.Length == 0 )
			return;

		_points.Clear();

		var centre = rowRect.Top + rowRect.Height * 0.5f;

		for ( var i = 0; i < text.Length; i++ )
		{
			var c = text[i];

			if ( c != ' ' && c != '\t' )
				continue;

			var startVisual = VisualAt( i );
			var x = ColumnX( startVisual, gutter, charWidth, scrollX );

			if ( x < gutter || x > rowRect.Right )
				continue;

			if ( c == ' ' )
			{
				_points.Add( new Vector2( x + charWidth * 0.5f, centre ) );
				continue;
			}

			var endVisual = VisualAt( i + 1 );
			var x1 = ColumnX( endVisual, gutter, charWidth, scrollX );

			Paint.SetPen( PrismTheme.Code.IndentGuide, 1f );
			Paint.DrawLine( new Vector2( x + 2f, centre ), new Vector2( x1 - 3f, centre ) );
			Paint.DrawLine( new Vector2( x1 - 6f, centre - 3f ), new Vector2( x1 - 3f, centre ) );
			Paint.DrawLine( new Vector2( x1 - 6f, centre + 3f ), new Vector2( x1 - 3f, centre ) );
		}

		if ( _points.Count == 0 )
			return;

		Paint.SetPen( PrismTheme.Code.IndentGuide, 2f );
		Paint.DrawPoints( _points );
	}

	void DrawBracket( Rect rowRect, int line, float gutter, float charWidth, float scrollX, BracketMatch match )
	{
		if ( !match.Found )
			return;

		Paint.ClearBrush();
		Paint.SetPen( PrismTheme.Accent.WithAlpha( 0.7f ), 1f );

		if ( match.Open.Line == line )
			DrawCharBox( rowRect, match.Open.Column, gutter, charWidth, scrollX );

		if ( match.Close.Line == line )
			DrawCharBox( rowRect, match.Close.Column, gutter, charWidth, scrollX );
	}

	void DrawCharBox( Rect rowRect, int column, float gutter, float charWidth, float scrollX )
	{
		var startVisual = VisualAt( column );
		var endVisual = VisualAt( column + 1 );

		var x = ColumnX( startVisual, gutter, charWidth, scrollX );
		var width = Math.Max( charWidth, (endVisual - startVisual) * charWidth );

		if ( x + width < gutter || x > rowRect.Right )
			return;

		Paint.DrawRect( new Rect( x, rowRect.Top + 1f, width, rowRect.Height - 2f ), 2f );
	}

	void DrawSquiggles( Rect rowRect, int line, float gutter, float charWidth, float scrollX )
	{
		var diagnostics = _editor.Diagnostics;

		if ( diagnostics.Count == 0 )
			return;

		var length = Document().GetLineLength( line );

		for ( var i = 0; i < diagnostics.Count; i++ )
		{
			var diagnostic = diagnostics[i];
			var range = diagnostic.Range.Normalized;

			if ( line < range.Start.Line || line > range.End.Line )
				continue;

			var startColumn = line == range.Start.Line ? range.Start.Column : 0;
			var endColumn = line == range.End.Line ? range.End.Column : length;

			if ( endColumn <= startColumn )
				endColumn = Math.Min( length, startColumn + 1 );

			var x0 = ColumnX( VisualAt( startColumn ), gutter, charWidth, scrollX );
			var x1 = ColumnX( VisualAt( endColumn ), gutter, charWidth, scrollX );

			if ( x1 <= x0 )
				x1 = x0 + charWidth;

			x0 = Math.Max( x0, gutter );

			if ( x1 < gutter || x0 > rowRect.Right )
				continue;

			x1 = Math.Min( x1, rowRect.Right );

			var baseline = rowRect.Bottom - 2f;

			_points.Clear();

			for ( var x = x0; x <= x1; x += 2f )
				_points.Add( new Vector2( x, baseline + (((int)(x / 2f) % 2) == 0 ? 0f : 2f) ) );

			if ( _points.Count < 2 )
				continue;

			Paint.SetPen( SquiggleColor( diagnostic.Severity ), 1f );
			Paint.DrawLine( _points );
		}
	}

	static Color SquiggleColor( DiagnosticSeverity severity ) => severity switch
	{
		DiagnosticSeverity.Error => PrismTheme.Code.ErrorUnderline,
		DiagnosticSeverity.Warning => PrismTheme.Code.WarningUnderline,
		_ => PrismTheme.Info
	};

	void DrawFoldedMarker( Rect rowRect, int line, float gutter, float charWidth, float scrollX )
	{
		var folding = _editor.Folding;

		if ( folding is null || !folding.IsCollapsed( line ) )
			return;

		var x = ColumnX( LineVisualLength + 1, gutter, charWidth, scrollX );

		if ( x > rowRect.Right )
			return;

		var box = new Rect( Math.Max( x, gutter ), rowRect.Top + 2f, charWidth * 3f, rowRect.Height - 4f );

		Paint.SetBrushAndPen( PrismTheme.Elevated, PrismTheme.BorderStrong, 1f );
		Paint.DrawRect( box, PrismTheme.RadiusChip );

		SetFontFor( TokenKind.None );
		Paint.SetPen( PrismTheme.TextMuted );
		Paint.DrawText( box, "…", TextFlag.Center | TextFlag.SingleLine );
	}

	void DrawCarets( Rect rowRect, int line, float gutter, float charWidth, float scrollX, SelectionSet selection )
	{
		if ( !_editor.CaretVisible )
			return;

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

			if ( caret.Position.Line != line )
				continue;

			var x = ColumnX( VisualAt( caret.Position.Column ), gutter, charWidth, scrollX );

			if ( x < gutter - 1f || x > rowRect.Right )
				continue;

			var color = caret.IsPrimary ? PrismTheme.Accent : PrismTheme.Accent.WithAlpha( 0.7f );

			if ( _editor.ReadOnly )
				color = PrismTheme.TextMuted;

			Paint.ClearPen();
			Paint.SetBrush( color );

			if ( _editor.Controller is { Overwrite: true } && !caret.HasSelection )
				Paint.DrawRect( new Rect( x, rowRect.Bottom - 2f, charWidth, 2f ) );
			else
				Paint.DrawRect( new Rect( x, rowRect.Top + 1f, 2f, rowRect.Height - 2f ) );
		}
	}

	// ---- gutter -----------------------------------------------------------

	void DrawGutter( Rect rect, int firstRow, int lastRow )
	{
		var document = Document();
		var gutter = _editor.GutterWidth;
		var lineHeight = _editor.LineHeight;
		var folding = _editor.Folding;

		Paint.ClearPen();
		Paint.SetBrush( PrismTheme.Code.Gutter );
		Paint.DrawRect( new Rect( 0f, 0f, gutter, rect.Height ) );

		Paint.SetPen( PrismTheme.BorderSubtle, 1f );
		Paint.DrawLine( new Vector2( gutter - 0.5f, 0f ), new Vector2( gutter - 0.5f, rect.Height ) );

		var caretLine = _editor.CaretPosition.Line;
		var markerWidth = _editor.MarkerMarginWidth;
		var numberWidth = _editor.LineNumberWidth;
		var foldLeft = markerWidth + numberWidth;

		SetFontFor( TokenKind.None );

		for ( var row = firstRow; row <= lastRow; row++ )
		{
			var line = folding is null ? row : folding.ToDocument( row );

			if ( line < 0 || line >= document.LineCount )
				continue;

			var y = row * lineHeight - _editor.ScrollY;
			var rowRect = new Rect( 0f, y, gutter, lineHeight );

			if ( document.IsLineModified( line ) )
			{
				Paint.ClearPen();
				Paint.SetBrush( PrismTheme.Warning.WithAlpha( 0.8f ) );
				Paint.DrawRect( new Rect( 0f, y, 2f, lineHeight ) );
			}

			var severity = WorstDiagnosticOn( line );

			if ( severity.HasValue )
			{
				Paint.ClearPen();
				Paint.SetBrush( PrismTheme.ForSeverity( severity.Value ) );
				Paint.DrawCircle( new Rect( 4f, y + lineHeight * 0.5f - 3f, 6f, 6f ) );
			}

			if ( _editor.ShowLineNumbers )
			{
				var isCaret = line == caretLine;
				Paint.SetPen( isCaret ? PrismTheme.Code.LineNumberActive : PrismTheme.Code.LineNumber );

				Paint.DrawText(
					new Rect( markerWidth, y, numberWidth - 6f, lineHeight ),
					LineNumberText( line ),
					TextFlag.RightCenter | TextFlag.SingleLine );
			}

			if ( !_editor.ShowFoldMargin || folding is null || !folding.IsFoldStart( line ) )
				continue;

			var collapsed = folding.IsCollapsed( line );
			var iconRect = new Rect( foldLeft, y, _editor.FoldMarginWidth, lineHeight );

			Paint.SetPen( collapsed ? PrismTheme.Accent : PrismTheme.TextMuted );
			Paint.DrawIcon( iconRect, collapsed ? "chevron_right" : "expand_more", 14f, TextFlag.Center );
		}
	}

	/// <summary>
	/// The gutter label for a line, memoised. A line's number never changes meaning, so the cache can
	/// only ever grow; it is dropped wholesale once it outgrows a large document's worth of scrolling.
	/// Without it the gutter allocates one string per visible row on every repaint, and the caret blinks
	/// twice a second whether or not anything was typed.
	/// </summary>
	string LineNumberText( int line )
	{
		if ( _lineNumbers.TryGetValue( line, out var text ) )
			return text;

		if ( _lineNumbers.Count >= MaxCachedLineNumbers )
			_lineNumbers.Clear();

		text = ( line + 1 ).ToString();
		_lineNumbers[line] = text;

		return text;
	}

	DiagnosticSeverity? WorstDiagnosticOn( int line )
	{
		DiagnosticSeverity? worst = null;
		var diagnostics = _editor.Diagnostics;

		for ( var i = 0; i < diagnostics.Count; i++ )
		{
			var range = diagnostics[i].Range.Normalized;

			if ( line < range.Start.Line || line > range.End.Line )
				continue;

			if ( !worst.HasValue || diagnostics[i].Severity > worst.Value )
				worst = diagnostics[i].Severity;
		}

		return worst;
	}

	TextDocument Document() => _editor.Document;
}