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