Editor/HotCodeEditor/CodeView/CodeCanvas.cs
using Sandbox;
using Editor;
using System;
using System.Collections.Generic;
using System.Linq;
/// <summary>
/// The painted surface inside a <see cref="CodeView"/>. Its size is the size of the whole
/// document; the scroll area moves it around and we only draw the rows in view.
///
/// Text is laid out in visual rows: one per line, or several when word wrap splits a line.
/// </summary>
public class CodeCanvas : Widget
{
const float TextPadding = 8;
private static float FontSize => CodeView.FontSize;
/// <summary>
/// Openers that get their closer typed for them, and the characters they may be followed by.
/// </summary>
private static readonly Dictionary<char, char> AutoClosePairs = new() { ['('] = ')', ['['] = ']', ['{'] = '}', ['"'] = '"', ['\''] = '\'' };
private const string AutoCloseBefore = ";:.,=}])> \t";
public TextBuffer Buffer { get; } = new();
public UndoHistory History { get; } = new();
private readonly CodeView _owner;
private readonly ScrollArea _scroller;
private readonly SyntaxLanguage _language;
// Caret and selection. The selection runs from Anchor to Caret; no anchor means no selection.
private TextPos _caret;
private TextPos? _anchor;
private float? _preferredColumn;
// Font metrics, measured on first paint
private float _charWidth = 8;
private float _lineHeight = 18;
private bool _measured;
// Highlighting: the lexer state each line starts in, recomputed after edits
private readonly List<LexState> _lineStates = new();
private bool _statesDirty = true;
// Row layout
private readonly List<int[]> _rowStarts = new();
private int[] _lineFirstRow = { 0, 1 };
private int _longestLine;
private bool _layoutDirty = true;
private bool _wrapApplied;
private int _wrapColumns;
// Find highlights
private IReadOnlyList<SearchMatch> _matches = Array.Empty<SearchMatch>();
private int _currentMatch = -1;
// Compile errors / warnings, underlined
private IReadOnlyList<EditorMarker> _markers = Array.Empty<EditorMarker>();
// Word under the mouse while Ctrl is held, drawn like a link for Ctrl+Click
private (TextPos Start, TextPos End)? _link;
// Autocomplete: everything Roslyn suggested for the word starting at (line, column); the popup shows the filtered list
private CompletionPopup _popup;
private IReadOnlyList<CompletionItem> _completionItems = Array.Empty<CompletionItem>();
private int _completionLine = -1;
private int _completionStart;
// Parameter hints for the call the caret is in
private SignaturePopup _signaturePopup;
private SignatureHelpResult _signature;
private bool _signaturePicked;
private bool _ensureCaretVisible;
private bool _dragging;
private double _caretBlinkStart;
private Vector2 _lastScroll;
private bool _lastCaretOn;
private int _settingsVersion = HotCodeSettings.Version;
private string _indentUnit = "\t";
public CodeCanvas( CodeView owner, ScrollArea scroller, SyntaxLanguage language ) : base( scroller )
{
_owner = owner;
_scroller = scroller;
_language = language;
FocusMode = FocusMode.Click;
Cursor = CursorShape.IBeam;
MouseTracking = true;
}
public TextPos Caret => _caret;
public string IndentUnit => _indentUnit;
public string LanguageName => _language.Name;
public bool HasSelection => _anchor.HasValue && _anchor.Value != _caret;
public TextPos SelStart => HasSelection ? TextPos.Min( _anchor.Value, _caret ) : _caret;
public TextPos SelEnd => HasSelection ? TextPos.Max( _anchor.Value, _caret ) : _caret;
public string SelectedText => HasSelection ? Buffer.GetText( SelStart, SelEnd ) : "";
private bool WordWrap => CodeView.WordWrap;
private bool AutoClose => !_language.IsPlain && HotCodeSettings.AutoCloseBrackets;
public void SetText( string text )
{
Buffer.Text = text;
_indentUnit = DetectIndent();
_caret = default;
_anchor = null;
History.Clear();
OnBufferChanged( notify: false );
}
/// <summary>
/// Swaps in new text (changed on disk) while keeping the caret roughly where it was.
/// </summary>
public void ReloadText( string text )
{
var caret = _caret;
Buffer.Text = text;
_caret = Buffer.Clamp( caret );
_anchor = null;
History.Clear();
OnBufferChanged( notify: false );
}
/// <summary>
/// Tabs unless the file clearly indents with spaces.
/// </summary>
private string DetectIndent()
{
int tabs = 0, spaces = 0;
foreach ( var line in Buffer.Lines.Take( 2000 ) )
{
if ( line.StartsWith( '\t' ) ) tabs++;
else if ( line.StartsWith( " " ) ) spaces++;
}
if ( spaces == tabs ) return HotCodeSettings.IndentWithSpaces ? new string( ' ', TextLayout.TabSize ) : "\t";
return spaces > tabs ? new string( ' ', TextLayout.TabSize ) : "\t";
}
// --- LAYOUT ---
private float GutterWidth => HotCodeSettings.ShowLineNumbers ? (Math.Max( 3, Buffer.LineCount.ToString().Length ) + 2) * _charWidth : 0;
private float TextLeft => GutterWidth + TextPadding;
private Vector2 Scroll => new( _scroller.HorizontalScrollbar.Value, _scroller.VerticalScrollbar.Value );
/// <summary>
/// The visible part of the scroll area (roughly; scrollbars eat a little).
/// </summary>
private Vector2 ViewportSize => _scroller.Size - 16;
private int TotalRows => _lineFirstRow[^1];
public void InvalidateLayout()
{
_layoutDirty = true;
Update();
}
private void UpdateLayout()
{
if ( !_layoutDirty ) return;
_layoutDirty = false;
_wrapApplied = WordWrap;
_wrapColumns = _wrapApplied ? Math.Max( 20, (int)((ViewportSize.x - TextLeft - TextPadding) / _charWidth) ) : 0;
_rowStarts.Clear();
_lineFirstRow = new int[Buffer.LineCount + 1];
_longestLine = 0;
var rows = 0;
for ( int i = 0; i < Buffer.LineCount; i++ )
{
var line = Buffer.Lines[i];
_lineFirstRow[i] = rows;
var starts = TextLayout.Wrap( line, _wrapColumns );
_rowStarts.Add( starts );
rows += starts.Length;
if ( !_wrapApplied ) _longestLine = Math.Max( _longestLine, TextLayout.Width( line ) );
}
_lineFirstRow[Buffer.LineCount] = rows;
// Extra space at the bottom so the last line can be scrolled up the screen
var width = _wrapApplied ? _scroller.Width - 2 : MathF.Max( TextLeft + (_longestLine + 4) * _charWidth, _scroller.Width - 2 );
var height = MathF.Max( rows * _lineHeight + MathF.Max( 0, _scroller.Height - _lineHeight * 3 ), _scroller.Height - 2 );
MinimumSize = new Vector2( width, height );
MaximumSize = _wrapApplied ? new Vector2( width, 1_000_000 ) : new Vector2( 1_000_000, 1_000_000 );
Size = new Vector2( width, height );
}
private int RowEnd( int line, int row )
{
var starts = _rowStarts[line];
return row + 1 < starts.Length ? starts[row + 1] : Buffer.Lines[line].Length;
}
/// <summary>
/// Which row of its line a position is on. A position exactly at a wrap point is on the next row.
/// </summary>
private int RowInLine( TextPos p )
{
var starts = _rowStarts[p.Line];
var row = 0;
while ( row + 1 < starts.Length && starts[row + 1] <= p.Col ) row++;
return row;
}
private int LineOfRow( int row )
{
var i = Array.BinarySearch( _lineFirstRow, 0, Buffer.LineCount, row );
if ( i < 0 ) i = ~i - 1;
// Several lines can't share a first row, but guard against empty layouts
return Math.Clamp( i, 0, Buffer.LineCount - 1 );
}
/// <summary>
/// Position in a row nearest to a column. On wrapped rows (not the last) the end is the
/// character before the break, so the caret doesn't jump to the next row.
/// </summary>
private TextPos PosInRow( int globalRow, float column )
{
globalRow = Math.Clamp( globalRow, 0, TotalRows - 1 );
var line = LineOfRow( globalRow );
var row = globalRow - _lineFirstRow[line];
var text = Buffer.Lines[line];
var start = _rowStarts[line][row];
var end = RowEnd( line, row );
var isLastRow = row == _rowStarts[line].Length - 1;
var index = TextLayout.IndexAtColumn( text, start, end, column );
if ( !isLastRow && index >= end && end > start ) index = end - 1;
return new TextPos( line, index );
}
private TextPos PosAt( Vector2 local ) => PosInRow( (int)MathF.Floor( local.y / _lineHeight ), (local.x - TextLeft) / _charWidth );
private int GlobalRow( TextPos p ) => _lineFirstRow[p.Line] + RowInLine( p );
private float ColumnInRow( TextPos p ) => TextLayout.ColumnOf( Buffer.Lines[p.Line], _rowStarts[p.Line][RowInLine( p )], p.Col );
private Rect CaretRect( TextPos p ) => new( TextLeft + ColumnInRow( p ) * _charWidth, GlobalRow( p ) * _lineHeight, 2, _lineHeight );
// --- SELECTION / CARET ---
private void MoveCaret( TextPos to, bool extendSelection, bool keepPreferredColumn = false )
{
if ( extendSelection ) _anchor ??= _caret;
else _anchor = null;
_caret = Buffer.Clamp( to );
if ( !keepPreferredColumn ) _preferredColumn = null;
History.Break();
CaretMoved();
}
private void CaretMoved()
{
_caretBlinkStart = RealTime.Now;
_ensureCaretVisible = true;
Update();
}
public void Select( TextPos start, TextPos end )
{
_anchor = Buffer.Clamp( start );
_caret = Buffer.Clamp( end );
_preferredColumn = null;
History.Break();
CaretMoved();
}
public void SelectAll() => Select( new TextPos( 0, 0 ), Buffer.End );
public void SetMarkers( IReadOnlyList<EditorMarker> markers )
{
_markers = markers ?? Array.Empty<EditorMarker>();
Update();
}
/// <summary>
/// Puts the caret at a position and scrolls it into view.
/// </summary>
public void GoTo( TextPos pos )
{
_anchor = null;
_caret = Buffer.Clamp( pos );
_preferredColumn = null;
History.Break();
CaretMoved();
CenterOnCaret();
}
public void SetFindHighlights( IReadOnlyList<SearchMatch> matches, int current )
{
_matches = matches ?? Array.Empty<SearchMatch>();
_currentMatch = current;
Update();
}
// --- EDITING ---
/// <summary>
/// Replaces the range a..b with text, records undo, and puts the caret after the new text
/// (or at <paramref name="caretAfter"/>).
/// </summary>
public void Replace( TextPos a, TextPos b, string text, EditKind kind = EditKind.Other, TextPos? caretAfter = null )
{
var start = Buffer.Clamp( TextPos.Min( a, b ) );
var end = Buffer.Clamp( TextPos.Max( a, b ) );
text = TextBuffer.Normalize( text );
var removed = Buffer.GetText( start, end );
if ( removed.Length == 0 && text.Length == 0 ) return;
var caretBefore = _caret;
var anchorBefore = _anchor;
Buffer.Delete( start, end );
var newEnd = Buffer.Insert( start, text );
_caret = Buffer.Clamp( caretAfter ?? newEnd );
_anchor = null;
_preferredColumn = null;
History.Record( new BufferEdit( start, removed, text ), kind, caretBefore, anchorBefore, _caret, RealTime.Now );
OnBufferChanged( notify: true );
}
public void BeginTransaction() => History.BeginTransaction( _caret, _anchor, RealTime.Now );
public void EndTransaction() => History.EndTransaction( _caret, _anchor );
public void Undo()
{
CloseCompletions();
CloseSignatureHelp();
var group = History.Undo( Buffer );
if ( group is null ) return;
_caret = Buffer.Clamp( group.CaretBefore );
_anchor = group.AnchorBefore.HasValue ? Buffer.Clamp( group.AnchorBefore.Value ) : null;
OnBufferChanged( notify: true );
}
public void Redo()
{
CloseCompletions();
CloseSignatureHelp();
var group = History.Redo( Buffer );
if ( group is null ) return;
_caret = Buffer.Clamp( group.CaretAfter );
_anchor = group.AnchorAfter.HasValue ? Buffer.Clamp( group.AnchorAfter.Value ) : null;
OnBufferChanged( notify: true );
}
private void OnBufferChanged( bool notify )
{
_statesDirty = true;
_layoutDirty = true;
UpdateLayout();
CaretMoved();
if ( notify ) _owner.NotifyTextChanged();
}
private char CharAt( TextPos p ) => p.Col < Buffer.Lines[p.Line].Length ? Buffer.Lines[p.Line][p.Col] : '\0';
private char CharBefore( TextPos p ) => p.Col > 0 ? Buffer.Lines[p.Line][p.Col - 1] : '\0';
private void TypeText( string text )
{
if ( text.Length == 1 && AutoClose && TryAutoClose( text[0] ) )
return;
// Typing "}" on a blank line drops one indent level to line up with its "{"
if ( text == "}" && !HasSelection )
{
var before = Buffer.Lines[_caret.Line][.._caret.Col];
if ( before.Length > 0 && string.IsNullOrWhiteSpace( before ) )
{
Replace( new TextPos( _caret.Line, 0 ), _caret, Unindent( before ) + "}", EditKind.Typing );
return;
}
}
Replace( SelStart, SelEnd, text, EditKind.Typing );
}
/// <summary>
/// Brackets and quotes: wrap a selection, type over a closer that's already there,
/// or insert the closer along with the opener. Returns false to type normally.
/// </summary>
private bool TryAutoClose( char c )
{
var isQuote = c is '"' or '\'';
if ( HasSelection )
{
if ( !AutoClosePairs.TryGetValue( c, out var closer ) ) return false;
// Wrap the selection and keep it selected inside the new pair
var start = SelStart;
var inner = SelectedText;
Replace( start, SelEnd, c + inner + closer );
var innerStart = start with { Col = start.Col + 1 };
Select( innerStart, TextBuffer.EndOf( innerStart, inner ) );
return true;
}
var next = CharAt( _caret );
var prev = CharBefore( _caret );
// Type over a closer instead of doubling it: (|) -> ()|
if ( next == c && (c is ')' or ']' or '}' || isQuote) )
{
if ( !isQuote || IsInString( _caret ) )
{
_caret = _caret with { Col = _caret.Col + 1 };
_preferredColumn = null;
CaretMoved();
return true;
}
}
if ( !AutoClosePairs.TryGetValue( c, out var close ) ) return false;
if ( next != '\0' && !AutoCloseBefore.Contains( next ) ) return false;
if ( IsInCommentOrString( _caret ) ) return false;
// Don't pair quotes after a word (it's probably an apostrophe or closing an existing string)
if ( isQuote && (char.IsLetterOrDigit( prev ) || prev == '_' || prev == c) ) return false;
if ( c == '\'' && !_language.CSharpStrings && !_language.SingleQuoteStrings ) return false;
Replace( _caret, _caret, $"{c}{close}", EditKind.Typing, _caret with { Col = _caret.Col + 1 } );
return true;
}
/// <summary>
/// The highlighter's token around a position, if any.
/// </summary>
private Token? TokenAt( TextPos p )
{
UpdateLineStates();
var tokens = new List<Token>();
var line = Buffer.Lines[p.Line];
_language.Tokenize( line, _lineStates[p.Line], tokens );
foreach ( var t in tokens )
{
var end = t.Start + t.Length;
if ( p.Col > t.Start && p.Col < end ) return t;
// At the very end of a comment, or of a string that hasn't been closed yet
if ( p.Col == end && end == line.Length && t.Kind == TokenKind.Comment ) return t;
if ( p.Col == end && t.Kind == TokenKind.String && !(t.Length >= 2 && line[end - 1] is '"' or '\'') ) return t;
}
return null;
}
private bool IsInString( TextPos p ) => TokenAt( p ) is { Kind: TokenKind.String };
private bool IsInCommentOrString( TextPos p ) => TokenAt( p ) is { Kind: TokenKind.String or TokenKind.Comment };
private void NewLine()
{
var line = Buffer.Lines[SelStart.Line];
var indent = new string( line.TakeWhile( c => c == ' ' || c == '\t' ).ToArray() );
if ( indent.Length > SelStart.Col ) indent = indent[..SelStart.Col];
var before = line[..SelStart.Col].TrimEnd();
var after = Buffer.Lines[SelEnd.Line][SelEnd.Col..].TrimStart();
if ( before.EndsWith( '{' ) || before.EndsWith( '[' ) || before.EndsWith( '(' ) )
{
if ( after.StartsWith( '}' ) || after.StartsWith( ']' ) || after.StartsWith( ')' ) )
{
// {|} -> {\n\t|\n}
var caret = new TextPos( SelStart.Line + 1, indent.Length + _indentUnit.Length );
Replace( SelStart, SelEnd, "\n" + indent + _indentUnit + "\n" + indent, EditKind.Other, caret );
return;
}
if ( before.EndsWith( '{' ) ) indent += _indentUnit;
}
Replace( SelStart, SelEnd, "\n" + indent );
}
private string Unindent( string whitespace )
{
if ( whitespace.EndsWith( '\t' ) ) return whitespace[..^1];
var spaces = Math.Min( whitespace.Length - whitespace.TrimEnd( ' ' ).Length, TextLayout.TabSize );
return whitespace[..^spaces];
}
/// <summary>
/// Tab / Shift+Tab. Indents whole lines when the selection spans lines, otherwise inserts an indent.
/// </summary>
private void Indent( bool outdent )
{
var multiLine = HasSelection && SelStart.Line != SelEnd.Line;
if ( !multiLine && !outdent )
{
Replace( SelStart, SelEnd, _indentUnit );
return;
}
var first = SelStart.Line;
var last = SelEnd.Line;
if ( multiLine && SelEnd.Col == 0 ) last--;
var lines = new List<string>();
for ( int i = first; i <= last; i++ )
{
var line = Buffer.Lines[i];
if ( !outdent )
{
lines.Add( line.Length > 0 ? _indentUnit + line : line );
continue;
}
var ws = new string( line.TakeWhile( c => c == ' ' || c == '\t' ).ToArray() );
lines.Add( ws.Length > 0 ? Unindent( ws ) + line[ws.Length..] : line );
}
var start = new TextPos( first, 0 );
var end = new TextPos( last, Buffer.Lines[last].Length );
var text = string.Join( "\n", lines );
if ( text == Buffer.GetText( start, end ) ) return;
Replace( start, end, text );
if ( multiLine )
Select( start, new TextPos( last, Buffer.Lines[last].Length ) );
}
private void Backspace( bool word )
{
if ( HasSelection ) { Replace( SelStart, SelEnd, "", EditKind.Deleting ); return; }
if ( _caret == default ) return;
// Deleting the opener of an empty pair deletes both: (|)
if ( !word && AutoClose && AutoClosePairs.TryGetValue( CharBefore( _caret ), out var closer ) && CharAt( _caret ) == closer )
{
Replace( _caret with { Col = _caret.Col - 1 }, _caret with { Col = _caret.Col + 1 }, "", EditKind.Deleting );
return;
}
var from = word ? WordLeft( _caret ) : Left( _caret );
// In leading spaces, delete back to the previous indent stop
if ( !word && _indentUnit != "\t" )
{
var before = Buffer.Lines[_caret.Line][.._caret.Col];
if ( before.Length > 0 && before.Trim().Length == 0 && !before.Contains( '\t' ) )
from = new TextPos( _caret.Line, (before.Length - 1) / TextLayout.TabSize * TextLayout.TabSize );
}
Replace( from, _caret, "", EditKind.Deleting );
}
private void DeleteForward( bool word )
{
if ( HasSelection ) { Replace( SelStart, SelEnd, "", EditKind.Deleting ); return; }
Replace( _caret, word ? WordRight( _caret ) : Right( _caret ), "", EditKind.Deleting );
}
/// <summary>
/// Trims trailing whitespace and/or adds a final newline as one undo step, leaving the caret and selection put.
/// </summary>
public void FormatForSave( bool trim, bool finalNewline )
{
var caret = _caret;
var anchor = _anchor;
BeginTransaction();
if ( trim )
{
for ( int i = Buffer.LineCount - 1; i >= 0; i-- )
{
var line = Buffer.Lines[i];
var trailing = line.Length - line.TrimEnd( ' ', '\t' ).Length;
if ( trailing > 0 ) Replace( new TextPos( i, line.Length - trailing ), new TextPos( i, line.Length ), "" );
}
}
if ( finalNewline && Buffer.Lines[^1].Length > 0 )
Replace( Buffer.End, Buffer.End, "\n" );
EndTransaction();
_caret = Buffer.Clamp( caret );
_anchor = anchor.HasValue ? Buffer.Clamp( anchor.Value ) : null;
Update();
}
// --- LINE COMMANDS ---
/// <summary>
/// The lines the selection touches (a selection ending at column 0 doesn't include that line).
/// </summary>
private (int First, int Last) SelectedLines()
{
var first = SelStart.Line;
var last = SelEnd.Line;
if ( HasSelection && last > first && SelEnd.Col == 0 ) last--;
return (first, last);
}
/// <summary>
/// Ctrl+/: comments the selected lines out with the language's line comment, or back in
/// if they're all commented already. Comments go at the shallowest indent so they line up.
/// </summary>
public void ToggleComment()
{
var prefix = _language.LineComment;
if ( string.IsNullOrEmpty( prefix ) ) return;
var (first, last) = SelectedLines();
var lines = Buffer.Lines.GetRange( first, last - first + 1 );
var nonBlank = lines.Where( l => l.Trim().Length > 0 ).ToList();
if ( nonBlank.Count == 0 ) return;
var allCommented = nonBlank.All( l => l.TrimStart().StartsWith( prefix ) );
var indent = nonBlank.Min( l => l.Length - l.TrimStart().Length );
var changed = lines.Select( l =>
{
if ( l.Trim().Length == 0 ) return l;
if ( !allCommented ) return l[..indent] + prefix + " " + l[indent..];
var at = l.IndexOf( prefix, StringComparison.Ordinal );
var after = at + prefix.Length;
if ( after < l.Length && l[after] == ' ' ) after++;
return l[..at] + l[after..];
} );
ReplaceLines( first, last, changed.ToList(), first, last );
}
/// <summary>
/// Ctrl+D: copies the selected lines (or the caret's line) below themselves.
/// </summary>
public void DuplicateLines()
{
var (first, last) = SelectedLines();
var block = Buffer.Lines.GetRange( first, last - first + 1 );
var count = block.Count;
ReplaceLines( first, last, block.Concat( block ).ToList(), first + count, last + count );
}
/// <summary>
/// Alt+Up / Alt+Down: moves the selected lines past the line above or below.
/// </summary>
public void MoveLines( int direction )
{
var (first, last) = SelectedLines();
if ( direction < 0 && first == 0 ) return;
if ( direction > 0 && last >= Buffer.LineCount - 1 ) return;
var block = Buffer.Lines.GetRange( first, last - first + 1 );
if ( direction < 0 )
{
var above = Buffer.Lines[first - 1];
ReplaceLines( first - 1, last, block.Append( above ).ToList(), first - 1, last - 1 );
}
else
{
var below = Buffer.Lines[last + 1];
ReplaceLines( first, last + 1, block.Prepend( below ).ToList(), first + 1, last + 1 );
}
}
/// <summary>
/// Replaces lines first..last with new ones as one undo step, then selects lines
/// selectFirst..selectLast (or puts the caret on that line when nothing was selected).
/// </summary>
private void ReplaceLines( int first, int last, List<string> lines, int selectFirst, int selectLast )
{
var hadSelection = HasSelection;
var caretColumn = _caret.Col;
var caretOffset = _caret.Line - SelectedLines().First;
var start = new TextPos( first, 0 );
var end = new TextPos( last, Buffer.Lines[last].Length );
var text = string.Join( "\n", lines );
if ( text == Buffer.GetText( start, end ) ) return;
Replace( start, end, text );
if ( hadSelection )
{
Select( new TextPos( selectFirst, 0 ), new TextPos( selectLast, Buffer.Lines[selectLast].Length ) );
}
else
{
var line = Math.Clamp( selectFirst + caretOffset, 0, Buffer.LineCount - 1 );
_caret = Buffer.Clamp( new TextPos( line, caretColumn ) );
CaretMoved();
}
}
public void Copy()
{
if ( HasSelection ) EditorUtility.Clipboard.Copy( SelectedText.Replace( "\n", Buffer.LineEnding ) );
}
public void Cut()
{
if ( !HasSelection ) return;
Copy();
Replace( SelStart, SelEnd, "" );
}
public void Paste()
{
var text = EditorUtility.Clipboard.Paste();
if ( !string.IsNullOrEmpty( text ) ) Replace( SelStart, SelEnd, text );
}
// --- CARET MOVEMENT ---
private TextPos Left( TextPos p ) => p.Col > 0 ? p with { Col = p.Col - 1 } : p.Line > 0 ? new TextPos( p.Line - 1, Buffer.Lines[p.Line - 1].Length ) : p;
private TextPos Right( TextPos p ) => p.Col < Buffer.Lines[p.Line].Length ? p with { Col = p.Col + 1 } : p.Line < Buffer.LineCount - 1 ? new TextPos( p.Line + 1, 0 ) : p;
private static int CharClass( char c ) => char.IsWhiteSpace( c ) ? 0 : char.IsLetterOrDigit( c ) || c == '_' ? 1 : 2;
private TextPos WordLeft( TextPos p )
{
if ( p.Col == 0 ) return Left( p );
var line = Buffer.Lines[p.Line];
var i = p.Col;
while ( i > 0 && CharClass( line[i - 1] ) == 0 ) i--;
if ( i > 0 )
{
var cls = CharClass( line[i - 1] );
while ( i > 0 && CharClass( line[i - 1] ) == cls ) i--;
}
return p with { Col = i };
}
private TextPos WordRight( TextPos p )
{
var line = Buffer.Lines[p.Line];
if ( p.Col >= line.Length ) return Right( p );
var i = p.Col;
var cls = CharClass( line[i] );
while ( i < line.Length && CharClass( line[i] ) == cls ) i++;
while ( i < line.Length && CharClass( line[i] ) == 0 ) i++;
return p with { Col = i };
}
private (TextPos Start, TextPos End) WordAt( TextPos p )
{
var line = Buffer.Lines[p.Line];
if ( line.Length == 0 ) return (p, p);
var i = Math.Min( p.Col, line.Length - 1 );
var cls = CharClass( line[i] );
var start = i;
var end = i;
while ( start > 0 && CharClass( line[start - 1] ) == cls ) start--;
while ( end < line.Length && CharClass( line[end] ) == cls ) end++;
return (p with { Col = start }, p with { Col = end });
}
/// <summary>
/// Home toggles between the first non-whitespace character and column 0.
/// </summary>
private TextPos SmartHome( TextPos p )
{
var line = Buffer.Lines[p.Line];
var firstText = line.Length - line.TrimStart().Length;
return p with { Col = p.Col == firstText ? 0 : firstText };
}
/// <summary>
/// Up/down by visual rows, keeping the column the caret started in.
/// </summary>
private void MoveVertical( int rows, bool extend )
{
_preferredColumn ??= ColumnInRow( _caret );
var current = GlobalRow( _caret );
var target = current + rows;
TextPos pos;
if ( target < 0 ) pos = new TextPos( 0, 0 );
else if ( target >= TotalRows ) pos = Buffer.End;
else pos = PosInRow( target, _preferredColumn.Value );
MoveCaret( pos, extend, keepPreferredColumn: true );
}
// --- INPUT ---
protected override void OnKeyPress( KeyEvent e )
{
if ( CompletionOpen && HandleCompletionKey( e ) )
{
e.Accepted = true;
return;
}
if ( SignatureOpen && HandleSignatureKey( e ) )
{
e.Accepted = true;
return;
}
var before = (_caret, Buffer.LineCount, Buffer.Lines[_caret.Line].Length);
HandleKey( e );
// Typing, deleting or moving the caret keeps the popup's list in step with the word
if ( CompletionOpen ) RefilterCompletions();
// ...and the hints in step with the argument (or gone, once the caret leaves the brackets)
if ( SignatureOpen && before != (_caret, Buffer.LineCount, Buffer.Lines[_caret.Line].Length) )
_owner.RequestSignatureHelp( _caret );
}
private void HandleKey( KeyEvent e )
{
var ctrl = e.HasCtrl;
var shift = e.HasShift;
var pageRows = Math.Max( 1, (int)(ViewportSize.y / _lineHeight) - 1 );
switch ( e.Key )
{
case KeyCode.Space when ctrl && shift: _owner.RequestSignatureHelp( _caret ); break;
case KeyCode.Space when ctrl: _owner.RequestCompletion( _caret ); break;
case KeyCode.Up when e.HasAlt && !ctrl: MoveLines( -1 ); break;
case KeyCode.Down when e.HasAlt && !ctrl: MoveLines( 1 ); break;
case KeyCode.Slash when ctrl: ToggleComment(); break;
case KeyCode.D when ctrl && !shift: DuplicateLines(); break;
case KeyCode.Equal or KeyCode.Plus when ctrl: CodeView.FontSize += 1; break;
case KeyCode.Minus when ctrl: CodeView.FontSize -= 1; break;
case KeyCode.Left when e.HasAlt && !ctrl: _owner.RequestNavigate( back: true ); break;
case KeyCode.Right when e.HasAlt && !ctrl: _owner.RequestNavigate( back: false ); break;
case KeyCode.F12: _owner.RequestDefinition( _caret ); break;
case KeyCode.Left: MoveCaret( !shift && HasSelection && !ctrl ? SelStart : ctrl ? WordLeft( _caret ) : Left( _caret ), shift ); break;
case KeyCode.Right: MoveCaret( !shift && HasSelection && !ctrl ? SelEnd : ctrl ? WordRight( _caret ) : Right( _caret ), shift ); break;
case KeyCode.Up: MoveVertical( -1, shift ); break;
case KeyCode.Down: MoveVertical( 1, shift ); break;
case KeyCode.PageUp: MoveVertical( -pageRows, shift ); break;
case KeyCode.PageDown: MoveVertical( pageRows, shift ); break;
case KeyCode.Home: MoveCaret( ctrl ? new TextPos( 0, 0 ) : SmartHome( _caret ), shift ); break;
case KeyCode.End: MoveCaret( ctrl ? Buffer.End : _caret with { Col = Buffer.Lines[_caret.Line].Length }, shift ); break;
case KeyCode.Backspace: Backspace( ctrl ); break;
case KeyCode.Delete: DeleteForward( ctrl ); break;
case KeyCode.Return:
case KeyCode.Enter: NewLine(); break;
case KeyCode.Tab: Indent( outdent: shift ); break;
case KeyCode.Backtab: Indent( outdent: true ); break;
case KeyCode.Escape when _owner.IsFindOpen: _owner.CloseFind(); break;
case KeyCode.Escape when HasSelection: _anchor = null; Update(); break;
case KeyCode.A when ctrl: SelectAll(); break;
case KeyCode.C when ctrl: Copy(); break;
case KeyCode.X when ctrl: Cut(); break;
case KeyCode.V when ctrl: Paste(); break;
case KeyCode.Z when ctrl && shift: Redo(); break;
case KeyCode.Z when ctrl: Undo(); break;
case KeyCode.Y when ctrl: Redo(); break;
case KeyCode.F when ctrl: _owner.OpenFind( replace: false ); break;
case KeyCode.H when ctrl: _owner.OpenFind( replace: true ); break;
case KeyCode.F3: _owner.FindNext( backwards: shift ); break;
case KeyCode.Z when e.HasAlt: CodeView.WordWrap = !CodeView.WordWrap; break;
default:
// Ctrl+Alt is AltGr on Windows keyboards, which types characters like { and }
var text = e.Text;
var typesCharacter = !string.IsNullOrEmpty( text ) && !char.IsControl( text[0] ) && (!ctrl || e.HasAlt);
if ( !typesCharacter )
{
base.OnKeyPress( e );
return;
}
TypeText( text );
MaybeTriggerCompletion( text );
if ( text is "(" or "," && !SignatureOpen && HotCodeSettings.ParameterHints ) _owner.RequestSignatureHelp( _caret );
break;
}
e.Accepted = true;
}
// --- PARAMETER HINTS ---
private bool SignatureOpen => _signaturePopup is not null && _signaturePopup.Visible;
/// <summary>
/// Hints arrived for the caret position; null means the caret isn't in a call any more.
/// </summary>
public void ShowSignatureHelp( SignatureHelpResult result )
{
if ( result is null || result.Signatures.Count == 0 )
{
CloseSignatureHelp();
return;
}
// Same call as before: keep the overload the user picked with Up/Down
var sameCall = _signature is not null && _signature.OpenParenLine == result.OpenParenLine
&& _signature.OpenParenColumn == result.OpenParenColumn && _signature.Signatures.Count == result.Signatures.Count;
if ( !sameCall ) _signaturePicked = false;
var index = _signaturePicked ? _signaturePopup.Index : result.ActiveSignature;
_signature = result;
_signaturePopup ??= new SignaturePopup( this );
_signaturePopup.SetResult( result, index );
PositionSignature();
_signaturePopup.Visible = true;
_signaturePopup.Raise();
}
public void CloseSignatureHelp()
{
if ( _signaturePopup is not null ) _signaturePopup.Visible = false;
_signature = null;
_signaturePicked = false;
}
/// <summary>
/// Above the caret's row so it doesn't cover what's being typed (or below if there's no room).
/// </summary>
private void PositionSignature()
{
var anchor = CaretRect( new TextPos( _signature.OpenParenLine, _signature.OpenParenColumn ) );
var row = CaretRect( _caret );
var scroll = Scroll;
var viewport = ViewportSize;
var x = MathF.Max( scroll.x, MathF.Min( anchor.Left - _charWidth * 4, scroll.x + viewport.x - _signaturePopup.Width ) );
var y = row.Top - _signaturePopup.Height - 2;
if ( y < scroll.y ) y = row.Bottom + 2 + (CompletionOpen ? _popup.Height + 2 : 0);
_signaturePopup.Position = new Vector2( x, y );
}
private bool HandleSignatureKey( KeyEvent e )
{
switch ( e.Key )
{
case KeyCode.Up when _signaturePopup.Count > 1 && !e.HasShift && !e.HasCtrl && !e.HasAlt:
_signaturePopup.Cycle( -1 );
_signaturePicked = true;
return true;
case KeyCode.Down when _signaturePopup.Count > 1 && !e.HasShift && !e.HasCtrl && !e.HasAlt:
_signaturePopup.Cycle( 1 );
_signaturePicked = true;
return true;
case KeyCode.Escape:
CloseSignatureHelp();
return true;
}
return false;
}
// --- AUTOCOMPLETE ---
private bool CompletionOpen => _popup is not null && _popup.Visible;
/// <summary>
/// Asks for suggestions after "." or when a new word starts. While the popup is open,
/// further typing just filters what's already there.
/// </summary>
private void MaybeTriggerCompletion( string typed )
{
if ( CompletionOpen || typed.Length != 1 || !HotCodeSettings.SuggestWhileTyping ) return;
var c = typed[0];
var wordStarts = (char.IsLetter( c ) || c == '_') && CharClass( CharBefore( _caret with { Col = _caret.Col - 1 } ) ) != 1;
if ( c == '.' || wordStarts )
_owner.RequestCompletion( _caret );
}
/// <summary>
/// Suggestions arrived. They're dropped if the caret has since left the word they're for.
/// </summary>
public void ShowCompletions( CompletionResult result )
{
if ( result.Line != _caret.Line || _caret.Col < result.StartColumn || result.Items.Count == 0 )
return;
_completionItems = result.Items;
_completionLine = result.Line;
_completionStart = result.StartColumn;
RefilterCompletions();
}
private void RefilterCompletions()
{
var line = Buffer.Lines[_caret.Line];
if ( _caret.Line != _completionLine || _caret.Col < _completionStart || _completionStart > line.Length )
{
CloseCompletions();
return;
}
var typed = line[_completionStart.._caret.Col];
if ( typed.Any( ch => CharClass( ch ) != 1 ) )
{
CloseCompletions();
return;
}
var matches = CompletionMatcher.Filter( _completionItems, typed );
// Nothing left, or the only match is exactly what's typed already
if ( matches.Count == 0 || (matches.Count == 1 && matches[0].Name == typed) )
{
CloseCompletions();
return;
}
if ( _popup is null )
{
_popup = new CompletionPopup( this );
_popup.Committed = CommitCompletion;
}
_popup.SetItems( matches.Count > 500 ? matches.GetRange( 0, 500 ) : matches, _lineHeight );
PositionPopup();
_popup.Visible = true;
_popup.Raise();
}
/// <summary>
/// Under the word, or above it when there isn't room below.
/// </summary>
private void PositionPopup()
{
var anchor = CaretRect( new TextPos( _completionLine, _completionStart ) );
var scroll = Scroll;
var viewport = ViewportSize;
var x = MathF.Min( anchor.Left, scroll.x + viewport.x - _popup.Width );
var y = anchor.Bottom + 2;
if ( y + _popup.Height > scroll.y + viewport.y && anchor.Top - _popup.Height - 2 >= scroll.y )
y = anchor.Top - _popup.Height - 2;
_popup.Position = new Vector2( MathF.Max( scroll.x, x ), y );
}
public void CloseCompletions()
{
if ( _popup is not null ) _popup.Visible = false;
_completionLine = -1;
}
private void CommitCompletion( CompletionItem item )
{
if ( item is null ) return;
var start = new TextPos( _completionLine, _completionStart );
CloseCompletions();
Replace( start, _caret, item.Name );
Focus( false );
}
/// <summary>
/// Keys the popup takes while it's open. Returns false to let the editor handle the key as usual.
/// </summary>
private bool HandleCompletionKey( KeyEvent e )
{
switch ( e.Key )
{
case KeyCode.Up: _popup.Move( -1 ); return true;
case KeyCode.Down: _popup.Move( 1 ); return true;
case KeyCode.PageUp: _popup.Move( -CompletionPopup.PageSize ); return true;
case KeyCode.PageDown: _popup.Move( CompletionPopup.PageSize ); return true;
case KeyCode.Return or KeyCode.Enter or KeyCode.Tab when !e.HasShift && !e.HasCtrl:
CommitCompletion( _popup.Selected );
return true;
case KeyCode.Escape:
CloseCompletions();
return true;
}
return false;
}
protected override void OnMousePress( MouseEvent e )
{
base.OnMousePress( e );
CloseCompletions();
CloseSignatureHelp();
if ( !e.LeftMouseButton ) return;
// Ctrl+Click: go to definition
if ( e.HasCtrl && WordUnder( e.LocalPosition ) is { } word )
{
Focus( false );
MoveCaret( word.Start, extendSelection: false );
SetLink( null );
_owner.RequestDefinition( word.Start );
e.Accepted = true;
return;
}
Focus( false );
_dragging = true;
MoveCaret( PosAt( e.LocalPosition ), e.HasShift );
_anchor ??= _caret;
e.Accepted = true;
}
protected override void OnMouseMove( MouseEvent e )
{
base.OnMouseMove( e );
if ( !_dragging )
{
UpdateMarkerTooltip( e.LocalPosition );
SetLink( e.HasCtrl ? WordUnder( e.LocalPosition ) : null );
return;
}
_anchor ??= _caret;
_caret = PosAt( e.LocalPosition );
CaretMoved();
}
/// <summary>
/// The identifier under a point, if the point is over text.
/// </summary>
private (TextPos Start, TextPos End)? WordUnder( Vector2 local )
{
var row = (int)MathF.Floor( local.y / _lineHeight );
if ( row < 0 || row >= TotalRows || local.x < TextLeft ) return null;
var pos = PosAt( local );
var line = Buffer.Lines[pos.Line];
var col = Math.Min( pos.Col, line.Length - 1 );
if ( col < 0 || CharClass( line[col] ) != 1 ) return null;
var (start, end) = WordAt( pos with { Col = col } );
return end > start ? (start, end) : null;
}
private void SetLink( (TextPos Start, TextPos End)? link )
{
if ( _link == link ) return;
_link = link;
Cursor = link is null ? CursorShape.IBeam : CursorShape.Finger;
Update();
}
protected override void OnMouseWheel( WheelEvent e )
{
if ( e.HasCtrl )
{
CodeView.FontSize += e.Delta > 0 ? 1 : -1;
e.Accept();
return;
}
base.OnMouseWheel( e );
}
protected override void OnKeyRelease( KeyEvent e )
{
base.OnKeyRelease( e );
if ( e.Key == KeyCode.Control ) SetLink( null );
}
/// <summary>
/// Hovering an underlined error shows its message.
/// </summary>
private void UpdateMarkerTooltip( Vector2 local )
{
string tip = null;
if ( _markers.Count > 0 )
{
var row = (int)MathF.Floor( local.y / _lineHeight );
if ( row >= 0 && row < TotalRows )
{
var pos = PosAt( local );
var marker = _markers.FirstOrDefault( m => pos >= m.Start && pos <= m.End );
if ( marker is not null ) tip = marker.Message;
}
}
if ( ToolTip != tip ) ToolTip = tip;
}
protected override void OnMouseReleased( MouseEvent e )
{
base.OnMouseReleased( e );
_dragging = false;
if ( _anchor == _caret ) _anchor = null;
}
protected override void OnDoubleClick( MouseEvent e )
{
base.OnDoubleClick( e );
if ( !e.LeftMouseButton ) return;
var (start, end) = WordAt( PosAt( e.LocalPosition ) );
_dragging = false;
Select( start, end );
}
protected override void OnContextMenu( ContextMenuEvent e )
{
var menu = new ContextMenu( this );
var clicked = PosAt( FromScreen( Editor.Application.CursorPosition ) );
menu.AddOption( "Go to Definition (F12)", "start", () => _owner.RequestDefinition( HasSelection ? SelStart : clicked ) );
menu.AddSeparator();
menu.AddOption( "Cut", "content_cut", Cut );
menu.AddOption( "Copy", "content_copy", Copy );
menu.AddOption( "Paste", "content_paste", Paste );
menu.AddSeparator();
menu.AddOption( "Undo", "undo", Undo );
menu.AddOption( "Redo", "redo", Redo );
menu.AddSeparator();
menu.AddOption( "Find...", "search", () => _owner.OpenFind( replace: false ) );
menu.AddOption( "Replace...", "find_replace", () => _owner.OpenFind( replace: true ) );
menu.AddOption( "Select All", "select_all", SelectAll );
menu.AddSeparator();
menu.AddOption( WordWrap ? "Turn Word Wrap Off" : "Turn Word Wrap On", "wrap_text", () => CodeView.WordWrap = !CodeView.WordWrap );
menu.OpenAtCursor();
e.Accepted = true;
}
protected override void OnFocus( FocusChangeReason reason )
{
base.OnFocus( reason );
CaretMoved();
}
protected override void OnBlur( FocusChangeReason reason )
{
base.OnBlur( reason );
CloseCompletions();
CloseSignatureHelp();
Update();
}
[EditorEvent.Frame]
public void Frame()
{
if ( !Visible ) return;
if ( _settingsVersion != HotCodeSettings.Version )
{
_settingsVersion = HotCodeSettings.Version;
_measured = false;
InvalidateLayout();
}
if ( _wrapApplied != WordWrap ) InvalidateLayout();
if ( _layoutDirty && _measured ) UpdateLayout();
if ( _ensureCaretVisible && _measured )
{
_ensureCaretVisible = false;
ScrollToCaret();
}
if ( CompletionOpen ) PositionPopup();
if ( SignatureOpen ) PositionSignature();
// Repaint on scroll (the gutter is pinned to the left edge) and when the caret blinks
var scroll = Scroll;
var caretOn = CaretVisible;
if ( scroll != _lastScroll || caretOn != _lastCaretOn )
{
_lastScroll = scroll;
_lastCaretOn = caretOn;
Update();
}
}
/// <summary>
/// Solid right after moving, then blinks.
/// </summary>
private bool CaretVisible => IsFocused && (!HotCodeSettings.CursorBlink || ((RealTime.Now - _caretBlinkStart) % 1.0) < 0.55);
public void ScrollToCaret()
{
UpdateLayout();
var rect = CaretRect( _caret );
var scroll = Scroll;
var viewport = ViewportSize;
if ( rect.Top < scroll.y ) _scroller.VerticalScrollbar.Value = (int)rect.Top;
else if ( rect.Bottom > scroll.y + viewport.y ) _scroller.VerticalScrollbar.Value = (int)(rect.Bottom - viewport.y);
if ( _wrapApplied ) return;
if ( rect.Left < scroll.x + TextLeft ) _scroller.HorizontalScrollbar.Value = (int)MathF.Max( 0, rect.Left - TextLeft - _charWidth * 4 );
else if ( rect.Right > scroll.x + viewport.x ) _scroller.HorizontalScrollbar.Value = (int)(rect.Right - viewport.x + _charWidth * 4);
}
/// <summary>
/// Scrolls so the caret sits about a third of the way down, for jumping to search results.
/// </summary>
public void CenterOnCaret()
{
UpdateLayout();
var rect = CaretRect( _caret );
var viewport = ViewportSize;
if ( rect.Top < Scroll.y || rect.Bottom > Scroll.y + viewport.y )
_scroller.VerticalScrollbar.Value = (int)MathF.Max( 0, rect.Top - viewport.y / 3 );
_ensureCaretVisible = true;
}
// --- PAINTING ---
private void Measure()
{
Paint.SetFont( HotCodeSettings.CodeFont, FontSize );
var size = Paint.MeasureText( new string( 'M', 100 ) );
var charWidth = size.x / 100f;
var lineHeight = MathF.Ceiling( size.y * HotCodeSettings.LineSpacing );
if ( charWidth > 0 && lineHeight > 0 && (!_measured || charWidth != _charWidth || lineHeight != _lineHeight) )
{
_charWidth = charWidth;
_lineHeight = lineHeight;
_measured = true;
_layoutDirty = true;
}
}
private void UpdateLineStates()
{
if ( !_statesDirty ) return;
_statesDirty = false;
_lineStates.Clear();
var state = LexState.Normal;
var scratch = new List<Token>();
foreach ( var line in Buffer.Lines )
{
_lineStates.Add( state );
scratch.Clear();
state = _language.Tokenize( line, state, scratch );
}
}
protected override void OnPaint()
{
Measure();
UpdateLayout();
UpdateLineStates();
var scroll = Scroll;
var viewport = _scroller.Size;
var firstRow = Math.Max( 0, (int)(scroll.y / _lineHeight) );
var lastRow = Math.Min( TotalRows - 1, (int)((scroll.y + viewport.y) / _lineHeight) + 1 );
var firstLine = LineOfRow( firstRow );
var lastLine = LineOfRow( lastRow );
// Background
Paint.ClearPen();
Paint.SetBrush( SyntaxColors.Background );
Paint.DrawRect( new Rect( scroll, viewport ) );
// Current row
if ( !HasSelection && HotCodeSettings.HighlightCurrentLine )
{
Paint.SetBrush( SyntaxColors.CurrentLine );
Paint.DrawRect( new Rect( scroll.x, GlobalRow( _caret ) * _lineHeight, viewport.x, _lineHeight ) );
}
if ( HasSelection )
DrawRange( SelStart, SelEnd, SyntaxColors.Selection, firstLine, lastLine );
// Find matches: binary search to the first visible one, then walk until past the view
if ( _matches.Count > 0 )
{
var i = FirstMatchFrom( firstLine );
for ( ; i < _matches.Count && _matches[i].Start.Line <= lastLine; i++ )
DrawRange( _matches[i].Start, _matches[i].End, i == _currentMatch ? SyntaxColors.FindCurrent : SyntaxColors.FindMatch, firstLine, lastLine );
}
// Text
Paint.SetFont( HotCodeSettings.CodeFont, FontSize );
var tokens = new List<Token>();
var tokensLine = -1;
for ( int row = firstRow; row <= lastRow; row++ )
{
var line = LineOfRow( row );
if ( line != tokensLine )
{
tokens.Clear();
_language.Tokenize( Buffer.Lines[line], _lineStates[line], tokens );
tokensLine = line;
}
var r = row - _lineFirstRow[line];
DrawRow( row, line, _rowStarts[line][r], RowEnd( line, r ), tokens );
}
foreach ( var marker in HotCodeSettings.UnderlineErrors ? _markers : Array.Empty<EditorMarker>() )
{
if ( marker.End.Line < firstLine || marker.Start.Line > lastLine ) continue;
DrawSquiggle( marker, firstLine, lastLine );
}
if ( _link is { } link )
{
var text = Buffer.Lines[link.Start.Line];
var r = RowInLine( link.Start );
var rowStart = _rowStarts[link.Start.Line][r];
var x0 = TextLeft + TextLayout.ColumnOf( text, rowStart, link.Start.Col ) * _charWidth;
var x1 = TextLeft + TextLayout.ColumnOf( text, rowStart, link.End.Col ) * _charWidth;
var y = (GlobalRow( link.Start ) + 1) * _lineHeight - 3;
Paint.SetPen( SyntaxColors.Link, 1 );
Paint.DrawLine( new Vector2( x0, y ), new Vector2( x1, y ) );
}
if ( CaretVisible )
{
Paint.ClearPen();
Paint.SetBrush( SyntaxColors.Caret );
Paint.DrawRect( CaretRect( _caret ) );
}
// Gutter, drawn last and pinned to the left edge so it stays put when scrolling sideways
if ( !HotCodeSettings.ShowLineNumbers ) return;
Paint.ClearPen();
Paint.SetBrush( SyntaxColors.Gutter );
Paint.DrawRect( new Rect( scroll.x, scroll.y, GutterWidth, viewport.y ) );
for ( int line = firstLine; line <= lastLine; line++ )
{
var row = _lineFirstRow[line];
if ( row < firstRow ) continue;
Paint.SetPen( line == _caret.Line ? SyntaxColors.LineNumberActive : SyntaxColors.LineNumber );
var rect = new Rect( scroll.x, row * _lineHeight, GutterWidth - _charWidth, _lineHeight );
Paint.DrawText( rect, (line + 1).ToString(), TextFlag.RightCenter );
}
}
/// <summary>
/// Wavy underline under a marker's range, row by row. Zero-width markers underline one character.
/// </summary>
private void DrawSquiggle( EditorMarker marker, int firstLine, int lastLine )
{
var start = Buffer.Clamp( marker.Start );
var end = Buffer.Clamp( marker.End );
if ( end < start ) end = start;
Paint.SetPen( marker.IsError ? SyntaxColors.Error : SyntaxColors.Warning, 1 );
for ( int line = Math.Max( firstLine, start.Line ); line <= Math.Min( lastLine, end.Line ); line++ )
{
var text = Buffer.Lines[line];
var from = line == start.Line ? start.Col : 0;
var to = line == end.Line ? end.Col : text.Length;
var starts = _rowStarts[line];
for ( int r = 0; r < starts.Length; r++ )
{
var a = Math.Max( from, starts[r] );
var b = Math.Min( to, RowEnd( line, r ) );
if ( b < a ) continue;
// Zero-width errors (like a missing ';') still get one character's worth of underline
var x0 = TextLeft + TextLayout.ColumnOf( text, starts[r], a ) * _charWidth;
var x1 = b > a ? TextLeft + TextLayout.ColumnOf( text, starts[r], b ) * _charWidth : x0 + _charWidth;
var y = (_lineFirstRow[line] + r + 1) * _lineHeight - 3;
var points = new List<Vector2>();
for ( var x = x0; x <= x1; x += 2 )
points.Add( new Vector2( x, y + (((int)((x - x0) / 2)) % 2 == 0 ? 0 : 2) ) );
if ( points.Count > 1 ) Paint.DrawLine( points );
}
}
}
private int FirstMatchFrom( int line )
{
int lo = 0, hi = _matches.Count;
while ( lo < hi )
{
var mid = (lo + hi) / 2;
if ( _matches[mid].End.Line < line ) lo = mid + 1;
else hi = mid;
}
return lo;
}
/// <summary>
/// Fills the area covered by a text range, row by row.
/// </summary>
private void DrawRange( TextPos start, TextPos end, Color color, int firstLine, int lastLine )
{
Paint.ClearPen();
Paint.SetBrush( color );
for ( int line = Math.Max( firstLine, start.Line ); line <= Math.Min( lastLine, end.Line ); line++ )
{
var text = Buffer.Lines[line];
var from = line == start.Line ? start.Col : 0;
var to = line == end.Line ? end.Col : text.Length;
var starts = _rowStarts[line];
for ( int r = 0; r < starts.Length; r++ )
{
var rowStart = starts[r];
var rowEnd = RowEnd( line, r );
var isLastRow = r == starts.Length - 1;
var a = Math.Max( from, rowStart );
var b = Math.Min( to, rowEnd );
// Selection running past the end of a line shows a little block for the newline
var newline = isLastRow && line < end.Line;
if ( a > b || (a == b && !newline) ) continue;
if ( !isLastRow && a == rowEnd ) continue;
var x0 = TextLayout.ColumnOf( text, rowStart, a );
var x1 = TextLayout.ColumnOf( text, rowStart, b ) + (newline ? 1 : 0);
var y = (_lineFirstRow[line] + r) * _lineHeight;
Paint.DrawRect( new Rect( TextLeft + x0 * _charWidth, y, (x1 - x0) * _charWidth, _lineHeight ) );
}
}
}
/// <summary>
/// Draws one row as runs of same-coloured text. Tabs are expanded so columns line up.
/// </summary>
private void DrawRow( int row, int lineIndex, int rowStart, int rowEnd, List<Token> tokens )
{
if ( rowEnd <= rowStart ) return;
var line = Buffer.Lines[lineIndex];
var y = row * _lineHeight;
var pos = rowStart;
foreach ( var token in tokens )
{
var tokenEnd = token.Start + token.Length;
if ( tokenEnd <= rowStart ) continue;
if ( token.Start >= rowEnd ) break;
var start = Math.Max( token.Start, rowStart );
var end = Math.Min( tokenEnd, rowEnd );
if ( start > pos ) DrawRun( line, rowStart, pos, start, TokenKind.Text, y );
DrawRun( line, rowStart, start, end, token.Kind, y );
pos = end;
}
if ( pos < rowEnd ) DrawRun( line, rowStart, pos, rowEnd, TokenKind.Text, y );
}
private void DrawRun( string line, int rowStart, int start, int end, TokenKind kind, float y )
{
var text = line.Substring( start, end - start );
if ( string.IsNullOrWhiteSpace( text ) ) return;
var startCol = TextLayout.ColumnOf( line, rowStart, start );
var display = text.Contains( '\t' ) ? ExpandTabs( text, startCol ) : text;
Paint.SetPen( SyntaxColors.For( kind ) );
var rect = new Rect( TextLeft + startCol * _charWidth, y, (display.Length + 2) * _charWidth, _lineHeight );
Paint.DrawText( rect, display, TextFlag.LeftCenter | TextFlag.SingleLine );
}
private static string ExpandTabs( string text, int startCol )
{
var sb = new System.Text.StringBuilder();
var col = startCol;
foreach ( var c in text )
{
if ( c == '\t' )
{
var next = (col / TextLayout.TabSize + 1) * TextLayout.TabSize;
sb.Append( ' ', next - col );
col = next;
}
else
{
sb.Append( c );
col++;
}
}
return sb.ToString();
}
}