s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
link Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=chrinteractive.hotcode&take=20
Showing code results for query:
*
(37 total matches found)
Editor
library
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();
}
}
Editor
library
using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;
/// <summary>
/// Runs one background job at a time and only cares about the newest request: if more come in
/// while a job runs, the last one runs next and the ones in between are skipped. Results are
/// handed back on the main thread by <see cref="Pump"/> (call it every frame).
///
/// For things like autocomplete, where only the answer for the current keystroke matters.
/// </summary>
public class LatestOnlyWorker<T>
{
private bool _busy;
private (Func<T> Work, Action<T> Apply)? _next;
private readonly ConcurrentQueue<(T Result, Exception Error, Action<T> Apply)> _done = new();
/// <summary>
/// Called on the main thread when a job throws.
/// </summary>
public Action<Exception> OnError;
public void Request( Func<T> work, Action<T> apply )
{
if ( _busy )
{
_next = (work, apply);
return;
}
Start( work, apply );
}
private void Start( Func<T> work, Action<T> apply )
{
_busy = true;
Task.Run( work ).ContinueWith( t => _done.Enqueue( t.IsFaulted
? (default, t.Exception?.InnerException ?? t.Exception, apply)
: (t.Result, null, apply) ) );
}
public void Pump()
{
while ( _done.TryDequeue( out var done ) )
{
_busy = false;
// A newer request is waiting, so this result is already out of date
if ( _next is { } next )
{
_next = null;
Start( next.Work, next.Apply );
continue;
}
if ( done.Error is not null ) OnError?.Invoke( done.Error );
else done.Apply( done.Result );
}
}
}
Editor
library
/// <summary>
/// A file open in a tab. Each document keeps its own CodeView so undo history and
/// scroll position survive switching tabs.
/// </summary>
public class EditorDocument
{
/// <summary>
/// Where the file lives. Changes when it's renamed or moved from the file tree.
/// </summary>
public string Path { get; set; }
public CodeView Editor { get; }
/// <summary>
/// Has edits that aren't written to disk yet.
/// </summary>
public bool IsDirty { get; set; }
/// <summary>
/// Opened with a single click; gets replaced by the next single-clicked file
/// unless it's edited or double-clicked.
/// </summary>
public bool IsPreview { get; set; }
/// <summary>
/// The file started with a UTF-8 byte order mark, so keep writing one.
/// </summary>
public bool HasBom { get; init; }
/// <summary>
/// RealTime.Now of the last edit, for auto save.
/// </summary>
public double LastEdit { get; set; }
public string Title => System.IO.Path.GetFileName( Path );
public EditorDocument( string path, CodeView editor )
{
Path = path;
Editor = editor;
}
}
Editor
library
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
/// <summary>
/// Adds HotCode to other projects by copying it into their Libraries folder, which is where
/// s&box's Library Manager puts libraries too. Next time that project is opened in s&box,
/// HotCode loads with it.
///
/// Until HotCode is published, this is the way to get it into another project; afterwards the
/// Library Manager can install (and update) it as well.
/// </summary>
public static class HotCodeInstaller
{
public const string Ident = "hotcode";
/// <summary>
/// A file only HotCode has, so it can be recognised whatever org it's published under.
/// </summary>
static readonly string MarkerFile = Path.Combine( "Editor", "Launcher", "HotCodeInstaller.cs" );
/// <summary>
/// "org.hotcode", using the org from HotCode's own .sbproj (the same naming the Library Manager uses).
/// </summary>
public static string FolderName( string sourceRoot )
{
try
{
using var doc = JsonDocument.Parse( File.ReadAllText( Path.Combine( sourceRoot, Ident + ".sbproj" ) ) );
if ( doc.RootElement.TryGetProperty( "Org", out var org ) && org.GetString() is { Length: > 0 } name )
return $"{name.ToLowerInvariant()}.{Ident}";
}
catch ( Exception e ) when ( e is IOException or JsonException or UnauthorizedAccessException )
{
}
return Ident;
}
/// <summary>
/// Folders never copied: build output, editor state, and the logo source script.
/// </summary>
static readonly HashSet<string> SkipFolders = new( StringComparer.OrdinalIgnoreCase ) { "obj", "bin", ".vs", ".git", ".idea", "logo", "Properties" };
/// <summary>
/// Files never copied: s&box generates these per project, with paths that only make sense where they were made.
/// </summary>
static readonly HashSet<string> SkipExtensions = new( StringComparer.OrdinalIgnoreCase ) { ".csproj", ".sln", ".slnx", ".user" };
/// <summary>
/// Where HotCode is installed in a project (any folder under Libraries whose .sbproj is HotCode's), or null.
/// </summary>
public static string FindInstalled( string projectRoot )
{
var libraries = Path.Combine( projectRoot, "Libraries" );
if ( !Directory.Exists( libraries ) ) return null;
foreach ( var folder in Directory.EnumerateDirectories( libraries ) )
{
foreach ( var sbproj in Directory.EnumerateFiles( folder, "*.sbproj" ) )
if ( IsHotCode( sbproj ) ) return folder;
}
return null;
}
/// <summary>
/// True when an .sbproj is HotCode's own: ident "hotcode" and HotCode's files next to it.
/// Doesn't depend on the org, so renaming or changing the org doesn't break anything.
/// </summary>
public static bool IsHotCode( string sbproj )
{
try
{
if ( !File.Exists( Path.Combine( Path.GetDirectoryName( sbproj ), MarkerFile ) ) ) return false;
using var doc = JsonDocument.Parse( File.ReadAllText( sbproj ) );
return doc.RootElement.TryGetProperty( "Ident", out var ident ) && string.Equals( ident.GetString(), Ident, StringComparison.OrdinalIgnoreCase );
}
catch ( Exception e ) when ( e is IOException or JsonException or UnauthorizedAccessException )
{
return false;
}
}
/// <summary>
/// Copies HotCode from <paramref name="sourceRoot"/> into the project. An existing copy is moved
/// to the Recycle Bin first (via <paramref name="recycle"/>), so updating never loses anything for good.
/// Returns the folder HotCode now lives in. Throws <see cref="InvalidOperationException"/> with a readable message.
/// </summary>
public static string Install( string sourceRoot, string projectRoot, Action<string> recycle )
{
sourceRoot = Path.GetFullPath( sourceRoot ).TrimEnd( '\\', '/' );
projectRoot = Path.GetFullPath( projectRoot ).TrimEnd( '\\', '/' );
if ( !File.Exists( Path.Combine( sourceRoot, Ident + ".sbproj" ) ) )
throw new InvalidOperationException( "Couldn't find HotCode's own files to copy." );
if ( ProjectFiles.IsUnder( projectRoot, sourceRoot ) || ProjectFiles.IsUnder( sourceRoot, projectRoot ) )
throw new InvalidOperationException( "That's the project HotCode is being developed in - it already has it." );
if ( !Directory.Exists( projectRoot ) || !Directory.EnumerateFiles( projectRoot, "*.sbproj" ).Any() )
throw new InvalidOperationException( "That folder isn't an s&box project (no .sbproj file)." );
// Replace an existing copy wherever it is, otherwise use the standard folder name
var target = FindInstalled( projectRoot ) ?? Path.Combine( projectRoot, "Libraries", FolderName( sourceRoot ) );
if ( ProjectFiles.IsUnder( target, sourceRoot ) )
throw new InvalidOperationException( "Refusing to overwrite HotCode's own source folder." );
// Copy into a staging folder first, so a failed copy never leaves a half-installed library
var staging = target + ".installing";
if ( Directory.Exists( staging ) ) Directory.Delete( staging, recursive: true );
CopyFolder( sourceRoot, staging );
if ( Directory.Exists( target ) ) recycle( target );
Directory.Move( staging, target );
return target;
}
/// <summary>
/// Removes HotCode from a project (to the Recycle Bin). Returns false if it wasn't installed.
/// </summary>
public static bool Uninstall( string sourceRoot, string projectRoot, Action<string> recycle )
{
var installed = FindInstalled( projectRoot );
if ( installed is null ) return false;
if ( ProjectFiles.IsUnder( installed, sourceRoot ) )
throw new InvalidOperationException( "That's HotCode's own source folder - not removing it." );
recycle( installed );
return true;
}
private static void CopyFolder( string from, string to )
{
Directory.CreateDirectory( to );
foreach ( var file in Directory.EnumerateFiles( from ) )
{
if ( SkipExtensions.Contains( Path.GetExtension( file ) ) ) continue;
File.Copy( file, Path.Combine( to, Path.GetFileName( file ) ), overwrite: true );
}
foreach ( var folder in Directory.EnumerateDirectories( from ) )
{
if ( SkipFolders.Contains( Path.GetFileName( folder ) ) ) continue;
CopyFolder( folder, Path.Combine( to, Path.GetFileName( folder ) ) );
}
}
}
Editor
library
using Sandbox;
using Editor;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
/// <summary>
/// Whether a file is open in a tab, and whether it's the one being edited.
/// </summary>
public enum FileState
{
Closed,
Open,
Active
}
/// <summary>
/// A lazily-built tree node for a file or folder on disk. Only shows code/text files
/// and skips build output folders.
/// </summary>
public class CodeFileNode : TreeNode
{
public static readonly HashSet<string> HiddenFolders = new( StringComparer.OrdinalIgnoreCase ) { "bin", "obj", ".vs", ".git", ".idea", ".sbox", "node_modules" };
static readonly HashSet<string> CodeExtensions = new( StringComparer.OrdinalIgnoreCase )
{
".cs", ".razor", ".scss", ".css", ".json", ".sbproj", ".csproj", ".sln", ".slnx",
".md", ".txt", ".shader", ".hlsl", ".editorconfig", ".gitignore", ".xml", ".yml", ".yaml"
};
public FileSystemInfo Info { get; }
public bool IsFolder => Info is DirectoryInfo;
public string FullPath => Info.FullName;
private readonly HotCodeEditor _host;
public CodeFileNode( HotCodeEditor host, FileSystemInfo info ) : base( info.FullName )
{
_host = host;
Info = info;
_host.RegisterNode( this );
}
public override string Name => Info.Name;
public override bool HasChildren => IsFolder;
public override string GetTooltip() => Info.FullName;
public static bool IsCodeFile( string path ) => CodeExtensions.Contains( Path.GetExtension( path ) ) || CodeExtensions.Contains( Path.GetFileName( path ) );
/// <summary>
/// True for paths inside bin/obj/.git etc, which the tree never shows.
/// </summary>
public static bool IsInHiddenFolder( string path ) => path.Split( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar ).Any( HiddenFolders.Contains );
public override void OnPaint( VirtualWidget item )
{
PaintSelection( item );
var state = IsFolder ? FileState.Closed : _host.StateOf( FullPath );
// Folders in s&box's folder colour; files coloured by type
var open = IsFolder && item.IsOpen;
Paint.SetPen( IsFolder ? Theme.Folder : HotCodeStyle.FileIconColor( FullPath ) );
Paint.DrawIcon( item.Rect, IsFolder ? (open ? "folder_open" : "folder") : HotCodeStyle.FileIcon( FullPath ), 15, TextFlag.LeftCenter );
// The file you're editing is bold; other open files are bright, the rest are dimmer
Paint.SetDefaultFont( HotCodeStyle.FontSize, state == FileState.Active ? 700 : 400 );
Paint.SetPen( IsFolder || state != FileState.Closed || item.Selected ? Theme.Text : Theme.TextLight );
Paint.DrawText( item.Rect.Shrink( 22, 0, 0, 0 ), Info.Name, TextFlag.LeftCenter );
if ( state != FileState.Closed && _host.IsUnsaved( FullPath ) )
{
Paint.ClearPen();
Paint.SetBrush( Theme.Text );
Paint.DrawCircle( new Vector2( item.Rect.Right - 10, item.Rect.Center.y ), 6 );
}
}
public override bool OnContextMenu()
{
_host.ShowFileContextMenu( this );
return true;
}
protected override void BuildChildren()
{
if ( Info is not DirectoryInfo dir )
return;
Clear();
IEnumerable<FileSystemInfo> entries;
try
{
dir.Refresh();
entries = dir.GetFileSystemInfos();
}
catch ( Exception e ) when ( e is IOException or UnauthorizedAccessException )
{
Log.Warning( $"[HotCode] Can't read {dir.FullName}: {e.Message}" );
return;
}
var folders = entries.OfType<DirectoryInfo>()
.Where( x => !HiddenFolders.Contains( x.Name ) && ShouldShowFolder( x ) )
.OrderBy( x => x.Name, StringComparer.OrdinalIgnoreCase );
var files = entries.OfType<FileInfo>()
.Where( x => IsCodeFile( x.Name ) )
.OrderBy( x => x.Name, StringComparer.OrdinalIgnoreCase );
AddItems( folders.Select( x => new CodeFileNode( _host, x ) ) );
AddItems( files.Select( x => new CodeFileNode( _host, x ) ) );
}
/// <summary>
/// Hide folders full of things we'd never open (e.g. Assets full of textures),
/// but keep empty ones so a folder you just made shows up.
/// </summary>
static bool ShouldShowFolder( DirectoryInfo dir )
{
try
{
var any = false;
foreach ( var f in dir.EnumerateFiles( "*", SearchOption.AllDirectories ) )
{
if ( IsInHiddenFolder( f.FullName ) ) continue;
if ( IsCodeFile( f.Name ) ) return true;
any = true;
}
return !any;
}
catch ( Exception e ) when ( e is IOException or UnauthorizedAccessException )
{
return false;
}
}
}
Editor
library
using System.Collections.Generic;
/// <summary>
/// Column maths for monospaced text: tab expansion and word wrapping.
/// A line is shown as one or more rows; each row starts at a character index.
/// Columns inside a row count from the start of that row.
/// </summary>
public static class TextLayout
{
/// <summary>
/// Columns per tab. Set from HotCode's settings.
/// </summary>
public static int TabSize { get; set; } = 4;
private static readonly int[] SingleRow = { 0 };
private static int Advance( char c, int col ) => c == '\t' ? (col / TabSize + 1) * TabSize : col + 1;
/// <summary>
/// Column that character <paramref name="index"/> lands on, counting from <paramref name="rowStart"/>.
/// </summary>
public static int ColumnOf( string line, int rowStart, int index )
{
int col = 0;
for ( int i = rowStart; i < index && i < line.Length; i++ )
col = Advance( line[i], col );
return col;
}
public static int Width( string line ) => ColumnOf( line, 0, line.Length );
/// <summary>
/// Character index in [rowStart, rowEnd] nearest to a (possibly fractional) column.
/// </summary>
public static int IndexAtColumn( string line, int rowStart, int rowEnd, float column )
{
int col = 0;
for ( int i = rowStart; i < rowEnd && i < line.Length; i++ )
{
var next = Advance( line[i], col );
if ( column < (col + next) / 2f ) return i;
col = next;
}
return rowEnd;
}
/// <summary>
/// Where each row of a line starts when wrapped to <paramref name="columns"/>. Breaks after
/// whitespace when it can, mid-word when a word is longer than a row. Columns <= 0 means no wrapping.
/// </summary>
public static int[] Wrap( string line, int columns )
{
if ( columns <= 0 || line.Length == 0 || Width( line ) <= columns )
return SingleRow;
var starts = new List<int> { 0 };
int rowStart = 0;
int col = 0;
int lastBreak = -1;
var rowHasText = false;
for ( int i = 0; i < line.Length; i++ )
{
var next = Advance( line[i], col );
// Loops at most twice: breaking at the last space can still leave this character
// (say, a tab) too wide, and then it moves to a row of its own start
while ( next > columns && i > rowStart )
{
// Whitespace stays at the end of the row it follows, so a row never starts with the break
var breakAt = lastBreak > rowStart && lastBreak <= i ? lastBreak : i;
starts.Add( breakAt );
rowStart = breakAt;
lastBreak = -1;
rowHasText = false;
col = ColumnOf( line, rowStart, i );
next = Advance( line[i], col );
}
col = next;
var isSpace = line[i] == ' ' || line[i] == '\t';
if ( !isSpace ) rowHasText = true;
// Leading indentation isn't a break point, or we'd get rows of nothing but indent
else if ( rowHasText ) lastBreak = i + 1;
}
return starts.ToArray();
}
}
Editor
library
using Sandbox;
using Editor;
using System;
using System.Linq;
/// <summary>
/// The HotCode flame: red outer flame, orange middle, yellow core, each with a vertical gradient.
/// The outlines are generated by logo/flame.py (HotCodeLogo.Shapes.cs), which also renders the
/// PNGs next to it in logo/, so the painted logo and the images always match.
/// </summary>
public static partial class HotCodeLogo
{
/// <summary>
/// Material icon name for places that only take an icon name (EditorApp, menus).
/// </summary>
public const string IconName = "local_fire_department";
private static readonly (Func<Vector2[]> Shape, Color Top, Color Bottom)[] Layers =
{
(() => Outer, Hex( "#E21D12" ), Hex( "#FF5A14" )),
(() => Middle, Hex( "#FF6A10" ), Hex( "#FFA01C" )),
(() => Core, Hex( "#FFC82A" ), Hex( "#FFF19A" )),
};
public static readonly Color BadgeBackground = Hex( "#1c1c1c" );
public static readonly Color Glow = Hex( "#FF5810" );
/// <summary>
/// Draws just the flame, filling <paramref name="rect"/> (keep it square).
/// </summary>
public static void PaintFlame( Rect rect )
{
Paint.Antialiasing = true;
Paint.ClearPen();
foreach ( var (shape, top, bottom) in Layers )
{
var points = shape().Select( p => new Vector2( rect.Left + p.x * rect.Width, rect.Top + p.y * rect.Height ) ).ToArray();
Paint.SetBrushLinear( new Vector2( rect.Left, rect.Top ), new Vector2( rect.Left, rect.Bottom ), top, bottom );
Paint.DrawPolygon( points );
}
}
/// <summary>
/// The flame on a dark rounded square with a warm glow, like the package thumbnail.
/// </summary>
public static void PaintBadge( Rect rect )
{
Paint.Antialiasing = true;
Paint.ClearPen();
Paint.SetBrush( BadgeBackground );
Paint.DrawRect( rect, rect.Width * 0.18f );
var glow = new Rect( rect.Left + rect.Width * 0.2f, rect.Top + rect.Height * 0.32f, rect.Width * 0.6f, rect.Height * 0.58f );
Paint.SetBrushRadial( glow.Center, glow.Width * 0.5f, Glow.WithAlpha( 0.35f ), Glow.WithAlpha( 0 ) );
Paint.DrawRect( glow );
var inset = rect.Width * 0.15f;
PaintFlame( new Rect( rect.Left + inset, rect.Top + rect.Height * 0.13f, rect.Width - inset * 2, rect.Height - inset * 2 ) );
}
private static Pixmap _windowIcon;
/// <summary>
/// The flame as a pixmap, for SetWindowIcon (window title bars and the taskbar).
/// </summary>
public static Pixmap WindowIcon
{
get
{
if ( _windowIcon is not null ) return _windowIcon;
_windowIcon = new Pixmap( 64, 64 );
_windowIcon.Clear( Color.Transparent );
using ( Paint.ToPixmap( _windowIcon ) )
{
PaintFlame( new Rect( 0, 0, 64, 64 ) );
}
return _windowIcon;
}
}
private static Color Hex( string hex ) => Color.Parse( hex ) ?? Color.White;
}
/// <summary>
/// A widget that just shows the logo: the badge, or the bare flame.
/// </summary>
public class LogoWidget : Widget
{
private readonly bool _badge;
public LogoWidget( Widget parent, float size, bool badge ) : base( parent )
{
_badge = badge;
FixedSize = new Vector2( size, size );
TransparentForMouseEvents = true;
}
protected override void OnPaint()
{
if ( _badge ) HotCodeLogo.PaintBadge( LocalRect );
else HotCodeLogo.PaintFlame( LocalRect );
}
}
Editor
library
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
public readonly record struct SearchMatch( TextPos Start, TextPos End, Match RegexMatch );
public record struct SearchOptions( string Query, bool MatchCase, bool WholeWord, bool UseRegex );
/// <summary>
/// Finding text in a <see cref="TextBuffer"/>. Plain searches go through Regex too (escaped)
/// so every option behaves the same way.
/// </summary>
public static class TextSearch
{
public const int MaxMatches = 10000;
/// <summary>
/// All matches in order. Returns an empty list and sets <paramref name="error"/> for a bad regex.
/// </summary>
public static List<SearchMatch> FindAll( TextBuffer buffer, SearchOptions options, out string error )
{
error = null;
var results = new List<SearchMatch>();
if ( string.IsNullOrEmpty( options.Query ) ) return results;
var regex = Build( options, out error );
if ( regex is null ) return results;
var text = string.Join( "\n", buffer.Lines );
var lineStarts = new int[buffer.LineCount];
for ( int i = 1; i < buffer.LineCount; i++ )
lineStarts[i] = lineStarts[i - 1] + buffer.Lines[i - 1].Length + 1;
try
{
for ( var m = regex.Match( text ); m.Success && results.Count < MaxMatches; m = m.NextMatch() )
{
// Zero-length matches (like "^") can't be selected or replaced sensibly
if ( m.Length == 0 ) continue;
results.Add( new SearchMatch( ToPos( lineStarts, m.Index ), ToPos( lineStarts, m.Index + m.Length ), m ) );
}
}
catch ( RegexMatchTimeoutException )
{
error = "Search took too long";
}
return results;
}
/// <summary>
/// What a match gets replaced with. With regex on, $1 / ${name} refer to groups.
/// </summary>
public static string ReplacementFor( SearchMatch match, string replacement, SearchOptions options )
{
replacement ??= "";
return options.UseRegex ? match.RegexMatch.Result( replacement ) : replacement;
}
private static Regex Build( SearchOptions options, out string error )
{
error = null;
var pattern = options.UseRegex ? options.Query : Regex.Escape( options.Query );
// Like \b, but also works when the query starts or ends with a symbol
if ( options.WholeWord ) pattern = $@"(?<![\w]){pattern}(?![\w])";
var flags = RegexOptions.Multiline | RegexOptions.CultureInvariant;
if ( !options.MatchCase ) flags |= RegexOptions.IgnoreCase;
try
{
return new Regex( pattern, flags, TimeSpan.FromMilliseconds( 250 ) );
}
catch ( ArgumentException e )
{
error = e.Message;
return null;
}
}
private static TextPos ToPos( int[] lineStarts, int offset )
{
var line = Array.BinarySearch( lineStarts, offset );
if ( line < 0 ) line = ~line - 1;
return new TextPos( line, offset - lineStarts[line] );
}
}
Editor
library
using Sandbox;
using Editor;
using System;
public enum AutoSaveMode
{
[Title( "Off" )] Off,
[Title( "After a short pause" )] AfterDelay,
[Title( "When switching tabs" )] WhenSwitchingTabs
}
public enum CodeTheme
{
[Title( "Dark+ (VS Code)" )] DarkPlus,
[Title( "Monokai" )] Monokai,
[Title( "One Dark" )] OneDark
}
/// <summary>
/// HotCode's settings. Static properties so s&box's ControlSheet can edit them directly, the same
/// way its own Editor Settings pages work. Saved as one editor cookie; <see cref="Version"/> goes up on
/// every change so open editors know to re-measure and re-layout.
/// </summary>
public static class HotCodeSettings
{
const string CookieKey = "hotcode.settings";
/// <summary>
/// The stored values. A plain class so it round-trips through JSON.
/// </summary>
public class Data
{
public float FontSize { get; set; } = 10;
public string FontFamily { get; set; } = "";
public float LineSpacing { get; set; } = 1.35f;
public int TabSize { get; set; } = 4;
public bool IndentWithSpaces { get; set; }
public bool WordWrap { get; set; }
public bool ShowLineNumbers { get; set; } = true;
public bool HighlightCurrentLine { get; set; } = true;
public bool CursorBlink { get; set; } = true;
public CodeTheme Theme { get; set; } = CodeTheme.DarkPlus;
public bool AutoCloseBrackets { get; set; } = true;
public bool SuggestWhileTyping { get; set; } = true;
public bool ParameterHints { get; set; } = true;
public bool UnderlineErrors { get; set; } = true;
public AutoSaveMode AutoSave { get; set; } = AutoSaveMode.Off;
public float AutoSaveDelay { get; set; } = 1.5f;
public bool TrimTrailingWhitespace { get; set; }
public bool InsertFinalNewline { get; set; }
public bool ReloadChangedFiles { get; set; } = true;
}
private static Data _data;
private static Data Current
{
get
{
if ( _data is not null ) return _data;
_data = EditorCookie.Get<Data>( CookieKey, null );
if ( _data is null )
{
// First run: carry over the settings that used to be stored on their own
_data = new Data
{
FontSize = EditorCookie.Get( "hotcode.fontsize", 10f ),
WordWrap = EditorCookie.Get( "hotcode.wordwrap", false )
};
}
Apply();
return _data;
}
}
/// <summary>
/// Goes up every time any setting changes.
/// </summary>
public static int Version { get; private set; }
private static void Set( Action<Data> change )
{
change( Current );
EditorCookie.Set( CookieKey, Current );
Apply();
Version++;
}
/// <summary>
/// Pushes settings into the places that read them as plain values.
/// </summary>
private static void Apply()
{
TextLayout.TabSize = Math.Clamp( _data.TabSize, 1, 16 );
SyntaxColors.Use( _data.Theme );
}
public static void ResetToDefaults() => Set( d =>
{
var defaults = new Data();
foreach ( var property in typeof( Data ).GetProperties() )
property.SetValue( d, property.GetValue( defaults ) );
} );
// --- EDITOR ---
[Title( "Font Size" ), Description( "Code font size in points. Ctrl+= / Ctrl+- or Ctrl+mouse wheel change it too." ), Range( 6, 28 ), Step( 1 )]
public static float FontSize { get => Current.FontSize; set => Set( d => d.FontSize = Math.Clamp( value, 6, 28 ) ); }
[Title( "Font" ), Description( "Code font family. Leave empty to use s&box's monospace font. Use a monospaced font so columns line up." )]
public static string FontFamily { get => Current.FontFamily; set => Set( d => d.FontFamily = value?.Trim() ?? "" ); }
[Title( "Line Spacing" ), Description( "Line height as a multiple of the font height." ), Range( 1, 2 ), Step( 0.05f )]
public static float LineSpacing { get => Current.LineSpacing; set => Set( d => d.LineSpacing = Math.Clamp( value, 1, 2 ) ); }
[Title( "Tab Size" ), Description( "How many columns a tab takes up." ), Range( 1, 8 ), Step( 1 )]
public static int TabSize { get => Current.TabSize; set => Set( d => d.TabSize = Math.Clamp( value, 1, 8 ) ); }
[Title( "Indent With Spaces" ), Description( "For files whose indentation can't be detected. Existing files keep whatever they already use." )]
public static bool IndentWithSpaces { get => Current.IndentWithSpaces; set => Set( d => d.IndentWithSpaces = value ); }
[Title( "Word Wrap" ), Description( "Wrap long lines to the width of the editor (Alt+Z)." )]
public static bool WordWrap { get => Current.WordWrap; set => Set( d => d.WordWrap = value ); }
[Title( "Line Numbers" )]
public static bool ShowLineNumbers { get => Current.ShowLineNumbers; set => Set( d => d.ShowLineNumbers = value ); }
[Title( "Highlight Current Line" )]
public static bool HighlightCurrentLine { get => Current.HighlightCurrentLine; set => Set( d => d.HighlightCurrentLine = value ); }
[Title( "Blinking Cursor" )]
public static bool CursorBlink { get => Current.CursorBlink; set => Set( d => d.CursorBlink = value ); }
[Title( "Code Colours" ), Description( "Syntax colours for the code area. The rest of HotCode follows s&box's theme." )]
public static CodeTheme CodeColors { get => Current.Theme; set => Set( d => d.Theme = value ); }
// --- CODE HELP ---
[Title( "Auto-Close Brackets and Quotes" ), Description( "Typing ( [ { \" or ' adds the closing one." )]
public static bool AutoCloseBrackets { get => Current.AutoCloseBrackets; set => Set( d => d.AutoCloseBrackets = value ); }
[Title( "Suggestions While Typing" ), Description( "Show autocomplete as you type. Ctrl+Space always works." )]
public static bool SuggestWhileTyping { get => Current.SuggestWhileTyping; set => Set( d => d.SuggestWhileTyping = value ); }
[Title( "Parameter Hints" ), Description( "Show a method's parameters when typing ( or , in a call. Ctrl+Shift+Space always works." )]
public static bool ParameterHints { get => Current.ParameterHints; set => Set( d => d.ParameterHints = value ); }
[Title( "Underline Compile Errors" ), Description( "Underline errors and warnings from s&box's last compile in the code." )]
public static bool UnderlineErrors { get => Current.UnderlineErrors; set => Set( d => d.UnderlineErrors = value ); }
// --- FILES ---
[Title( "Auto Save" ), Description( "Saving triggers s&box's hotload, so auto save means changes show up in the game as you type." )]
public static AutoSaveMode AutoSave { get => Current.AutoSave; set => Set( d => d.AutoSave = value ); }
[Title( "Auto Save Delay" ), Description( "Seconds after you stop typing, when Auto Save is \"After a short pause\"." ), Range( 0.5f, 10 ), Step( 0.5f )]
public static float AutoSaveDelay { get => Current.AutoSaveDelay; set => Set( d => d.AutoSaveDelay = Math.Clamp( value, 0.5f, 10 ) ); }
[Title( "Trim Trailing Whitespace" ), Description( "Remove spaces and tabs at the ends of lines when saving." )]
public static bool TrimTrailingWhitespace { get => Current.TrimTrailingWhitespace; set => Set( d => d.TrimTrailingWhitespace = value ); }
[Title( "Insert Final Newline" ), Description( "Make sure files end with a newline when saving." )]
public static bool InsertFinalNewline { get => Current.InsertFinalNewline; set => Set( d => d.InsertFinalNewline = value ); }
[Title( "Reload Files Changed Elsewhere" ), Description( "When another program changes an open file that has no unsaved edits here, reload it." )]
public static bool ReloadChangedFiles { get => Current.ReloadChangedFiles; set => Set( d => d.ReloadChangedFiles = value ); }
/// <summary>
/// The code font to use: the chosen family, or s&box's monospace font.
/// </summary>
public static string CodeFont => string.IsNullOrWhiteSpace( FontFamily ) ? Editor.Theme.MonospaceFont : FontFamily;
}
Editor
library
using Sandbox;
using Editor;
using System;
/// <summary>
/// Shared drawing for HotCode's chrome so it matches the rest of the s&box editor.
/// Colours come from <see cref="Theme"/> (s&box's tools/assets/styles/theme.json), so they
/// follow s&box if it changes. Only the code area itself uses its own dark colours (<see cref="SyntaxColors"/>).
/// </summary>
public static class HotCodeStyle
{
public const float TabHeight = 26;
public const float FontSize = 8;
/// <summary>
/// Draws a tab the way s&box's QTabBar style does: rounded top corners, a 2px Primary line
/// on the selected tab, light text when inactive, a surface highlight on hover.
/// Returns the rect left over for content (after padding).
/// </summary>
public static Rect DrawTab( Rect rect, bool selected, bool hovered )
{
if ( selected || hovered )
{
// Extend past the bottom so only the top corners show as rounded
Paint.ClearPen();
Paint.SetBrush( selected ? Theme.TabBackground : Theme.SurfaceBackground );
Paint.DrawRect( new Rect( rect.Left, rect.Top, rect.Width, rect.Height + Theme.ControlRadius ), Theme.ControlRadius );
}
if ( selected )
{
Paint.SetBrush( Theme.Primary );
Paint.DrawRect( new Rect( rect.Left + 1, rect.Top, rect.Width - 2, 2 ), 1 );
}
return rect.Shrink( 12, 2, 8, 0 );
}
public static Color TabTextColor( bool selected, bool hovered ) => selected || hovered ? Theme.Text : Theme.TextLight;
public static void SetTabFont( bool italic = false ) => Paint.SetDefaultFont( FontSize, 500, italic );
/// <summary>
/// Material icon for a file, by extension.
/// </summary>
public static string FileIcon( string path ) => System.IO.Path.GetExtension( path ).ToLowerInvariant() switch
{
".cs" => "code",
".razor" => "web",
".scss" or ".css" => "palette",
".json" => "data_object",
".sbproj" or ".csproj" => "settings",
".sln" or ".slnx" => "account_tree",
".shader" or ".hlsl" => "gradient",
".md" or ".txt" => "notes",
_ => "description"
};
public static Color FileIconColor( string path ) => System.IO.Path.GetExtension( path ).ToLowerInvariant() switch
{
".cs" => Theme.Blue,
".razor" => Theme.Pink,
".scss" or ".css" => Theme.Pink,
".json" => Theme.Yellow,
".sbproj" or ".csproj" or ".sln" or ".slnx" => Theme.Green,
".shader" or ".hlsl" => Theme.Green,
_ => Theme.TextLight
};
}
/// <summary>
/// A row of panel tabs (like "Problems" / "Output") painted to match s&box's tabs, with an optional
/// count badge per tab.
/// </summary>
public class PanelTabBar : Widget
{
private readonly System.Collections.Generic.List<(string Key, string Text, string Icon)> _tabs = new();
private readonly System.Collections.Generic.Dictionary<string, (string Text, Color Color)> _badges = new();
private int _hovered = -1;
public string Selected { get; private set; }
public Action<string> OnSelected;
public PanelTabBar( Widget parent ) : base( parent )
{
FixedHeight = HotCodeStyle.TabHeight;
MouseTracking = true;
}
public void AddTab( string key, string text, string icon )
{
_tabs.Add( (key, text, icon) );
Selected ??= key;
Update();
}
public void Select( string key )
{
if ( Selected == key ) return;
Selected = key;
Update();
OnSelected?.Invoke( key );
}
/// <summary>
/// A small count shown after a tab's name (null or empty clears it).
/// </summary>
public void SetBadge( string key, string text, Color color )
{
if ( string.IsNullOrEmpty( text ) ) _badges.Remove( key );
else _badges[key] = (text, color);
Update();
}
private float TabWidth( int i )
{
HotCodeStyle.SetTabFont();
var width = Paint.MeasureText( _tabs[i].Text ).x + 20 + 22;
if ( _badges.TryGetValue( _tabs[i].Key, out var badge ) )
{
Paint.SetDefaultFont( 7, 600 );
width += Paint.MeasureText( badge.Text ).x + 14;
}
return width;
}
private int TabAt( Vector2 pos )
{
float x = 4;
for ( int i = 0; i < _tabs.Count; i++ )
{
var w = TabWidth( i );
if ( pos.x >= x && pos.x < x + w ) return i;
x += w + 2;
}
return -1;
}
protected override void OnMouseMove( MouseEvent e )
{
base.OnMouseMove( e );
var i = TabAt( e.LocalPosition );
if ( i != _hovered ) { _hovered = i; Update(); }
}
protected override void OnMouseLeave()
{
base.OnMouseLeave();
_hovered = -1;
Update();
}
protected override void OnMousePress( MouseEvent e )
{
base.OnMousePress( e );
var i = TabAt( e.LocalPosition );
if ( i >= 0 && e.LeftMouseButton ) Select( _tabs[i].Key );
}
protected override void OnPaint()
{
Paint.ClearPen();
Paint.SetBrush( Theme.TabBarBackground );
Paint.DrawRect( LocalRect );
float x = 4;
for ( int i = 0; i < _tabs.Count; i++ )
{
var tab = _tabs[i];
var width = TabWidth( i );
var selected = tab.Key == Selected;
var hovered = i == _hovered;
var content = HotCodeStyle.DrawTab( new Rect( x, 2, width, Height - 2 ), selected, hovered );
var color = HotCodeStyle.TabTextColor( selected, hovered );
Paint.SetPen( color );
Paint.DrawIcon( new Rect( content.Left, content.Top, 16, content.Height ), tab.Icon, 14, TextFlag.LeftCenter );
HotCodeStyle.SetTabFont();
var textRect = new Rect( content.Left + 20, content.Top, content.Width - 20, content.Height );
Paint.DrawText( textRect, tab.Text, TextFlag.LeftCenter );
if ( _badges.TryGetValue( tab.Key, out var badge ) )
{
var textWidth = Paint.MeasureText( tab.Text ).x;
Paint.SetDefaultFont( 7, 600 );
var badgeWidth = Paint.MeasureText( badge.Text ).x + 10;
var badgeRect = new Rect( textRect.Left + textWidth + 6, content.Center.y - 7, badgeWidth, 14 );
Paint.ClearPen();
Paint.SetBrush( badge.Color.WithAlpha( 0.25f ) );
Paint.DrawRect( badgeRect, 7 );
Paint.SetPen( badge.Color );
Paint.DrawText( badgeRect, badge.Text, TextFlag.Center );
}
x += width + 2;
}
}
}
/// <summary>
/// A short vertical line between status bar items.
/// </summary>
public class StatusDivider : Widget
{
public StatusDivider( Widget parent ) : base( parent )
{
FixedWidth = 9;
FixedHeight = 20;
}
protected override void OnPaint()
{
Paint.SetPen( Theme.Border );
Paint.DrawLine( new Vector2( 4, 4 ), new Vector2( 4, Height - 4 ) );
}
}
/// <summary>
/// A clickable piece of text (with an optional icon) for the status bar.
/// </summary>
public class StatusItem : Widget
{
private string _text = "";
private string _icon;
private Color? _iconColor;
public Action Clicked;
public StatusItem( Widget parent, string tooltip = null ) : base( parent )
{
ToolTip = tooltip;
MouseTracking = true;
FixedHeight = 20;
}
public void Set( string text, string icon = null, Color? iconColor = null )
{
if ( _text == text && _icon == icon && Nullable.Equals( _iconColor, iconColor ) ) return;
_text = text ?? "";
_icon = icon;
_iconColor = iconColor;
Paint.SetDefaultFont( HotCodeStyle.FontSize );
FixedWidth = Paint.MeasureText( _text ).x + (icon is null ? 0 : 18) + 14;
Visible = _text.Length > 0 || icon is not null;
Update();
}
protected override void OnMouseEnter()
{
base.OnMouseEnter();
Cursor = Clicked is null ? CursorShape.Arrow : CursorShape.Finger;
Update();
}
protected override void OnMouseLeave()
{
base.OnMouseLeave();
Update();
}
protected override void OnMousePress( MouseEvent e )
{
base.OnMousePress( e );
if ( e.LeftMouseButton ) Clicked?.Invoke();
}
protected override void OnPaint()
{
if ( Clicked is not null && IsUnderMouse )
{
Paint.ClearPen();
Paint.SetBrush( Theme.SurfaceBackground );
Paint.DrawRect( LocalRect, Theme.ControlRadius );
}
var x = 7f;
if ( _icon is not null )
{
Paint.SetPen( _iconColor ?? Theme.TextLight );
Paint.DrawIcon( new Rect( x, 0, 16, Height ), _icon, 13, TextFlag.LeftCenter );
x += 18;
}
Paint.SetDefaultFont( HotCodeStyle.FontSize );
Paint.SetPen( IsUnderMouse && Clicked is not null ? Theme.Text : Theme.TextLight );
Paint.DrawText( new Rect( x, 0, Width - x, Height ), _text, TextFlag.LeftCenter );
}
}
Game
library
global using static Sandbox.Internal.GlobalGameNamespace;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
[assembly: global::System.Reflection.AssemblyMetadata( "AddonTitle", "HotCode" )]
[assembly: global::System.Reflection.AssemblyMetadata( "AddonIdent", "hotcode" )]
[assembly: global::System.Reflection.AssemblyMetadata( "OrgIdent", "chrinteractive" )]
[assembly: global::System.Reflection.AssemblyMetadata( "Ident", "chrinteractive.hotcode" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineVersion", "29" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineMinorVersion", "1" )]
[assembly: System.Runtime.Versioning.TargetFramework( ".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0" )]
[assembly: global::System.Reflection.AssemblyMetadata( "CompileTime", "2026-10-02T00:57:09.8934384Z" )]
[assembly: global::System.Reflection.AssemblyVersion("0.0.113.0")]
[assembly: global::System.Reflection.AssemblyFileVersion("0.0.113.0")]
Editor
library
using Sandbox;
using Editor;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
/// <summary>
/// The HotCode IDE window: file tree on the left, tabbed code editors on the right,
/// problems and output underneath. Saving a file inside a project s&box has loaded
/// lets s&box pick it up and hotload it.
/// </summary>
public class HotCodeEditor : Window
{
private readonly HotCodeEntry _entry;
private readonly string _root;
private readonly TreeView _tree;
private readonly Widget _explorer;
private CodeFileNode _rootNode;
private readonly EditorTabBar _tabs;
private readonly Widget _editorStack;
private readonly WelcomePage _welcome;
// Status bar
private readonly StatusItem _buildStatus;
private readonly StatusItem _position;
private readonly StatusItem _indent;
private readonly StatusItem _lineEnding;
private readonly StatusItem _encoding;
private readonly StatusItem _language;
private bool _projectLoaded;
private double _nextLoadedCheck;
private readonly OutputPanel _output;
private readonly List<EditorDocument> _documents = new();
private EditorDocument _active;
// Tree nodes by folder path, so file system changes can refresh the right folder
private readonly Dictionary<string, CodeFileNode> _nodes = new( StringComparer.OrdinalIgnoreCase );
// Go to definition runs in the background; results come back through this queue
private readonly ConcurrentQueue<(EditorDocument Doc, DefinitionResult Result)> _definitionResults = new();
private bool _definitionPending;
// Autocomplete and parameter hints run in the background, one at a time, newest request wins
private readonly LatestOnlyWorker<CompletionResult> _completions = new();
private readonly LatestOnlyWorker<SignatureHelpResult> _signatures = new();
// Alt+Left / Alt+Right history of where we jumped from
private record NavPoint( string Path, TextPos Pos );
private readonly List<NavPoint> _navBack = new();
private readonly List<NavPoint> _navForward = new();
private readonly FileSystemWatcher _watcher;
// (path, structural): structural changes (add/delete/rename) refresh the tree; edits only reload open files
private readonly ConcurrentQueue<(string Path, bool Structural)> _changedPaths = new();
public HotCodeEditor( HotCodeEntry entry )
{
_entry = entry;
_root = Path.GetFullPath( entry.Directory );
DeleteOnClose = true;
SetWindowIcon( HotCodeLogo.WindowIcon );
Size = new Vector2( 1200, 800 );
MinimumSize = new Vector2( 600, 400 );
Canvas = new Widget( this );
Canvas.Layout = Layout.Column();
// Top: tree | editors. Bottom: problems / output.
var vertical = new Splitter( Canvas );
vertical.IsVertical = true;
Canvas.Layout.Add( vertical, 1 );
var splitter = new Splitter( vertical );
splitter.IsHorizontal = true;
// LEFT: explorer header + file tree. Single click previews, double click opens for keeps.
_explorer = new Widget( splitter );
_explorer.Layout = Layout.Column();
_explorer.Layout.Add( BuildExplorerHeader( _explorer, entry.Title ) );
_explorer.Layout.AddSeparator( 1, Theme.Border );
_tree = _explorer.Layout.Add( new TreeView( _explorer ), 1 );
_tree.MinimumWidth = 200;
_tree.ItemSelected += item => { if ( item is CodeFileNode { IsFolder: false } node ) OpenFile( node.FullPath, preview: true ); };
_tree.ItemActivated += item => { if ( item is CodeFileNode { IsFolder: false } node ) OpenFile( node.FullPath, preview: false ); };
_rootNode = new CodeFileNode( this, new DirectoryInfo( _root ) );
_tree.AddItem( _rootNode );
_tree.Open( _rootNode );
// RIGHT: tabs + editors
var right = new Widget( splitter );
right.Layout = Layout.Column();
_tabs = right.Layout.Add( new EditorTabBar( right ) );
right.Layout.AddSeparator( 1, Theme.Border );
_tabs.OnTabSelected = Activate;
_tabs.OnTabCloseRequested = RequestClose;
_tabs.OnTabPinned = doc => { doc.IsPreview = false; _tabs.Update(); };
_tabs.GetTabTooltip = doc => Path.GetRelativePath( _root, doc.Path );
_editorStack = right.Layout.Add( new Widget( right ), 1 );
_editorStack.Layout = Layout.Column();
_welcome = _editorStack.Layout.Add( new WelcomePage( _editorStack, entry.Title, _root, path => OpenFile( path, preview: false ) ), 1 );
_welcome.SetRecentFiles( RecentFiles() );
splitter.AddWidget( _explorer );
splitter.AddWidget( right );
splitter.SetStretch( 0, 1 );
splitter.SetStretch( 1, 4 );
// BOTTOM: problems / output
_output = new OutputPanel( vertical, _root );
_output.ProblemActivated = GoToProblem;
_output.ProblemsChanged = _ => ApplyMarkers();
vertical.AddWidget( splitter );
vertical.AddWidget( _output );
vertical.SetStretch( 0, 4 );
vertical.SetStretch( 1, 1 );
// Status bar: build state on the left, facts about the open file on the right
if ( !StatusBar.IsValid() ) StatusBar = new StatusBar( this );
var flame = new LogoWidget( StatusBar, 14, badge: false ) { ToolTip = "HotCode" };
StatusBar.AddWidgetLeft( flame, 0 );
_buildStatus = new StatusItem( StatusBar, "Click to show problems" ) { Clicked = _output.ShowProblems };
StatusBar.AddWidgetLeft( _buildStatus, 0 );
_position = new StatusItem( StatusBar, "Go to line (Ctrl+G)" ) { Clicked = GoToLine };
_indent = new StatusItem( StatusBar, "Indentation, detected from the file" );
_lineEnding = new StatusItem( StatusBar, "Line endings, kept as they were when saving" );
_encoding = new StatusItem( StatusBar, "Encoding" );
_language = new StatusItem( StatusBar, "Language" );
foreach ( var item in new[] { _position, _indent, _lineEnding, _encoding, _language } )
{
if ( item != _position ) StatusBar.AddWidgetRight( new StatusDivider( StatusBar ), 0 );
StatusBar.AddWidgetRight( item, 0 );
}
BuildMenuBar();
// Keep the tree (and unmodified open files) in sync with changes made outside HotCode
try
{
_watcher = new FileSystemWatcher( _root )
{
IncludeSubdirectories = true,
NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite
};
_watcher.Created += ( _, e ) => QueueChange( e.FullPath, structural: true );
_watcher.Deleted += ( _, e ) => QueueChange( e.FullPath, structural: true );
_watcher.Changed += ( _, e ) => QueueChange( e.FullPath, structural: false );
_watcher.Renamed += ( _, e ) => { QueueChange( e.OldFullPath, structural: true ); QueueChange( e.FullPath, structural: true ); };
_watcher.EnableRaisingEvents = true;
}
catch ( Exception e ) when ( e is IOException or ArgumentException or UnauthorizedAccessException )
{
Log.Warning( $"[HotCode] Can't watch {_root} for changes: {e.Message}" );
}
UpdateTitle();
Show();
}
public override void OnDestroyed()
{
_watcher?.Dispose();
base.OnDestroyed();
}
// --- OPENING / SWITCHING ---
private EditorDocument FindDocument( string path ) => _documents.FirstOrDefault( d => string.Equals( d.Path, path, StringComparison.OrdinalIgnoreCase ) );
private EditorDocument OpenFile( string path, bool preview )
{
var existing = FindDocument( path );
if ( existing is not null )
{
if ( !preview ) existing.IsPreview = false;
Activate( existing );
return existing;
}
if ( !TryReadFile( path, out var contents, out var hasBom ) )
return null;
var editor = new CodeView( _editorStack, SyntaxLanguage.FromPath( path ) );
editor.Text = contents;
editor.Visible = false;
_editorStack.Layout.Add( editor, 1 );
var doc = new EditorDocument( path, editor ) { IsPreview = preview, HasBom = hasBom };
editor.TextChanged += () => OnEdited( doc );
editor.DefinitionRequested = pos => GoToDefinition( doc, pos );
editor.NavigateRequested = Navigate;
editor.CompletionRequested = pos => RequestCompletion( doc, pos );
editor.SignatureHelpRequested = pos => RequestSignatureHelp( doc, pos );
// A new preview takes the place of the old one, if the old one wasn't touched
var oldPreview = preview ? _documents.FirstOrDefault( d => d.IsPreview && !d.IsDirty ) : null;
if ( oldPreview is not null )
{
var index = _documents.IndexOf( oldPreview );
RemoveDocument( oldPreview );
_documents.Insert( index, doc );
_tabs.Insert( index, doc );
}
else
{
_documents.Add( doc );
_tabs.Add( doc );
}
ApplyMarkers( doc );
Activate( doc );
return doc;
}
private static bool TryReadFile( string path, out string contents, out bool hasBom )
{
contents = null;
hasBom = false;
try
{
var bytes = File.ReadAllBytes( path );
hasBom = bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF;
contents = new UTF8Encoding( false ).GetString( bytes, hasBom ? 3 : 0, bytes.Length - (hasBom ? 3 : 0) );
return true;
}
catch ( Exception e ) when ( e is IOException or UnauthorizedAccessException )
{
Log.Warning( $"[HotCode] Couldn't open {path}: {e.Message}" );
return false;
}
}
private void Activate( EditorDocument doc )
{
if ( _active is not null && _active != doc && _active.IsDirty && HotCodeSettings.AutoSave == AutoSaveMode.WhenSwitchingTabs )
SaveDocument( _active );
if ( _active is not null && _active != doc )
_active.Editor.Visible = false;
_active = doc;
_welcome.Visible = doc is null;
if ( doc is not null )
{
doc.Editor.Visible = true;
doc.Editor.FocusEditor();
if ( !doc.IsPreview ) AddRecentFile( doc.Path );
}
else
{
_welcome.SetRecentFiles( RecentFiles() );
}
_tabs.SetActive( doc );
_tree.Update();
UpdateTitle();
}
private void OnEdited( EditorDocument doc )
{
// IsModified goes back to false when edits are undone back to the saved text
var dirty = doc.Editor.IsModified;
var changed = dirty != doc.IsDirty || (dirty && doc.IsPreview);
doc.LastEdit = RealTime.Now;
doc.IsDirty = dirty;
if ( dirty ) doc.IsPreview = false;
if ( changed )
{
_tabs.Update();
_tree.Update();
UpdateTitle();
}
}
// --- PROBLEMS ---
private void GoToProblem( ProblemItem item )
{
PushNavPoint();
var doc = OpenFile( item.Path, preview: false );
doc?.Editor.GoTo( item.Line, item.Column );
}
private void ApplyMarkers()
{
foreach ( var doc in _documents )
ApplyMarkers( doc );
}
/// <summary>
/// Underlines this file's compile errors and warnings.
/// </summary>
private void ApplyMarkers( EditorDocument doc )
{
var markers = _output.Problems
.Where( p => string.Equals( Path.GetFullPath( p.Path ), Path.GetFullPath( doc.Path ), StringComparison.OrdinalIgnoreCase ) )
.Select( p => new EditorMarker( new TextPos( p.Line, p.Column ), new TextPos( p.EndLine, p.EndColumn ), p.Severity == DiagnosticSeverity.Error, $"{p.Id}: {p.Message}" ) )
.ToList();
doc.Editor.SetMarkers( markers );
}
// --- GO TO DEFINITION ---
private void GoToDefinition( EditorDocument doc, TextPos pos )
{
if ( !doc.Path.EndsWith( ".cs", StringComparison.OrdinalIgnoreCase ) )
{
doc.Editor.ShowMessage( "Go to Definition only works in C# files." );
return;
}
if ( _definitionPending ) return;
// Read s&box's compiler state here on the main thread; the slow Roslyn work happens in the background
RoslynProject.Snapshot snapshot;
try
{
snapshot = RoslynProject.TakeSnapshot( doc.Path, _root, _documents.Select( d => (d.Path, d.Editor.Text) ) );
}
catch ( Exception e )
{
doc.Editor.ShowMessage( $"Couldn't look that up: {e.Message}" );
return;
}
_definitionPending = true;
doc.Editor.ShowMessage( "Finding definition...", 60 );
Task.Run( () => DefinitionFinder.Find( snapshot, pos.Line, pos.Col ) )
.ContinueWith( t => _definitionResults.Enqueue( (doc, t.IsFaulted ? DefinitionResult.Fail( t.Exception?.InnerException?.Message ) : t.Result) ) );
}
// --- AUTOCOMPLETE ---
/// <summary>
/// What Roslyn needs to know about the code right now. Read on the main thread, used in the background.
/// </summary>
private RoslynProject.Snapshot TakeSnapshot( EditorDocument doc )
{
if ( !doc.Path.EndsWith( ".cs", StringComparison.OrdinalIgnoreCase ) ) return null;
try
{
return RoslynProject.TakeSnapshot( doc.Path, _root, _documents.Select( d => (d.Path, d.Editor.Text) ) );
}
catch ( Exception e )
{
Log.Warning( $"[HotCode] Couldn't read the project for code help: {e.Message}" );
return null;
}
}
private void RequestCompletion( EditorDocument doc, TextPos pos )
{
var snapshot = TakeSnapshot( doc );
if ( snapshot is null ) return;
_completions.Request( () => CompletionProvider.Get( snapshot, pos.Line, pos.Col ), result =>
{
if ( result.Error is not null ) Log.Warning( $"[HotCode] Autocomplete failed: {result.Error}" );
else if ( _documents.Contains( doc ) ) doc.Editor.ShowCompletions( result );
} );
}
// --- PARAMETER HINTS ---
private void RequestSignatureHelp( EditorDocument doc, TextPos pos )
{
var snapshot = TakeSnapshot( doc );
if ( snapshot is null ) return;
_signatures.Request( () => SignatureHelpProvider.Get( snapshot, pos.Line, pos.Col ), result =>
{
if ( _documents.Contains( doc ) ) doc.Editor.ShowSignatureHelp( result );
} );
}
private void ShowDefinitionResult( EditorDocument doc, DefinitionResult result )
{
_definitionPending = false;
var open = _documents.Contains( doc );
if ( result.Error is not null )
{
if ( open ) doc.Editor.ShowMessage( result.Error );
return;
}
if ( result.Locations.Count == 0 )
{
if ( open ) doc.Editor.ShowMessage( $"'{result.Symbol}' is defined in {result.Assembly ?? "a compiled library"}, which has no source here.", 6 );
return;
}
if ( open ) doc.Editor.ShowMessage( "", 0 );
if ( result.Locations.Count == 1 )
{
JumpTo( result.Locations[0] );
return;
}
// Several (partial classes): let the user pick
var menu = new ContextMenu( this );
foreach ( var location in result.Locations )
{
var label = $"{Path.GetRelativePath( _root, location.Path )}:{location.Line + 1} {location.Preview}";
menu.AddOption( label, "code", () => JumpTo( location ) );
}
menu.OpenAtCursor();
}
private void JumpTo( DefinitionLocation location )
{
PushNavPoint();
var target = OpenFile( location.Path, preview: false );
target?.Editor.GoTo( location.Line, location.Column );
}
private void PushNavPoint()
{
if ( _active is null ) return;
_navBack.Add( new NavPoint( _active.Path, _active.Editor.CaretPosition ) );
if ( _navBack.Count > 100 ) _navBack.RemoveAt( 0 );
_navForward.Clear();
}
/// <summary>
/// Alt+Left / Alt+Right: back to where we jumped from, and forward again.
/// </summary>
private void Navigate( bool back )
{
var from = back ? _navBack : _navForward;
var to = back ? _navForward : _navBack;
// Skip spots in files that have since been deleted
while ( from.Count > 0 && !File.Exists( from[^1].Path ) ) from.RemoveAt( from.Count - 1 );
if ( from.Count == 0 ) return;
var target = from[^1];
from.RemoveAt( from.Count - 1 );
if ( _active is not null ) to.Add( new NavPoint( _active.Path, _active.Editor.CaretPosition ) );
var doc = OpenFile( target.Path, preview: false );
doc?.Editor.GoTo( target.Pos.Line, target.Pos.Col );
}
[Shortcut( "hotcode.toggle-output", "CTRL+J" )]
public void ToggleOutput() => _output.Visible = !_output.Visible;
// --- FILE TREE ---
internal FileState StateOf( string path )
{
var doc = FindDocument( path );
return doc is null ? FileState.Closed : doc == _active ? FileState.Active : FileState.Open;
}
internal bool IsUnsaved( string path ) => FindDocument( path )?.IsDirty == true;
// --- MENU BAR ---
/// <summary>
/// File / Edit / View / Go / Project / Help. Shortcuts are shown after a tab ("Save\tCtrl+S"), which
/// Qt displays right-aligned without registering a second key binding next to the ones the editor handles.
/// Menus that show on/off state rebuild each time they open.
/// </summary>
private void BuildMenuBar()
{
MenuBar.Clear();
var file = MenuBar.AddMenu( "File" );
file.AddOption( "New File in Code Folder...", "note_add", () => NewFileInCodeFolder( component: false ) );
file.AddOption( "New C# Component...", "code", () => NewFileInCodeFolder( component: true ) );
file.AddOption( "Open File...", "file_open", OpenFileDialog );
file.AddSeparator();
file.AddOption( "Save\tCtrl+S", "save", Save );
file.AddOption( "Save All\tCtrl+Shift+S", "save_as", SaveAll );
file.AddSeparator();
file.AddOption( "Close Tab\tCtrl+W", "close", CloseActiveTab );
file.AddOption( "Close Other Tabs", "tab_unselected", CloseOtherTabs );
file.AddOption( "Close All Tabs", "clear_all", CloseAllTabs );
file.AddSeparator();
file.AddOption( "Reveal Current File in Explorer", "folder_open", () => { if ( _active is not null ) EditorUtility.OpenFileFolder( _active.Path ); } );
file.AddOption( "Copy Current File Path", "content_copy", () => { if ( _active is not null ) EditorUtility.Clipboard.Copy( _active.Path ); } );
file.AddSeparator();
file.AddOption( "Settings...\tCtrl+,", "settings", HotCodeSettingsWindow.Open );
file.AddOption( "HotCode Launcher", HotCodeLogo.IconName, () => new HotCodeLauncher() );
file.AddOption( "Close Window", "logout", () => Close() );
var edit = MenuBar.AddMenu( "Edit" );
edit.AddOption( "Undo\tCtrl+Z", "undo", () => _active?.Editor.Undo() );
edit.AddOption( "Redo\tCtrl+Y", "redo", () => _active?.Editor.Redo() );
edit.AddSeparator();
edit.AddOption( "Cut\tCtrl+X", "content_cut", () => _active?.Editor.Cut() );
edit.AddOption( "Copy\tCtrl+C", "content_copy", () => _active?.Editor.Copy() );
edit.AddOption( "Paste\tCtrl+V", "content_paste", () => _active?.Editor.Paste() );
edit.AddOption( "Select All\tCtrl+A", "select_all", () => _active?.Editor.SelectAll() );
edit.AddSeparator();
edit.AddOption( "Find\tCtrl+F", "search", () => _active?.Editor.OpenFind( replace: false ) );
edit.AddOption( "Replace\tCtrl+H", "find_replace", () => _active?.Editor.OpenFind( replace: true ) );
edit.AddSeparator();
edit.AddOption( "Toggle Comment\tCtrl+/", "comment", () => _active?.Editor.ToggleComment() );
edit.AddOption( "Duplicate Line\tCtrl+D", "content_copy", () => _active?.Editor.DuplicateLines() );
edit.AddOption( "Move Line Up\tAlt+Up", "arrow_upward", () => _active?.Editor.MoveLines( -1 ) );
edit.AddOption( "Move Line Down\tAlt+Down", "arrow_downward", () => _active?.Editor.MoveLines( 1 ) );
edit.AddSeparator();
edit.AddOption( "Trigger Suggestions\tCtrl+Space", "auto_awesome", () => _active?.Editor.RequestCompletionAtCaret() );
edit.AddOption( "Parameter Hints\tCtrl+Shift+Space", "info", () => _active?.Editor.RequestSignatureHelpAtCaret() );
var view = MenuBar.AddMenu( "View" );
view.AboutToShow += () => FillViewMenu( view );
var go = MenuBar.AddMenu( "Go" );
go.AddOption( "Go to Definition\tF12", "start", () => _active?.Editor.RequestDefinitionAtCaret() );
go.AddOption( "Go to Line...\tCtrl+G", "format_list_numbered", GoToLine );
go.AddSeparator();
go.AddOption( "Back\tAlt+Left", "arrow_back", () => Navigate( back: true ) );
go.AddOption( "Forward\tAlt+Right", "arrow_forward", () => Navigate( back: false ) );
go.AddSeparator();
go.AddOption( "Next Problem\tF8", "error", NextProblem );
go.AddOption( "Previous Problem\tShift+F8", "error_outline", PreviousProblem );
var project = MenuBar.AddMenu( "Project" );
project.AboutToShow += () => FillProjectMenu( project );
var help = MenuBar.AddMenu( "Help" );
help.AddOption( "Welcome Page / Shortcuts", "home", ShowWelcomePage );
help.AddOption( "Settings...", "settings", HotCodeSettingsWindow.Open );
help.AddOption( "About HotCode", HotCodeLogo.IconName, () =>
new PopupWindow( "About HotCode", "HotCode - a code editor built into s&box.\n\nRoslyn-powered autocomplete, parameter hints and go to definition, with live compile errors from s&box.", "Close", new Dictionary<string, Action>() ).Show() );
}
private void FillProjectMenu( Menu project )
{
project.Clear();
project.AddOption( "Open Project Folder", "folder_open", () => EditorUtility.OpenFolder( _root ) );
project.AddOption( "Open in External Code Editor", "launch", () => CodeEditor.OpenSolution() );
project.AddOption( "Generate Solution", "account_tree", () =>
{
EditorUtility.Projects.GenerateSolution();
Log.Info( "[HotCode] Generated solution files" );
} );
project.AddSeparator();
project.AddOption( "Refresh File Tree", "refresh", () => _rootNode.Dirty() );
project.AddSeparator();
HotCodeInstallUi.AddMenuOptions( project, _entry, null );
project.AddOption( "Add HotCode to Another Project...", HotCodeLogo.IconName, () =>
{
var fd = new FileDialog( null );
fd.Title = "Add HotCode to Project";
fd.Directory = Path.GetDirectoryName( _root );
fd.SetFindExistingFile();
fd.SetModeOpen();
fd.SetNameFilter( "s&box Project (*.sbproj)" );
if ( fd.Execute() ) HotCodeInstallUi.AskInstall( HotCodeEntry.FromPath( fd.SelectedFile ) );
} );
}
private void FillViewMenu( Menu view )
{
view.Clear();
AddToggle( view, "File Tree", "account_tree", _explorer.Visible, on => _explorer.Visible = on );
AddToggle( view, "Bottom Panel\tCtrl+J", "dock_to_bottom", _output.Visible, on => _output.Visible = on );
AddToggle( view, "Status Bar", "power_input", StatusBar.Visible, on => StatusBar.Visible = on );
view.AddSeparator();
AddToggle( view, "Word Wrap\tAlt+Z", "wrap_text", CodeView.WordWrap, on => CodeView.WordWrap = on );
view.AddSeparator();
view.AddOption( "Zoom In\tCtrl+=", "zoom_in", () => CodeView.FontSize += 1 );
view.AddOption( "Zoom Out\tCtrl+-", "zoom_out", () => CodeView.FontSize -= 1 );
view.AddOption( $"Reset Zoom ({CodeView.FontSize:0}pt)", "youtube_searched_for", () => CodeView.FontSize = CodeView.DefaultFontSize );
view.AddSeparator();
view.AddOption( "Problems", "error_outline", _output.ShowProblems );
view.AddOption( "Output", "terminal", _output.ShowOutput );
}
private static void AddToggle( Menu menu, string text, string icon, bool on, Action<bool> set )
{
var option = menu.AddOption( text, icon );
option.Checkable = true;
option.Checked = on;
option.Toggled += set;
}
/// <summary>
/// New files from the menu go in the project's Code folder (or the project root if there isn't one).
/// </summary>
private void NewFileInCodeFolder( bool component )
{
var code = Path.Combine( _root, "Code" );
var folder = Directory.Exists( code ) ? code : _root;
if ( component )
AskCreate( folder, "New Component", "Component name:", "MyComponent", name => ProjectFiles.CreateComponent( folder, name ) );
else
AskCreate( folder, "New File", "File name:", "NewFile.cs", name => ProjectFiles.CreateFile( folder, name ) );
}
private void OpenFileDialog()
{
var fd = new FileDialog( null );
fd.Title = "Open File";
fd.Directory = _active is not null ? Path.GetDirectoryName( _active.Path ) : _root;
fd.SetFindExistingFile();
fd.SetModeOpen();
if ( fd.Execute() ) OpenFile( fd.SelectedFile, preview: false );
}
[Shortcut( "hotcode.settings", "CTRL+," )]
public void OpenSettings() => HotCodeSettingsWindow.Open();
[Shortcut( "hotcode.save-all", "CTRL+SHIFT+S" )]
public void SaveAll()
{
foreach ( var doc in _documents.Where( d => d.IsDirty ).ToList() )
SaveDocument( doc );
}
private void CloseOtherTabs()
{
foreach ( var doc in _documents.Where( d => d != _active ).ToList() )
RequestClose( doc );
}
private void CloseAllTabs()
{
foreach ( var doc in _documents.ToList() )
RequestClose( doc );
}
/// <summary>
/// Shows the welcome page without closing anything; clicking a tab goes back to it.
/// </summary>
private void ShowWelcomePage()
{
if ( _active is not null ) _active.Editor.Visible = false;
_active = null;
_welcome.SetRecentFiles( RecentFiles() );
_welcome.Visible = true;
_tabs.SetActive( null );
_tree.Update();
}
[Shortcut( "hotcode.next-problem", "F8" )]
public void NextProblem() => StepProblem( 1 );
[Shortcut( "hotcode.previous-problem", "SHIFT+F8" )]
public void PreviousProblem() => StepProblem( -1 );
private int _problemIndex = -1;
private void StepProblem( int direction )
{
var problems = _output.Problems;
if ( problems.Count == 0 ) return;
_problemIndex = ((_problemIndex + direction) % problems.Count + problems.Count) % problems.Count;
GoToProblem( problems[_problemIndex] );
}
// --- EXPLORER HEADER ---
private Widget BuildExplorerHeader( Widget parent, string title )
{
var header = new Widget( parent );
header.FixedHeight = HotCodeStyle.TabHeight;
header.OnPaintOverride = () =>
{
Paint.ClearPen();
Paint.SetBrush( Theme.TabBarBackground );
Paint.DrawRect( header.LocalRect );
Paint.SetDefaultFont( 7, 700 );
Paint.SetPen( Theme.TextLight );
Paint.DrawText( new Rect( 10, 0, header.Width - 120, header.Height ), Paint.GetElidedText( $"EXPLORER - {title.ToUpperInvariant()}", header.Width - 130, ElideMode.Right ), TextFlag.LeftCenter );
return false;
};
header.Layout = Layout.Row();
header.Layout.Margin = new Sandbox.UI.Margin( 0, 0, 4, 0 );
header.Layout.AddStretchCell();
var rootFolder = Directory.Exists( Path.Combine( _root, "Code" ) ) ? Path.Combine( _root, "Code" ) : _root;
header.Layout.Add( new IconButton( "note_add", () => NewFileInCodeFolder( component: false ) ) { ToolTip = "New File" } );
header.Layout.Add( new IconButton( "create_new_folder", () => AskCreate( rootFolder, "New Folder", "Folder name:", "NewFolder", name => ProjectFiles.CreateFolder( rootFolder, name ) ) ) { ToolTip = "New Folder" } );
header.Layout.Add( new IconButton( "refresh", () => _rootNode.Dirty() ) { ToolTip = "Refresh" } );
header.Layout.Add( new IconButton( "unfold_less", () =>
{
_tree.Close( _rootNode, recursive: true );
_tree.Open( _rootNode );
} ) { ToolTip = "Collapse All" } );
return header;
}
// --- RECENT FILES ---
private string RecentFilesCookie => "hotcode.recentfiles." + _root.ToLowerInvariant().Replace( '\\', '/' );
private List<string> RecentFiles() => EditorCookie.Get<List<string>>( RecentFilesCookie, null ) ?? new();
private void AddRecentFile( string path )
{
var files = RecentFiles();
files.RemoveAll( f => string.Equals( f, path, StringComparison.OrdinalIgnoreCase ) );
files.Insert( 0, path );
if ( files.Count > 20 ) files.RemoveRange( 20, files.Count - 20 );
EditorCookie.Set( RecentFilesCookie, files );
}
// --- GO TO LINE ---
/// <summary>
/// Ctrl+G: "42" or "42:7" (line:column).
/// </summary>
[Shortcut( "hotcode.goto-line", "CTRL+G" )]
public void GoToLine()
{
var doc = _active;
if ( doc is null ) return;
var current = doc.Editor.CaretPosition;
Dialog.AskString( text =>
{
var parts = text.Split( ':', ',' );
if ( !int.TryParse( parts[0].Trim(), out var line ) ) return;
var column = parts.Length > 1 && int.TryParse( parts[1].Trim(), out var c ) ? c : 1;
PushNavPoint();
doc.Editor.GoTo( Math.Clamp( line, 1, doc.Editor.LineCount ) - 1, Math.Max( 1, column ) - 1 );
doc.Editor.FocusEditor();
}, $"Line number (1 - {doc.Editor.LineCount}), or line:column", okay: "Go", initialName: (current.Line + 1).ToString(), title: "Go to Line" );
}
// --- AUTO SAVE ---
/// <summary>
/// With Auto Save "after a short pause", saves files once you've stopped typing for a moment.
/// Saving is what triggers s&box's hotload, so the game keeps up with your edits.
/// </summary>
private void AutoSaveIfIdle()
{
if ( HotCodeSettings.AutoSave != AutoSaveMode.AfterDelay ) return;
foreach ( var doc in _documents )
{
if ( doc.IsDirty && RealTime.Now - doc.LastEdit > HotCodeSettings.AutoSaveDelay && File.Exists( doc.Path ) )
SaveDocument( doc );
}
}
// --- STATUS BAR ---
private void UpdateStatusBar()
{
// Is s&box compiling this project? If not, there's no live build to report on.
if ( RealTime.Now > _nextLoadedCheck )
{
_nextLoadedCheck = RealTime.Now + 2;
_projectLoaded = EditorUtility.Projects.GetAll().Any( p =>
{
var root = p.GetRootPath();
return !string.IsNullOrEmpty( root ) && (ProjectFiles.IsUnder( _root, root ) || ProjectFiles.IsUnder( root, _root ));
} );
}
if ( !_projectLoaded )
_buildStatus.Set( "Not loaded in s&box - no live compile", "cloud_off", Theme.TextLight );
else if ( _output.IsBuilding )
_buildStatus.Set( "Building...", "sync", Theme.Primary );
else if ( _output.ErrorCount > 0 )
_buildStatus.Set( $"{_output.ErrorCount} error{(_output.ErrorCount == 1 ? "" : "s")}, {_output.WarningCount} warning{(_output.WarningCount == 1 ? "" : "s")}", "error", Theme.Red );
else if ( _output.WarningCount > 0 )
_buildStatus.Set( $"Build OK, {_output.WarningCount} warning{(_output.WarningCount == 1 ? "" : "s")}", "warning", Theme.Yellow );
else if ( _output.LastBuildSucceeded == true )
_buildStatus.Set( "Build succeeded", "check_circle", Theme.Green );
else
_buildStatus.Set( "Ready", "check", Theme.TextLight );
var doc = _active;
if ( doc is null )
{
foreach ( var item in new[] { _position, _indent, _lineEnding, _encoding, _language } )
item.Set( "" );
return;
}
var caret = doc.Editor.CaretPosition;
var selected = doc.Editor.SelectionLength;
_position.Set( $"Ln {caret.Line + 1}, Col {caret.Col + 1}" + (selected > 0 ? $" ({selected} selected)" : "") );
_indent.Set( doc.Editor.IndentDescription );
_lineEnding.Set( doc.Editor.LineEndingName );
_encoding.Set( doc.HasBom ? "UTF-8 with BOM" : "UTF-8" );
_language.Set( doc.Editor.LanguageName, HotCodeStyle.FileIcon( doc.Path ), HotCodeStyle.FileIconColor( doc.Path ) );
}
internal void RegisterNode( CodeFileNode node )
{
if ( node.IsFolder ) _nodes[Normalize( node.FullPath )] = node;
}
private static string Normalize( string path ) => Path.GetFullPath( path ).TrimEnd( '\\', '/' );
/// <summary>
/// Rebuilds a folder's children in the tree (and opens it, so new items are visible).
/// </summary>
private void RefreshFolder( string folder, bool open = false )
{
if ( !_nodes.TryGetValue( Normalize( folder ), out var node ) ) return;
node.Dirty();
if ( open ) _tree.Open( node );
}
internal void ShowFileContextMenu( CodeFileNode node )
{
var path = node.FullPath;
var folder = node.IsFolder ? path : Path.GetDirectoryName( path );
var isRoot = string.Equals( Normalize( path ), Normalize( _root ), StringComparison.OrdinalIgnoreCase );
var menu = new ContextMenu( this );
if ( !node.IsFolder )
{
menu.AddOption( "Open", "open_in_new", () => OpenFile( path, preview: false ) );
menu.AddOption( "Open in External Editor", "launch", () => CodeEditor.OpenFile( path ) );
menu.AddSeparator();
}
menu.AddOption( "New File...", "note_add", () => AskCreate( folder, "New File", "File name:", "NewFile.cs", name => ProjectFiles.CreateFile( folder, name ) ) );
menu.AddOption( "New C# Component...", "code", () => AskCreate( folder, "New Component", "Component name:", "MyComponent", name => ProjectFiles.CreateComponent( folder, name ) ) );
menu.AddOption( "New Folder...", "create_new_folder", () => AskCreate( folder, "New Folder", "Folder name:", "NewFolder", name => ProjectFiles.CreateFolder( folder, name ) ) );
menu.AddSeparator();
if ( !isRoot )
{
menu.AddOption( "Rename...", "drive_file_rename_outline", () => AskRename( node ) );
menu.AddOption( "Delete", "delete", () => AskDelete( node ) );
menu.AddSeparator();
}
menu.AddOption( "Copy Path", "content_copy", () => EditorUtility.Clipboard.Copy( path ) );
menu.AddOption( "Copy Relative Path", "content_copy", () => EditorUtility.Clipboard.Copy( Path.GetRelativePath( _root, path ) ) );
menu.AddOption( "Reveal in File Explorer", "folder_open", () => EditorUtility.OpenFileFolder( path ) );
if ( node.IsFolder )
menu.AddOption( "Refresh", "refresh", () => node.Dirty() );
menu.OpenAtCursor();
}
private void AskCreate( string folder, string title, string question, string suggestion, Func<string, string> create )
{
Dialog.AskString( name =>
{
if ( !TryFileOp( () => create( name ), out var created ) ) return;
RefreshFolder( folder, open: true );
if ( File.Exists( created ) ) OpenFile( created, preview: false );
}, question, okay: "Create", initialName: suggestion, title: title );
}
private void AskRename( CodeFileNode node )
{
var oldPath = node.FullPath;
Dialog.AskString( name =>
{
if ( !TryFileOp( () => ProjectFiles.Rename( oldPath, name ), out var newPath ) ) return;
// Open tabs follow the file (or everything inside a renamed folder)
foreach ( var doc in _documents )
{
if ( string.Equals( doc.Path, oldPath, StringComparison.OrdinalIgnoreCase ) )
doc.Path = newPath;
else if ( ProjectFiles.IsUnder( doc.Path, oldPath ) )
doc.Path = newPath + doc.Path[oldPath.Length..];
}
_tabs.Update();
RefreshFolder( Path.GetDirectoryName( oldPath ) );
}, "New name:", okay: "Rename", initialName: node.Name, title: "Rename" );
}
private void AskDelete( CodeFileNode node )
{
var path = node.FullPath;
var affected = _documents.Where( d => ProjectFiles.IsUnder( d.Path, path ) ).ToList();
var warning = affected.Any( d => d.IsDirty ) ? " Unsaved changes in open files will be lost." : "";
Dialog.AskConfirm( () =>
{
if ( !TryFileOp( () => { ProjectFiles.Delete( path ); return path; }, out _ ) ) return;
foreach ( var doc in affected ) CloseDocument( doc );
RefreshFolder( Path.GetDirectoryName( path ) );
}, $"Move '{node.Name}' to the Recycle Bin?{warning}", "Delete", "Delete", "Cancel" );
}
/// <summary>
/// Runs a file operation and shows any error instead of throwing.
/// </summary>
private bool TryFileOp( Func<string> op, out string result )
{
try
{
result = op();
return true;
}
catch ( Exception e ) when ( e is InvalidOperationException or IOException or UnauthorizedAccessException or OperationCanceledException )
{
result = null;
Log.Warning( $"[HotCode] {e.Message}" );
new PopupWindow( "HotCode", e.Message, "OK", new Dictionary<string, Action>() ).Show();
return false;
}
}
// --- WATCHING THE DISK ---
private void QueueChange( string path, bool structural )
{
// Build output churns constantly and is never shown; ignore it
if ( CodeFileNode.IsInHiddenFolder( Path.GetRelativePath( _root, path ) ) ) return;
_changedPaths.Enqueue( (path, structural) );
}
[EditorEvent.Frame]
public void Frame()
{
while ( _definitionResults.TryDequeue( out var definition ) )
ShowDefinitionResult( definition.Doc, definition.Result );
_completions.Pump();
_signatures.Pump();
UpdateStatusBar();
AutoSaveIfIdle();
if ( _changedPaths.IsEmpty ) return;
var folders = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
var files = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
while ( _changedPaths.TryDequeue( out var change ) )
{
files.Add( change.Path );
var parent = Path.GetDirectoryName( change.Path );
if ( change.Structural && parent is not null ) folders.Add( parent );
}
foreach ( var folder in folders )
RefreshFolder( folder );
foreach ( var file in files )
ReloadIfChangedOnDisk( file );
}
/// <summary>
/// Picks up edits made by other programs to a file that's open but has no unsaved changes here.
/// </summary>
private void ReloadIfChangedOnDisk( string path )
{
var doc = FindDocument( path );
if ( doc is null || doc.IsDirty || !File.Exists( path ) || !HotCodeSettings.ReloadChangedFiles ) return;
if ( !TryReadFile( path, out var contents, out _ ) ) return;
// Our own saves land here too; only reload when the text really differs
if ( TextBuffer.Normalize( contents ) == TextBuffer.Normalize( doc.Editor.Text ) ) return;
doc.Editor.ReloadText( contents );
ApplyMarkers( doc );
Log.Info( $"[HotCode] Reloaded {doc.Title} (changed outside HotCode)" );
}
// --- CLOSING ---
[Shortcut( "hotcode.close-tab", "CTRL+W" )]
public void CloseActiveTab()
{
if ( _active is not null )
RequestClose( _active );
}
private void RequestClose( EditorDocument doc )
{
if ( !doc.IsDirty )
{
CloseDocument( doc );
return;
}
var confirm = new PopupWindow(
"Unsaved Changes", $"Save changes to {doc.Title}?", "Cancel",
new Dictionary<string, Action>()
{
{ "Don't Save", () => CloseDocument( doc ) },
{ "Save", () => { if ( SaveDocument( doc ) ) CloseDocument( doc ); } }
}
);
confirm.Show();
}
private void CloseDocument( EditorDocument doc )
{
var index = _documents.IndexOf( doc );
if ( index < 0 ) return;
var wasActive = doc == _active;
RemoveDocument( doc );
if ( wasActive )
{
// Same as most IDEs: move to the tab that slid into this spot, or the one before it
var next = _documents.Count == 0 ? null : _documents[Math.Min( index, _documents.Count - 1 )];
_active = null;
Activate( next );
}
UpdateTitle();
}
/// <summary>
/// Drops a document without any prompts or activating anything else.
/// </summary>
private void RemoveDocument( EditorDocument doc )
{
_documents.Remove( doc );
_tabs.Remove( doc );
doc.Editor.Destroy();
if ( _active == doc )
_active = null;
}
// --- SAVING ---
[Shortcut( "editor.save", "CTRL+S" )]
public void Save()
{
if ( _active is not null )
SaveDocument( _active );
}
private bool SaveDocument( EditorDocument doc )
{
if ( !doc.IsDirty )
return true;
if ( HotCodeSettings.TrimTrailingWhitespace || HotCodeSettings.InsertFinalNewline )
doc.Editor.FormatForSave( HotCodeSettings.TrimTrailingWhitespace, HotCodeSettings.InsertFinalNewline );
try
{
File.WriteAllText( doc.Path, doc.Editor.Text, new UTF8Encoding( doc.HasBom ) );
Log.Info( $"[HotCode] Saved {doc.Title}" );
}
catch ( Exception e ) when ( e is IOException or UnauthorizedAccessException )
{
Log.Warning( $"[HotCode] Couldn't save {doc.Path}: {e.Message}" );
return false;
}
doc.Editor.MarkSaved();
doc.IsDirty = false;
_tabs.Update();
_tree.Update();
UpdateTitle();
return true;
}
private void UpdateTitle()
{
var unsaved = _documents.Count( d => d.IsDirty );
WindowTitle = unsaved > 0
? $"HotCode - {_entry.Title} ({unsaved} unsaved)"
: $"HotCode - {_entry.Title}";
}
protected override bool OnClose()
{
var dirty = _documents.Where( d => d.IsDirty ).ToList();
if ( dirty.Count == 0 )
return true;
var confirm = new PopupWindow(
"Unsaved Changes", $"{dirty.Count} file(s) have unsaved changes. Save before closing?", "Cancel",
new Dictionary<string, Action>()
{
{ "Don't Save", () => { dirty.ForEach( d => d.IsDirty = false ); Close(); } },
{ "Save", () => { if ( dirty.All( SaveDocument ) ) Close(); } }
}
);
confirm.Show();
return false;
}
}
Editor
library
using Sandbox;
using Editor;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
public enum HotCodeEntryKind
{
Project,
Solution
}
/// <summary>
/// Something the launcher can open: an s&box project (.sbproj) or a solution (.sln/.slnx).
/// </summary>
public sealed record HotCodeEntry( string Path, HotCodeEntryKind Kind, string Title, string Type )
{
/// <summary>
/// When it was last opened from HotCode, if it's in the recents list.
/// </summary>
public DateTime? LastOpened { get; init; }
/// <summary>
/// Pinned entries stay at the top and never drop off the recents list.
/// </summary>
public bool Pinned { get; init; }
public string Directory => System.IO.Path.GetDirectoryName( Path );
public string Icon => Kind == HotCodeEntryKind.Solution ? "account_tree" : Type switch
{
"game" => "sports_esports",
"library" => "local_library",
"map" => "public",
"content" => "perm_media",
"tool" => "hardware",
"addon" => "extension",
_ => "folder_special"
};
/// <summary>
/// "Game", "Library", "Solution"...
/// </summary>
public string TypeName => Kind == HotCodeEntryKind.Solution ? "Solution"
: string.IsNullOrEmpty( Type ) ? "Project" : char.ToUpperInvariant( Type[0] ) + Type[1..];
public Color TypeColor => Kind == HotCodeEntryKind.Solution ? Theme.Pink : Type switch
{
"game" => Theme.Green,
"library" => Theme.Blue,
"addon" or "content" or "map" => Theme.Yellow,
_ => Theme.TextLight
};
public static HotCodeEntry FromPath( string path )
{
var ext = System.IO.Path.GetExtension( path ).ToLowerInvariant();
if ( ext is ".sln" or ".slnx" )
return new HotCodeEntry( path, HotCodeEntryKind.Solution, System.IO.Path.GetFileNameWithoutExtension( path ), "solution" );
var title = System.IO.Path.GetFileNameWithoutExtension( path );
var type = "";
try
{
using var doc = JsonDocument.Parse( File.ReadAllText( path ) );
if ( doc.RootElement.TryGetProperty( "Title", out var t ) && t.GetString() is { Length: > 0 } s ) title = s;
if ( doc.RootElement.TryGetProperty( "Type", out var ty ) ) type = ty.GetString() ?? "";
}
catch ( Exception e )
{
Log.Warning( $"[HotCode] Couldn't read {path}: {e.Message}" );
}
return new HotCodeEntry( path, HotCodeEntryKind.Project, title, type );
}
}
/// <summary>
/// One remembered project or solution. Stored in an editor cookie, so it's a plain class for JSON.
/// </summary>
public class RecentRecord
{
public string Path { get; set; }
public DateTime LastOpened { get; set; }
public bool Pinned { get; set; }
}
/// <summary>
/// Recently opened projects/solutions, persisted across editor sessions.
/// </summary>
public static class RecentEntries
{
const string CookieKey = "hotcode.recent.v2";
const string OldCookieKey = "hotcode.recent";
/// <summary>
/// How many unpinned entries to keep. Pinned ones don't count.
/// </summary>
const int MaxEntries = 8;
private static List<RecentRecord> Load()
{
var records = EditorCookie.Get<List<RecentRecord>>( CookieKey, null );
if ( records is not null ) return records;
// Carry over the old list of plain paths
var old = EditorCookie.Get<List<string>>( OldCookieKey, null ) ?? new();
records = old.Select( ( p, i ) => new RecentRecord { Path = p, LastOpened = DateTime.Now.AddMinutes( -i ) } ).ToList();
Save( records );
return records;
}
private static void Save( List<RecentRecord> records ) => EditorCookie.Set( CookieKey, records );
/// <summary>
/// Recent entries that still exist on disk: pinned first, then most recent.
/// </summary>
public static IEnumerable<HotCodeEntry> GetAll() => Load()
.Where( r => File.Exists( r.Path ) )
.OrderByDescending( r => r.Pinned )
.ThenByDescending( r => r.LastOpened )
.Select( r => HotCodeEntry.FromPath( r.Path ) with { LastOpened = r.LastOpened, Pinned = r.Pinned } );
/// <summary>
/// Marks a path as just opened (adding it if needed).
/// </summary>
public static void Touch( string path )
{
var records = Load();
var record = Find( records, path );
if ( record is null )
{
record = new RecentRecord { Path = Normalize( path ) };
records.Add( record );
}
record.LastOpened = DateTime.Now;
// Drop the oldest unpinned ones past the limit
var extra = records.Where( r => !r.Pinned ).OrderByDescending( r => r.LastOpened ).Skip( MaxEntries ).ToList();
records.RemoveAll( extra.Contains );
Save( records );
}
public static void SetPinned( string path, bool pinned )
{
var records = Load();
var record = Find( records, path );
if ( record is null )
{
record = new RecentRecord { Path = Normalize( path ), LastOpened = DateTime.Now };
records.Add( record );
}
record.Pinned = pinned;
Save( records );
}
public static void Remove( string path )
{
var records = Load();
records.RemoveAll( r => Normalize( r.Path ) == Normalize( path ) );
Save( records );
}
private static RecentRecord Find( List<RecentRecord> records, string path ) => records.FirstOrDefault( r => Normalize( r.Path ) == Normalize( path ) );
public static string Normalize( string path ) => System.IO.Path.GetFullPath( path ).Replace( '\\', '/' );
/// <summary>
/// "just now", "5 minutes ago", "yesterday", "12 Mar"...
/// </summary>
public static string Ago( DateTime time )
{
var span = DateTime.Now - time;
if ( span.TotalMinutes < 1 ) return "just now";
if ( span.TotalMinutes < 60 ) return $"{(int)span.TotalMinutes} min ago";
if ( span.TotalHours < 24 ) return $"{(int)span.TotalHours} hour{((int)span.TotalHours == 1 ? "" : "s")} ago";
if ( span.TotalDays < 2 ) return "yesterday";
if ( span.TotalDays < 7 ) return $"{(int)span.TotalDays} days ago";
return time.ToString( time.Year == DateTime.Now.Year ? "d MMM" : "d MMM yyyy" );
}
}
Editor
library
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
/// <summary>
/// Where a symbol is declared. Line and Column are zero-based.
/// </summary>
public record DefinitionLocation( string Path, int Line, int Column, string Preview );
/// <summary>
/// The answer to "go to definition": source locations, or the assembly a symbol comes from
/// when there's no source for it (like s&box's own types).
/// </summary>
public record DefinitionResult( string Symbol, IReadOnlyList<DefinitionLocation> Locations, string Assembly, string Error )
{
public static DefinitionResult Fail( string error ) => new( null, Array.Empty<DefinitionLocation>(), null, error );
}
/// <summary>
/// Go to definition, using the compilation from <see cref="RoslynProject"/>.
/// </summary>
public static class DefinitionFinder
{
/// <summary>
/// Finds the definition of whatever is at (line, column) in the snapshot's file. Safe to call off the main thread.
/// </summary>
public static DefinitionResult Find( RoslynProject.Snapshot snapshot, int line, int column )
{
try
{
return FindInternal( snapshot, line, column );
}
catch ( Exception e )
{
return DefinitionResult.Fail( $"Couldn't look that up: {e.Message}" );
}
}
private static DefinitionResult FindInternal( RoslynProject.Snapshot snapshot, int line, int column )
{
var (compilation, target) = RoslynProject.GetCompilation( snapshot );
if ( target is null )
return DefinitionResult.Fail( "Go to Definition works in C# files inside the project's code folders." );
var model = compilation.GetSemanticModel( target );
var symbol = SymbolAt( model, target, line, column );
if ( symbol is null )
return DefinitionResult.Fail( "Nothing to go to here." );
symbol = Unwrap( symbol );
var display = symbol.ToDisplayString( SymbolDisplayFormat.CSharpShortErrorMessageFormat );
var locations = symbol.Locations
.Where( l => l.IsInSource && System.IO.File.Exists( l.SourceTree?.FilePath ) )
.Select( l =>
{
var span = l.GetLineSpan();
var source = l.SourceTree.GetText().Lines[span.StartLinePosition.Line].ToString().Trim();
return new DefinitionLocation( l.SourceTree.FilePath, span.StartLinePosition.Line, span.StartLinePosition.Character, source );
} )
.ToList();
return new DefinitionResult( display, locations, symbol.ContainingAssembly?.Name, null );
}
/// <summary>
/// The symbol for the identifier at a position. Also works with the caret just after a word.
/// </summary>
private static ISymbol SymbolAt( SemanticModel model, SyntaxTree tree, int line, int column )
{
var text = tree.GetText();
if ( line < 0 || line >= text.Lines.Count ) return null;
var lineInfo = text.Lines[line];
var position = Math.Min( lineInfo.Start + column, lineInfo.End );
var root = tree.GetRoot();
foreach ( var candidate in new[] { position, position - 1 } )
{
if ( candidate < lineInfo.Start ) continue;
var token = root.FindToken( candidate );
if ( !token.Span.Contains( candidate ) && token.Span.End != candidate ) continue;
if ( !token.IsKind( SyntaxKind.IdentifierToken ) && !SyntaxFacts.IsPredefinedType( token.Kind() ) && !token.IsKind( SyntaxKind.BaseKeyword ) && !token.IsKind( SyntaxKind.ThisKeyword ) )
continue;
var symbol = SymbolFor( model, token );
if ( symbol is not null ) return symbol;
}
return null;
}
private static ISymbol SymbolFor( SemanticModel model, SyntaxToken token )
{
var node = token.Parent;
if ( node is null ) return null;
// On a declaration's own name: go to that declaration (useful for partial classes and overrides)
var declared = model.GetDeclaredSymbol( node );
if ( declared is not null && token == DeclarationIdentifier( node ) )
{
// An override's name jumps to what it overrides, since you're already at its definition
return declared switch
{
IMethodSymbol { OverriddenMethod: { } m } => m,
IPropertySymbol { OverriddenProperty: { } p } => p,
IEventSymbol { OverriddenEvent: { } ev } => ev,
_ => declared
};
}
var info = model.GetSymbolInfo( node );
var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault();
if ( symbol is null && node.Parent is not null )
{
info = model.GetSymbolInfo( node.Parent );
symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault();
}
// `new Foo()` with no constructor written lands on Foo itself
if ( symbol is IMethodSymbol { MethodKind: MethodKind.Constructor, IsImplicitlyDeclared: true } ctor )
symbol = ctor.ContainingType;
// `var` resolves to the type it stands for
symbol ??= model.GetTypeInfo( node ).Type;
return symbol;
}
private static SyntaxToken? DeclarationIdentifier( SyntaxNode node ) => node switch
{
BaseTypeDeclarationSyntax t => t.Identifier,
DelegateDeclarationSyntax d => d.Identifier,
MethodDeclarationSyntax m => m.Identifier,
PropertyDeclarationSyntax p => p.Identifier,
EventDeclarationSyntax e => e.Identifier,
VariableDeclaratorSyntax v => v.Identifier,
ParameterSyntax p => p.Identifier,
ConstructorDeclarationSyntax c => c.Identifier,
EnumMemberDeclarationSyntax e => e.Identifier,
_ => null
};
/// <summary>
/// From a use of a generic or extension method back to the thing that was declared.
/// </summary>
private static ISymbol Unwrap( ISymbol symbol )
{
if ( symbol is IMethodSymbol { ReducedFrom: { } reduced } ) symbol = reduced;
if ( symbol is IAliasSymbol alias ) symbol = alias.Target;
return symbol.OriginalDefinition ?? symbol;
}
}
Editor
library
using Sandbox;
using Editor;
using System;
/// <summary>
/// HotCode's settings, laid out like s&box's own Editor Settings: pages down the left, and a
/// ControlSheet of properties on each page. Changes save and apply straight away.
/// </summary>
public class HotCodeSettingsWindow : Window
{
private static HotCodeSettingsWindow _open;
/// <summary>
/// Opens the settings, or brings the already-open window to the front.
/// </summary>
public static void Open()
{
if ( _open.IsValid() )
{
_open.Raise();
_open.Focus( false );
return;
}
_open = new HotCodeSettingsWindow();
}
[Menu( "Editor", "HotCode/Settings", HotCodeLogo.IconName )]
public static void OpenFromEditorMenu() => Open();
private HotCodeSettingsWindow()
{
WindowTitle = "HotCode Settings";
SetWindowIcon( HotCodeLogo.WindowIcon );
DeleteOnClose = true;
Size = new Vector2( 860, 620 );
MinimumSize = new Vector2( 640, 420 );
Canvas = new Widget( this );
Canvas.Layout = Layout.Column();
Canvas.Layout.Margin = 4;
Canvas.Layout.Spacing = 4;
var nav = Canvas.Layout.Add( new NavigationView( Canvas ), 1 );
nav.AddSectionHeader( "HotCode" );
nav.AddPage( "Editor", "edit_note", Page( "Editor", sheet =>
{
sheet.AddProperty( () => HotCodeSettings.FontSize );
sheet.AddProperty( () => HotCodeSettings.FontFamily );
sheet.AddProperty( () => HotCodeSettings.LineSpacing );
sheet.AddProperty( () => HotCodeSettings.TabSize );
sheet.AddProperty( () => HotCodeSettings.IndentWithSpaces );
sheet.AddProperty( () => HotCodeSettings.WordWrap );
} ) );
nav.AddPage( "Appearance", "palette", Page( "Appearance", sheet =>
{
sheet.AddProperty( () => HotCodeSettings.CodeColors );
sheet.AddProperty( () => HotCodeSettings.ShowLineNumbers );
sheet.AddProperty( () => HotCodeSettings.HighlightCurrentLine );
sheet.AddProperty( () => HotCodeSettings.CursorBlink );
} ) );
nav.AddPage( "Code Help", "auto_awesome", Page( "Code Help", sheet =>
{
sheet.AddProperty( () => HotCodeSettings.SuggestWhileTyping );
sheet.AddProperty( () => HotCodeSettings.ParameterHints );
sheet.AddProperty( () => HotCodeSettings.AutoCloseBrackets );
sheet.AddProperty( () => HotCodeSettings.UnderlineErrors );
} ) );
nav.AddPage( "Files", "save", Page( "Files", sheet =>
{
sheet.AddProperty( () => HotCodeSettings.AutoSave );
sheet.AddProperty( () => HotCodeSettings.AutoSaveDelay );
sheet.AddProperty( () => HotCodeSettings.TrimTrailingWhitespace );
sheet.AddProperty( () => HotCodeSettings.InsertFinalNewline );
sheet.AddProperty( () => HotCodeSettings.ReloadChangedFiles );
} ) );
// Footer
Canvas.Layout.AddSeparator( 1, Theme.Border );
var footer = Canvas.Layout.AddRow();
footer.Margin = new Sandbox.UI.Margin( 8, 4, 8, 6 );
footer.Spacing = 8;
var note = footer.Add( new Label( "Changes save and apply immediately.", Canvas ) );
note.SetStyles( $"color: {Theme.TextLight.Hex};" );
footer.AddStretchCell();
footer.Add( new Button( "Reset to Defaults", "settings_backup_restore", Canvas )
{
Clicked = () => Dialog.AskConfirm( () =>
{
HotCodeSettings.ResetToDefaults();
_open = null;
Close();
Open();
}, "Reset every HotCode setting to its default?", "Reset Settings", "Reset", "Cancel" )
} );
footer.Add( new Button.Primary( "Done", "done", Canvas ) { Clicked = () => Close() } );
Show();
}
/// <summary>
/// A settings page: a heading and a property sheet, padded like s&box's preference pages.
/// </summary>
private Widget Page( string title, Action<ControlSheet> fill )
{
var page = new Widget( this );
page.Layout = Layout.Column();
page.Layout.Margin = 32;
page.Layout.Spacing = 8;
var heading = page.Layout.AddRow();
heading.Spacing = 10;
heading.Add( new LogoWidget( page, 22, badge: false ) );
heading.Add( new Label.Subtitle( title, page ) );
heading.AddStretchCell();
var sheet = new ControlSheet();
fill( sheet );
page.Layout.Add( sheet );
page.Layout.AddStretchCell();
return page;
}
}
Editor
library
using System;
using System.Collections.Generic;
using System.IO;
public enum TokenKind
{
Text,
Keyword,
ControlKeyword,
Type,
Method,
String,
Number,
Comment,
Preprocessor,
Property
}
public readonly record struct Token( int Start, int Length, TokenKind Kind );
/// <summary>
/// What a line starts "inside of", for constructs that span lines.
/// </summary>
public enum LexState
{
Normal,
BlockComment,
VerbatimString,
RawString
}
/// <summary>
/// A small, line-based tokenizer configured per language. It isn't a real parser: types and
/// methods are guessed (PascalCase word = type, word followed by "(" = method), which is
/// right most of the time for C# and cheap enough to run on every keystroke.
/// </summary>
public class SyntaxLanguage
{
public string Name { get; init; }
public HashSet<string> Keywords { get; init; } = new();
public HashSet<string> ControlKeywords { get; init; } = new();
public string LineComment { get; init; }
public bool BlockComments { get; init; }
public bool CSharpStrings { get; init; }
public bool SingleQuoteStrings { get; init; }
public bool HashPreprocessor { get; init; }
public bool GuessTypesAndMethods { get; init; }
/// <summary>
/// CSS-style: identifiers may contain '-', and "name:" is a property.
/// </summary>
public bool CssIdentifiers { get; init; }
/// <summary>
/// No highlighting at all.
/// </summary>
public bool IsPlain { get; init; }
public static readonly SyntaxLanguage Plain = new() { Name = "Plain Text", IsPlain = true };
public static readonly SyntaxLanguage CSharp = new()
{
Name = "C#",
LineComment = "//",
BlockComments = true,
CSharpStrings = true,
HashPreprocessor = true,
GuessTypesAndMethods = true,
Keywords = new()
{
"abstract", "as", "base", "bool", "byte", "char", "checked", "class", "const", "decimal", "default", "delegate",
"double", "enum", "event", "explicit", "extern", "false", "fixed", "float", "implicit", "in", "int", "interface",
"internal", "is", "lock", "long", "namespace", "new", "null", "object", "operator", "out", "override", "params",
"private", "protected", "public", "readonly", "ref", "sbyte", "sealed", "short", "sizeof", "stackalloc", "static",
"string", "struct", "this", "true", "typeof", "uint", "ulong", "unchecked", "unsafe", "ushort", "using", "virtual",
"void", "volatile", "var", "dynamic", "async", "get", "set", "init", "value", "partial", "where", "record", "nameof",
"required", "global", "with", "not", "and", "or", "nint", "nuint", "file", "scoped", "field", "add", "remove", "let",
"from", "select", "orderby", "group", "into", "on", "equals", "by", "ascending", "descending", "join", "alias"
},
ControlKeywords = new()
{
"if", "else", "switch", "case", "for", "foreach", "while", "do", "break", "continue", "return", "goto", "try",
"catch", "finally", "throw", "yield", "await", "when"
}
};
public static readonly SyntaxLanguage Json = new()
{
Name = "JSON",
LineComment = "//",
BlockComments = true,
Keywords = new() { "true", "false", "null" }
};
public static readonly SyntaxLanguage Css = new()
{
Name = "SCSS",
LineComment = "//",
BlockComments = true,
SingleQuoteStrings = true,
CssIdentifiers = true,
Keywords = new() { "!important", "@import", "@use", "@media", "@keyframes", "@mixin", "@include", "@extend", "@if", "@else", "@each", "@for", "@function", "@return" }
};
public static SyntaxLanguage FromPath( string path ) => Path.GetExtension( path ).ToLowerInvariant() switch
{
".cs" => CSharp,
".json" or ".sbproj" => Json,
".scss" or ".css" => Css,
_ => Plain
};
/// <summary>
/// Appends the colored tokens of <paramref name="line"/> to <paramref name="tokens"/> (plain text gets no token)
/// and returns the state the next line starts in.
/// </summary>
public LexState Tokenize( string line, LexState state, List<Token> tokens )
{
if ( IsPlain ) return LexState.Normal;
int i = 0;
int n = line.Length;
// Continue whatever the previous line left open
switch ( state )
{
case LexState.BlockComment:
{
var end = line.IndexOf( "*/", StringComparison.Ordinal );
if ( end < 0 ) { Add( tokens, 0, n, TokenKind.Comment ); return state; }
Add( tokens, 0, end + 2, TokenKind.Comment );
i = end + 2;
break;
}
case LexState.VerbatimString:
{
var end = FindVerbatimEnd( line, 0 );
if ( end < 0 ) { Add( tokens, 0, n, TokenKind.String ); return state; }
Add( tokens, 0, end, TokenKind.String );
i = end;
break;
}
case LexState.RawString:
{
var end = line.IndexOf( "\"\"\"", StringComparison.Ordinal );
if ( end < 0 ) { Add( tokens, 0, n, TokenKind.String ); return state; }
Add( tokens, 0, end + 3, TokenKind.String );
i = end + 3;
break;
}
}
if ( HashPreprocessor && i == 0 )
{
var first = 0;
while ( first < n && char.IsWhiteSpace( line[first] ) ) first++;
if ( first < n && line[first] == '#' )
{
Add( tokens, first, n - first, TokenKind.Preprocessor );
return LexState.Normal;
}
}
while ( i < n )
{
var c = line[i];
if ( char.IsWhiteSpace( c ) ) { i++; continue; }
// Comments
if ( LineComment is not null && string.CompareOrdinal( line, i, LineComment, 0, LineComment.Length ) == 0 )
{
Add( tokens, i, n - i, TokenKind.Comment );
return LexState.Normal;
}
if ( BlockComments && c == '/' && i + 1 < n && line[i + 1] == '*' )
{
var end = line.IndexOf( "*/", i + 2, StringComparison.Ordinal );
if ( end < 0 ) { Add( tokens, i, n - i, TokenKind.Comment ); return LexState.BlockComment; }
Add( tokens, i, end + 2 - i, TokenKind.Comment );
i = end + 2;
continue;
}
// Strings
if ( CSharpStrings && TryStartCSharpString( line, i, out var prefix, out var verbatim, out var raw ) )
{
var bodyStart = i + prefix;
if ( raw )
{
var end = line.IndexOf( "\"\"\"", bodyStart, StringComparison.Ordinal );
if ( end < 0 ) { Add( tokens, i, n - i, TokenKind.String ); return LexState.RawString; }
Add( tokens, i, end + 3 - i, TokenKind.String );
i = end + 3;
}
else if ( verbatim )
{
var end = FindVerbatimEnd( line, bodyStart );
if ( end < 0 ) { Add( tokens, i, n - i, TokenKind.String ); return LexState.VerbatimString; }
Add( tokens, i, end - i, TokenKind.String );
i = end;
}
else
{
var end = FindQuotedEnd( line, bodyStart, '"' );
Add( tokens, i, end - i, TokenKind.String );
i = end;
}
continue;
}
if ( c == '"' || (c == '\'' && (CSharpStrings || SingleQuoteStrings)) )
{
var end = FindQuotedEnd( line, i + 1, c );
Add( tokens, i, end - i, TokenKind.String );
i = end;
continue;
}
// Numbers
if ( char.IsDigit( c ) || (c == '.' && i + 1 < n && char.IsDigit( line[i + 1] )) )
{
var start = i;
while ( i < n && (char.IsLetterOrDigit( line[i] ) || line[i] == '.' || line[i] == '_') ) i++;
if ( CssIdentifiers && i < n && line[i] == '%' ) i++;
Add( tokens, start, i - start, TokenKind.Number );
continue;
}
// CSS hex colours: #fff, #a0b1c2
if ( CssIdentifiers && c == '#' && i + 1 < n && Uri.IsHexDigit( line[i + 1] ) )
{
var start = i++;
while ( i < n && char.IsLetterOrDigit( line[i] ) ) i++;
Add( tokens, start, i - start, TokenKind.Number );
continue;
}
// Words
if ( IsIdentStart( c ) || (c == '@' && i + 1 < n && IsIdentStart( line[i + 1] )) || (CssIdentifiers && (c == '$' || c == '!')) )
{
var start = i++;
while ( i < n && IsIdentPart( line[i] ) ) i++;
var word = line.Substring( start, i - start );
var kind = ClassifyWord( line, word, i );
if ( kind != TokenKind.Text ) Add( tokens, start, i - start, kind );
continue;
}
i++;
}
return LexState.Normal;
}
private TokenKind ClassifyWord( string line, string word, int after )
{
if ( ControlKeywords.Contains( word ) ) return TokenKind.ControlKeyword;
if ( Keywords.Contains( word ) ) return TokenKind.Keyword;
if ( CssIdentifiers )
{
if ( word[0] == '$' ) return TokenKind.Type;
// "color: red" is a property, but "a:hover {" is a selector
var isFirstOnLine = string.IsNullOrWhiteSpace( line[..(after - word.Length)] );
return isFirstOnLine && NextNonSpace( line, after ) == ':' && !line.Contains( '{' ) ? TokenKind.Property : TokenKind.Text;
}
if ( !GuessTypesAndMethods ) return TokenKind.Text;
var nextChar = NextNonSpace( line, after );
if ( nextChar == '(' ) return TokenKind.Method;
var bare = word[0] == '@' ? word[1..] : word;
if ( bare.Length > 0 && char.IsUpper( bare[0] ) ) return TokenKind.Type;
return TokenKind.Text;
}
private bool IsIdentStart( char c ) => char.IsLetter( c ) || c == '_';
private bool IsIdentPart( char c ) => char.IsLetterOrDigit( c ) || c == '_' || (CssIdentifiers && c == '-');
private static char SafeChar( string s, int i ) => i < s.Length ? s[i] : '\0';
private static char NextNonSpace( string line, int i )
{
while ( i < line.Length && line[i] == ' ' ) i++;
return SafeChar( line, i );
}
/// <summary>
/// Recognises the C# string openers: " $" @" $@" @$" """ $""" (and $$""" etc).
/// </summary>
private static bool TryStartCSharpString( string line, int i, out int prefixLength, out bool verbatim, out bool raw )
{
prefixLength = 0;
verbatim = false;
raw = false;
var j = i;
while ( j < line.Length && (line[j] == '$' || line[j] == '@') )
{
if ( line[j] == '@' ) verbatim = true;
j++;
}
if ( j >= line.Length || line[j] != '"' )
return false;
// Plain "..." is handled by the generic quote branch
if ( j == i && !(j + 2 < line.Length && line[j + 1] == '"' && line[j + 2] == '"') )
return false;
if ( !verbatim && j + 2 < line.Length && line[j + 1] == '"' && line[j + 2] == '"' )
{
raw = true;
var k = j;
while ( k < line.Length && line[k] == '"' ) k++;
prefixLength = k - i;
return true;
}
prefixLength = j + 1 - i;
return true;
}
/// <summary>
/// Index just past the closing quote of a verbatim string ("" is an escaped quote), or -1 if it runs off the line.
/// </summary>
private static int FindVerbatimEnd( string line, int i )
{
while ( i < line.Length )
{
if ( line[i] == '"' )
{
if ( i + 1 < line.Length && line[i + 1] == '"' ) { i += 2; continue; }
return i + 1;
}
i++;
}
return -1;
}
/// <summary>
/// Index just past the closing quote, honouring backslash escapes. Unterminated strings end at the line end.
/// </summary>
private static int FindQuotedEnd( string line, int i, char quote )
{
while ( i < line.Length )
{
if ( line[i] == '\\' ) { i += 2; continue; }
if ( line[i] == quote ) return i + 1;
i++;
}
return line.Length;
}
private static void Add( List<Token> tokens, int start, int length, TokenKind kind )
{
if ( length > 0 ) tokens.Add( new Token( start, length, kind ) );
}
}
Editor
library
using Sandbox;
using Editor;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
/// <summary>
/// The confirmations and messages around <see cref="HotCodeInstaller"/>, shared by the launcher and the editor.
/// </summary>
public static class HotCodeInstallUi
{
/// <summary>
/// Where this copy of HotCode lives: the loaded project whose .sbproj is HotCode's.
/// </summary>
public static string SourceRoot
{
get
{
var loaded = EditorUtility.Projects.GetAll()
.Where( p => p.ConfigFilePath is not null && HotCodeInstaller.IsHotCode( p.ConfigFilePath ) )
.Select( p => Path.GetDirectoryName( p.ConfigFilePath ) )
.FirstOrDefault();
if ( loaded is not null ) return loaded;
// Libraries might not be in the project list: look in the open project's Libraries folder
var current = Project.Current?.GetRootPath();
return string.IsNullOrEmpty( current ) ? null : HotCodeInstaller.FindInstalled( current );
}
}
public enum State
{
/// <summary>Not a project HotCode can go in (a solution, or HotCode's own source).</summary>
NotApplicable,
NotInstalled,
Installed
}
public static State StateOf( HotCodeEntry entry )
{
if ( entry.Kind != HotCodeEntryKind.Project ) return State.NotApplicable;
var source = SourceRoot;
if ( source is not null && (ProjectFiles.IsUnder( entry.Directory, source ) || ProjectFiles.IsUnder( source, entry.Directory )) )
return State.NotApplicable;
try
{
return HotCodeInstaller.FindInstalled( entry.Directory ) is null ? State.NotInstalled : State.Installed;
}
catch ( Exception e ) when ( e is IOException or UnauthorizedAccessException )
{
return State.NotApplicable;
}
}
/// <summary>
/// Adds the install/update/remove options for a project to a menu.
/// </summary>
public static void AddMenuOptions( Menu menu, HotCodeEntry entry, Action changed )
{
switch ( StateOf( entry ) )
{
case State.NotInstalled:
menu.AddOption( "Add HotCode to This Project", HotCodeLogo.IconName, () => AskInstall( entry, changed ) );
break;
case State.Installed:
menu.AddOption( "Update HotCode in This Project", "system_update_alt", () => AskInstall( entry, changed ) );
menu.AddOption( "Remove HotCode from This Project", "remove_circle_outline", () => AskUninstall( entry, changed ) );
break;
}
}
public static void AskInstall( HotCodeEntry entry, Action changed = null )
{
var source = SourceRoot;
if ( source is null )
{
Show( "Add HotCode", "Couldn't find HotCode's own files. Open HotCode's project (or one that has it) in s&box first." );
return;
}
var updating = StateOf( entry ) == State.Installed;
var question = updating
? $"Update the copy of HotCode in '{entry.Title}' to this version?\n\nThe old copy goes to the Recycle Bin."
: $"Add HotCode to '{entry.Title}'?\n\nThis copies HotCode into its Libraries folder, the same place the Library Manager puts libraries. Next time you open that project in s&box, HotCode will be there.";
Dialog.AskConfirm( () =>
{
try
{
var folder = HotCodeInstaller.Install( source, entry.Directory, ProjectFiles.Delete );
Log.Info( $"[HotCode] {(updating ? "Updated" : "Added")} HotCode in {folder}" );
Show( updating ? "HotCode Updated" : "HotCode Added",
$"HotCode is now in '{entry.Title}'.\n\n{folder}\n\nOpen (or reopen) that project in s&box to use it." );
changed?.Invoke();
}
catch ( Exception e ) when ( e is InvalidOperationException or IOException or UnauthorizedAccessException )
{
Show( "Couldn't Add HotCode", e.Message );
}
}, question, updating ? "Update HotCode" : "Add HotCode", updating ? "Update" : "Add", "Cancel" );
}
public static void AskUninstall( HotCodeEntry entry, Action changed = null )
{
var source = SourceRoot ?? "";
Dialog.AskConfirm( () =>
{
try
{
if ( HotCodeInstaller.Uninstall( source, entry.Directory, ProjectFiles.Delete ) )
Log.Info( $"[HotCode] Removed HotCode from {entry.Directory}" );
changed?.Invoke();
}
catch ( Exception e ) when ( e is InvalidOperationException or IOException or UnauthorizedAccessException )
{
Show( "Couldn't Remove HotCode", e.Message );
}
}, $"Remove HotCode from '{entry.Title}'?\n\nIts copy goes to the Recycle Bin.", "Remove HotCode", "Remove", "Cancel" );
}
private static void Show( string title, string message ) =>
new PopupWindow( title, message, "OK", new Dictionary<string, Action>() ).Show();
}
Editor
library
using System;
using System.Collections.Generic;
using System.Text;
/// <summary>
/// A position in a <see cref="TextBuffer"/>: zero-based line and character index within that line.
/// </summary>
public readonly record struct TextPos( int Line, int Col ) : IComparable<TextPos>
{
public int CompareTo( TextPos other ) => Line != other.Line ? Line.CompareTo( other.Line ) : Col.CompareTo( other.Col );
public static bool operator <( TextPos a, TextPos b ) => a.CompareTo( b ) < 0;
public static bool operator >( TextPos a, TextPos b ) => a.CompareTo( b ) > 0;
public static bool operator <=( TextPos a, TextPos b ) => a.CompareTo( b ) <= 0;
public static bool operator >=( TextPos a, TextPos b ) => a.CompareTo( b ) >= 0;
public static TextPos Min( TextPos a, TextPos b ) => a <= b ? a : b;
public static TextPos Max( TextPos a, TextPos b ) => a >= b ? a : b;
}
/// <summary>
/// Plain text stored as a list of lines. Knows nothing about carets, undo or drawing.
/// </summary>
public class TextBuffer
{
public List<string> Lines { get; } = new() { "" };
/// <summary>
/// Line ending used when joining lines back into text. Detected from the loaded text.
/// </summary>
public string LineEnding { get; set; } = "\n";
public int LineCount => Lines.Count;
public string Text
{
get => string.Join( LineEnding, Lines );
set
{
value ??= "";
LineEnding = value.Contains( "\r\n" ) ? "\r\n" : "\n";
Lines.Clear();
Lines.AddRange( Normalize( value ).Split( '\n' ) );
}
}
/// <summary>
/// Converts any line endings to \n.
/// </summary>
public static string Normalize( string text ) => text.Replace( "\r\n", "\n" ).Replace( '\r', '\n' );
public TextPos Clamp( TextPos p )
{
var line = Math.Clamp( p.Line, 0, Lines.Count - 1 );
return new TextPos( line, Math.Clamp( p.Col, 0, Lines[line].Length ) );
}
public TextPos End => new( Lines.Count - 1, Lines[^1].Length );
/// <summary>
/// Where text inserted at <paramref name="start"/> would end.
/// </summary>
public static TextPos EndOf( TextPos start, string text )
{
var lastNewline = text.LastIndexOf( '\n' );
if ( lastNewline < 0 )
return new TextPos( start.Line, start.Col + text.Length );
var newlines = 0;
foreach ( var c in text ) if ( c == '\n' ) newlines++;
return new TextPos( start.Line + newlines, text.Length - lastNewline - 1 );
}
public string GetText( TextPos a, TextPos b )
{
var start = Clamp( TextPos.Min( a, b ) );
var end = Clamp( TextPos.Max( a, b ) );
if ( start.Line == end.Line )
return Lines[start.Line].Substring( start.Col, end.Col - start.Col );
var sb = new StringBuilder();
sb.Append( Lines[start.Line], start.Col, Lines[start.Line].Length - start.Col );
for ( int i = start.Line + 1; i < end.Line; i++ )
sb.Append( '\n' ).Append( Lines[i] );
sb.Append( '\n' ).Append( Lines[end.Line], 0, end.Col );
return sb.ToString();
}
public void Delete( TextPos a, TextPos b )
{
var start = Clamp( TextPos.Min( a, b ) );
var end = Clamp( TextPos.Max( a, b ) );
if ( start == end ) return;
var head = Lines[start.Line][..start.Col];
var tail = Lines[end.Line][end.Col..];
Lines.RemoveRange( start.Line + 1, end.Line - start.Line );
Lines[start.Line] = head + tail;
}
/// <summary>
/// Inserts text (which may contain newlines) and returns the position just after it.
/// </summary>
public TextPos Insert( TextPos at, string text )
{
at = Clamp( at );
text = Normalize( text );
if ( text.Length == 0 ) return at;
var line = Lines[at.Line];
var head = line[..at.Col];
var tail = line[at.Col..];
var parts = text.Split( '\n' );
if ( parts.Length == 1 )
{
Lines[at.Line] = head + text + tail;
return new TextPos( at.Line, at.Col + text.Length );
}
Lines[at.Line] = head + parts[0];
for ( int i = 1; i < parts.Length - 1; i++ )
Lines.Insert( at.Line + i, parts[i] );
Lines.Insert( at.Line + parts.Length - 1, parts[^1] + tail );
return new TextPos( at.Line + parts.Length - 1, parts[^1].Length );
}
}
Editor
library
using System;
using System.Collections.Generic;
/// <summary>
/// One change to a <see cref="TextBuffer"/>: at <see cref="Start"/>, <see cref="Removed"/> was replaced by <see cref="Inserted"/>.
/// </summary>
public readonly record struct BufferEdit( TextPos Start, string Removed, string Inserted );
/// <summary>
/// What kind of edit this was, which decides whether it can merge with the one before it.
/// </summary>
public enum EditKind
{
/// <summary>Paste, cut, indent, new line... always its own undo step.</summary>
Other,
Typing,
Deleting
}
/// <summary>
/// One undo step. Usually a single edit, but typing a word, backspacing, or a "replace all"
/// becomes one group.
/// </summary>
public class UndoGroup
{
public int Id { get; init; }
public EditKind Kind { get; init; }
public List<BufferEdit> Edits { get; } = new();
public TextPos CaretBefore { get; init; }
public TextPos? AnchorBefore { get; init; }
public TextPos CaretAfter { get; set; }
public TextPos? AnchorAfter { get; set; }
public double Time { get; set; }
}
/// <summary>
/// Undo/redo stacks with editor-style merging:
/// <list type="bullet">
/// <item>typing merges per word ("hello world" undoes as "world", then "hello ")</item>
/// <item>backspace / delete runs merge</item>
/// <item>moving the caret, saving, pausing, or doing anything else starts a new step</item>
/// </list>
/// Also tracks the save point, so undoing back to the saved text counts as unmodified.
/// </summary>
public class UndoHistory
{
/// <summary>
/// A pause longer than this starts a new undo step.
/// </summary>
public const double MergeTimeout = 3.0;
const int MaxSteps = 1000;
private readonly List<UndoGroup> _undo = new();
private readonly List<UndoGroup> _redo = new();
private int _nextId = 1;
private int _savedId;
private bool _breakNext = true;
private UndoGroup _transaction;
private int _transactionDepth;
/// <summary>
/// Id of the text as it is now; 0 is the text as loaded.
/// </summary>
public int CurrentId => _undo.Count > 0 ? _undo[^1].Id : 0;
/// <summary>
/// Differs from what was last loaded or saved.
/// </summary>
public bool IsModified => CurrentId != _savedId;
public bool CanUndo => _undo.Count > 0;
public bool CanRedo => _redo.Count > 0;
public void Clear()
{
_undo.Clear();
_redo.Clear();
_savedId = 0;
_breakNext = true;
}
public void MarkSaved()
{
_savedId = CurrentId;
_breakNext = true;
}
/// <summary>
/// The next edit starts a new undo step (call when the caret moves, etc).
/// </summary>
public void Break() => _breakNext = true;
/// <summary>
/// Everything recorded until the matching <see cref="EndTransaction"/> becomes one undo step.
/// </summary>
public void BeginTransaction( TextPos caret, TextPos? anchor, double now )
{
if ( _transactionDepth++ > 0 ) return;
_transaction = new UndoGroup { Id = _nextId++, Kind = EditKind.Other, CaretBefore = caret, AnchorBefore = anchor, CaretAfter = caret, Time = now };
}
public void EndTransaction( TextPos caret, TextPos? anchor )
{
if ( _transactionDepth == 0 || --_transactionDepth > 0 ) return;
var group = _transaction;
_transaction = null;
if ( group.Edits.Count == 0 ) return;
group.CaretAfter = caret;
group.AnchorAfter = anchor;
Push( group );
_breakNext = true;
}
public void Record( BufferEdit edit, EditKind kind, TextPos caretBefore, TextPos? anchorBefore, TextPos caretAfter, double now )
{
if ( _transaction is not null )
{
_transaction.Edits.Add( edit );
_transaction.CaretAfter = caretAfter;
return;
}
var top = _undo.Count > 0 ? _undo[^1] : null;
if ( top is not null && CanMerge( top, edit, kind, now ) )
{
top.Edits.Add( edit );
top.CaretAfter = caretAfter;
top.AnchorAfter = null;
top.Time = now;
_redo.Clear();
return;
}
var group = new UndoGroup { Id = _nextId++, Kind = kind, CaretBefore = caretBefore, AnchorBefore = anchorBefore, CaretAfter = caretAfter, Time = now };
group.Edits.Add( edit );
Push( group );
// Typing and deleting stay open for merging; anything else stands alone
_breakNext = kind == EditKind.Other;
}
private void Push( UndoGroup group )
{
_undo.Add( group );
_redo.Clear();
if ( _undo.Count > MaxSteps ) _undo.RemoveAt( 0 );
}
private bool CanMerge( UndoGroup top, BufferEdit edit, EditKind kind, double now )
{
if ( _breakNext || kind == EditKind.Other || top.Kind != kind ) return false;
if ( now - top.Time > MergeTimeout ) return false;
// Never merge into the saved state, or undo would step past it
if ( top.Id == _savedId ) return false;
var last = top.Edits[^1];
if ( kind == EditKind.Typing )
{
if ( edit.Removed.Length != 0 || edit.Inserted.Contains( '\n' ) ) return false;
if ( edit.Start != TextBuffer.EndOf( last.Start, last.Inserted ) ) return false;
// Start a new step at each new word: "hello |world"
var lastChar = last.Inserted.Length > 0 ? last.Inserted[^1] : ' ';
return !(char.IsWhiteSpace( lastChar ) && !char.IsWhiteSpace( edit.Inserted[0] ));
}
if ( kind == EditKind.Deleting )
{
if ( edit.Inserted.Length != 0 || edit.Removed.Contains( '\n' ) ) return false;
var isBackspace = TextBuffer.EndOf( edit.Start, edit.Removed ) == last.Start;
var isForwardDelete = edit.Start == last.Start;
return isBackspace || isForwardDelete;
}
return false;
}
/// <summary>
/// Reverts the last step. Returns it so the caller can restore the caret, or null if there's nothing to undo.
/// </summary>
public UndoGroup Undo( TextBuffer buffer )
{
if ( _undo.Count == 0 ) return null;
var group = _undo[^1];
_undo.RemoveAt( _undo.Count - 1 );
for ( int i = group.Edits.Count - 1; i >= 0; i-- )
{
var e = group.Edits[i];
buffer.Delete( e.Start, TextBuffer.EndOf( e.Start, e.Inserted ) );
buffer.Insert( e.Start, e.Removed );
}
_redo.Add( group );
_breakNext = true;
return group;
}
public UndoGroup Redo( TextBuffer buffer )
{
if ( _redo.Count == 0 ) return null;
var group = _redo[^1];
_redo.RemoveAt( _redo.Count - 1 );
foreach ( var e in group.Edits )
{
buffer.Delete( e.Start, TextBuffer.EndOf( e.Start, e.Removed ) );
buffer.Insert( e.Start, e.Inserted );
}
_undo.Add( group );
_breakNext = true;
return group;
}
}
Editor
library
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Threading;
using System.Xml;
using System.Xml.Linq;
using Microsoft.CodeAnalysis;
/// <summary>
/// Hands Roslyn the doc comments from a compiler-generated XML doc file (Foo.xml next to Foo.dll).
/// Roslyn's own XmlDocumentationProvider is in an assembly HotCode doesn't reference, and this is all it does.
/// The file is read on first use, not when the reference is created.
/// </summary>
public sealed class XmlDocs : DocumentationProvider
{
private readonly string _path;
private readonly string _text;
private Dictionary<string, string> _members;
private XmlDocs( string path, string text )
{
_path = path;
_text = text;
}
public static XmlDocs FromFile( string path ) => new( path, null );
public static XmlDocs FromText( string xml ) => new( null, xml );
protected override string GetDocumentationForSymbol( string documentationMemberID, CultureInfo preferredCulture, CancellationToken cancellationToken = default )
{
var members = LazyInitializer.EnsureInitialized( ref _members, Load );
return members.TryGetValue( documentationMemberID, out var xml ) ? xml : "";
}
/// <summary>
/// Member id ("M:Sandbox.GameObject.Destroy") -> its <member> element as XML.
/// </summary>
private Dictionary<string, string> Load()
{
var members = new Dictionary<string, string>( StringComparer.Ordinal );
try
{
var doc = _text is not null ? XDocument.Parse( _text ) : XDocument.Load( _path );
foreach ( var member in doc.Descendants( "member" ) )
{
var name = (string)member.Attribute( "name" );
if ( !string.IsNullOrEmpty( name ) ) members[name] = member.ToString( SaveOptions.DisableFormatting );
}
}
catch ( Exception e ) when ( e is XmlException or IOException or UnauthorizedAccessException )
{
// No docs is fine; hints just show without them
}
return members;
}
public override bool Equals( object obj ) => obj is XmlDocs other && other._path == _path && ReferenceEquals( other._text, _text );
public override int GetHashCode() => HashCode.Combine( _path, _text );
}
Debug: View Raw JSON Response
{
"TotalCount": 37,
"Files": [
{
"Ident": "chrinteractive.hotcode",
"Path": "Editor/HotCodeEditor/CodeView/CodeCanvas.cs",
"FileName": "CodeCanvas.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 399294,
"IsPrivate": false,
"Code": "using Sandbox;\r\nusing Editor;\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\n\r\n/// <summary>\r\n/// The painted surface inside a <see cref=\"CodeView\"/>. Its size is the size of the whole\r\n/// document; the scroll area moves it around and we only draw the rows in view.\r\n///\r\n/// Text is laid out in visual rows: one per line, or several when word wrap splits a line.\r\n/// </summary>\r\npublic class CodeCanvas : Widget\r\n{\r\n\tconst float TextPadding = 8;\r\n\tprivate static float FontSize => CodeView.FontSize;\r\n\r\n\t/// <summary>\r\n\t/// Openers that get their closer typed for them, and the characters they may be followed by.\r\n\t/// </summary>\r\n\tprivate static readonly Dictionary<char, char> AutoClosePairs = new() { ['('] = ')', ['['] = ']', ['{'] = '}', ['\"'] = '\"', ['\\''] = '\\'' };\r\n\tprivate const string AutoCloseBefore = \";:.,=}])> \\t\";\r\n\r\n\tpublic TextBuffer Buffer { get; } = new();\r\n\tpublic UndoHistory History { get; } = new();\r\n\r\n\tprivate readonly CodeView _owner;\r\n\tprivate readonly ScrollArea _scroller;\r\n\tprivate readonly SyntaxLanguage _language;\r\n\r\n\t// Caret and selection. The selection runs from Anchor to Caret; no anchor means no selection.\r\n\tprivate TextPos _caret;\r\n\tprivate TextPos? _anchor;\r\n\tprivate float? _preferredColumn;\r\n\r\n\t// Font metrics, measured on first paint\r\n\tprivate float _charWidth = 8;\r\n\tprivate float _lineHeight = 18;\r\n\tprivate bool _measured;\r\n\r\n\t// Highlighting: the lexer state each line starts in, recomputed after edits\r\n\tprivate readonly List<LexState> _lineStates = new();\r\n\tprivate bool _statesDirty = true;\r\n\r\n\t// Row layout\r\n\tprivate readonly List<int[]> _rowStarts = new();\r\n\tprivate int[] _lineFirstRow = { 0, 1 };\r\n\tprivate int _longestLine;\r\n\tprivate bool _layoutDirty = true;\r\n\tprivate bool _wrapApplied;\r\n\tprivate int _wrapColumns;\r\n\r\n\t// Find highlights\r\n\tprivate IReadOnlyList<SearchMatch> _matches = Array.Empty<SearchMatch>();\r\n\tprivate int _currentMatch = -1;\r\n\r\n\t// Compile errors / warnings, underlined\r\n\tprivate IReadOnlyList<EditorMarker> _markers = Array.Empty<EditorMarker>();\r\n\r\n\t// Word under the mouse while Ctrl is held, drawn like a link for Ctrl+Click\r\n\tprivate (TextPos Start, TextPos End)? _link;\r\n\r\n\t// Autocomplete: everything Roslyn suggested for the word starting at (line, column); the popup shows the filtered list\r\n\tprivate CompletionPopup _popup;\r\n\tprivate IReadOnlyList<CompletionItem> _completionItems = Array.Empty<CompletionItem>();\r\n\tprivate int _completionLine = -1;\r\n\tprivate int _completionStart;\r\n\r\n\t// Parameter hints for the call the caret is in\r\n\tprivate SignaturePopup _signaturePopup;\r\n\tprivate SignatureHelpResult _signature;\r\n\tprivate bool _signaturePicked;\r\n\r\n\tprivate bool _ensureCaretVisible;\r\n\tprivate bool _dragging;\r\n\tprivate double _caretBlinkStart;\r\n\tprivate Vector2 _lastScroll;\r\n\tprivate bool _lastCaretOn;\r\n\tprivate int _settingsVersion = HotCodeSettings.Version;\r\n\r\n\tprivate string _indentUnit = \"\\t\";\r\n\r\n\tpublic CodeCanvas( CodeView owner, ScrollArea scroller, SyntaxLanguage language ) : base( scroller )\r\n\t{\r\n\t\t_owner = owner;\r\n\t\t_scroller = scroller;\r\n\t\t_language = language;\r\n\r\n\t\tFocusMode = FocusMode.Click;\r\n\t\tCursor = CursorShape.IBeam;\r\n\t\tMouseTracking = true;\r\n\t}\r\n\r\n\tpublic TextPos Caret => _caret;\r\n\tpublic string IndentUnit => _indentUnit;\r\n\tpublic string LanguageName => _language.Name;\r\n\tpublic bool HasSelection => _anchor.HasValue && _anchor.Value != _caret;\r\n\tpublic TextPos SelStart => HasSelection ? TextPos.Min( _anchor.Value, _caret ) : _caret;\r\n\tpublic TextPos SelEnd => HasSelection ? TextPos.Max( _anchor.Value, _caret ) : _caret;\r\n\tpublic string SelectedText => HasSelection ? Buffer.GetText( SelStart, SelEnd ) : \"\";\r\n\r\n\tprivate bool WordWrap => CodeView.WordWrap;\r\n\tprivate bool AutoClose => !_language.IsPlain && HotCodeSettings.AutoCloseBrackets;\r\n\r\n\tpublic void SetText( string text )\r\n\t{\r\n\t\tBuffer.Text = text;\r\n\t\t_indentUnit = DetectIndent();\r\n\t\t_caret = default;\r\n\t\t_anchor = null;\r\n\t\tHistory.Clear();\r\n\t\tOnBufferChanged( notify: false );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Swaps in new text (changed on disk) while keeping the caret roughly where it was.\r\n\t/// </summary>\r\n\tpublic void ReloadText( string text )\r\n\t{\r\n\t\tvar caret = _caret;\r\n\t\tBuffer.Text = text;\r\n\t\t_caret = Buffer.Clamp( caret );\r\n\t\t_anchor = null;\r\n\t\tHistory.Clear();\r\n\t\tOnBufferChanged( notify: false );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Tabs unless the file clearly indents with spaces.\r\n\t/// </summary>\r\n\tprivate string DetectIndent()\r\n\t{\r\n\t\tint tabs = 0, spaces = 0;\r\n\t\tforeach ( var line in Buffer.Lines.Take( 2000 ) )\r\n\t\t{\r\n\t\t\tif ( line.StartsWith( '\\t' ) ) tabs++;\r\n\t\t\telse if ( line.StartsWith( \" \" ) ) spaces++;\r\n\t\t}\r\n\r\n\t\tif ( spaces == tabs ) return HotCodeSettings.IndentWithSpaces ? new string( ' ', TextLayout.TabSize ) : \"\\t\";\r\n\t\treturn spaces > tabs ? new string( ' ', TextLayout.TabSize ) : \"\\t\";\r\n\t}\r\n\r\n\t// --- LAYOUT ---\r\n\r\n\tprivate float GutterWidth => HotCodeSettings.ShowLineNumbers ? (Math.Max( 3, Buffer.LineCount.ToString().Length ) + 2) * _charWidth : 0;\r\n\tprivate float TextLeft => GutterWidth + TextPadding;\r\n\r\n\tprivate Vector2 Scroll => new( _scroller.HorizontalScrollbar.Value, _scroller.VerticalScrollbar.Value );\r\n\r\n\t/// <summary>\r\n\t/// The visible part of the scroll area (roughly; scrollbars eat a little).\r\n\t/// </summary>\r\n\tprivate Vector2 ViewportSize => _scroller.Size - 16;\r\n\r\n\tprivate int TotalRows => _lineFirstRow[^1];\r\n\r\n\tpublic void InvalidateLayout()\r\n\t{\r\n\t\t_layoutDirty = true;\r\n\t\tUpdate();\r\n\t}\r\n\r\n\tprivate void UpdateLayout()\r\n\t{\r\n\t\tif ( !_layoutDirty ) return;\r\n\t\t_layoutDirty = false;\r\n\r\n\t\t_wrapApplied = WordWrap;\r\n\t\t_wrapColumns = _wrapApplied ? Math.Max( 20, (int)((ViewportSize.x - TextLeft - TextPadding) / _charWidth) ) : 0;\r\n\r\n\t\t_rowStarts.Clear();\r\n\t\t_lineFirstRow = new int[Buffer.LineCount + 1];\r\n\t\t_longestLine = 0;\r\n\r\n\t\tvar rows = 0;\r\n\t\tfor ( int i = 0; i < Buffer.LineCount; i++ )\r\n\t\t{\r\n\t\t\tvar line = Buffer.Lines[i];\r\n\t\t\t_lineFirstRow[i] = rows;\r\n\r\n\t\t\tvar starts = TextLayout.Wrap( line, _wrapColumns );\r\n\t\t\t_rowStarts.Add( starts );\r\n\t\t\trows += starts.Length;\r\n\r\n\t\t\tif ( !_wrapApplied ) _longestLine = Math.Max( _longestLine, TextLayout.Width( line ) );\r\n\t\t}\r\n\r\n\t\t_lineFirstRow[Buffer.LineCount] = rows;\r\n\r\n\t\t// Extra space at the bottom so the last line can be scrolled up the screen\r\n\t\tvar width = _wrapApplied ? _scroller.Width - 2 : MathF.Max( TextLeft + (_longestLine + 4) * _charWidth, _scroller.Width - 2 );\r\n\t\tvar height = MathF.Max( rows * _lineHeight + MathF.Max( 0, _scroller.Height - _lineHeight * 3 ), _scroller.Height - 2 );\r\n\r\n\t\tMinimumSize = new Vector2( width, height );\r\n\t\tMaximumSize = _wrapApplied ? new Vector2( width, 1_000_000 ) : new Vector2( 1_000_000, 1_000_000 );\r\n\t\tSize = new Vector2( width, height );\r\n\t}\r\n\r\n\tprivate int RowEnd( int line, int row )\r\n\t{\r\n\t\tvar starts = _rowStarts[line];\r\n\t\treturn row + 1 < starts.Length ? starts[row + 1] : Buffer.Lines[line].Length;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Which row of its line a position is on. A position exactly at a wrap point is on the next row.\r\n\t/// </summary>\r\n\tprivate int RowInLine( TextPos p )\r\n\t{\r\n\t\tvar starts = _rowStarts[p.Line];\r\n\t\tvar row = 0;\r\n\t\twhile ( row + 1 < starts.Length && starts[row + 1] <= p.Col ) row++;\r\n\t\treturn row;\r\n\t}\r\n\r\n\tprivate int LineOfRow( int row )\r\n\t{\r\n\t\tvar i = Array.BinarySearch( _lineFirstRow, 0, Buffer.LineCount, row );\r\n\t\tif ( i < 0 ) i = ~i - 1;\r\n\r\n\t\t// Several lines can't share a first row, but guard against empty layouts\r\n\t\treturn Math.Clamp( i, 0, Buffer.LineCount - 1 );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Position in a row nearest to a column. On wrapped rows (not the last) the end is the\r\n\t/// character before the break, so the caret doesn't jump to the next row.\r\n\t/// </summary>\r\n\tprivate TextPos PosInRow( int globalRow, float column )\r\n\t{\r\n\t\tglobalRow = Math.Clamp( globalRow, 0, TotalRows - 1 );\r\n\t\tvar line = LineOfRow( globalRow );\r\n\t\tvar row = globalRow - _lineFirstRow[line];\r\n\t\tvar text = Buffer.Lines[line];\r\n\t\tvar start = _rowStarts[line][row];\r\n\t\tvar end = RowEnd( line, row );\r\n\t\tvar isLastRow = row == _rowStarts[line].Length - 1;\r\n\r\n\t\tvar index = TextLayout.IndexAtColumn( text, start, end, column );\r\n\t\tif ( !isLastRow && index >= end && end > start ) index = end - 1;\r\n\r\n\t\treturn new TextPos( line, index );\r\n\t}\r\n\r\n\tprivate TextPos PosAt( Vector2 local ) => PosInRow( (int)MathF.Floor( local.y / _lineHeight ), (local.x - TextLeft) / _charWidth );\r\n\r\n\tprivate int GlobalRow( TextPos p ) => _lineFirstRow[p.Line] + RowInLine( p );\r\n\r\n\tprivate float ColumnInRow( TextPos p ) => TextLayout.ColumnOf( Buffer.Lines[p.Line], _rowStarts[p.Line][RowInLine( p )], p.Col );\r\n\r\n\tprivate Rect CaretRect( TextPos p ) => new( TextLeft + ColumnInRow( p ) * _charWidth, GlobalRow( p ) * _lineHeight, 2, _lineHeight );\r\n\r\n\t// --- SELECTION / CARET ---\r\n\r\n\tprivate void MoveCaret( TextPos to, bool extendSelection, bool keepPreferredColumn = false )\r\n\t{\r\n\t\tif ( extendSelection ) _anchor ??= _caret;\r\n\t\telse _anchor = null;\r\n\r\n\t\t_caret = Buffer.Clamp( to );\r\n\t\tif ( !keepPreferredColumn ) _preferredColumn = null;\r\n\r\n\t\tHistory.Break();\r\n\t\tCaretMoved();\r\n\t}\r\n\r\n\tprivate void CaretMoved()\r\n\t{\r\n\t\t_caretBlinkStart = RealTime.Now;\r\n\t\t_ensureCaretVisible = true;\r\n\t\tUpdate();\r\n\t}\r\n\r\n\tpublic void Select( TextPos start, TextPos end )\r\n\t{\r\n\t\t_anchor = Buffer.Clamp( start );\r\n\t\t_caret = Buffer.Clamp( end );\r\n\t\t_preferredColumn = null;\r\n\t\tHistory.Break();\r\n\t\tCaretMoved();\r\n\t}\r\n\r\n\tpublic void SelectAll() => Select( new TextPos( 0, 0 ), Buffer.End );\r\n\r\n\tpublic void SetMarkers( IReadOnlyList<EditorMarker> markers )\r\n\t{\r\n\t\t_markers = markers ?? Array.Empty<EditorMarker>();\r\n\t\tUpdate();\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Puts the caret at a position and scrolls it into view.\r\n\t/// </summary>\r\n\tpublic void GoTo( TextPos pos )\r\n\t{\r\n\t\t_anchor = null;\r\n\t\t_caret = Buffer.Clamp( pos );\r\n\t\t_preferredColumn = null;\r\n\t\tHistory.Break();\r\n\t\tCaretMoved();\r\n\t\tCenterOnCaret();\r\n\t}\r\n\r\n\tpublic void SetFindHighlights( IReadOnlyList<SearchMatch> matches, int current )\r\n\t{\r\n\t\t_matches = matches ?? Array.Empty<SearchMatch>();\r\n\t\t_currentMatch = current;\r\n\t\tUpdate();\r\n\t}\r\n\r\n\t// --- EDITING ---\r\n\r\n\t/// <summary>\r\n\t/// Replaces the range a..b with text, records undo, and puts the caret after the new text\r\n\t/// (or at <paramref name=\"caretAfter\"/>).\r\n\t/// </summary>\r\n\tpublic void Replace( TextPos a, TextPos b, string text, EditKind kind = EditKind.Other, TextPos? caretAfter = null )\r\n\t{\r\n\t\tvar start = Buffer.Clamp( TextPos.Min( a, b ) );\r\n\t\tvar end = Buffer.Clamp( TextPos.Max( a, b ) );\r\n\t\ttext = TextBuffer.Normalize( text );\r\n\r\n\t\tvar removed = Buffer.GetText( start, end );\r\n\t\tif ( removed.Length == 0 && text.Length == 0 ) return;\r\n\r\n\t\tvar caretBefore = _caret;\r\n\t\tvar anchorBefore = _anchor;\r\n\r\n\t\tBuffer.Delete( start, end );\r\n\t\tvar newEnd = Buffer.Insert( start, text );\r\n\r\n\t\t_caret = Buffer.Clamp( caretAfter ?? newEnd );\r\n\t\t_anchor = null;\r\n\t\t_preferredColumn = null;\r\n\r\n\t\tHistory.Record( new BufferEdit( start, removed, text ), kind, caretBefore, anchorBefore, _caret, RealTime.Now );\r\n\t\tOnBufferChanged( notify: true );\r\n\t}\r\n\r\n\tpublic void BeginTransaction() => History.BeginTransaction( _caret, _anchor, RealTime.Now );\r\n\tpublic void EndTransaction() => History.EndTransaction( _caret, _anchor );\r\n\r\n\tpublic void Undo()\r\n\t{\r\n\t\tCloseCompletions();\r\n\t\tCloseSignatureHelp();\r\n\t\tvar group = History.Undo( Buffer );\r\n\t\tif ( group is null ) return;\r\n\r\n\t\t_caret = Buffer.Clamp( group.CaretBefore );\r\n\t\t_anchor = group.AnchorBefore.HasValue ? Buffer.Clamp( group.AnchorBefore.Value ) : null;\r\n\t\tOnBufferChanged( notify: true );\r\n\t}\r\n\r\n\tpublic void Redo()\r\n\t{\r\n\t\tCloseCompletions();\r\n\t\tCloseSignatureHelp();\r\n\t\tvar group = History.Redo( Buffer );\r\n\t\tif ( group is null ) return;\r\n\r\n\t\t_caret = Buffer.Clamp( group.CaretAfter );\r\n\t\t_anchor = group.AnchorAfter.HasValue ? Buffer.Clamp( group.AnchorAfter.Value ) : null;\r\n\t\tOnBufferChanged( notify: true );\r\n\t}\r\n\r\n\tprivate void OnBufferChanged( bool notify )\r\n\t{\r\n\t\t_statesDirty = true;\r\n\t\t_layoutDirty = true;\r\n\t\tUpdateLayout();\r\n\t\tCaretMoved();\r\n\r\n\t\tif ( notify ) _owner.NotifyTextChanged();\r\n\t}\r\n\r\n\tprivate char CharAt( TextPos p ) => p.Col < Buffer.Lines[p.Line].Length ? Buffer.Lines[p.Line][p.Col] : '\\0';\r\n\tprivate char CharBefore( TextPos p ) => p.Col > 0 ? Buffer.Lines[p.Line][p.Col - 1] : '\\0';\r\n\r\n\tprivate void TypeText( string text )\r\n\t{\r\n\t\tif ( text.Length == 1 && AutoClose && TryAutoClose( text[0] ) )\r\n\t\t\treturn;\r\n\r\n\t\t// Typing \"}\" on a blank line drops one indent level to line up with its \"{\"\r\n\t\tif ( text == \"}\" && !HasSelection )\r\n\t\t{\r\n\t\t\tvar before = Buffer.Lines[_caret.Line][.._caret.Col];\r\n\t\t\tif ( before.Length > 0 && string.IsNullOrWhiteSpace( before ) )\r\n\t\t\t{\r\n\t\t\t\tReplace( new TextPos( _caret.Line, 0 ), _caret, Unindent( before ) + \"}\", EditKind.Typing );\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tReplace( SelStart, SelEnd, text, EditKind.Typing );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Brackets and quotes: wrap a selection, type over a closer that's already there,\r\n\t/// or insert the closer along with the opener. Returns false to type normally.\r\n\t/// </summary>\r\n\tprivate bool TryAutoClose( char c )\r\n\t{\r\n\t\tvar isQuote = c is '\"' or '\\'';\r\n\r\n\t\tif ( HasSelection )\r\n\t\t{\r\n\t\t\tif ( !AutoClosePairs.TryGetValue( c, out var closer ) ) return false;\r\n\r\n\t\t\t// Wrap the selection and keep it selected inside the new pair\r\n\t\t\tvar start = SelStart;\r\n\t\t\tvar inner = SelectedText;\r\n\t\t\tReplace( start, SelEnd, c + inner + closer );\r\n\r\n\t\t\tvar innerStart = start with { Col = start.Col + 1 };\r\n\t\t\tSelect( innerStart, TextBuffer.EndOf( innerStart, inner ) );\r\n\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\tvar next = CharAt( _caret );\r\n\t\tvar prev = CharBefore( _caret );\r\n\r\n\t\t// Type over a closer instead of doubling it: (|) -> ()|\r\n\t\tif ( next == c && (c is ')' or ']' or '}' || isQuote) )\r\n\t\t{\r\n\t\t\tif ( !isQuote || IsInString( _caret ) )\r\n\t\t\t{\r\n\t\t\t\t_caret = _caret with { Col = _caret.Col + 1 };\r\n\t\t\t\t_preferredColumn = null;\r\n\t\t\t\tCaretMoved();\r\n\t\t\t\treturn true;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tif ( !AutoClosePairs.TryGetValue( c, out var close ) ) return false;\r\n\t\tif ( next != '\\0' && !AutoCloseBefore.Contains( next ) ) return false;\r\n\t\tif ( IsInCommentOrString( _caret ) ) return false;\r\n\r\n\t\t// Don't pair quotes after a word (it's probably an apostrophe or closing an existing string)\r\n\t\tif ( isQuote && (char.IsLetterOrDigit( prev ) || prev == '_' || prev == c) ) return false;\r\n\t\tif ( c == '\\'' && !_language.CSharpStrings && !_language.SingleQuoteStrings ) return false;\r\n\r\n\t\tReplace( _caret, _caret, $\"{c}{close}\", EditKind.Typing, _caret with { Col = _caret.Col + 1 } );\r\n\t\treturn true;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The highlighter's token around a position, if any.\r\n\t/// </summary>\r\n\tprivate Token? TokenAt( TextPos p )\r\n\t{\r\n\t\tUpdateLineStates();\r\n\r\n\t\tvar tokens = new List<Token>();\r\n\t\tvar line = Buffer.Lines[p.Line];\r\n\t\t_language.Tokenize( line, _lineStates[p.Line], tokens );\r\n\r\n\t\tforeach ( var t in tokens )\r\n\t\t{\r\n\t\t\tvar end = t.Start + t.Length;\r\n\t\t\tif ( p.Col > t.Start && p.Col < end ) return t;\r\n\r\n\t\t\t// At the very end of a comment, or of a string that hasn't been closed yet\r\n\t\t\tif ( p.Col == end && end == line.Length && t.Kind == TokenKind.Comment ) return t;\r\n\t\t\tif ( p.Col == end && t.Kind == TokenKind.String && !(t.Length >= 2 && line[end - 1] is '\"' or '\\'') ) return t;\r\n\t\t}\r\n\r\n\t\treturn null;\r\n\t}\r\n\r\n\tprivate bool IsInString( TextPos p ) => TokenAt( p ) is { Kind: TokenKind.String };\r\n\tprivate bool IsInCommentOrString( TextPos p ) => TokenAt( p ) is { Kind: TokenKind.String or TokenKind.Comment };\r\n\r\n\tprivate void NewLine()\r\n\t{\r\n\t\tvar line = Buffer.Lines[SelStart.Line];\r\n\t\tvar indent = new string( line.TakeWhile( c => c == ' ' || c == '\\t' ).ToArray() );\r\n\t\tif ( indent.Length > SelStart.Col ) indent = indent[..SelStart.Col];\r\n\r\n\t\tvar before = line[..SelStart.Col].TrimEnd();\r\n\t\tvar after = Buffer.Lines[SelEnd.Line][SelEnd.Col..].TrimStart();\r\n\r\n\t\tif ( before.EndsWith( '{' ) || before.EndsWith( '[' ) || before.EndsWith( '(' ) )\r\n\t\t{\r\n\t\t\tif ( after.StartsWith( '}' ) || after.StartsWith( ']' ) || after.StartsWith( ')' ) )\r\n\t\t\t{\r\n\t\t\t\t// {|} -> {\\n\\t|\\n}\r\n\t\t\t\tvar caret = new TextPos( SelStart.Line + 1, indent.Length + _indentUnit.Length );\r\n\t\t\t\tReplace( SelStart, SelEnd, \"\\n\" + indent + _indentUnit + \"\\n\" + indent, EditKind.Other, caret );\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\r\n\t\t\tif ( before.EndsWith( '{' ) ) indent += _indentUnit;\r\n\t\t}\r\n\r\n\t\tReplace( SelStart, SelEnd, \"\\n\" + indent );\r\n\t}\r\n\r\n\tprivate string Unindent( string whitespace )\r\n\t{\r\n\t\tif ( whitespace.EndsWith( '\\t' ) ) return whitespace[..^1];\r\n\t\tvar spaces = Math.Min( whitespace.Length - whitespace.TrimEnd( ' ' ).Length, TextLayout.TabSize );\r\n\t\treturn whitespace[..^spaces];\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Tab / Shift+Tab. Indents whole lines when the selection spans lines, otherwise inserts an indent.\r\n\t/// </summary>\r\n\tprivate void Indent( bool outdent )\r\n\t{\r\n\t\tvar multiLine = HasSelection && SelStart.Line != SelEnd.Line;\r\n\r\n\t\tif ( !multiLine && !outdent )\r\n\t\t{\r\n\t\t\tReplace( SelStart, SelEnd, _indentUnit );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar first = SelStart.Line;\r\n\t\tvar last = SelEnd.Line;\r\n\t\tif ( multiLine && SelEnd.Col == 0 ) last--;\r\n\r\n\t\tvar lines = new List<string>();\r\n\t\tfor ( int i = first; i <= last; i++ )\r\n\t\t{\r\n\t\t\tvar line = Buffer.Lines[i];\r\n\t\t\tif ( !outdent )\r\n\t\t\t{\r\n\t\t\t\tlines.Add( line.Length > 0 ? _indentUnit + line : line );\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\tvar ws = new string( line.TakeWhile( c => c == ' ' || c == '\\t' ).ToArray() );\r\n\t\t\tlines.Add( ws.Length > 0 ? Unindent( ws ) + line[ws.Length..] : line );\r\n\t\t}\r\n\r\n\t\tvar start = new TextPos( first, 0 );\r\n\t\tvar end = new TextPos( last, Buffer.Lines[last].Length );\r\n\t\tvar text = string.Join( \"\\n\", lines );\r\n\t\tif ( text == Buffer.GetText( start, end ) ) return;\r\n\r\n\t\tReplace( start, end, text );\r\n\r\n\t\tif ( multiLine )\r\n\t\t\tSelect( start, new TextPos( last, Buffer.Lines[last].Length ) );\r\n\t}\r\n\r\n\tprivate void Backspace( bool word )\r\n\t{\r\n\t\tif ( HasSelection ) { Replace( SelStart, SelEnd, \"\", EditKind.Deleting ); return; }\r\n\t\tif ( _caret == default ) return;\r\n\r\n\t\t// Deleting the opener of an empty pair deletes both: (|)\r\n\t\tif ( !word && AutoClose && AutoClosePairs.TryGetValue( CharBefore( _caret ), out var closer ) && CharAt( _caret ) == closer )\r\n\t\t{\r\n\t\t\tReplace( _caret with { Col = _caret.Col - 1 }, _caret with { Col = _caret.Col + 1 }, \"\", EditKind.Deleting );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar from = word ? WordLeft( _caret ) : Left( _caret );\r\n\r\n\t\t// In leading spaces, delete back to the previous indent stop\r\n\t\tif ( !word && _indentUnit != \"\\t\" )\r\n\t\t{\r\n\t\t\tvar before = Buffer.Lines[_caret.Line][.._caret.Col];\r\n\t\t\tif ( before.Length > 0 && before.Trim().Length == 0 && !before.Contains( '\\t' ) )\r\n\t\t\t\tfrom = new TextPos( _caret.Line, (before.Length - 1) / TextLayout.TabSize * TextLayout.TabSize );\r\n\t\t}\r\n\r\n\t\tReplace( from, _caret, \"\", EditKind.Deleting );\r\n\t}\r\n\r\n\tprivate void DeleteForward( bool word )\r\n\t{\r\n\t\tif ( HasSelection ) { Replace( SelStart, SelEnd, \"\", EditKind.Deleting ); return; }\r\n\t\tReplace( _caret, word ? WordRight( _caret ) : Right( _caret ), \"\", EditKind.Deleting );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Trims trailing whitespace and/or adds a final newline as one undo step, leaving the caret and selection put.\r\n\t/// </summary>\r\n\tpublic void FormatForSave( bool trim, bool finalNewline )\r\n\t{\r\n\t\tvar caret = _caret;\r\n\t\tvar anchor = _anchor;\r\n\r\n\t\tBeginTransaction();\r\n\r\n\t\tif ( trim )\r\n\t\t{\r\n\t\t\tfor ( int i = Buffer.LineCount - 1; i >= 0; i-- )\r\n\t\t\t{\r\n\t\t\t\tvar line = Buffer.Lines[i];\r\n\t\t\t\tvar trailing = line.Length - line.TrimEnd( ' ', '\\t' ).Length;\r\n\t\t\t\tif ( trailing > 0 ) Replace( new TextPos( i, line.Length - trailing ), new TextPos( i, line.Length ), \"\" );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tif ( finalNewline && Buffer.Lines[^1].Length > 0 )\r\n\t\t\tReplace( Buffer.End, Buffer.End, \"\\n\" );\r\n\r\n\t\tEndTransaction();\r\n\r\n\t\t_caret = Buffer.Clamp( caret );\r\n\t\t_anchor = anchor.HasValue ? Buffer.Clamp( anchor.Value ) : null;\r\n\t\tUpdate();\r\n\t}\r\n\r\n\t// --- LINE COMMANDS ---\r\n\r\n\t/// <summary>\r\n\t/// The lines the selection touches (a selection ending at column 0 doesn't include that line).\r\n\t/// </summary>\r\n\tprivate (int First, int Last) SelectedLines()\r\n\t{\r\n\t\tvar first = SelStart.Line;\r\n\t\tvar last = SelEnd.Line;\r\n\t\tif ( HasSelection && last > first && SelEnd.Col == 0 ) last--;\r\n\t\treturn (first, last);\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Ctrl+/: comments the selected lines out with the language's line comment, or back in\r\n\t/// if they're all commented already. Comments go at the shallowest indent so they line up.\r\n\t/// </summary>\r\n\tpublic void ToggleComment()\r\n\t{\r\n\t\tvar prefix = _language.LineComment;\r\n\t\tif ( string.IsNullOrEmpty( prefix ) ) return;\r\n\r\n\t\tvar (first, last) = SelectedLines();\r\n\t\tvar lines = Buffer.Lines.GetRange( first, last - first + 1 );\r\n\t\tvar nonBlank = lines.Where( l => l.Trim().Length > 0 ).ToList();\r\n\t\tif ( nonBlank.Count == 0 ) return;\r\n\r\n\t\tvar allCommented = nonBlank.All( l => l.TrimStart().StartsWith( prefix ) );\r\n\t\tvar indent = nonBlank.Min( l => l.Length - l.TrimStart().Length );\r\n\r\n\t\tvar changed = lines.Select( l =>\r\n\t\t{\r\n\t\t\tif ( l.Trim().Length == 0 ) return l;\r\n\t\t\tif ( !allCommented ) return l[..indent] + prefix + \" \" + l[indent..];\r\n\r\n\t\t\tvar at = l.IndexOf( prefix, StringComparison.Ordinal );\r\n\t\t\tvar after = at + prefix.Length;\r\n\t\t\tif ( after < l.Length && l[after] == ' ' ) after++;\r\n\t\t\treturn l[..at] + l[after..];\r\n\t\t} );\r\n\r\n\t\tReplaceLines( first, last, changed.ToList(), first, last );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Ctrl+D: copies the selected lines (or the caret's line) below themselves.\r\n\t/// </summary>\r\n\tpublic void DuplicateLines()\r\n\t{\r\n\t\tvar (first, last) = SelectedLines();\r\n\t\tvar block = Buffer.Lines.GetRange( first, last - first + 1 );\r\n\t\tvar count = block.Count;\r\n\r\n\t\tReplaceLines( first, last, block.Concat( block ).ToList(), first + count, last + count );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Alt+Up / Alt+Down: moves the selected lines past the line above or below.\r\n\t/// </summary>\r\n\tpublic void MoveLines( int direction )\r\n\t{\r\n\t\tvar (first, last) = SelectedLines();\r\n\t\tif ( direction < 0 && first == 0 ) return;\r\n\t\tif ( direction > 0 && last >= Buffer.LineCount - 1 ) return;\r\n\r\n\t\tvar block = Buffer.Lines.GetRange( first, last - first + 1 );\r\n\t\tif ( direction < 0 )\r\n\t\t{\r\n\t\t\tvar above = Buffer.Lines[first - 1];\r\n\t\t\tReplaceLines( first - 1, last, block.Append( above ).ToList(), first - 1, last - 1 );\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\tvar below = Buffer.Lines[last + 1];\r\n\t\t\tReplaceLines( first, last + 1, block.Prepend( below ).ToList(), first + 1, last + 1 );\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Replaces lines first..last with new ones as one undo step, then selects lines\r\n\t/// selectFirst..selectLast (or puts the caret on that line when nothing was selected).\r\n\t/// </summary>\r\n\tprivate void ReplaceLines( int first, int last, List<string> lines, int selectFirst, int selectLast )\r\n\t{\r\n\t\tvar hadSelection = HasSelection;\r\n\t\tvar caretColumn = _caret.Col;\r\n\t\tvar caretOffset = _caret.Line - SelectedLines().First;\r\n\r\n\t\tvar start = new TextPos( first, 0 );\r\n\t\tvar end = new TextPos( last, Buffer.Lines[last].Length );\r\n\t\tvar text = string.Join( \"\\n\", lines );\r\n\t\tif ( text == Buffer.GetText( start, end ) ) return;\r\n\r\n\t\tReplace( start, end, text );\r\n\r\n\t\tif ( hadSelection )\r\n\t\t{\r\n\t\t\tSelect( new TextPos( selectFirst, 0 ), new TextPos( selectLast, Buffer.Lines[selectLast].Length ) );\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\tvar line = Math.Clamp( selectFirst + caretOffset, 0, Buffer.LineCount - 1 );\r\n\t\t\t_caret = Buffer.Clamp( new TextPos( line, caretColumn ) );\r\n\t\t\tCaretMoved();\r\n\t\t}\r\n\t}\r\n\r\n\tpublic void Copy()\r\n\t{\r\n\t\tif ( HasSelection ) EditorUtility.Clipboard.Copy( SelectedText.Replace( \"\\n\", Buffer.LineEnding ) );\r\n\t}\r\n\r\n\tpublic void Cut()\r\n\t{\r\n\t\tif ( !HasSelection ) return;\r\n\t\tCopy();\r\n\t\tReplace( SelStart, SelEnd, \"\" );\r\n\t}\r\n\r\n\tpublic void Paste()\r\n\t{\r\n\t\tvar text = EditorUtility.Clipboard.Paste();\r\n\t\tif ( !string.IsNullOrEmpty( text ) ) Replace( SelStart, SelEnd, text );\r\n\t}\r\n\r\n\t// --- CARET MOVEMENT ---\r\n\r\n\tprivate 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;\r\n\tprivate 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;\r\n\r\n\tprivate static int CharClass( char c ) => char.IsWhiteSpace( c ) ? 0 : char.IsLetterOrDigit( c ) || c == '_' ? 1 : 2;\r\n\r\n\tprivate TextPos WordLeft( TextPos p )\r\n\t{\r\n\t\tif ( p.Col == 0 ) return Left( p );\r\n\r\n\t\tvar line = Buffer.Lines[p.Line];\r\n\t\tvar i = p.Col;\r\n\t\twhile ( i > 0 && CharClass( line[i - 1] ) == 0 ) i--;\r\n\t\tif ( i > 0 )\r\n\t\t{\r\n\t\t\tvar cls = CharClass( line[i - 1] );\r\n\t\t\twhile ( i > 0 && CharClass( line[i - 1] ) == cls ) i--;\r\n\t\t}\r\n\r\n\t\treturn p with { Col = i };\r\n\t}\r\n\r\n\tprivate TextPos WordRight( TextPos p )\r\n\t{\r\n\t\tvar line = Buffer.Lines[p.Line];\r\n\t\tif ( p.Col >= line.Length ) return Right( p );\r\n\r\n\t\tvar i = p.Col;\r\n\t\tvar cls = CharClass( line[i] );\r\n\t\twhile ( i < line.Length && CharClass( line[i] ) == cls ) i++;\r\n\t\twhile ( i < line.Length && CharClass( line[i] ) == 0 ) i++;\r\n\r\n\t\treturn p with { Col = i };\r\n\t}\r\n\r\n\tprivate (TextPos Start, TextPos End) WordAt( TextPos p )\r\n\t{\r\n\t\tvar line = Buffer.Lines[p.Line];\r\n\t\tif ( line.Length == 0 ) return (p, p);\r\n\r\n\t\tvar i = Math.Min( p.Col, line.Length - 1 );\r\n\t\tvar cls = CharClass( line[i] );\r\n\t\tvar start = i;\r\n\t\tvar end = i;\r\n\t\twhile ( start > 0 && CharClass( line[start - 1] ) == cls ) start--;\r\n\t\twhile ( end < line.Length && CharClass( line[end] ) == cls ) end++;\r\n\r\n\t\treturn (p with { Col = start }, p with { Col = end });\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Home toggles between the first non-whitespace character and column 0.\r\n\t/// </summary>\r\n\tprivate TextPos SmartHome( TextPos p )\r\n\t{\r\n\t\tvar line = Buffer.Lines[p.Line];\r\n\t\tvar firstText = line.Length - line.TrimStart().Length;\r\n\t\treturn p with { Col = p.Col == firstText ? 0 : firstText };\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Up/down by visual rows, keeping the column the caret started in.\r\n\t/// </summary>\r\n\tprivate void MoveVertical( int rows, bool extend )\r\n\t{\r\n\t\t_preferredColumn ??= ColumnInRow( _caret );\r\n\r\n\t\tvar current = GlobalRow( _caret );\r\n\t\tvar target = current + rows;\r\n\r\n\t\tTextPos pos;\r\n\t\tif ( target < 0 ) pos = new TextPos( 0, 0 );\r\n\t\telse if ( target >= TotalRows ) pos = Buffer.End;\r\n\t\telse pos = PosInRow( target, _preferredColumn.Value );\r\n\r\n\t\tMoveCaret( pos, extend, keepPreferredColumn: true );\r\n\t}\r\n\r\n\t// --- INPUT ---\r\n\r\n\tprotected override void OnKeyPress( KeyEvent e )\r\n\t{\r\n\t\tif ( CompletionOpen && HandleCompletionKey( e ) )\r\n\t\t{\r\n\t\t\te.Accepted = true;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( SignatureOpen && HandleSignatureKey( e ) )\r\n\t\t{\r\n\t\t\te.Accepted = true;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar before = (_caret, Buffer.LineCount, Buffer.Lines[_caret.Line].Length);\r\n\t\tHandleKey( e );\r\n\r\n\t\t// Typing, deleting or moving the caret keeps the popup's list in step with the word\r\n\t\tif ( CompletionOpen ) RefilterCompletions();\r\n\r\n\t\t// ...and the hints in step with the argument (or gone, once the caret leaves the brackets)\r\n\t\tif ( SignatureOpen && before != (_caret, Buffer.LineCount, Buffer.Lines[_caret.Line].Length) )\r\n\t\t\t_owner.RequestSignatureHelp( _caret );\r\n\t}\r\n\r\n\tprivate void HandleKey( KeyEvent e )\r\n\t{\r\n\t\tvar ctrl = e.HasCtrl;\r\n\t\tvar shift = e.HasShift;\r\n\t\tvar pageRows = Math.Max( 1, (int)(ViewportSize.y / _lineHeight) - 1 );\r\n\r\n\t\tswitch ( e.Key )\r\n\t\t{\r\n\t\t\tcase KeyCode.Space when ctrl && shift: _owner.RequestSignatureHelp( _caret ); break;\r\n\t\t\tcase KeyCode.Space when ctrl: _owner.RequestCompletion( _caret ); break;\r\n\t\t\tcase KeyCode.Up when e.HasAlt && !ctrl: MoveLines( -1 ); break;\r\n\t\t\tcase KeyCode.Down when e.HasAlt && !ctrl: MoveLines( 1 ); break;\r\n\t\t\tcase KeyCode.Slash when ctrl: ToggleComment(); break;\r\n\t\t\tcase KeyCode.D when ctrl && !shift: DuplicateLines(); break;\r\n\t\t\tcase KeyCode.Equal or KeyCode.Plus when ctrl: CodeView.FontSize += 1; break;\r\n\t\t\tcase KeyCode.Minus when ctrl: CodeView.FontSize -= 1; break;\r\n\t\t\tcase KeyCode.Left when e.HasAlt && !ctrl: _owner.RequestNavigate( back: true ); break;\r\n\t\t\tcase KeyCode.Right when e.HasAlt && !ctrl: _owner.RequestNavigate( back: false ); break;\r\n\t\t\tcase KeyCode.F12: _owner.RequestDefinition( _caret ); break;\r\n\t\t\tcase KeyCode.Left: MoveCaret( !shift && HasSelection && !ctrl ? SelStart : ctrl ? WordLeft( _caret ) : Left( _caret ), shift ); break;\r\n\t\t\tcase KeyCode.Right: MoveCaret( !shift && HasSelection && !ctrl ? SelEnd : ctrl ? WordRight( _caret ) : Right( _caret ), shift ); break;\r\n\t\t\tcase KeyCode.Up: MoveVertical( -1, shift ); break;\r\n\t\t\tcase KeyCode.Down: MoveVertical( 1, shift ); break;\r\n\t\t\tcase KeyCode.PageUp: MoveVertical( -pageRows, shift ); break;\r\n\t\t\tcase KeyCode.PageDown: MoveVertical( pageRows, shift ); break;\r\n\t\t\tcase KeyCode.Home: MoveCaret( ctrl ? new TextPos( 0, 0 ) : SmartHome( _caret ), shift ); break;\r\n\t\t\tcase KeyCode.End: MoveCaret( ctrl ? Buffer.End : _caret with { Col = Buffer.Lines[_caret.Line].Length }, shift ); break;\r\n\t\t\tcase KeyCode.Backspace: Backspace( ctrl ); break;\r\n\t\t\tcase KeyCode.Delete: DeleteForward( ctrl ); break;\r\n\t\t\tcase KeyCode.Return:\r\n\t\t\tcase KeyCode.Enter: NewLine(); break;\r\n\t\t\tcase KeyCode.Tab: Indent( outdent: shift ); break;\r\n\t\t\tcase KeyCode.Backtab: Indent( outdent: true ); break;\r\n\t\t\tcase KeyCode.Escape when _owner.IsFindOpen: _owner.CloseFind(); break;\r\n\t\t\tcase KeyCode.Escape when HasSelection: _anchor = null; Update(); break;\r\n\t\t\tcase KeyCode.A when ctrl: SelectAll(); break;\r\n\t\t\tcase KeyCode.C when ctrl: Copy(); break;\r\n\t\t\tcase KeyCode.X when ctrl: Cut(); break;\r\n\t\t\tcase KeyCode.V when ctrl: Paste(); break;\r\n\t\t\tcase KeyCode.Z when ctrl && shift: Redo(); break;\r\n\t\t\tcase KeyCode.Z when ctrl: Undo(); break;\r\n\t\t\tcase KeyCode.Y when ctrl: Redo(); break;\r\n\t\t\tcase KeyCode.F when ctrl: _owner.OpenFind( replace: false ); break;\r\n\t\t\tcase KeyCode.H when ctrl: _owner.OpenFind( replace: true ); break;\r\n\t\t\tcase KeyCode.F3: _owner.FindNext( backwards: shift ); break;\r\n\t\t\tcase KeyCode.Z when e.HasAlt: CodeView.WordWrap = !CodeView.WordWrap; break;\r\n\r\n\t\t\tdefault:\r\n\t\t\t\t// Ctrl+Alt is AltGr on Windows keyboards, which types characters like { and }\r\n\t\t\t\tvar text = e.Text;\r\n\t\t\t\tvar typesCharacter = !string.IsNullOrEmpty( text ) && !char.IsControl( text[0] ) && (!ctrl || e.HasAlt);\r\n\t\t\t\tif ( !typesCharacter )\r\n\t\t\t\t{\r\n\t\t\t\t\tbase.OnKeyPress( e );\r\n\t\t\t\t\treturn;\r\n\t\t\t\t}\r\n\r\n\t\t\t\tTypeText( text );\r\n\t\t\t\tMaybeTriggerCompletion( text );\r\n\t\t\t\tif ( text is \"(\" or \",\" && !SignatureOpen && HotCodeSettings.ParameterHints ) _owner.RequestSignatureHelp( _caret );\r\n\t\t\t\tbreak;\r\n\t\t}\r\n\r\n\t\te.Accepted = true;\r\n\t}\r\n\r\n\t// --- PARAMETER HINTS ---\r\n\r\n\tprivate bool SignatureOpen => _signaturePopup is not null && _signaturePopup.Visible;\r\n\r\n\t/// <summary>\r\n\t/// Hints arrived for the caret position; null means the caret isn't in a call any more.\r\n\t/// </summary>\r\n\tpublic void ShowSignatureHelp( SignatureHelpResult result )\r\n\t{\r\n\t\tif ( result is null || result.Signatures.Count == 0 )\r\n\t\t{\r\n\t\t\tCloseSignatureHelp();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// Same call as before: keep the overload the user picked with Up/Down\r\n\t\tvar sameCall = _signature is not null && _signature.OpenParenLine == result.OpenParenLine\r\n\t\t\t&& _signature.OpenParenColumn == result.OpenParenColumn && _signature.Signatures.Count == result.Signatures.Count;\r\n\t\tif ( !sameCall ) _signaturePicked = false;\r\n\r\n\t\tvar index = _signaturePicked ? _signaturePopup.Index : result.ActiveSignature;\r\n\t\t_signature = result;\r\n\r\n\t\t_signaturePopup ??= new SignaturePopup( this );\r\n\t\t_signaturePopup.SetResult( result, index );\r\n\t\tPositionSignature();\r\n\t\t_signaturePopup.Visible = true;\r\n\t\t_signaturePopup.Raise();\r\n\t}\r\n\r\n\tpublic void CloseSignatureHelp()\r\n\t{\r\n\t\tif ( _signaturePopup is not null ) _signaturePopup.Visible = false;\r\n\t\t_signature = null;\r\n\t\t_signaturePicked = false;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Above the caret's row so it doesn't cover what's being typed (or below if there's no room).\r\n\t/// </summary>\r\n\tprivate void PositionSignature()\r\n\t{\r\n\t\tvar anchor = CaretRect( new TextPos( _signature.OpenParenLine, _signature.OpenParenColumn ) );\r\n\t\tvar row = CaretRect( _caret );\r\n\t\tvar scroll = Scroll;\r\n\t\tvar viewport = ViewportSize;\r\n\r\n\t\tvar x = MathF.Max( scroll.x, MathF.Min( anchor.Left - _charWidth * 4, scroll.x + viewport.x - _signaturePopup.Width ) );\r\n\t\tvar y = row.Top - _signaturePopup.Height - 2;\r\n\t\tif ( y < scroll.y ) y = row.Bottom + 2 + (CompletionOpen ? _popup.Height + 2 : 0);\r\n\r\n\t\t_signaturePopup.Position = new Vector2( x, y );\r\n\t}\r\n\r\n\tprivate bool HandleSignatureKey( KeyEvent e )\r\n\t{\r\n\t\tswitch ( e.Key )\r\n\t\t{\r\n\t\t\tcase KeyCode.Up when _signaturePopup.Count > 1 && !e.HasShift && !e.HasCtrl && !e.HasAlt:\r\n\t\t\t\t_signaturePopup.Cycle( -1 );\r\n\t\t\t\t_signaturePicked = true;\r\n\t\t\t\treturn true;\r\n\r\n\t\t\tcase KeyCode.Down when _signaturePopup.Count > 1 && !e.HasShift && !e.HasCtrl && !e.HasAlt:\r\n\t\t\t\t_signaturePopup.Cycle( 1 );\r\n\t\t\t\t_signaturePicked = true;\r\n\t\t\t\treturn true;\r\n\r\n\t\t\tcase KeyCode.Escape:\r\n\t\t\t\tCloseSignatureHelp();\r\n\t\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\treturn false;\r\n\t}\r\n\r\n\t// --- AUTOCOMPLETE ---\r\n\r\n\tprivate bool CompletionOpen => _popup is not null && _popup.Visible;\r\n\r\n\t/// <summary>\r\n\t/// Asks for suggestions after \".\" or when a new word starts. While the popup is open,\r\n\t/// further typing just filters what's already there.\r\n\t/// </summary>\r\n\tprivate void MaybeTriggerCompletion( string typed )\r\n\t{\r\n\t\tif ( CompletionOpen || typed.Length != 1 || !HotCodeSettings.SuggestWhileTyping ) return;\r\n\r\n\t\tvar c = typed[0];\r\n\t\tvar wordStarts = (char.IsLetter( c ) || c == '_') && CharClass( CharBefore( _caret with { Col = _caret.Col - 1 } ) ) != 1;\r\n\r\n\t\tif ( c == '.' || wordStarts )\r\n\t\t\t_owner.RequestCompletion( _caret );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Suggestions arrived. They're dropped if the caret has since left the word they're for.\r\n\t/// </summary>\r\n\tpublic void ShowCompletions( CompletionResult result )\r\n\t{\r\n\t\tif ( result.Line != _caret.Line || _caret.Col < result.StartColumn || result.Items.Count == 0 )\r\n\t\t\treturn;\r\n\r\n\t\t_completionItems = result.Items;\r\n\t\t_completionLine = result.Line;\r\n\t\t_completionStart = result.StartColumn;\r\n\t\tRefilterCompletions();\r\n\t}\r\n\r\n\tprivate void RefilterCompletions()\r\n\t{\r\n\t\tvar line = Buffer.Lines[_caret.Line];\r\n\t\tif ( _caret.Line != _completionLine || _caret.Col < _completionStart || _completionStart > line.Length )\r\n\t\t{\r\n\t\t\tCloseCompletions();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar typed = line[_completionStart.._caret.Col];\r\n\t\tif ( typed.Any( ch => CharClass( ch ) != 1 ) )\r\n\t\t{\r\n\t\t\tCloseCompletions();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar matches = CompletionMatcher.Filter( _completionItems, typed );\r\n\r\n\t\t// Nothing left, or the only match is exactly what's typed already\r\n\t\tif ( matches.Count == 0 || (matches.Count == 1 && matches[0].Name == typed) )\r\n\t\t{\r\n\t\t\tCloseCompletions();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( _popup is null )\r\n\t\t{\r\n\t\t\t_popup = new CompletionPopup( this );\r\n\t\t\t_popup.Committed = CommitCompletion;\r\n\t\t}\r\n\r\n\t\t_popup.SetItems( matches.Count > 500 ? matches.GetRange( 0, 500 ) : matches, _lineHeight );\r\n\t\tPositionPopup();\r\n\t\t_popup.Visible = true;\r\n\t\t_popup.Raise();\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Under the word, or above it when there isn't room below.\r\n\t/// </summary>\r\n\tprivate void PositionPopup()\r\n\t{\r\n\t\tvar anchor = CaretRect( new TextPos( _completionLine, _completionStart ) );\r\n\t\tvar scroll = Scroll;\r\n\t\tvar viewport = ViewportSize;\r\n\r\n\t\tvar x = MathF.Min( anchor.Left, scroll.x + viewport.x - _popup.Width );\r\n\t\tvar y = anchor.Bottom + 2;\r\n\t\tif ( y + _popup.Height > scroll.y + viewport.y && anchor.Top - _popup.Height - 2 >= scroll.y )\r\n\t\t\ty = anchor.Top - _popup.Height - 2;\r\n\r\n\t\t_popup.Position = new Vector2( MathF.Max( scroll.x, x ), y );\r\n\t}\r\n\r\n\tpublic void CloseCompletions()\r\n\t{\r\n\t\tif ( _popup is not null ) _popup.Visible = false;\r\n\t\t_completionLine = -1;\r\n\t}\r\n\r\n\tprivate void CommitCompletion( CompletionItem item )\r\n\t{\r\n\t\tif ( item is null ) return;\r\n\r\n\t\tvar start = new TextPos( _completionLine, _completionStart );\r\n\t\tCloseCompletions();\r\n\t\tReplace( start, _caret, item.Name );\r\n\t\tFocus( false );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Keys the popup takes while it's open. Returns false to let the editor handle the key as usual.\r\n\t/// </summary>\r\n\tprivate bool HandleCompletionKey( KeyEvent e )\r\n\t{\r\n\t\tswitch ( e.Key )\r\n\t\t{\r\n\t\t\tcase KeyCode.Up: _popup.Move( -1 ); return true;\r\n\t\t\tcase KeyCode.Down: _popup.Move( 1 ); return true;\r\n\t\t\tcase KeyCode.PageUp: _popup.Move( -CompletionPopup.PageSize ); return true;\r\n\t\t\tcase KeyCode.PageDown: _popup.Move( CompletionPopup.PageSize ); return true;\r\n\t\t\tcase KeyCode.Return or KeyCode.Enter or KeyCode.Tab when !e.HasShift && !e.HasCtrl:\r\n\t\t\t\tCommitCompletion( _popup.Selected );\r\n\t\t\t\treturn true;\r\n\t\t\tcase KeyCode.Escape:\r\n\t\t\t\tCloseCompletions();\r\n\t\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\treturn false;\r\n\t}\r\n\r\n\tprotected override void OnMousePress( MouseEvent e )\r\n\t{\r\n\t\tbase.OnMousePress( e );\r\n\t\tCloseCompletions();\r\n\t\tCloseSignatureHelp();\r\n\t\tif ( !e.LeftMouseButton ) return;\r\n\r\n\t\t// Ctrl+Click: go to definition\r\n\t\tif ( e.HasCtrl && WordUnder( e.LocalPosition ) is { } word )\r\n\t\t{\r\n\t\t\tFocus( false );\r\n\t\t\tMoveCaret( word.Start, extendSelection: false );\r\n\t\t\tSetLink( null );\r\n\t\t\t_owner.RequestDefinition( word.Start );\r\n\t\t\te.Accepted = true;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tFocus( false );\r\n\t\t_dragging = true;\r\n\t\tMoveCaret( PosAt( e.LocalPosition ), e.HasShift );\r\n\t\t_anchor ??= _caret;\r\n\t\te.Accepted = true;\r\n\t}\r\n\r\n\tprotected override void OnMouseMove( MouseEvent e )\r\n\t{\r\n\t\tbase.OnMouseMove( e );\r\n\r\n\t\tif ( !_dragging )\r\n\t\t{\r\n\t\t\tUpdateMarkerTooltip( e.LocalPosition );\r\n\t\t\tSetLink( e.HasCtrl ? WordUnder( e.LocalPosition ) : null );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t_anchor ??= _caret;\r\n\t\t_caret = PosAt( e.LocalPosition );\r\n\t\tCaretMoved();\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The identifier under a point, if the point is over text.\r\n\t/// </summary>\r\n\tprivate (TextPos Start, TextPos End)? WordUnder( Vector2 local )\r\n\t{\r\n\t\tvar row = (int)MathF.Floor( local.y / _lineHeight );\r\n\t\tif ( row < 0 || row >= TotalRows || local.x < TextLeft ) return null;\r\n\r\n\t\tvar pos = PosAt( local );\r\n\t\tvar line = Buffer.Lines[pos.Line];\r\n\t\tvar col = Math.Min( pos.Col, line.Length - 1 );\r\n\t\tif ( col < 0 || CharClass( line[col] ) != 1 ) return null;\r\n\r\n\t\tvar (start, end) = WordAt( pos with { Col = col } );\r\n\t\treturn end > start ? (start, end) : null;\r\n\t}\r\n\r\n\tprivate void SetLink( (TextPos Start, TextPos End)? link )\r\n\t{\r\n\t\tif ( _link == link ) return;\r\n\t\t_link = link;\r\n\t\tCursor = link is null ? CursorShape.IBeam : CursorShape.Finger;\r\n\t\tUpdate();\r\n\t}\r\n\r\n\tprotected override void OnMouseWheel( WheelEvent e )\r\n\t{\r\n\t\tif ( e.HasCtrl )\r\n\t\t{\r\n\t\t\tCodeView.FontSize += e.Delta > 0 ? 1 : -1;\r\n\t\t\te.Accept();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tbase.OnMouseWheel( e );\r\n\t}\r\n\r\n\tprotected override void OnKeyRelease( KeyEvent e )\r\n\t{\r\n\t\tbase.OnKeyRelease( e );\r\n\t\tif ( e.Key == KeyCode.Control ) SetLink( null );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Hovering an underlined error shows its message.\r\n\t/// </summary>\r\n\tprivate void UpdateMarkerTooltip( Vector2 local )\r\n\t{\r\n\t\tstring tip = null;\r\n\r\n\t\tif ( _markers.Count > 0 )\r\n\t\t{\r\n\t\t\tvar row = (int)MathF.Floor( local.y / _lineHeight );\r\n\t\t\tif ( row >= 0 && row < TotalRows )\r\n\t\t\t{\r\n\t\t\t\tvar pos = PosAt( local );\r\n\t\t\t\tvar marker = _markers.FirstOrDefault( m => pos >= m.Start && pos <= m.End );\r\n\t\t\t\tif ( marker is not null ) tip = marker.Message;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tif ( ToolTip != tip ) ToolTip = tip;\r\n\t}\r\n\r\n\tprotected override void OnMouseReleased( MouseEvent e )\r\n\t{\r\n\t\tbase.OnMouseReleased( e );\r\n\t\t_dragging = false;\r\n\t\tif ( _anchor == _caret ) _anchor = null;\r\n\t}\r\n\r\n\tprotected override void OnDoubleClick( MouseEvent e )\r\n\t{\r\n\t\tbase.OnDoubleClick( e );\r\n\t\tif ( !e.LeftMouseButton ) return;\r\n\r\n\t\tvar (start, end) = WordAt( PosAt( e.LocalPosition ) );\r\n\t\t_dragging = false;\r\n\t\tSelect( start, end );\r\n\t}\r\n\r\n\tprotected override void OnContextMenu( ContextMenuEvent e )\r\n\t{\r\n\t\tvar menu = new ContextMenu( this );\r\n\t\tvar clicked = PosAt( FromScreen( Editor.Application.CursorPosition ) );\r\n\t\tmenu.AddOption( \"Go to Definition (F12)\", \"start\", () => _owner.RequestDefinition( HasSelection ? SelStart : clicked ) );\r\n\t\tmenu.AddSeparator();\r\n\t\tmenu.AddOption( \"Cut\", \"content_cut\", Cut );\r\n\t\tmenu.AddOption( \"Copy\", \"content_copy\", Copy );\r\n\t\tmenu.AddOption( \"Paste\", \"content_paste\", Paste );\r\n\t\tmenu.AddSeparator();\r\n\t\tmenu.AddOption( \"Undo\", \"undo\", Undo );\r\n\t\tmenu.AddOption( \"Redo\", \"redo\", Redo );\r\n\t\tmenu.AddSeparator();\r\n\t\tmenu.AddOption( \"Find...\", \"search\", () => _owner.OpenFind( replace: false ) );\r\n\t\tmenu.AddOption( \"Replace...\", \"find_replace\", () => _owner.OpenFind( replace: true ) );\r\n\t\tmenu.AddOption( \"Select All\", \"select_all\", SelectAll );\r\n\t\tmenu.AddSeparator();\r\n\t\tmenu.AddOption( WordWrap ? \"Turn Word Wrap Off\" : \"Turn Word Wrap On\", \"wrap_text\", () => CodeView.WordWrap = !CodeView.WordWrap );\r\n\t\tmenu.OpenAtCursor();\r\n\t\te.Accepted = true;\r\n\t}\r\n\r\n\tprotected override void OnFocus( FocusChangeReason reason )\r\n\t{\r\n\t\tbase.OnFocus( reason );\r\n\t\tCaretMoved();\r\n\t}\r\n\r\n\tprotected override void OnBlur( FocusChangeReason reason )\r\n\t{\r\n\t\tbase.OnBlur( reason );\r\n\t\tCloseCompletions();\r\n\t\tCloseSignatureHelp();\r\n\t\tUpdate();\r\n\t}\r\n\r\n\t[EditorEvent.Frame]\r\n\tpublic void Frame()\r\n\t{\r\n\t\tif ( !Visible ) return;\r\n\r\n\t\tif ( _settingsVersion != HotCodeSettings.Version )\r\n\t\t{\r\n\t\t\t_settingsVersion = HotCodeSettings.Version;\r\n\t\t\t_measured = false;\r\n\t\t\tInvalidateLayout();\r\n\t\t}\r\n\r\n\t\tif ( _wrapApplied != WordWrap ) InvalidateLayout();\r\n\t\tif ( _layoutDirty && _measured ) UpdateLayout();\r\n\r\n\t\tif ( _ensureCaretVisible && _measured )\r\n\t\t{\r\n\t\t\t_ensureCaretVisible = false;\r\n\t\t\tScrollToCaret();\r\n\t\t}\r\n\r\n\t\tif ( CompletionOpen ) PositionPopup();\r\n\t\tif ( SignatureOpen ) PositionSignature();\r\n\r\n\t\t// Repaint on scroll (the gutter is pinned to the left edge) and when the caret blinks\r\n\t\tvar scroll = Scroll;\r\n\t\tvar caretOn = CaretVisible;\r\n\t\tif ( scroll != _lastScroll || caretOn != _lastCaretOn )\r\n\t\t{\r\n\t\t\t_lastScroll = scroll;\r\n\t\t\t_lastCaretOn = caretOn;\r\n\t\t\tUpdate();\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Solid right after moving, then blinks.\r\n\t/// </summary>\r\n\tprivate bool CaretVisible => IsFocused && (!HotCodeSettings.CursorBlink || ((RealTime.Now - _caretBlinkStart) % 1.0) < 0.55);\r\n\r\n\tpublic void ScrollToCaret()\r\n\t{\r\n\t\tUpdateLayout();\r\n\r\n\t\tvar rect = CaretRect( _caret );\r\n\t\tvar scroll = Scroll;\r\n\t\tvar viewport = ViewportSize;\r\n\r\n\t\tif ( rect.Top < scroll.y ) _scroller.VerticalScrollbar.Value = (int)rect.Top;\r\n\t\telse if ( rect.Bottom > scroll.y + viewport.y ) _scroller.VerticalScrollbar.Value = (int)(rect.Bottom - viewport.y);\r\n\r\n\t\tif ( _wrapApplied ) return;\r\n\r\n\t\tif ( rect.Left < scroll.x + TextLeft ) _scroller.HorizontalScrollbar.Value = (int)MathF.Max( 0, rect.Left - TextLeft - _charWidth * 4 );\r\n\t\telse if ( rect.Right > scroll.x + viewport.x ) _scroller.HorizontalScrollbar.Value = (int)(rect.Right - viewport.x + _charWidth * 4);\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Scrolls so the caret sits about a third of the way down, for jumping to search results.\r\n\t/// </summary>\r\n\tpublic void CenterOnCaret()\r\n\t{\r\n\t\tUpdateLayout();\r\n\t\tvar rect = CaretRect( _caret );\r\n\t\tvar viewport = ViewportSize;\r\n\t\tif ( rect.Top < Scroll.y || rect.Bottom > Scroll.y + viewport.y )\r\n\t\t\t_scroller.VerticalScrollbar.Value = (int)MathF.Max( 0, rect.Top - viewport.y / 3 );\r\n\t\t_ensureCaretVisible = true;\r\n\t}\r\n\r\n\t// --- PAINTING ---\r\n\r\n\tprivate void Measure()\r\n\t{\r\n\t\tPaint.SetFont( HotCodeSettings.CodeFont, FontSize );\r\n\t\tvar size = Paint.MeasureText( new string( 'M', 100 ) );\r\n\t\tvar charWidth = size.x / 100f;\r\n\t\tvar lineHeight = MathF.Ceiling( size.y * HotCodeSettings.LineSpacing );\r\n\r\n\t\tif ( charWidth > 0 && lineHeight > 0 && (!_measured || charWidth != _charWidth || lineHeight != _lineHeight) )\r\n\t\t{\r\n\t\t\t_charWidth = charWidth;\r\n\t\t\t_lineHeight = lineHeight;\r\n\t\t\t_measured = true;\r\n\t\t\t_layoutDirty = true;\r\n\t\t}\r\n\t}\r\n\r\n\tprivate void UpdateLineStates()\r\n\t{\r\n\t\tif ( !_statesDirty ) return;\r\n\t\t_statesDirty = false;\r\n\r\n\t\t_lineStates.Clear();\r\n\t\tvar state = LexState.Normal;\r\n\t\tvar scratch = new List<Token>();\r\n\t\tforeach ( var line in Buffer.Lines )\r\n\t\t{\r\n\t\t\t_lineStates.Add( state );\r\n\t\t\tscratch.Clear();\r\n\t\t\tstate = _language.Tokenize( line, state, scratch );\r\n\t\t}\r\n\t}\r\n\r\n\tprotected override void OnPaint()\r\n\t{\r\n\t\tMeasure();\r\n\t\tUpdateLayout();\r\n\t\tUpdateLineStates();\r\n\r\n\t\tvar scroll = Scroll;\r\n\t\tvar viewport = _scroller.Size;\r\n\t\tvar firstRow = Math.Max( 0, (int)(scroll.y / _lineHeight) );\r\n\t\tvar lastRow = Math.Min( TotalRows - 1, (int)((scroll.y + viewport.y) / _lineHeight) + 1 );\r\n\t\tvar firstLine = LineOfRow( firstRow );\r\n\t\tvar lastLine = LineOfRow( lastRow );\r\n\r\n\t\t// Background\r\n\t\tPaint.ClearPen();\r\n\t\tPaint.SetBrush( SyntaxColors.Background );\r\n\t\tPaint.DrawRect( new Rect( scroll, viewport ) );\r\n\r\n\t\t// Current row\r\n\t\tif ( !HasSelection && HotCodeSettings.HighlightCurrentLine )\r\n\t\t{\r\n\t\t\tPaint.SetBrush( SyntaxColors.CurrentLine );\r\n\t\t\tPaint.DrawRect( new Rect( scroll.x, GlobalRow( _caret ) * _lineHeight, viewport.x, _lineHeight ) );\r\n\t\t}\r\n\r\n\t\tif ( HasSelection )\r\n\t\t\tDrawRange( SelStart, SelEnd, SyntaxColors.Selection, firstLine, lastLine );\r\n\r\n\t\t// Find matches: binary search to the first visible one, then walk until past the view\r\n\t\tif ( _matches.Count > 0 )\r\n\t\t{\r\n\t\t\tvar i = FirstMatchFrom( firstLine );\r\n\t\t\tfor ( ; i < _matches.Count && _matches[i].Start.Line <= lastLine; i++ )\r\n\t\t\t\tDrawRange( _matches[i].Start, _matches[i].End, i == _currentMatch ? SyntaxColors.FindCurrent : SyntaxColors.FindMatch, firstLine, lastLine );\r\n\t\t}\r\n\r\n\t\t// Text\r\n\t\tPaint.SetFont( HotCodeSettings.CodeFont, FontSize );\r\n\t\tvar tokens = new List<Token>();\r\n\t\tvar tokensLine = -1;\r\n\t\tfor ( int row = firstRow; row <= lastRow; row++ )\r\n\t\t{\r\n\t\t\tvar line = LineOfRow( row );\r\n\t\t\tif ( line != tokensLine )\r\n\t\t\t{\r\n\t\t\t\ttokens.Clear();\r\n\t\t\t\t_language.Tokenize( Buffer.Lines[line], _lineStates[line], tokens );\r\n\t\t\t\ttokensLine = line;\r\n\t\t\t}\r\n\r\n\t\t\tvar r = row - _lineFirstRow[line];\r\n\t\t\tDrawRow( row, line, _rowStarts[line][r], RowEnd( line, r ), tokens );\r\n\t\t}\r\n\r\n\t\tforeach ( var marker in HotCodeSettings.UnderlineErrors ? _markers : Array.Empty<EditorMarker>() )\r\n\t\t{\r\n\t\t\tif ( marker.End.Line < firstLine || marker.Start.Line > lastLine ) continue;\r\n\t\t\tDrawSquiggle( marker, firstLine, lastLine );\r\n\t\t}\r\n\r\n\t\tif ( _link is { } link )\r\n\t\t{\r\n\t\t\tvar text = Buffer.Lines[link.Start.Line];\r\n\t\t\tvar r = RowInLine( link.Start );\r\n\t\t\tvar rowStart = _rowStarts[link.Start.Line][r];\r\n\t\t\tvar x0 = TextLeft + TextLayout.ColumnOf( text, rowStart, link.Start.Col ) * _charWidth;\r\n\t\t\tvar x1 = TextLeft + TextLayout.ColumnOf( text, rowStart, link.End.Col ) * _charWidth;\r\n\t\t\tvar y = (GlobalRow( link.Start ) + 1) * _lineHeight - 3;\r\n\t\t\tPaint.SetPen( SyntaxColors.Link, 1 );\r\n\t\t\tPaint.DrawLine( new Vector2( x0, y ), new Vector2( x1, y ) );\r\n\t\t}\r\n\r\n\t\tif ( CaretVisible )\r\n\t\t{\r\n\t\t\tPaint.ClearPen();\r\n\t\t\tPaint.SetBrush( SyntaxColors.Caret );\r\n\t\t\tPaint.DrawRect( CaretRect( _caret ) );\r\n\t\t}\r\n\r\n\t\t// Gutter, drawn last and pinned to the left edge so it stays put when scrolling sideways\r\n\t\tif ( !HotCodeSettings.ShowLineNumbers ) return;\r\n\r\n\t\tPaint.ClearPen();\r\n\t\tPaint.SetBrush( SyntaxColors.Gutter );\r\n\t\tPaint.DrawRect( new Rect( scroll.x, scroll.y, GutterWidth, viewport.y ) );\r\n\r\n\t\tfor ( int line = firstLine; line <= lastLine; line++ )\r\n\t\t{\r\n\t\t\tvar row = _lineFirstRow[line];\r\n\t\t\tif ( row < firstRow ) continue;\r\n\r\n\t\t\tPaint.SetPen( line == _caret.Line ? SyntaxColors.LineNumberActive : SyntaxColors.LineNumber );\r\n\t\t\tvar rect = new Rect( scroll.x, row * _lineHeight, GutterWidth - _charWidth, _lineHeight );\r\n\t\t\tPaint.DrawText( rect, (line + 1).ToString(), TextFlag.RightCenter );\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Wavy underline under a marker's range, row by row. Zero-width markers underline one character.\r\n\t/// </summary>\r\n\tprivate void DrawSquiggle( EditorMarker marker, int firstLine, int lastLine )\r\n\t{\r\n\t\tvar start = Buffer.Clamp( marker.Start );\r\n\t\tvar end = Buffer.Clamp( marker.End );\r\n\t\tif ( end < start ) end = start;\r\n\r\n\t\tPaint.SetPen( marker.IsError ? SyntaxColors.Error : SyntaxColors.Warning, 1 );\r\n\r\n\t\tfor ( int line = Math.Max( firstLine, start.Line ); line <= Math.Min( lastLine, end.Line ); line++ )\r\n\t\t{\r\n\t\t\tvar text = Buffer.Lines[line];\r\n\t\t\tvar from = line == start.Line ? start.Col : 0;\r\n\t\t\tvar to = line == end.Line ? end.Col : text.Length;\r\n\t\t\tvar starts = _rowStarts[line];\r\n\r\n\t\t\tfor ( int r = 0; r < starts.Length; r++ )\r\n\t\t\t{\r\n\t\t\t\tvar a = Math.Max( from, starts[r] );\r\n\t\t\t\tvar b = Math.Min( to, RowEnd( line, r ) );\r\n\r\n\t\t\t\tif ( b < a ) continue;\r\n\r\n\t\t\t\t// Zero-width errors (like a missing ';') still get one character's worth of underline\r\n\t\t\t\tvar x0 = TextLeft + TextLayout.ColumnOf( text, starts[r], a ) * _charWidth;\r\n\t\t\t\tvar x1 = b > a ? TextLeft + TextLayout.ColumnOf( text, starts[r], b ) * _charWidth : x0 + _charWidth;\r\n\r\n\t\t\t\tvar y = (_lineFirstRow[line] + r + 1) * _lineHeight - 3;\r\n\t\t\t\tvar points = new List<Vector2>();\r\n\t\t\t\tfor ( var x = x0; x <= x1; x += 2 )\r\n\t\t\t\t\tpoints.Add( new Vector2( x, y + (((int)((x - x0) / 2)) % 2 == 0 ? 0 : 2) ) );\r\n\t\t\t\tif ( points.Count > 1 ) Paint.DrawLine( points );\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\tprivate int FirstMatchFrom( int line )\r\n\t{\r\n\t\tint lo = 0, hi = _matches.Count;\r\n\t\twhile ( lo < hi )\r\n\t\t{\r\n\t\t\tvar mid = (lo + hi) / 2;\r\n\t\t\tif ( _matches[mid].End.Line < line ) lo = mid + 1;\r\n\t\t\telse hi = mid;\r\n\t\t}\r\n\r\n\t\treturn lo;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Fills the area covered by a text range, row by row.\r\n\t/// </summary>\r\n\tprivate void DrawRange( TextPos start, TextPos end, Color color, int firstLine, int lastLine )\r\n\t{\r\n\t\tPaint.ClearPen();\r\n\t\tPaint.SetBrush( color );\r\n\r\n\t\tfor ( int line = Math.Max( firstLine, start.Line ); line <= Math.Min( lastLine, end.Line ); line++ )\r\n\t\t{\r\n\t\t\tvar text = Buffer.Lines[line];\r\n\t\t\tvar from = line == start.Line ? start.Col : 0;\r\n\t\t\tvar to = line == end.Line ? end.Col : text.Length;\r\n\t\t\tvar starts = _rowStarts[line];\r\n\r\n\t\t\tfor ( int r = 0; r < starts.Length; r++ )\r\n\t\t\t{\r\n\t\t\t\tvar rowStart = starts[r];\r\n\t\t\t\tvar rowEnd = RowEnd( line, r );\r\n\t\t\t\tvar isLastRow = r == starts.Length - 1;\r\n\r\n\t\t\t\tvar a = Math.Max( from, rowStart );\r\n\t\t\t\tvar b = Math.Min( to, rowEnd );\r\n\r\n\t\t\t\t// Selection running past the end of a line shows a little block for the newline\r\n\t\t\t\tvar newline = isLastRow && line < end.Line;\r\n\t\t\t\tif ( a > b || (a == b && !newline) ) continue;\r\n\t\t\t\tif ( !isLastRow && a == rowEnd ) continue;\r\n\r\n\t\t\t\tvar x0 = TextLayout.ColumnOf( text, rowStart, a );\r\n\t\t\t\tvar x1 = TextLayout.ColumnOf( text, rowStart, b ) + (newline ? 1 : 0);\r\n\t\t\t\tvar y = (_lineFirstRow[line] + r) * _lineHeight;\r\n\t\t\t\tPaint.DrawRect( new Rect( TextLeft + x0 * _charWidth, y, (x1 - x0) * _charWidth, _lineHeight ) );\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Draws one row as runs of same-coloured text. Tabs are expanded so columns line up.\r\n\t/// </summary>\r\n\tprivate void DrawRow( int row, int lineIndex, int rowStart, int rowEnd, List<Token> tokens )\r\n\t{\r\n\t\tif ( rowEnd <= rowStart ) return;\r\n\r\n\t\tvar line = Buffer.Lines[lineIndex];\r\n\t\tvar y = row * _lineHeight;\r\n\r\n\t\tvar pos = rowStart;\r\n\t\tforeach ( var token in tokens )\r\n\t\t{\r\n\t\t\tvar tokenEnd = token.Start + token.Length;\r\n\t\t\tif ( tokenEnd <= rowStart ) continue;\r\n\t\t\tif ( token.Start >= rowEnd ) break;\r\n\r\n\t\t\tvar start = Math.Max( token.Start, rowStart );\r\n\t\t\tvar end = Math.Min( tokenEnd, rowEnd );\r\n\t\t\tif ( start > pos ) DrawRun( line, rowStart, pos, start, TokenKind.Text, y );\r\n\t\t\tDrawRun( line, rowStart, start, end, token.Kind, y );\r\n\t\t\tpos = end;\r\n\t\t}\r\n\r\n\t\tif ( pos < rowEnd ) DrawRun( line, rowStart, pos, rowEnd, TokenKind.Text, y );\r\n\t}\r\n\r\n\tprivate void DrawRun( string line, int rowStart, int start, int end, TokenKind kind, float y )\r\n\t{\r\n\t\tvar text = line.Substring( start, end - start );\r\n\t\tif ( string.IsNullOrWhiteSpace( text ) ) return;\r\n\r\n\t\tvar startCol = TextLayout.ColumnOf( line, rowStart, start );\r\n\t\tvar display = text.Contains( '\\t' ) ? ExpandTabs( text, startCol ) : text;\r\n\r\n\t\tPaint.SetPen( SyntaxColors.For( kind ) );\r\n\t\tvar rect = new Rect( TextLeft + startCol * _charWidth, y, (display.Length + 2) * _charWidth, _lineHeight );\r\n\t\tPaint.DrawText( rect, display, TextFlag.LeftCenter | TextFlag.SingleLine );\r\n\t}\r\n\r\n\tprivate static string ExpandTabs( string text, int startCol )\r\n\t{\r\n\t\tvar sb = new System.Text.StringBuilder();\r\n\t\tvar col = startCol;\r\n\t\tforeach ( var c in text )\r\n\t\t{\r\n\t\t\tif ( c == '\\t' )\r\n\t\t\t{\r\n\t\t\t\tvar next = (col / TextLayout.TabSize + 1) * TextLayout.TabSize;\r\n\t\t\t\tsb.Append( ' ', next - col );\r\n\t\t\t\tcol = next;\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\tsb.Append( c );\r\n\t\t\t\tcol++;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn sb.ToString();\r\n\t}\r\n}\r\n"
},
{
"Ident": "chrinteractive.hotcode",
"Path": "Editor/HotCodeEditor/Navigation/LatestOnlyWorker.cs",
"FileName": "LatestOnlyWorker.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 399294,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Concurrent;\nusing System.Threading.Tasks;\n\n/// <summary>\n/// Runs one background job at a time and only cares about the newest request: if more come in\n/// while a job runs, the last one runs next and the ones in between are skipped. Results are\n/// handed back on the main thread by <see cref=\"Pump\"/> (call it every frame).\n///\n/// For things like autocomplete, where only the answer for the current keystroke matters.\n/// </summary>\npublic class LatestOnlyWorker<T>\n{\n\tprivate bool _busy;\n\tprivate (Func<T> Work, Action<T> Apply)? _next;\n\tprivate readonly ConcurrentQueue<(T Result, Exception Error, Action<T> Apply)> _done = new();\n\n\t/// <summary>\n\t/// Called on the main thread when a job throws.\n\t/// </summary>\n\tpublic Action<Exception> OnError;\n\n\tpublic void Request( Func<T> work, Action<T> apply )\n\t{\n\t\tif ( _busy )\n\t\t{\n\t\t\t_next = (work, apply);\n\t\t\treturn;\n\t\t}\n\n\t\tStart( work, apply );\n\t}\n\n\tprivate void Start( Func<T> work, Action<T> apply )\n\t{\n\t\t_busy = true;\n\t\tTask.Run( work ).ContinueWith( t => _done.Enqueue( t.IsFaulted\n\t\t\t? (default, t.Exception?.InnerException ?? t.Exception, apply)\n\t\t\t: (t.Result, null, apply) ) );\n\t}\n\n\tpublic void Pump()\n\t{\n\t\twhile ( _done.TryDequeue( out var done ) )\n\t\t{\n\t\t\t_busy = false;\n\n\t\t\t// A newer request is waiting, so this result is already out of date\n\t\t\tif ( _next is { } next )\n\t\t\t{\n\t\t\t\t_next = null;\n\t\t\t\tStart( next.Work, next.Apply );\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif ( done.Error is not null ) OnError?.Invoke( done.Error );\n\t\t\telse done.Apply( done.Result );\n\t\t}\n\t}\n}\n"
},
{
"Ident": "chrinteractive.hotcode",
"Path": "Editor/HotCodeEditor/EditorDocument.cs",
"FileName": "EditorDocument.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 399294,
"IsPrivate": false,
"Code": "/// <summary>\r\n/// A file open in a tab. Each document keeps its own CodeView so undo history and\r\n/// scroll position survive switching tabs.\r\n/// </summary>\r\npublic class EditorDocument\r\n{\r\n\t/// <summary>\r\n\t/// Where the file lives. Changes when it's renamed or moved from the file tree.\r\n\t/// </summary>\r\n\tpublic string Path { get; set; }\r\n\tpublic CodeView Editor { get; }\r\n\r\n\t/// <summary>\r\n\t/// Has edits that aren't written to disk yet.\r\n\t/// </summary>\r\n\tpublic bool IsDirty { get; set; }\r\n\r\n\t/// <summary>\r\n\t/// Opened with a single click; gets replaced by the next single-clicked file\r\n\t/// unless it's edited or double-clicked.\r\n\t/// </summary>\r\n\tpublic bool IsPreview { get; set; }\r\n\r\n\t/// <summary>\r\n\t/// The file started with a UTF-8 byte order mark, so keep writing one.\r\n\t/// </summary>\r\n\tpublic bool HasBom { get; init; }\r\n\r\n\t/// <summary>\r\n\t/// RealTime.Now of the last edit, for auto save.\r\n\t/// </summary>\r\n\tpublic double LastEdit { get; set; }\r\n\r\n\tpublic string Title => System.IO.Path.GetFileName( Path );\r\n\r\n\tpublic EditorDocument( string path, CodeView editor )\r\n\t{\r\n\t\tPath = path;\r\n\t\tEditor = editor;\r\n\t}\r\n}\r\n"
},
{
"Ident": "chrinteractive.hotcode",
"Path": "Editor/Launcher/HotCodeInstaller.cs",
"FileName": "HotCodeInstaller.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 399294,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Linq;\nusing System.Text.Json;\n\n/// <summary>\n/// Adds HotCode to other projects by copying it into their Libraries folder, which is where\n/// s&box's Library Manager puts libraries too. Next time that project is opened in s&box,\n/// HotCode loads with it.\n///\n/// Until HotCode is published, this is the way to get it into another project; afterwards the\n/// Library Manager can install (and update) it as well.\n/// </summary>\npublic static class HotCodeInstaller\n{\n\tpublic const string Ident = \"hotcode\";\n\n\t/// <summary>\n\t/// A file only HotCode has, so it can be recognised whatever org it's published under.\n\t/// </summary>\n\tstatic readonly string MarkerFile = Path.Combine( \"Editor\", \"Launcher\", \"HotCodeInstaller.cs\" );\n\n\t/// <summary>\n\t/// \"org.hotcode\", using the org from HotCode's own .sbproj (the same naming the Library Manager uses).\n\t/// </summary>\n\tpublic static string FolderName( string sourceRoot )\n\t{\n\t\ttry\n\t\t{\n\t\t\tusing var doc = JsonDocument.Parse( File.ReadAllText( Path.Combine( sourceRoot, Ident + \".sbproj\" ) ) );\n\t\t\tif ( doc.RootElement.TryGetProperty( \"Org\", out var org ) && org.GetString() is { Length: > 0 } name )\n\t\t\t\treturn $\"{name.ToLowerInvariant()}.{Ident}\";\n\t\t}\n\t\tcatch ( Exception e ) when ( e is IOException or JsonException or UnauthorizedAccessException )\n\t\t{\n\t\t}\n\n\t\treturn Ident;\n\t}\n\n\t/// <summary>\n\t/// Folders never copied: build output, editor state, and the logo source script.\n\t/// </summary>\n\tstatic readonly HashSet<string> SkipFolders = new( StringComparer.OrdinalIgnoreCase ) { \"obj\", \"bin\", \".vs\", \".git\", \".idea\", \"logo\", \"Properties\" };\n\n\t/// <summary>\n\t/// Files never copied: s&box generates these per project, with paths that only make sense where they were made.\n\t/// </summary>\n\tstatic readonly HashSet<string> SkipExtensions = new( StringComparer.OrdinalIgnoreCase ) { \".csproj\", \".sln\", \".slnx\", \".user\" };\n\n\t/// <summary>\n\t/// Where HotCode is installed in a project (any folder under Libraries whose .sbproj is HotCode's), or null.\n\t/// </summary>\n\tpublic static string FindInstalled( string projectRoot )\n\t{\n\t\tvar libraries = Path.Combine( projectRoot, \"Libraries\" );\n\t\tif ( !Directory.Exists( libraries ) ) return null;\n\n\t\tforeach ( var folder in Directory.EnumerateDirectories( libraries ) )\n\t\t{\n\t\t\tforeach ( var sbproj in Directory.EnumerateFiles( folder, \"*.sbproj\" ) )\n\t\t\t\tif ( IsHotCode( sbproj ) ) return folder;\n\t\t}\n\n\t\treturn null;\n\t}\n\n\t/// <summary>\n\t/// True when an .sbproj is HotCode's own: ident \"hotcode\" and HotCode's files next to it.\n\t/// Doesn't depend on the org, so renaming or changing the org doesn't break anything.\n\t/// </summary>\n\tpublic static bool IsHotCode( string sbproj )\n\t{\n\t\ttry\n\t\t{\n\t\t\tif ( !File.Exists( Path.Combine( Path.GetDirectoryName( sbproj ), MarkerFile ) ) ) return false;\n\n\t\t\tusing var doc = JsonDocument.Parse( File.ReadAllText( sbproj ) );\n\t\t\treturn doc.RootElement.TryGetProperty( \"Ident\", out var ident ) && string.Equals( ident.GetString(), Ident, StringComparison.OrdinalIgnoreCase );\n\t\t}\n\t\tcatch ( Exception e ) when ( e is IOException or JsonException or UnauthorizedAccessException )\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// Copies HotCode from <paramref name=\"sourceRoot\"/> into the project. An existing copy is moved\n\t/// to the Recycle Bin first (via <paramref name=\"recycle\"/>), so updating never loses anything for good.\n\t/// Returns the folder HotCode now lives in. Throws <see cref=\"InvalidOperationException\"/> with a readable message.\n\t/// </summary>\n\tpublic static string Install( string sourceRoot, string projectRoot, Action<string> recycle )\n\t{\n\t\tsourceRoot = Path.GetFullPath( sourceRoot ).TrimEnd( '\\\\', '/' );\n\t\tprojectRoot = Path.GetFullPath( projectRoot ).TrimEnd( '\\\\', '/' );\n\n\t\tif ( !File.Exists( Path.Combine( sourceRoot, Ident + \".sbproj\" ) ) )\n\t\t\tthrow new InvalidOperationException( \"Couldn't find HotCode's own files to copy.\" );\n\n\t\tif ( ProjectFiles.IsUnder( projectRoot, sourceRoot ) || ProjectFiles.IsUnder( sourceRoot, projectRoot ) )\n\t\t\tthrow new InvalidOperationException( \"That's the project HotCode is being developed in - it already has it.\" );\n\n\t\tif ( !Directory.Exists( projectRoot ) || !Directory.EnumerateFiles( projectRoot, \"*.sbproj\" ).Any() )\n\t\t\tthrow new InvalidOperationException( \"That folder isn't an s&box project (no .sbproj file).\" );\n\n\t\t// Replace an existing copy wherever it is, otherwise use the standard folder name\n\t\tvar target = FindInstalled( projectRoot ) ?? Path.Combine( projectRoot, \"Libraries\", FolderName( sourceRoot ) );\n\t\tif ( ProjectFiles.IsUnder( target, sourceRoot ) )\n\t\t\tthrow new InvalidOperationException( \"Refusing to overwrite HotCode's own source folder.\" );\n\n\t\t// Copy into a staging folder first, so a failed copy never leaves a half-installed library\n\t\tvar staging = target + \".installing\";\n\t\tif ( Directory.Exists( staging ) ) Directory.Delete( staging, recursive: true );\n\t\tCopyFolder( sourceRoot, staging );\n\n\t\tif ( Directory.Exists( target ) ) recycle( target );\n\t\tDirectory.Move( staging, target );\n\n\t\treturn target;\n\t}\n\n\t/// <summary>\n\t/// Removes HotCode from a project (to the Recycle Bin). Returns false if it wasn't installed.\n\t/// </summary>\n\tpublic static bool Uninstall( string sourceRoot, string projectRoot, Action<string> recycle )\n\t{\n\t\tvar installed = FindInstalled( projectRoot );\n\t\tif ( installed is null ) return false;\n\n\t\tif ( ProjectFiles.IsUnder( installed, sourceRoot ) )\n\t\t\tthrow new InvalidOperationException( \"That's HotCode's own source folder - not removing it.\" );\n\n\t\trecycle( installed );\n\t\treturn true;\n\t}\n\n\tprivate static void CopyFolder( string from, string to )\n\t{\n\t\tDirectory.CreateDirectory( to );\n\n\t\tforeach ( var file in Directory.EnumerateFiles( from ) )\n\t\t{\n\t\t\tif ( SkipExtensions.Contains( Path.GetExtension( file ) ) ) continue;\n\t\t\tFile.Copy( file, Path.Combine( to, Path.GetFileName( file ) ), overwrite: true );\n\t\t}\n\n\t\tforeach ( var folder in Directory.EnumerateDirectories( from ) )\n\t\t{\n\t\t\tif ( SkipFolders.Contains( Path.GetFileName( folder ) ) ) continue;\n\t\t\tCopyFolder( folder, Path.Combine( to, Path.GetFileName( folder ) ) );\n\t\t}\n\t}\n}\n"
},
{
"Ident": "chrinteractive.hotcode",
"Path": "Editor/HotCodeEditor/CodeFileTree.cs",
"FileName": "CodeFileTree.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 399294,
"IsPrivate": false,
"Code": "using Sandbox;\nusing Editor;\nusing System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Linq;\n\n/// <summary>\n/// Whether a file is open in a tab, and whether it's the one being edited.\n/// </summary>\npublic enum FileState\n{\n\tClosed,\n\tOpen,\n\tActive\n}\n\n/// <summary>\n/// A lazily-built tree node for a file or folder on disk. Only shows code/text files\n/// and skips build output folders.\n/// </summary>\npublic class CodeFileNode : TreeNode\n{\n\tpublic static readonly HashSet<string> HiddenFolders = new( StringComparer.OrdinalIgnoreCase ) { \"bin\", \"obj\", \".vs\", \".git\", \".idea\", \".sbox\", \"node_modules\" };\n\n\tstatic readonly HashSet<string> CodeExtensions = new( StringComparer.OrdinalIgnoreCase )\n\t{\n\t\t\".cs\", \".razor\", \".scss\", \".css\", \".json\", \".sbproj\", \".csproj\", \".sln\", \".slnx\",\n\t\t\".md\", \".txt\", \".shader\", \".hlsl\", \".editorconfig\", \".gitignore\", \".xml\", \".yml\", \".yaml\"\n\t};\n\n\tpublic FileSystemInfo Info { get; }\n\tpublic bool IsFolder => Info is DirectoryInfo;\n\tpublic string FullPath => Info.FullName;\n\n\tprivate readonly HotCodeEditor _host;\n\n\tpublic CodeFileNode( HotCodeEditor host, FileSystemInfo info ) : base( info.FullName )\n\t{\n\t\t_host = host;\n\t\tInfo = info;\n\t\t_host.RegisterNode( this );\n\t}\n\n\tpublic override string Name => Info.Name;\n\tpublic override bool HasChildren => IsFolder;\n\tpublic override string GetTooltip() => Info.FullName;\n\n\tpublic static bool IsCodeFile( string path ) => CodeExtensions.Contains( Path.GetExtension( path ) ) || CodeExtensions.Contains( Path.GetFileName( path ) );\n\n\t/// <summary>\n\t/// True for paths inside bin/obj/.git etc, which the tree never shows.\n\t/// </summary>\n\tpublic static bool IsInHiddenFolder( string path ) => path.Split( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar ).Any( HiddenFolders.Contains );\n\n\tpublic override void OnPaint( VirtualWidget item )\n\t{\n\t\tPaintSelection( item );\n\n\t\tvar state = IsFolder ? FileState.Closed : _host.StateOf( FullPath );\n\n\t\t// Folders in s&box's folder colour; files coloured by type\n\t\tvar open = IsFolder && item.IsOpen;\n\t\tPaint.SetPen( IsFolder ? Theme.Folder : HotCodeStyle.FileIconColor( FullPath ) );\n\t\tPaint.DrawIcon( item.Rect, IsFolder ? (open ? \"folder_open\" : \"folder\") : HotCodeStyle.FileIcon( FullPath ), 15, TextFlag.LeftCenter );\n\n\t\t// The file you're editing is bold; other open files are bright, the rest are dimmer\n\t\tPaint.SetDefaultFont( HotCodeStyle.FontSize, state == FileState.Active ? 700 : 400 );\n\t\tPaint.SetPen( IsFolder || state != FileState.Closed || item.Selected ? Theme.Text : Theme.TextLight );\n\t\tPaint.DrawText( item.Rect.Shrink( 22, 0, 0, 0 ), Info.Name, TextFlag.LeftCenter );\n\n\t\tif ( state != FileState.Closed && _host.IsUnsaved( FullPath ) )\n\t\t{\n\t\t\tPaint.ClearPen();\n\t\t\tPaint.SetBrush( Theme.Text );\n\t\t\tPaint.DrawCircle( new Vector2( item.Rect.Right - 10, item.Rect.Center.y ), 6 );\n\t\t}\n\t}\n\n\tpublic override bool OnContextMenu()\n\t{\n\t\t_host.ShowFileContextMenu( this );\n\t\treturn true;\n\t}\n\n\n\tprotected override void BuildChildren()\n\t{\n\t\tif ( Info is not DirectoryInfo dir )\n\t\t\treturn;\n\n\t\tClear();\n\n\t\tIEnumerable<FileSystemInfo> entries;\n\t\ttry\n\t\t{\n\t\t\tdir.Refresh();\n\t\t\tentries = dir.GetFileSystemInfos();\n\t\t}\n\t\tcatch ( Exception e ) when ( e is IOException or UnauthorizedAccessException )\n\t\t{\n\t\t\tLog.Warning( $\"[HotCode] Can't read {dir.FullName}: {e.Message}\" );\n\t\t\treturn;\n\t\t}\n\n\t\tvar folders = entries.OfType<DirectoryInfo>()\n\t\t\t.Where( x => !HiddenFolders.Contains( x.Name ) && ShouldShowFolder( x ) )\n\t\t\t.OrderBy( x => x.Name, StringComparer.OrdinalIgnoreCase );\n\n\t\tvar files = entries.OfType<FileInfo>()\n\t\t\t.Where( x => IsCodeFile( x.Name ) )\n\t\t\t.OrderBy( x => x.Name, StringComparer.OrdinalIgnoreCase );\n\n\t\tAddItems( folders.Select( x => new CodeFileNode( _host, x ) ) );\n\t\tAddItems( files.Select( x => new CodeFileNode( _host, x ) ) );\n\t}\n\n\t/// <summary>\n\t/// Hide folders full of things we'd never open (e.g. Assets full of textures),\n\t/// but keep empty ones so a folder you just made shows up.\n\t/// </summary>\n\tstatic bool ShouldShowFolder( DirectoryInfo dir )\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar any = false;\n\t\t\tforeach ( var f in dir.EnumerateFiles( \"*\", SearchOption.AllDirectories ) )\n\t\t\t{\n\t\t\t\tif ( IsInHiddenFolder( f.FullName ) ) continue;\n\t\t\t\tif ( IsCodeFile( f.Name ) ) return true;\n\t\t\t\tany = true;\n\t\t\t}\n\n\t\t\treturn !any;\n\t\t}\n\t\tcatch ( Exception e ) when ( e is IOException or UnauthorizedAccessException )\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t}\n}\n"
},
{
"Ident": "chrinteractive.hotcode",
"Path": "Editor/HotCodeEditor/CodeView/TextLayout.cs",
"FileName": "TextLayout.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 399294,
"IsPrivate": false,
"Code": "using System.Collections.Generic;\n\n/// <summary>\n/// Column maths for monospaced text: tab expansion and word wrapping.\n/// A line is shown as one or more rows; each row starts at a character index.\n/// Columns inside a row count from the start of that row.\n/// </summary>\npublic static class TextLayout\n{\n\t/// <summary>\n\t/// Columns per tab. Set from HotCode's settings.\n\t/// </summary>\n\tpublic static int TabSize { get; set; } = 4;\n\n\tprivate static readonly int[] SingleRow = { 0 };\n\n\tprivate static int Advance( char c, int col ) => c == '\\t' ? (col / TabSize + 1) * TabSize : col + 1;\n\n\t/// <summary>\n\t/// Column that character <paramref name=\"index\"/> lands on, counting from <paramref name=\"rowStart\"/>.\n\t/// </summary>\n\tpublic static int ColumnOf( string line, int rowStart, int index )\n\t{\n\t\tint col = 0;\n\t\tfor ( int i = rowStart; i < index && i < line.Length; i++ )\n\t\t\tcol = Advance( line[i], col );\n\t\treturn col;\n\t}\n\n\tpublic static int Width( string line ) => ColumnOf( line, 0, line.Length );\n\n\t/// <summary>\n\t/// Character index in [rowStart, rowEnd] nearest to a (possibly fractional) column.\n\t/// </summary>\n\tpublic static int IndexAtColumn( string line, int rowStart, int rowEnd, float column )\n\t{\n\t\tint col = 0;\n\t\tfor ( int i = rowStart; i < rowEnd && i < line.Length; i++ )\n\t\t{\n\t\t\tvar next = Advance( line[i], col );\n\t\t\tif ( column < (col + next) / 2f ) return i;\n\t\t\tcol = next;\n\t\t}\n\n\t\treturn rowEnd;\n\t}\n\n\t/// <summary>\n\t/// Where each row of a line starts when wrapped to <paramref name=\"columns\"/>. Breaks after\n\t/// whitespace when it can, mid-word when a word is longer than a row. Columns <= 0 means no wrapping.\n\t/// </summary>\n\tpublic static int[] Wrap( string line, int columns )\n\t{\n\t\tif ( columns <= 0 || line.Length == 0 || Width( line ) <= columns )\n\t\t\treturn SingleRow;\n\n\t\tvar starts = new List<int> { 0 };\n\t\tint rowStart = 0;\n\t\tint col = 0;\n\t\tint lastBreak = -1;\n\t\tvar rowHasText = false;\n\n\t\tfor ( int i = 0; i < line.Length; i++ )\n\t\t{\n\t\t\tvar next = Advance( line[i], col );\n\n\t\t\t// Loops at most twice: breaking at the last space can still leave this character\n\t\t\t// (say, a tab) too wide, and then it moves to a row of its own start\n\t\t\twhile ( next > columns && i > rowStart )\n\t\t\t{\n\t\t\t\t// Whitespace stays at the end of the row it follows, so a row never starts with the break\n\t\t\t\tvar breakAt = lastBreak > rowStart && lastBreak <= i ? lastBreak : i;\n\t\t\t\tstarts.Add( breakAt );\n\t\t\t\trowStart = breakAt;\n\t\t\t\tlastBreak = -1;\n\t\t\t\trowHasText = false;\n\n\t\t\t\tcol = ColumnOf( line, rowStart, i );\n\t\t\t\tnext = Advance( line[i], col );\n\t\t\t}\n\n\t\t\tcol = next;\n\n\t\t\tvar isSpace = line[i] == ' ' || line[i] == '\\t';\n\t\t\tif ( !isSpace ) rowHasText = true;\n\n\t\t\t// Leading indentation isn't a break point, or we'd get rows of nothing but indent\n\t\t\telse if ( rowHasText ) lastBreak = i + 1;\n\t\t}\n\n\t\treturn starts.ToArray();\n\t}\n}\n"
},
{
"Ident": "chrinteractive.hotcode",
"Path": "Editor/HotCodeEditor/Ui/HotCodeLogo.cs",
"FileName": "HotCodeLogo.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 399294,
"IsPrivate": false,
"Code": "using Sandbox;\nusing Editor;\nusing System;\nusing System.Linq;\n\n/// <summary>\n/// The HotCode flame: red outer flame, orange middle, yellow core, each with a vertical gradient.\n/// The outlines are generated by logo/flame.py (HotCodeLogo.Shapes.cs), which also renders the\n/// PNGs next to it in logo/, so the painted logo and the images always match.\n/// </summary>\npublic static partial class HotCodeLogo\n{\n\t/// <summary>\n\t/// Material icon name for places that only take an icon name (EditorApp, menus).\n\t/// </summary>\n\tpublic const string IconName = \"local_fire_department\";\n\n\tprivate static readonly (Func<Vector2[]> Shape, Color Top, Color Bottom)[] Layers =\n\t{\n\t\t(() => Outer, Hex( \"#E21D12\" ), Hex( \"#FF5A14\" )),\n\t\t(() => Middle, Hex( \"#FF6A10\" ), Hex( \"#FFA01C\" )),\n\t\t(() => Core, Hex( \"#FFC82A\" ), Hex( \"#FFF19A\" )),\n\t};\n\n\tpublic static readonly Color BadgeBackground = Hex( \"#1c1c1c\" );\n\tpublic static readonly Color Glow = Hex( \"#FF5810\" );\n\n\t/// <summary>\n\t/// Draws just the flame, filling <paramref name=\"rect\"/> (keep it square).\n\t/// </summary>\n\tpublic static void PaintFlame( Rect rect )\n\t{\n\t\tPaint.Antialiasing = true;\n\t\tPaint.ClearPen();\n\n\t\tforeach ( var (shape, top, bottom) in Layers )\n\t\t{\n\t\t\tvar points = shape().Select( p => new Vector2( rect.Left + p.x * rect.Width, rect.Top + p.y * rect.Height ) ).ToArray();\n\t\t\tPaint.SetBrushLinear( new Vector2( rect.Left, rect.Top ), new Vector2( rect.Left, rect.Bottom ), top, bottom );\n\t\t\tPaint.DrawPolygon( points );\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// The flame on a dark rounded square with a warm glow, like the package thumbnail.\n\t/// </summary>\n\tpublic static void PaintBadge( Rect rect )\n\t{\n\t\tPaint.Antialiasing = true;\n\t\tPaint.ClearPen();\n\t\tPaint.SetBrush( BadgeBackground );\n\t\tPaint.DrawRect( rect, rect.Width * 0.18f );\n\n\t\tvar glow = new Rect( rect.Left + rect.Width * 0.2f, rect.Top + rect.Height * 0.32f, rect.Width * 0.6f, rect.Height * 0.58f );\n\t\tPaint.SetBrushRadial( glow.Center, glow.Width * 0.5f, Glow.WithAlpha( 0.35f ), Glow.WithAlpha( 0 ) );\n\t\tPaint.DrawRect( glow );\n\n\t\tvar inset = rect.Width * 0.15f;\n\t\tPaintFlame( new Rect( rect.Left + inset, rect.Top + rect.Height * 0.13f, rect.Width - inset * 2, rect.Height - inset * 2 ) );\n\t}\n\n\tprivate static Pixmap _windowIcon;\n\n\t/// <summary>\n\t/// The flame as a pixmap, for SetWindowIcon (window title bars and the taskbar).\n\t/// </summary>\n\tpublic static Pixmap WindowIcon\n\t{\n\t\tget\n\t\t{\n\t\t\tif ( _windowIcon is not null ) return _windowIcon;\n\n\t\t\t_windowIcon = new Pixmap( 64, 64 );\n\t\t\t_windowIcon.Clear( Color.Transparent );\n\t\t\tusing ( Paint.ToPixmap( _windowIcon ) )\n\t\t\t{\n\t\t\t\tPaintFlame( new Rect( 0, 0, 64, 64 ) );\n\t\t\t}\n\n\t\t\treturn _windowIcon;\n\t\t}\n\t}\n\n\tprivate static Color Hex( string hex ) => Color.Parse( hex ) ?? Color.White;\n}\n\n/// <summary>\n/// A widget that just shows the logo: the badge, or the bare flame.\n/// </summary>\npublic class LogoWidget : Widget\n{\n\tprivate readonly bool _badge;\n\n\tpublic LogoWidget( Widget parent, float size, bool badge ) : base( parent )\n\t{\n\t\t_badge = badge;\n\t\tFixedSize = new Vector2( size, size );\n\t\tTransparentForMouseEvents = true;\n\t}\n\n\tprotected override void OnPaint()\n\t{\n\t\tif ( _badge ) HotCodeLogo.PaintBadge( LocalRect );\n\t\telse HotCodeLogo.PaintFlame( LocalRect );\n\t}\n}\n"
},
{
"Ident": "chrinteractive.hotcode",
"Path": "Editor/HotCodeEditor/CodeView/TextSearch.cs",
"FileName": "TextSearch.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 399294,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\nusing System.Text.RegularExpressions;\n\npublic readonly record struct SearchMatch( TextPos Start, TextPos End, Match RegexMatch );\n\npublic record struct SearchOptions( string Query, bool MatchCase, bool WholeWord, bool UseRegex );\n\n/// <summary>\n/// Finding text in a <see cref=\"TextBuffer\"/>. Plain searches go through Regex too (escaped)\n/// so every option behaves the same way.\n/// </summary>\npublic static class TextSearch\n{\n\tpublic const int MaxMatches = 10000;\n\n\t/// <summary>\n\t/// All matches in order. Returns an empty list and sets <paramref name=\"error\"/> for a bad regex.\n\t/// </summary>\n\tpublic static List<SearchMatch> FindAll( TextBuffer buffer, SearchOptions options, out string error )\n\t{\n\t\terror = null;\n\t\tvar results = new List<SearchMatch>();\n\t\tif ( string.IsNullOrEmpty( options.Query ) ) return results;\n\n\t\tvar regex = Build( options, out error );\n\t\tif ( regex is null ) return results;\n\n\t\tvar text = string.Join( \"\\n\", buffer.Lines );\n\t\tvar lineStarts = new int[buffer.LineCount];\n\t\tfor ( int i = 1; i < buffer.LineCount; i++ )\n\t\t\tlineStarts[i] = lineStarts[i - 1] + buffer.Lines[i - 1].Length + 1;\n\n\t\ttry\n\t\t{\n\t\t\tfor ( var m = regex.Match( text ); m.Success && results.Count < MaxMatches; m = m.NextMatch() )\n\t\t\t{\n\t\t\t\t// Zero-length matches (like \"^\") can't be selected or replaced sensibly\n\t\t\t\tif ( m.Length == 0 ) continue;\n\t\t\t\tresults.Add( new SearchMatch( ToPos( lineStarts, m.Index ), ToPos( lineStarts, m.Index + m.Length ), m ) );\n\t\t\t}\n\t\t}\n\t\tcatch ( RegexMatchTimeoutException )\n\t\t{\n\t\t\terror = \"Search took too long\";\n\t\t}\n\n\t\treturn results;\n\t}\n\n\t/// <summary>\n\t/// What a match gets replaced with. With regex on, $1 / ${name} refer to groups.\n\t/// </summary>\n\tpublic static string ReplacementFor( SearchMatch match, string replacement, SearchOptions options )\n\t{\n\t\treplacement ??= \"\";\n\t\treturn options.UseRegex ? match.RegexMatch.Result( replacement ) : replacement;\n\t}\n\n\tprivate static Regex Build( SearchOptions options, out string error )\n\t{\n\t\terror = null;\n\n\t\tvar pattern = options.UseRegex ? options.Query : Regex.Escape( options.Query );\n\n\t\t// Like \\b, but also works when the query starts or ends with a symbol\n\t\tif ( options.WholeWord ) pattern = $@\"(?<![\\w]){pattern}(?![\\w])\";\n\n\t\tvar flags = RegexOptions.Multiline | RegexOptions.CultureInvariant;\n\t\tif ( !options.MatchCase ) flags |= RegexOptions.IgnoreCase;\n\n\t\ttry\n\t\t{\n\t\t\treturn new Regex( pattern, flags, TimeSpan.FromMilliseconds( 250 ) );\n\t\t}\n\t\tcatch ( ArgumentException e )\n\t\t{\n\t\t\terror = e.Message;\n\t\t\treturn null;\n\t\t}\n\t}\n\n\tprivate static TextPos ToPos( int[] lineStarts, int offset )\n\t{\n\t\tvar line = Array.BinarySearch( lineStarts, offset );\n\t\tif ( line < 0 ) line = ~line - 1;\n\t\treturn new TextPos( line, offset - lineStarts[line] );\n\t}\n}\n"
},
{
"Ident": "chrinteractive.hotcode",
"Path": "Editor/HotCodeEditor/Settings/HotCodeSettings.cs",
"FileName": "HotCodeSettings.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 399294,
"IsPrivate": false,
"Code": "using Sandbox;\nusing Editor;\nusing System;\n\npublic enum AutoSaveMode\n{\n\t[Title( \"Off\" )] Off,\n\t[Title( \"After a short pause\" )] AfterDelay,\n\t[Title( \"When switching tabs\" )] WhenSwitchingTabs\n}\n\npublic enum CodeTheme\n{\n\t[Title( \"Dark+ (VS Code)\" )] DarkPlus,\n\t[Title( \"Monokai\" )] Monokai,\n\t[Title( \"One Dark\" )] OneDark\n}\n\n/// <summary>\n/// HotCode's settings. Static properties so s&box's ControlSheet can edit them directly, the same\n/// way its own Editor Settings pages work. Saved as one editor cookie; <see cref=\"Version\"/> goes up on\n/// every change so open editors know to re-measure and re-layout.\n/// </summary>\npublic static class HotCodeSettings\n{\n\tconst string CookieKey = \"hotcode.settings\";\n\n\t/// <summary>\n\t/// The stored values. A plain class so it round-trips through JSON.\n\t/// </summary>\n\tpublic class Data\n\t{\n\t\tpublic float FontSize { get; set; } = 10;\n\t\tpublic string FontFamily { get; set; } = \"\";\n\t\tpublic float LineSpacing { get; set; } = 1.35f;\n\t\tpublic int TabSize { get; set; } = 4;\n\t\tpublic bool IndentWithSpaces { get; set; }\n\t\tpublic bool WordWrap { get; set; }\n\t\tpublic bool ShowLineNumbers { get; set; } = true;\n\t\tpublic bool HighlightCurrentLine { get; set; } = true;\n\t\tpublic bool CursorBlink { get; set; } = true;\n\t\tpublic CodeTheme Theme { get; set; } = CodeTheme.DarkPlus;\n\n\t\tpublic bool AutoCloseBrackets { get; set; } = true;\n\t\tpublic bool SuggestWhileTyping { get; set; } = true;\n\t\tpublic bool ParameterHints { get; set; } = true;\n\t\tpublic bool UnderlineErrors { get; set; } = true;\n\n\t\tpublic AutoSaveMode AutoSave { get; set; } = AutoSaveMode.Off;\n\t\tpublic float AutoSaveDelay { get; set; } = 1.5f;\n\t\tpublic bool TrimTrailingWhitespace { get; set; }\n\t\tpublic bool InsertFinalNewline { get; set; }\n\t\tpublic bool ReloadChangedFiles { get; set; } = true;\n\t}\n\n\tprivate static Data _data;\n\n\tprivate static Data Current\n\t{\n\t\tget\n\t\t{\n\t\t\tif ( _data is not null ) return _data;\n\n\t\t\t_data = EditorCookie.Get<Data>( CookieKey, null );\n\t\t\tif ( _data is null )\n\t\t\t{\n\t\t\t\t// First run: carry over the settings that used to be stored on their own\n\t\t\t\t_data = new Data\n\t\t\t\t{\n\t\t\t\t\tFontSize = EditorCookie.Get( \"hotcode.fontsize\", 10f ),\n\t\t\t\t\tWordWrap = EditorCookie.Get( \"hotcode.wordwrap\", false )\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tApply();\n\t\t\treturn _data;\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// Goes up every time any setting changes.\n\t/// </summary>\n\tpublic static int Version { get; private set; }\n\n\tprivate static void Set( Action<Data> change )\n\t{\n\t\tchange( Current );\n\t\tEditorCookie.Set( CookieKey, Current );\n\t\tApply();\n\t\tVersion++;\n\t}\n\n\t/// <summary>\n\t/// Pushes settings into the places that read them as plain values.\n\t/// </summary>\n\tprivate static void Apply()\n\t{\n\t\tTextLayout.TabSize = Math.Clamp( _data.TabSize, 1, 16 );\n\t\tSyntaxColors.Use( _data.Theme );\n\t}\n\n\tpublic static void ResetToDefaults() => Set( d =>\n\t{\n\t\tvar defaults = new Data();\n\t\tforeach ( var property in typeof( Data ).GetProperties() )\n\t\t\tproperty.SetValue( d, property.GetValue( defaults ) );\n\t} );\n\n\t// --- EDITOR ---\n\n\t[Title( \"Font Size\" ), Description( \"Code font size in points. Ctrl+= / Ctrl+- or Ctrl+mouse wheel change it too.\" ), Range( 6, 28 ), Step( 1 )]\n\tpublic static float FontSize { get => Current.FontSize; set => Set( d => d.FontSize = Math.Clamp( value, 6, 28 ) ); }\n\n\t[Title( \"Font\" ), Description( \"Code font family. Leave empty to use s&box's monospace font. Use a monospaced font so columns line up.\" )]\n\tpublic static string FontFamily { get => Current.FontFamily; set => Set( d => d.FontFamily = value?.Trim() ?? \"\" ); }\n\n\t[Title( \"Line Spacing\" ), Description( \"Line height as a multiple of the font height.\" ), Range( 1, 2 ), Step( 0.05f )]\n\tpublic static float LineSpacing { get => Current.LineSpacing; set => Set( d => d.LineSpacing = Math.Clamp( value, 1, 2 ) ); }\n\n\t[Title( \"Tab Size\" ), Description( \"How many columns a tab takes up.\" ), Range( 1, 8 ), Step( 1 )]\n\tpublic static int TabSize { get => Current.TabSize; set => Set( d => d.TabSize = Math.Clamp( value, 1, 8 ) ); }\n\n\t[Title( \"Indent With Spaces\" ), Description( \"For files whose indentation can't be detected. Existing files keep whatever they already use.\" )]\n\tpublic static bool IndentWithSpaces { get => Current.IndentWithSpaces; set => Set( d => d.IndentWithSpaces = value ); }\n\n\t[Title( \"Word Wrap\" ), Description( \"Wrap long lines to the width of the editor (Alt+Z).\" )]\n\tpublic static bool WordWrap { get => Current.WordWrap; set => Set( d => d.WordWrap = value ); }\n\n\t[Title( \"Line Numbers\" )]\n\tpublic static bool ShowLineNumbers { get => Current.ShowLineNumbers; set => Set( d => d.ShowLineNumbers = value ); }\n\n\t[Title( \"Highlight Current Line\" )]\n\tpublic static bool HighlightCurrentLine { get => Current.HighlightCurrentLine; set => Set( d => d.HighlightCurrentLine = value ); }\n\n\t[Title( \"Blinking Cursor\" )]\n\tpublic static bool CursorBlink { get => Current.CursorBlink; set => Set( d => d.CursorBlink = value ); }\n\n\t[Title( \"Code Colours\" ), Description( \"Syntax colours for the code area. The rest of HotCode follows s&box's theme.\" )]\n\tpublic static CodeTheme CodeColors { get => Current.Theme; set => Set( d => d.Theme = value ); }\n\n\t// --- CODE HELP ---\n\n\t[Title( \"Auto-Close Brackets and Quotes\" ), Description( \"Typing ( [ { \\\" or ' adds the closing one.\" )]\n\tpublic static bool AutoCloseBrackets { get => Current.AutoCloseBrackets; set => Set( d => d.AutoCloseBrackets = value ); }\n\n\t[Title( \"Suggestions While Typing\" ), Description( \"Show autocomplete as you type. Ctrl+Space always works.\" )]\n\tpublic static bool SuggestWhileTyping { get => Current.SuggestWhileTyping; set => Set( d => d.SuggestWhileTyping = value ); }\n\n\t[Title( \"Parameter Hints\" ), Description( \"Show a method's parameters when typing ( or , in a call. Ctrl+Shift+Space always works.\" )]\n\tpublic static bool ParameterHints { get => Current.ParameterHints; set => Set( d => d.ParameterHints = value ); }\n\n\t[Title( \"Underline Compile Errors\" ), Description( \"Underline errors and warnings from s&box's last compile in the code.\" )]\n\tpublic static bool UnderlineErrors { get => Current.UnderlineErrors; set => Set( d => d.UnderlineErrors = value ); }\n\n\t// --- FILES ---\n\n\t[Title( \"Auto Save\" ), Description( \"Saving triggers s&box's hotload, so auto save means changes show up in the game as you type.\" )]\n\tpublic static AutoSaveMode AutoSave { get => Current.AutoSave; set => Set( d => d.AutoSave = value ); }\n\n\t[Title( \"Auto Save Delay\" ), Description( \"Seconds after you stop typing, when Auto Save is \\\"After a short pause\\\".\" ), Range( 0.5f, 10 ), Step( 0.5f )]\n\tpublic static float AutoSaveDelay { get => Current.AutoSaveDelay; set => Set( d => d.AutoSaveDelay = Math.Clamp( value, 0.5f, 10 ) ); }\n\n\t[Title( \"Trim Trailing Whitespace\" ), Description( \"Remove spaces and tabs at the ends of lines when saving.\" )]\n\tpublic static bool TrimTrailingWhitespace { get => Current.TrimTrailingWhitespace; set => Set( d => d.TrimTrailingWhitespace = value ); }\n\n\t[Title( \"Insert Final Newline\" ), Description( \"Make sure files end with a newline when saving.\" )]\n\tpublic static bool InsertFinalNewline { get => Current.InsertFinalNewline; set => Set( d => d.InsertFinalNewline = value ); }\n\n\t[Title( \"Reload Files Changed Elsewhere\" ), Description( \"When another program changes an open file that has no unsaved edits here, reload it.\" )]\n\tpublic static bool ReloadChangedFiles { get => Current.ReloadChangedFiles; set => Set( d => d.ReloadChangedFiles = value ); }\n\n\t/// <summary>\n\t/// The code font to use: the chosen family, or s&box's monospace font.\n\t/// </summary>\n\tpublic static string CodeFont => string.IsNullOrWhiteSpace( FontFamily ) ? Editor.Theme.MonospaceFont : FontFamily;\n}\n"
},
{
"Ident": "chrinteractive.hotcode",
"Path": "Editor/HotCodeEditor/Ui/HotCodeStyle.cs",
"FileName": "HotCodeStyle.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 399294,
"IsPrivate": false,
"Code": "using Sandbox;\nusing Editor;\nusing System;\n\n/// <summary>\n/// Shared drawing for HotCode's chrome so it matches the rest of the s&box editor.\n/// Colours come from <see cref=\"Theme\"/> (s&box's tools/assets/styles/theme.json), so they\n/// follow s&box if it changes. Only the code area itself uses its own dark colours (<see cref=\"SyntaxColors\"/>).\n/// </summary>\npublic static class HotCodeStyle\n{\n\tpublic const float TabHeight = 26;\n\tpublic const float FontSize = 8;\n\n\t/// <summary>\n\t/// Draws a tab the way s&box's QTabBar style does: rounded top corners, a 2px Primary line\n\t/// on the selected tab, light text when inactive, a surface highlight on hover.\n\t/// Returns the rect left over for content (after padding).\n\t/// </summary>\n\tpublic static Rect DrawTab( Rect rect, bool selected, bool hovered )\n\t{\n\t\tif ( selected || hovered )\n\t\t{\n\t\t\t// Extend past the bottom so only the top corners show as rounded\n\t\t\tPaint.ClearPen();\n\t\t\tPaint.SetBrush( selected ? Theme.TabBackground : Theme.SurfaceBackground );\n\t\t\tPaint.DrawRect( new Rect( rect.Left, rect.Top, rect.Width, rect.Height + Theme.ControlRadius ), Theme.ControlRadius );\n\t\t}\n\n\t\tif ( selected )\n\t\t{\n\t\t\tPaint.SetBrush( Theme.Primary );\n\t\t\tPaint.DrawRect( new Rect( rect.Left + 1, rect.Top, rect.Width - 2, 2 ), 1 );\n\t\t}\n\n\t\treturn rect.Shrink( 12, 2, 8, 0 );\n\t}\n\n\tpublic static Color TabTextColor( bool selected, bool hovered ) => selected || hovered ? Theme.Text : Theme.TextLight;\n\n\tpublic static void SetTabFont( bool italic = false ) => Paint.SetDefaultFont( FontSize, 500, italic );\n\n\t/// <summary>\n\t/// Material icon for a file, by extension.\n\t/// </summary>\n\tpublic static string FileIcon( string path ) => System.IO.Path.GetExtension( path ).ToLowerInvariant() switch\n\t{\n\t\t\".cs\" => \"code\",\n\t\t\".razor\" => \"web\",\n\t\t\".scss\" or \".css\" => \"palette\",\n\t\t\".json\" => \"data_object\",\n\t\t\".sbproj\" or \".csproj\" => \"settings\",\n\t\t\".sln\" or \".slnx\" => \"account_tree\",\n\t\t\".shader\" or \".hlsl\" => \"gradient\",\n\t\t\".md\" or \".txt\" => \"notes\",\n\t\t_ => \"description\"\n\t};\n\n\tpublic static Color FileIconColor( string path ) => System.IO.Path.GetExtension( path ).ToLowerInvariant() switch\n\t{\n\t\t\".cs\" => Theme.Blue,\n\t\t\".razor\" => Theme.Pink,\n\t\t\".scss\" or \".css\" => Theme.Pink,\n\t\t\".json\" => Theme.Yellow,\n\t\t\".sbproj\" or \".csproj\" or \".sln\" or \".slnx\" => Theme.Green,\n\t\t\".shader\" or \".hlsl\" => Theme.Green,\n\t\t_ => Theme.TextLight\n\t};\n}\n\n/// <summary>\n/// A row of panel tabs (like \"Problems\" / \"Output\") painted to match s&box's tabs, with an optional\n/// count badge per tab.\n/// </summary>\npublic class PanelTabBar : Widget\n{\n\tprivate readonly System.Collections.Generic.List<(string Key, string Text, string Icon)> _tabs = new();\n\tprivate readonly System.Collections.Generic.Dictionary<string, (string Text, Color Color)> _badges = new();\n\tprivate int _hovered = -1;\n\n\tpublic string Selected { get; private set; }\n\tpublic Action<string> OnSelected;\n\n\tpublic PanelTabBar( Widget parent ) : base( parent )\n\t{\n\t\tFixedHeight = HotCodeStyle.TabHeight;\n\t\tMouseTracking = true;\n\t}\n\n\tpublic void AddTab( string key, string text, string icon )\n\t{\n\t\t_tabs.Add( (key, text, icon) );\n\t\tSelected ??= key;\n\t\tUpdate();\n\t}\n\n\tpublic void Select( string key )\n\t{\n\t\tif ( Selected == key ) return;\n\t\tSelected = key;\n\t\tUpdate();\n\t\tOnSelected?.Invoke( key );\n\t}\n\n\t/// <summary>\n\t/// A small count shown after a tab's name (null or empty clears it).\n\t/// </summary>\n\tpublic void SetBadge( string key, string text, Color color )\n\t{\n\t\tif ( string.IsNullOrEmpty( text ) ) _badges.Remove( key );\n\t\telse _badges[key] = (text, color);\n\t\tUpdate();\n\t}\n\n\tprivate float TabWidth( int i )\n\t{\n\t\tHotCodeStyle.SetTabFont();\n\t\tvar width = Paint.MeasureText( _tabs[i].Text ).x + 20 + 22;\n\t\tif ( _badges.TryGetValue( _tabs[i].Key, out var badge ) )\n\t\t{\n\t\t\tPaint.SetDefaultFont( 7, 600 );\n\t\t\twidth += Paint.MeasureText( badge.Text ).x + 14;\n\t\t}\n\t\treturn width;\n\t}\n\n\tprivate int TabAt( Vector2 pos )\n\t{\n\t\tfloat x = 4;\n\t\tfor ( int i = 0; i < _tabs.Count; i++ )\n\t\t{\n\t\t\tvar w = TabWidth( i );\n\t\t\tif ( pos.x >= x && pos.x < x + w ) return i;\n\t\t\tx += w + 2;\n\t\t}\n\t\treturn -1;\n\t}\n\n\tprotected override void OnMouseMove( MouseEvent e )\n\t{\n\t\tbase.OnMouseMove( e );\n\t\tvar i = TabAt( e.LocalPosition );\n\t\tif ( i != _hovered ) { _hovered = i; Update(); }\n\t}\n\n\tprotected override void OnMouseLeave()\n\t{\n\t\tbase.OnMouseLeave();\n\t\t_hovered = -1;\n\t\tUpdate();\n\t}\n\n\tprotected override void OnMousePress( MouseEvent e )\n\t{\n\t\tbase.OnMousePress( e );\n\t\tvar i = TabAt( e.LocalPosition );\n\t\tif ( i >= 0 && e.LeftMouseButton ) Select( _tabs[i].Key );\n\t}\n\n\tprotected override void OnPaint()\n\t{\n\t\tPaint.ClearPen();\n\t\tPaint.SetBrush( Theme.TabBarBackground );\n\t\tPaint.DrawRect( LocalRect );\n\n\t\tfloat x = 4;\n\t\tfor ( int i = 0; i < _tabs.Count; i++ )\n\t\t{\n\t\t\tvar tab = _tabs[i];\n\t\t\tvar width = TabWidth( i );\n\t\t\tvar selected = tab.Key == Selected;\n\t\t\tvar hovered = i == _hovered;\n\n\t\t\tvar content = HotCodeStyle.DrawTab( new Rect( x, 2, width, Height - 2 ), selected, hovered );\n\t\t\tvar color = HotCodeStyle.TabTextColor( selected, hovered );\n\n\t\t\tPaint.SetPen( color );\n\t\t\tPaint.DrawIcon( new Rect( content.Left, content.Top, 16, content.Height ), tab.Icon, 14, TextFlag.LeftCenter );\n\n\t\t\tHotCodeStyle.SetTabFont();\n\t\t\tvar textRect = new Rect( content.Left + 20, content.Top, content.Width - 20, content.Height );\n\t\t\tPaint.DrawText( textRect, tab.Text, TextFlag.LeftCenter );\n\n\t\t\tif ( _badges.TryGetValue( tab.Key, out var badge ) )\n\t\t\t{\n\t\t\t\tvar textWidth = Paint.MeasureText( tab.Text ).x;\n\t\t\t\tPaint.SetDefaultFont( 7, 600 );\n\t\t\t\tvar badgeWidth = Paint.MeasureText( badge.Text ).x + 10;\n\t\t\t\tvar badgeRect = new Rect( textRect.Left + textWidth + 6, content.Center.y - 7, badgeWidth, 14 );\n\n\t\t\t\tPaint.ClearPen();\n\t\t\t\tPaint.SetBrush( badge.Color.WithAlpha( 0.25f ) );\n\t\t\t\tPaint.DrawRect( badgeRect, 7 );\n\t\t\t\tPaint.SetPen( badge.Color );\n\t\t\t\tPaint.DrawText( badgeRect, badge.Text, TextFlag.Center );\n\t\t\t}\n\n\t\t\tx += width + 2;\n\t\t}\n\t}\n}\n\n/// <summary>\n/// A short vertical line between status bar items.\n/// </summary>\npublic class StatusDivider : Widget\n{\n\tpublic StatusDivider( Widget parent ) : base( parent )\n\t{\n\t\tFixedWidth = 9;\n\t\tFixedHeight = 20;\n\t}\n\n\tprotected override void OnPaint()\n\t{\n\t\tPaint.SetPen( Theme.Border );\n\t\tPaint.DrawLine( new Vector2( 4, 4 ), new Vector2( 4, Height - 4 ) );\n\t}\n}\n\n/// <summary>\n/// A clickable piece of text (with an optional icon) for the status bar.\n/// </summary>\npublic class StatusItem : Widget\n{\n\tprivate string _text = \"\";\n\tprivate string _icon;\n\tprivate Color? _iconColor;\n\n\tpublic Action Clicked;\n\n\tpublic StatusItem( Widget parent, string tooltip = null ) : base( parent )\n\t{\n\t\tToolTip = tooltip;\n\t\tMouseTracking = true;\n\t\tFixedHeight = 20;\n\t}\n\n\tpublic void Set( string text, string icon = null, Color? iconColor = null )\n\t{\n\t\tif ( _text == text && _icon == icon && Nullable.Equals( _iconColor, iconColor ) ) return;\n\n\t\t_text = text ?? \"\";\n\t\t_icon = icon;\n\t\t_iconColor = iconColor;\n\n\t\tPaint.SetDefaultFont( HotCodeStyle.FontSize );\n\t\tFixedWidth = Paint.MeasureText( _text ).x + (icon is null ? 0 : 18) + 14;\n\t\tVisible = _text.Length > 0 || icon is not null;\n\t\tUpdate();\n\t}\n\n\tprotected override void OnMouseEnter()\n\t{\n\t\tbase.OnMouseEnter();\n\t\tCursor = Clicked is null ? CursorShape.Arrow : CursorShape.Finger;\n\t\tUpdate();\n\t}\n\n\tprotected override void OnMouseLeave()\n\t{\n\t\tbase.OnMouseLeave();\n\t\tUpdate();\n\t}\n\n\tprotected override void OnMousePress( MouseEvent e )\n\t{\n\t\tbase.OnMousePress( e );\n\t\tif ( e.LeftMouseButton ) Clicked?.Invoke();\n\t}\n\n\tprotected override void OnPaint()\n\t{\n\t\tif ( Clicked is not null && IsUnderMouse )\n\t\t{\n\t\t\tPaint.ClearPen();\n\t\t\tPaint.SetBrush( Theme.SurfaceBackground );\n\t\t\tPaint.DrawRect( LocalRect, Theme.ControlRadius );\n\t\t}\n\n\t\tvar x = 7f;\n\t\tif ( _icon is not null )\n\t\t{\n\t\t\tPaint.SetPen( _iconColor ?? Theme.TextLight );\n\t\t\tPaint.DrawIcon( new Rect( x, 0, 16, Height ), _icon, 13, TextFlag.LeftCenter );\n\t\t\tx += 18;\n\t\t}\n\n\t\tPaint.SetDefaultFont( HotCodeStyle.FontSize );\n\t\tPaint.SetPen( IsUnderMouse && Clicked is not null ? Theme.Text : Theme.TextLight );\n\t\tPaint.DrawText( new Rect( x, 0, Width - x, Height ), _text, TextFlag.LeftCenter );\n\t}\n}\n"
},
{
"Ident": "chrinteractive.hotcode",
"Path": ".obj/__compiler_extra.cs",
"FileName": "__compiler_extra.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 399294,
"IsPrivate": false,
"Code": "global using static Sandbox.Internal.GlobalGameNamespace;\r\nglobal using Microsoft.AspNetCore.Components;\r\nglobal using Microsoft.AspNetCore.Components.Rendering;\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonTitle\", \"HotCode\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonIdent\", \"hotcode\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"OrgIdent\", \"chrinteractive\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"Ident\", \"chrinteractive.hotcode\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineVersion\", \"29\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineMinorVersion\", \"1\" )]\r\n\r\n[assembly: System.Runtime.Versioning.TargetFramework( \".NETCoreApp,Version=v9.0\", FrameworkDisplayName = \".NET 9.0\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"CompileTime\", \"2026-10-02T00:57:09.8934384Z\" )]\r\n[assembly: global::System.Reflection.AssemblyVersion(\"0.0.113.0\")]\r\n[assembly: global::System.Reflection.AssemblyFileVersion(\"0.0.113.0\")]"
},
{
"Ident": "chrinteractive.hotcode",
"Path": "Editor/HotCodeEditor/HotCodeEditor.cs",
"FileName": "HotCodeEditor.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 399294,
"IsPrivate": false,
"Code": "using Sandbox;\nusing Editor;\nusing System;\nusing System.Collections.Concurrent;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Linq;\nusing System.Text;\nusing System.Threading.Tasks;\nusing Microsoft.CodeAnalysis;\n\n/// <summary>\n/// The HotCode IDE window: file tree on the left, tabbed code editors on the right,\n/// problems and output underneath. Saving a file inside a project s&box has loaded\n/// lets s&box pick it up and hotload it.\n/// </summary>\npublic class HotCodeEditor : Window\n{\n\tprivate readonly HotCodeEntry _entry;\n\tprivate readonly string _root;\n\tprivate readonly TreeView _tree;\n\tprivate readonly Widget _explorer;\n\tprivate CodeFileNode _rootNode;\n\tprivate readonly EditorTabBar _tabs;\n\tprivate readonly Widget _editorStack;\n\tprivate readonly WelcomePage _welcome;\n\n\t// Status bar\n\tprivate readonly StatusItem _buildStatus;\n\tprivate readonly StatusItem _position;\n\tprivate readonly StatusItem _indent;\n\tprivate readonly StatusItem _lineEnding;\n\tprivate readonly StatusItem _encoding;\n\tprivate readonly StatusItem _language;\n\tprivate bool _projectLoaded;\n\tprivate double _nextLoadedCheck;\n\tprivate readonly OutputPanel _output;\n\n\tprivate readonly List<EditorDocument> _documents = new();\n\tprivate EditorDocument _active;\n\n\t// Tree nodes by folder path, so file system changes can refresh the right folder\n\tprivate readonly Dictionary<string, CodeFileNode> _nodes = new( StringComparer.OrdinalIgnoreCase );\n\n\t// Go to definition runs in the background; results come back through this queue\n\tprivate readonly ConcurrentQueue<(EditorDocument Doc, DefinitionResult Result)> _definitionResults = new();\n\tprivate bool _definitionPending;\n\n\t// Autocomplete and parameter hints run in the background, one at a time, newest request wins\n\tprivate readonly LatestOnlyWorker<CompletionResult> _completions = new();\n\tprivate readonly LatestOnlyWorker<SignatureHelpResult> _signatures = new();\n\n\t// Alt+Left / Alt+Right history of where we jumped from\n\tprivate record NavPoint( string Path, TextPos Pos );\n\tprivate readonly List<NavPoint> _navBack = new();\n\tprivate readonly List<NavPoint> _navForward = new();\n\n\tprivate readonly FileSystemWatcher _watcher;\n\t// (path, structural): structural changes (add/delete/rename) refresh the tree; edits only reload open files\n\tprivate readonly ConcurrentQueue<(string Path, bool Structural)> _changedPaths = new();\n\n\tpublic HotCodeEditor( HotCodeEntry entry )\n\t{\n\t\t_entry = entry;\n\t\t_root = Path.GetFullPath( entry.Directory );\n\n\t\tDeleteOnClose = true;\n\t\tSetWindowIcon( HotCodeLogo.WindowIcon );\n\t\tSize = new Vector2( 1200, 800 );\n\t\tMinimumSize = new Vector2( 600, 400 );\n\n\t\tCanvas = new Widget( this );\n\t\tCanvas.Layout = Layout.Column();\n\n\t\t// Top: tree | editors. Bottom: problems / output.\n\t\tvar vertical = new Splitter( Canvas );\n\t\tvertical.IsVertical = true;\n\t\tCanvas.Layout.Add( vertical, 1 );\n\n\t\tvar splitter = new Splitter( vertical );\n\t\tsplitter.IsHorizontal = true;\n\n\t\t// LEFT: explorer header + file tree. Single click previews, double click opens for keeps.\n\t\t_explorer = new Widget( splitter );\n\t\t_explorer.Layout = Layout.Column();\n\t\t_explorer.Layout.Add( BuildExplorerHeader( _explorer, entry.Title ) );\n\t\t_explorer.Layout.AddSeparator( 1, Theme.Border );\n\n\t\t_tree = _explorer.Layout.Add( new TreeView( _explorer ), 1 );\n\t\t_tree.MinimumWidth = 200;\n\t\t_tree.ItemSelected += item => { if ( item is CodeFileNode { IsFolder: false } node ) OpenFile( node.FullPath, preview: true ); };\n\t\t_tree.ItemActivated += item => { if ( item is CodeFileNode { IsFolder: false } node ) OpenFile( node.FullPath, preview: false ); };\n\n\t\t_rootNode = new CodeFileNode( this, new DirectoryInfo( _root ) );\n\t\t_tree.AddItem( _rootNode );\n\t\t_tree.Open( _rootNode );\n\n\t\t// RIGHT: tabs + editors\n\t\tvar right = new Widget( splitter );\n\t\tright.Layout = Layout.Column();\n\n\t\t_tabs = right.Layout.Add( new EditorTabBar( right ) );\n\t\tright.Layout.AddSeparator( 1, Theme.Border );\n\t\t_tabs.OnTabSelected = Activate;\n\t\t_tabs.OnTabCloseRequested = RequestClose;\n\t\t_tabs.OnTabPinned = doc => { doc.IsPreview = false; _tabs.Update(); };\n\t\t_tabs.GetTabTooltip = doc => Path.GetRelativePath( _root, doc.Path );\n\n\t\t_editorStack = right.Layout.Add( new Widget( right ), 1 );\n\t\t_editorStack.Layout = Layout.Column();\n\n\t\t_welcome = _editorStack.Layout.Add( new WelcomePage( _editorStack, entry.Title, _root, path => OpenFile( path, preview: false ) ), 1 );\n\t\t_welcome.SetRecentFiles( RecentFiles() );\n\n\t\tsplitter.AddWidget( _explorer );\n\t\tsplitter.AddWidget( right );\n\t\tsplitter.SetStretch( 0, 1 );\n\t\tsplitter.SetStretch( 1, 4 );\n\n\t\t// BOTTOM: problems / output\n\t\t_output = new OutputPanel( vertical, _root );\n\t\t_output.ProblemActivated = GoToProblem;\n\t\t_output.ProblemsChanged = _ => ApplyMarkers();\n\n\t\tvertical.AddWidget( splitter );\n\t\tvertical.AddWidget( _output );\n\t\tvertical.SetStretch( 0, 4 );\n\t\tvertical.SetStretch( 1, 1 );\n\n\t\t// Status bar: build state on the left, facts about the open file on the right\n\t\tif ( !StatusBar.IsValid() ) StatusBar = new StatusBar( this );\n\n\t\tvar flame = new LogoWidget( StatusBar, 14, badge: false ) { ToolTip = \"HotCode\" };\n\t\tStatusBar.AddWidgetLeft( flame, 0 );\n\n\t\t_buildStatus = new StatusItem( StatusBar, \"Click to show problems\" ) { Clicked = _output.ShowProblems };\n\t\tStatusBar.AddWidgetLeft( _buildStatus, 0 );\n\n\t\t_position = new StatusItem( StatusBar, \"Go to line (Ctrl+G)\" ) { Clicked = GoToLine };\n\t\t_indent = new StatusItem( StatusBar, \"Indentation, detected from the file\" );\n\t\t_lineEnding = new StatusItem( StatusBar, \"Line endings, kept as they were when saving\" );\n\t\t_encoding = new StatusItem( StatusBar, \"Encoding\" );\n\t\t_language = new StatusItem( StatusBar, \"Language\" );\n\n\t\tforeach ( var item in new[] { _position, _indent, _lineEnding, _encoding, _language } )\n\t\t{\n\t\t\tif ( item != _position ) StatusBar.AddWidgetRight( new StatusDivider( StatusBar ), 0 );\n\t\t\tStatusBar.AddWidgetRight( item, 0 );\n\t\t}\n\n\t\tBuildMenuBar();\n\n\t\t// Keep the tree (and unmodified open files) in sync with changes made outside HotCode\n\t\ttry\n\t\t{\n\t\t\t_watcher = new FileSystemWatcher( _root )\n\t\t\t{\n\t\t\t\tIncludeSubdirectories = true,\n\t\t\t\tNotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite\n\t\t\t};\n\t\t\t_watcher.Created += ( _, e ) => QueueChange( e.FullPath, structural: true );\n\t\t\t_watcher.Deleted += ( _, e ) => QueueChange( e.FullPath, structural: true );\n\t\t\t_watcher.Changed += ( _, e ) => QueueChange( e.FullPath, structural: false );\n\t\t\t_watcher.Renamed += ( _, e ) => { QueueChange( e.OldFullPath, structural: true ); QueueChange( e.FullPath, structural: true ); };\n\t\t\t_watcher.EnableRaisingEvents = true;\n\t\t}\n\t\tcatch ( Exception e ) when ( e is IOException or ArgumentException or UnauthorizedAccessException )\n\t\t{\n\t\t\tLog.Warning( $\"[HotCode] Can't watch {_root} for changes: {e.Message}\" );\n\t\t}\n\n\t\tUpdateTitle();\n\t\tShow();\n\t}\n\n\tpublic override void OnDestroyed()\n\t{\n\t\t_watcher?.Dispose();\n\t\tbase.OnDestroyed();\n\t}\n\n\t// --- OPENING / SWITCHING ---\n\n\tprivate EditorDocument FindDocument( string path ) => _documents.FirstOrDefault( d => string.Equals( d.Path, path, StringComparison.OrdinalIgnoreCase ) );\n\n\tprivate EditorDocument OpenFile( string path, bool preview )\n\t{\n\t\tvar existing = FindDocument( path );\n\t\tif ( existing is not null )\n\t\t{\n\t\t\tif ( !preview ) existing.IsPreview = false;\n\t\t\tActivate( existing );\n\t\t\treturn existing;\n\t\t}\n\n\t\tif ( !TryReadFile( path, out var contents, out var hasBom ) )\n\t\t\treturn null;\n\n\t\tvar editor = new CodeView( _editorStack, SyntaxLanguage.FromPath( path ) );\n\t\teditor.Text = contents;\n\t\teditor.Visible = false;\n\t\t_editorStack.Layout.Add( editor, 1 );\n\n\t\tvar doc = new EditorDocument( path, editor ) { IsPreview = preview, HasBom = hasBom };\n\t\teditor.TextChanged += () => OnEdited( doc );\n\t\teditor.DefinitionRequested = pos => GoToDefinition( doc, pos );\n\t\teditor.NavigateRequested = Navigate;\n\t\teditor.CompletionRequested = pos => RequestCompletion( doc, pos );\n\t\teditor.SignatureHelpRequested = pos => RequestSignatureHelp( doc, pos );\n\n\t\t// A new preview takes the place of the old one, if the old one wasn't touched\n\t\tvar oldPreview = preview ? _documents.FirstOrDefault( d => d.IsPreview && !d.IsDirty ) : null;\n\t\tif ( oldPreview is not null )\n\t\t{\n\t\t\tvar index = _documents.IndexOf( oldPreview );\n\t\t\tRemoveDocument( oldPreview );\n\t\t\t_documents.Insert( index, doc );\n\t\t\t_tabs.Insert( index, doc );\n\t\t}\n\t\telse\n\t\t{\n\t\t\t_documents.Add( doc );\n\t\t\t_tabs.Add( doc );\n\t\t}\n\n\t\tApplyMarkers( doc );\n\t\tActivate( doc );\n\t\treturn doc;\n\t}\n\n\tprivate static bool TryReadFile( string path, out string contents, out bool hasBom )\n\t{\n\t\tcontents = null;\n\t\thasBom = false;\n\n\t\ttry\n\t\t{\n\t\t\tvar bytes = File.ReadAllBytes( path );\n\t\t\thasBom = bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF;\n\t\t\tcontents = new UTF8Encoding( false ).GetString( bytes, hasBom ? 3 : 0, bytes.Length - (hasBom ? 3 : 0) );\n\t\t\treturn true;\n\t\t}\n\t\tcatch ( Exception e ) when ( e is IOException or UnauthorizedAccessException )\n\t\t{\n\t\t\tLog.Warning( $\"[HotCode] Couldn't open {path}: {e.Message}\" );\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tprivate void Activate( EditorDocument doc )\n\t{\n\t\tif ( _active is not null && _active != doc && _active.IsDirty && HotCodeSettings.AutoSave == AutoSaveMode.WhenSwitchingTabs )\n\t\t\tSaveDocument( _active );\n\n\t\tif ( _active is not null && _active != doc )\n\t\t\t_active.Editor.Visible = false;\n\n\t\t_active = doc;\n\t\t_welcome.Visible = doc is null;\n\n\t\tif ( doc is not null )\n\t\t{\n\t\t\tdoc.Editor.Visible = true;\n\t\t\tdoc.Editor.FocusEditor();\n\t\t\tif ( !doc.IsPreview ) AddRecentFile( doc.Path );\n\t\t}\n\t\telse\n\t\t{\n\t\t\t_welcome.SetRecentFiles( RecentFiles() );\n\t\t}\n\n\t\t_tabs.SetActive( doc );\n\t\t_tree.Update();\n\t\tUpdateTitle();\n\t}\n\n\tprivate void OnEdited( EditorDocument doc )\n\t{\n\t\t// IsModified goes back to false when edits are undone back to the saved text\n\t\tvar dirty = doc.Editor.IsModified;\n\t\tvar changed = dirty != doc.IsDirty || (dirty && doc.IsPreview);\n\t\tdoc.LastEdit = RealTime.Now;\n\t\tdoc.IsDirty = dirty;\n\t\tif ( dirty ) doc.IsPreview = false;\n\n\t\tif ( changed )\n\t\t{\n\t\t\t_tabs.Update();\n\t\t\t_tree.Update();\n\t\t\tUpdateTitle();\n\t\t}\n\t}\n\n\t// --- PROBLEMS ---\n\n\tprivate void GoToProblem( ProblemItem item )\n\t{\n\t\tPushNavPoint();\n\t\tvar doc = OpenFile( item.Path, preview: false );\n\t\tdoc?.Editor.GoTo( item.Line, item.Column );\n\t}\n\n\tprivate void ApplyMarkers()\n\t{\n\t\tforeach ( var doc in _documents )\n\t\t\tApplyMarkers( doc );\n\t}\n\n\t/// <summary>\n\t/// Underlines this file's compile errors and warnings.\n\t/// </summary>\n\tprivate void ApplyMarkers( EditorDocument doc )\n\t{\n\t\tvar markers = _output.Problems\n\t\t\t.Where( p => string.Equals( Path.GetFullPath( p.Path ), Path.GetFullPath( doc.Path ), StringComparison.OrdinalIgnoreCase ) )\n\t\t\t.Select( p => new EditorMarker( new TextPos( p.Line, p.Column ), new TextPos( p.EndLine, p.EndColumn ), p.Severity == DiagnosticSeverity.Error, $\"{p.Id}: {p.Message}\" ) )\n\t\t\t.ToList();\n\n\t\tdoc.Editor.SetMarkers( markers );\n\t}\n\n\t// --- GO TO DEFINITION ---\n\n\tprivate void GoToDefinition( EditorDocument doc, TextPos pos )\n\t{\n\t\tif ( !doc.Path.EndsWith( \".cs\", StringComparison.OrdinalIgnoreCase ) )\n\t\t{\n\t\t\tdoc.Editor.ShowMessage( \"Go to Definition only works in C# files.\" );\n\t\t\treturn;\n\t\t}\n\n\t\tif ( _definitionPending ) return;\n\n\t\t// Read s&box's compiler state here on the main thread; the slow Roslyn work happens in the background\n\t\tRoslynProject.Snapshot snapshot;\n\t\ttry\n\t\t{\n\t\t\tsnapshot = RoslynProject.TakeSnapshot( doc.Path, _root, _documents.Select( d => (d.Path, d.Editor.Text) ) );\n\t\t}\n\t\tcatch ( Exception e )\n\t\t{\n\t\t\tdoc.Editor.ShowMessage( $\"Couldn't look that up: {e.Message}\" );\n\t\t\treturn;\n\t\t}\n\n\t\t_definitionPending = true;\n\t\tdoc.Editor.ShowMessage( \"Finding definition...\", 60 );\n\n\t\tTask.Run( () => DefinitionFinder.Find( snapshot, pos.Line, pos.Col ) )\n\t\t\t.ContinueWith( t => _definitionResults.Enqueue( (doc, t.IsFaulted ? DefinitionResult.Fail( t.Exception?.InnerException?.Message ) : t.Result) ) );\n\t}\n\n\t// --- AUTOCOMPLETE ---\n\n\t/// <summary>\n\t/// What Roslyn needs to know about the code right now. Read on the main thread, used in the background.\n\t/// </summary>\n\tprivate RoslynProject.Snapshot TakeSnapshot( EditorDocument doc )\n\t{\n\t\tif ( !doc.Path.EndsWith( \".cs\", StringComparison.OrdinalIgnoreCase ) ) return null;\n\n\t\ttry\n\t\t{\n\t\t\treturn RoslynProject.TakeSnapshot( doc.Path, _root, _documents.Select( d => (d.Path, d.Editor.Text) ) );\n\t\t}\n\t\tcatch ( Exception e )\n\t\t{\n\t\t\tLog.Warning( $\"[HotCode] Couldn't read the project for code help: {e.Message}\" );\n\t\t\treturn null;\n\t\t}\n\t}\n\n\tprivate void RequestCompletion( EditorDocument doc, TextPos pos )\n\t{\n\t\tvar snapshot = TakeSnapshot( doc );\n\t\tif ( snapshot is null ) return;\n\n\t\t_completions.Request( () => CompletionProvider.Get( snapshot, pos.Line, pos.Col ), result =>\n\t\t{\n\t\t\tif ( result.Error is not null ) Log.Warning( $\"[HotCode] Autocomplete failed: {result.Error}\" );\n\t\t\telse if ( _documents.Contains( doc ) ) doc.Editor.ShowCompletions( result );\n\t\t} );\n\t}\n\n\t// --- PARAMETER HINTS ---\n\n\tprivate void RequestSignatureHelp( EditorDocument doc, TextPos pos )\n\t{\n\t\tvar snapshot = TakeSnapshot( doc );\n\t\tif ( snapshot is null ) return;\n\n\t\t_signatures.Request( () => SignatureHelpProvider.Get( snapshot, pos.Line, pos.Col ), result =>\n\t\t{\n\t\t\tif ( _documents.Contains( doc ) ) doc.Editor.ShowSignatureHelp( result );\n\t\t} );\n\t}\n\n\tprivate void ShowDefinitionResult( EditorDocument doc, DefinitionResult result )\n\t{\n\t\t_definitionPending = false;\n\t\tvar open = _documents.Contains( doc );\n\n\t\tif ( result.Error is not null )\n\t\t{\n\t\t\tif ( open ) doc.Editor.ShowMessage( result.Error );\n\t\t\treturn;\n\t\t}\n\n\t\tif ( result.Locations.Count == 0 )\n\t\t{\n\t\t\tif ( open ) doc.Editor.ShowMessage( $\"'{result.Symbol}' is defined in {result.Assembly ?? \"a compiled library\"}, which has no source here.\", 6 );\n\t\t\treturn;\n\t\t}\n\n\t\tif ( open ) doc.Editor.ShowMessage( \"\", 0 );\n\n\t\tif ( result.Locations.Count == 1 )\n\t\t{\n\t\t\tJumpTo( result.Locations[0] );\n\t\t\treturn;\n\t\t}\n\n\t\t// Several (partial classes): let the user pick\n\t\tvar menu = new ContextMenu( this );\n\t\tforeach ( var location in result.Locations )\n\t\t{\n\t\t\tvar label = $\"{Path.GetRelativePath( _root, location.Path )}:{location.Line + 1} {location.Preview}\";\n\t\t\tmenu.AddOption( label, \"code\", () => JumpTo( location ) );\n\t\t}\n\t\tmenu.OpenAtCursor();\n\t}\n\n\tprivate void JumpTo( DefinitionLocation location )\n\t{\n\t\tPushNavPoint();\n\t\tvar target = OpenFile( location.Path, preview: false );\n\t\ttarget?.Editor.GoTo( location.Line, location.Column );\n\t}\n\n\tprivate void PushNavPoint()\n\t{\n\t\tif ( _active is null ) return;\n\n\t\t_navBack.Add( new NavPoint( _active.Path, _active.Editor.CaretPosition ) );\n\t\tif ( _navBack.Count > 100 ) _navBack.RemoveAt( 0 );\n\t\t_navForward.Clear();\n\t}\n\n\t/// <summary>\n\t/// Alt+Left / Alt+Right: back to where we jumped from, and forward again.\n\t/// </summary>\n\tprivate void Navigate( bool back )\n\t{\n\t\tvar from = back ? _navBack : _navForward;\n\t\tvar to = back ? _navForward : _navBack;\n\n\t\t// Skip spots in files that have since been deleted\n\t\twhile ( from.Count > 0 && !File.Exists( from[^1].Path ) ) from.RemoveAt( from.Count - 1 );\n\t\tif ( from.Count == 0 ) return;\n\n\t\tvar target = from[^1];\n\t\tfrom.RemoveAt( from.Count - 1 );\n\t\tif ( _active is not null ) to.Add( new NavPoint( _active.Path, _active.Editor.CaretPosition ) );\n\n\t\tvar doc = OpenFile( target.Path, preview: false );\n\t\tdoc?.Editor.GoTo( target.Pos.Line, target.Pos.Col );\n\t}\n\n\t[Shortcut( \"hotcode.toggle-output\", \"CTRL+J\" )]\n\tpublic void ToggleOutput() => _output.Visible = !_output.Visible;\n\n\t// --- FILE TREE ---\n\n\tinternal FileState StateOf( string path )\n\t{\n\t\tvar doc = FindDocument( path );\n\t\treturn doc is null ? FileState.Closed : doc == _active ? FileState.Active : FileState.Open;\n\t}\n\n\tinternal bool IsUnsaved( string path ) => FindDocument( path )?.IsDirty == true;\n\n\t// --- MENU BAR ---\n\n\t/// <summary>\n\t/// File / Edit / View / Go / Project / Help. Shortcuts are shown after a tab (\"Save\\tCtrl+S\"), which\n\t/// Qt displays right-aligned without registering a second key binding next to the ones the editor handles.\n\t/// Menus that show on/off state rebuild each time they open.\n\t/// </summary>\n\tprivate void BuildMenuBar()\n\t{\n\t\tMenuBar.Clear();\n\n\t\tvar file = MenuBar.AddMenu( \"File\" );\n\t\tfile.AddOption( \"New File in Code Folder...\", \"note_add\", () => NewFileInCodeFolder( component: false ) );\n\t\tfile.AddOption( \"New C# Component...\", \"code\", () => NewFileInCodeFolder( component: true ) );\n\t\tfile.AddOption( \"Open File...\", \"file_open\", OpenFileDialog );\n\t\tfile.AddSeparator();\n\t\tfile.AddOption( \"Save\\tCtrl+S\", \"save\", Save );\n\t\tfile.AddOption( \"Save All\\tCtrl+Shift+S\", \"save_as\", SaveAll );\n\t\tfile.AddSeparator();\n\t\tfile.AddOption( \"Close Tab\\tCtrl+W\", \"close\", CloseActiveTab );\n\t\tfile.AddOption( \"Close Other Tabs\", \"tab_unselected\", CloseOtherTabs );\n\t\tfile.AddOption( \"Close All Tabs\", \"clear_all\", CloseAllTabs );\n\t\tfile.AddSeparator();\n\t\tfile.AddOption( \"Reveal Current File in Explorer\", \"folder_open\", () => { if ( _active is not null ) EditorUtility.OpenFileFolder( _active.Path ); } );\n\t\tfile.AddOption( \"Copy Current File Path\", \"content_copy\", () => { if ( _active is not null ) EditorUtility.Clipboard.Copy( _active.Path ); } );\n\t\tfile.AddSeparator();\n\t\tfile.AddOption( \"Settings...\\tCtrl+,\", \"settings\", HotCodeSettingsWindow.Open );\n\t\tfile.AddOption( \"HotCode Launcher\", HotCodeLogo.IconName, () => new HotCodeLauncher() );\n\t\tfile.AddOption( \"Close Window\", \"logout\", () => Close() );\n\n\t\tvar edit = MenuBar.AddMenu( \"Edit\" );\n\t\tedit.AddOption( \"Undo\\tCtrl+Z\", \"undo\", () => _active?.Editor.Undo() );\n\t\tedit.AddOption( \"Redo\\tCtrl+Y\", \"redo\", () => _active?.Editor.Redo() );\n\t\tedit.AddSeparator();\n\t\tedit.AddOption( \"Cut\\tCtrl+X\", \"content_cut\", () => _active?.Editor.Cut() );\n\t\tedit.AddOption( \"Copy\\tCtrl+C\", \"content_copy\", () => _active?.Editor.Copy() );\n\t\tedit.AddOption( \"Paste\\tCtrl+V\", \"content_paste\", () => _active?.Editor.Paste() );\n\t\tedit.AddOption( \"Select All\\tCtrl+A\", \"select_all\", () => _active?.Editor.SelectAll() );\n\t\tedit.AddSeparator();\n\t\tedit.AddOption( \"Find\\tCtrl+F\", \"search\", () => _active?.Editor.OpenFind( replace: false ) );\n\t\tedit.AddOption( \"Replace\\tCtrl+H\", \"find_replace\", () => _active?.Editor.OpenFind( replace: true ) );\n\t\tedit.AddSeparator();\n\t\tedit.AddOption( \"Toggle Comment\\tCtrl+/\", \"comment\", () => _active?.Editor.ToggleComment() );\n\t\tedit.AddOption( \"Duplicate Line\\tCtrl+D\", \"content_copy\", () => _active?.Editor.DuplicateLines() );\n\t\tedit.AddOption( \"Move Line Up\\tAlt+Up\", \"arrow_upward\", () => _active?.Editor.MoveLines( -1 ) );\n\t\tedit.AddOption( \"Move Line Down\\tAlt+Down\", \"arrow_downward\", () => _active?.Editor.MoveLines( 1 ) );\n\t\tedit.AddSeparator();\n\t\tedit.AddOption( \"Trigger Suggestions\\tCtrl+Space\", \"auto_awesome\", () => _active?.Editor.RequestCompletionAtCaret() );\n\t\tedit.AddOption( \"Parameter Hints\\tCtrl+Shift+Space\", \"info\", () => _active?.Editor.RequestSignatureHelpAtCaret() );\n\n\t\tvar view = MenuBar.AddMenu( \"View\" );\n\t\tview.AboutToShow += () => FillViewMenu( view );\n\n\t\tvar go = MenuBar.AddMenu( \"Go\" );\n\t\tgo.AddOption( \"Go to Definition\\tF12\", \"start\", () => _active?.Editor.RequestDefinitionAtCaret() );\n\t\tgo.AddOption( \"Go to Line...\\tCtrl+G\", \"format_list_numbered\", GoToLine );\n\t\tgo.AddSeparator();\n\t\tgo.AddOption( \"Back\\tAlt+Left\", \"arrow_back\", () => Navigate( back: true ) );\n\t\tgo.AddOption( \"Forward\\tAlt+Right\", \"arrow_forward\", () => Navigate( back: false ) );\n\t\tgo.AddSeparator();\n\t\tgo.AddOption( \"Next Problem\\tF8\", \"error\", NextProblem );\n\t\tgo.AddOption( \"Previous Problem\\tShift+F8\", \"error_outline\", PreviousProblem );\n\n\t\tvar project = MenuBar.AddMenu( \"Project\" );\n\t\tproject.AboutToShow += () => FillProjectMenu( project );\n\n\t\tvar help = MenuBar.AddMenu( \"Help\" );\n\t\thelp.AddOption( \"Welcome Page / Shortcuts\", \"home\", ShowWelcomePage );\n\t\thelp.AddOption( \"Settings...\", \"settings\", HotCodeSettingsWindow.Open );\n\t\thelp.AddOption( \"About HotCode\", HotCodeLogo.IconName, () =>\n\t\t\tnew PopupWindow( \"About HotCode\", \"HotCode - a code editor built into s&box.\\n\\nRoslyn-powered autocomplete, parameter hints and go to definition, with live compile errors from s&box.\", \"Close\", new Dictionary<string, Action>() ).Show() );\n\t}\n\n\tprivate void FillProjectMenu( Menu project )\n\t{\n\t\tproject.Clear();\n\t\tproject.AddOption( \"Open Project Folder\", \"folder_open\", () => EditorUtility.OpenFolder( _root ) );\n\t\tproject.AddOption( \"Open in External Code Editor\", \"launch\", () => CodeEditor.OpenSolution() );\n\t\tproject.AddOption( \"Generate Solution\", \"account_tree\", () =>\n\t\t{\n\t\t\tEditorUtility.Projects.GenerateSolution();\n\t\t\tLog.Info( \"[HotCode] Generated solution files\" );\n\t\t} );\n\t\tproject.AddSeparator();\n\t\tproject.AddOption( \"Refresh File Tree\", \"refresh\", () => _rootNode.Dirty() );\n\t\tproject.AddSeparator();\n\n\t\tHotCodeInstallUi.AddMenuOptions( project, _entry, null );\n\t\tproject.AddOption( \"Add HotCode to Another Project...\", HotCodeLogo.IconName, () =>\n\t\t{\n\t\t\tvar fd = new FileDialog( null );\n\t\t\tfd.Title = \"Add HotCode to Project\";\n\t\t\tfd.Directory = Path.GetDirectoryName( _root );\n\t\t\tfd.SetFindExistingFile();\n\t\t\tfd.SetModeOpen();\n\t\t\tfd.SetNameFilter( \"s&box Project (*.sbproj)\" );\n\t\t\tif ( fd.Execute() ) HotCodeInstallUi.AskInstall( HotCodeEntry.FromPath( fd.SelectedFile ) );\n\t\t} );\n\t}\n\n\tprivate void FillViewMenu( Menu view )\n\t{\n\t\tview.Clear();\n\n\t\tAddToggle( view, \"File Tree\", \"account_tree\", _explorer.Visible, on => _explorer.Visible = on );\n\t\tAddToggle( view, \"Bottom Panel\\tCtrl+J\", \"dock_to_bottom\", _output.Visible, on => _output.Visible = on );\n\t\tAddToggle( view, \"Status Bar\", \"power_input\", StatusBar.Visible, on => StatusBar.Visible = on );\n\t\tview.AddSeparator();\n\t\tAddToggle( view, \"Word Wrap\\tAlt+Z\", \"wrap_text\", CodeView.WordWrap, on => CodeView.WordWrap = on );\n\t\tview.AddSeparator();\n\t\tview.AddOption( \"Zoom In\\tCtrl+=\", \"zoom_in\", () => CodeView.FontSize += 1 );\n\t\tview.AddOption( \"Zoom Out\\tCtrl+-\", \"zoom_out\", () => CodeView.FontSize -= 1 );\n\t\tview.AddOption( $\"Reset Zoom ({CodeView.FontSize:0}pt)\", \"youtube_searched_for\", () => CodeView.FontSize = CodeView.DefaultFontSize );\n\t\tview.AddSeparator();\n\t\tview.AddOption( \"Problems\", \"error_outline\", _output.ShowProblems );\n\t\tview.AddOption( \"Output\", \"terminal\", _output.ShowOutput );\n\t}\n\n\tprivate static void AddToggle( Menu menu, string text, string icon, bool on, Action<bool> set )\n\t{\n\t\tvar option = menu.AddOption( text, icon );\n\t\toption.Checkable = true;\n\t\toption.Checked = on;\n\t\toption.Toggled += set;\n\t}\n\n\t/// <summary>\n\t/// New files from the menu go in the project's Code folder (or the project root if there isn't one).\n\t/// </summary>\n\tprivate void NewFileInCodeFolder( bool component )\n\t{\n\t\tvar code = Path.Combine( _root, \"Code\" );\n\t\tvar folder = Directory.Exists( code ) ? code : _root;\n\n\t\tif ( component )\n\t\t\tAskCreate( folder, \"New Component\", \"Component name:\", \"MyComponent\", name => ProjectFiles.CreateComponent( folder, name ) );\n\t\telse\n\t\t\tAskCreate( folder, \"New File\", \"File name:\", \"NewFile.cs\", name => ProjectFiles.CreateFile( folder, name ) );\n\t}\n\n\tprivate void OpenFileDialog()\n\t{\n\t\tvar fd = new FileDialog( null );\n\t\tfd.Title = \"Open File\";\n\t\tfd.Directory = _active is not null ? Path.GetDirectoryName( _active.Path ) : _root;\n\t\tfd.SetFindExistingFile();\n\t\tfd.SetModeOpen();\n\t\tif ( fd.Execute() ) OpenFile( fd.SelectedFile, preview: false );\n\t}\n\n\t[Shortcut( \"hotcode.settings\", \"CTRL+,\" )]\n\tpublic void OpenSettings() => HotCodeSettingsWindow.Open();\n\n\t[Shortcut( \"hotcode.save-all\", \"CTRL+SHIFT+S\" )]\n\tpublic void SaveAll()\n\t{\n\t\tforeach ( var doc in _documents.Where( d => d.IsDirty ).ToList() )\n\t\t\tSaveDocument( doc );\n\t}\n\n\tprivate void CloseOtherTabs()\n\t{\n\t\tforeach ( var doc in _documents.Where( d => d != _active ).ToList() )\n\t\t\tRequestClose( doc );\n\t}\n\n\tprivate void CloseAllTabs()\n\t{\n\t\tforeach ( var doc in _documents.ToList() )\n\t\t\tRequestClose( doc );\n\t}\n\n\t/// <summary>\n\t/// Shows the welcome page without closing anything; clicking a tab goes back to it.\n\t/// </summary>\n\tprivate void ShowWelcomePage()\n\t{\n\t\tif ( _active is not null ) _active.Editor.Visible = false;\n\t\t_active = null;\n\t\t_welcome.SetRecentFiles( RecentFiles() );\n\t\t_welcome.Visible = true;\n\t\t_tabs.SetActive( null );\n\t\t_tree.Update();\n\t}\n\n\t[Shortcut( \"hotcode.next-problem\", \"F8\" )]\n\tpublic void NextProblem() => StepProblem( 1 );\n\n\t[Shortcut( \"hotcode.previous-problem\", \"SHIFT+F8\" )]\n\tpublic void PreviousProblem() => StepProblem( -1 );\n\n\tprivate int _problemIndex = -1;\n\n\tprivate void StepProblem( int direction )\n\t{\n\t\tvar problems = _output.Problems;\n\t\tif ( problems.Count == 0 ) return;\n\n\t\t_problemIndex = ((_problemIndex + direction) % problems.Count + problems.Count) % problems.Count;\n\t\tGoToProblem( problems[_problemIndex] );\n\t}\n\n\t// --- EXPLORER HEADER ---\n\n\tprivate Widget BuildExplorerHeader( Widget parent, string title )\n\t{\n\t\tvar header = new Widget( parent );\n\t\theader.FixedHeight = HotCodeStyle.TabHeight;\n\t\theader.OnPaintOverride = () =>\n\t\t{\n\t\t\tPaint.ClearPen();\n\t\t\tPaint.SetBrush( Theme.TabBarBackground );\n\t\t\tPaint.DrawRect( header.LocalRect );\n\n\t\t\tPaint.SetDefaultFont( 7, 700 );\n\t\t\tPaint.SetPen( Theme.TextLight );\n\t\t\tPaint.DrawText( new Rect( 10, 0, header.Width - 120, header.Height ), Paint.GetElidedText( $\"EXPLORER - {title.ToUpperInvariant()}\", header.Width - 130, ElideMode.Right ), TextFlag.LeftCenter );\n\t\t\treturn false;\n\t\t};\n\n\t\theader.Layout = Layout.Row();\n\t\theader.Layout.Margin = new Sandbox.UI.Margin( 0, 0, 4, 0 );\n\t\theader.Layout.AddStretchCell();\n\n\t\tvar rootFolder = Directory.Exists( Path.Combine( _root, \"Code\" ) ) ? Path.Combine( _root, \"Code\" ) : _root;\n\t\theader.Layout.Add( new IconButton( \"note_add\", () => NewFileInCodeFolder( component: false ) ) { ToolTip = \"New File\" } );\n\t\theader.Layout.Add( new IconButton( \"create_new_folder\", () => AskCreate( rootFolder, \"New Folder\", \"Folder name:\", \"NewFolder\", name => ProjectFiles.CreateFolder( rootFolder, name ) ) ) { ToolTip = \"New Folder\" } );\n\t\theader.Layout.Add( new IconButton( \"refresh\", () => _rootNode.Dirty() ) { ToolTip = \"Refresh\" } );\n\t\theader.Layout.Add( new IconButton( \"unfold_less\", () =>\n\t\t{\n\t\t\t_tree.Close( _rootNode, recursive: true );\n\t\t\t_tree.Open( _rootNode );\n\t\t} ) { ToolTip = \"Collapse All\" } );\n\n\t\treturn header;\n\t}\n\n\t// --- RECENT FILES ---\n\n\tprivate string RecentFilesCookie => \"hotcode.recentfiles.\" + _root.ToLowerInvariant().Replace( '\\\\', '/' );\n\n\tprivate List<string> RecentFiles() => EditorCookie.Get<List<string>>( RecentFilesCookie, null ) ?? new();\n\n\tprivate void AddRecentFile( string path )\n\t{\n\t\tvar files = RecentFiles();\n\t\tfiles.RemoveAll( f => string.Equals( f, path, StringComparison.OrdinalIgnoreCase ) );\n\t\tfiles.Insert( 0, path );\n\t\tif ( files.Count > 20 ) files.RemoveRange( 20, files.Count - 20 );\n\t\tEditorCookie.Set( RecentFilesCookie, files );\n\t}\n\n\t// --- GO TO LINE ---\n\n\t/// <summary>\n\t/// Ctrl+G: \"42\" or \"42:7\" (line:column).\n\t/// </summary>\n\t[Shortcut( \"hotcode.goto-line\", \"CTRL+G\" )]\n\tpublic void GoToLine()\n\t{\n\t\tvar doc = _active;\n\t\tif ( doc is null ) return;\n\n\t\tvar current = doc.Editor.CaretPosition;\n\t\tDialog.AskString( text =>\n\t\t{\n\t\t\tvar parts = text.Split( ':', ',' );\n\t\t\tif ( !int.TryParse( parts[0].Trim(), out var line ) ) return;\n\t\t\tvar column = parts.Length > 1 && int.TryParse( parts[1].Trim(), out var c ) ? c : 1;\n\n\t\t\tPushNavPoint();\n\t\t\tdoc.Editor.GoTo( Math.Clamp( line, 1, doc.Editor.LineCount ) - 1, Math.Max( 1, column ) - 1 );\n\t\t\tdoc.Editor.FocusEditor();\n\t\t}, $\"Line number (1 - {doc.Editor.LineCount}), or line:column\", okay: \"Go\", initialName: (current.Line + 1).ToString(), title: \"Go to Line\" );\n\t}\n\n\t// --- AUTO SAVE ---\n\n\t/// <summary>\n\t/// With Auto Save \"after a short pause\", saves files once you've stopped typing for a moment.\n\t/// Saving is what triggers s&box's hotload, so the game keeps up with your edits.\n\t/// </summary>\n\tprivate void AutoSaveIfIdle()\n\t{\n\t\tif ( HotCodeSettings.AutoSave != AutoSaveMode.AfterDelay ) return;\n\n\t\tforeach ( var doc in _documents )\n\t\t{\n\t\t\tif ( doc.IsDirty && RealTime.Now - doc.LastEdit > HotCodeSettings.AutoSaveDelay && File.Exists( doc.Path ) )\n\t\t\t\tSaveDocument( doc );\n\t\t}\n\t}\n\n\t// --- STATUS BAR ---\n\n\tprivate void UpdateStatusBar()\n\t{\n\t\t// Is s&box compiling this project? If not, there's no live build to report on.\n\t\tif ( RealTime.Now > _nextLoadedCheck )\n\t\t{\n\t\t\t_nextLoadedCheck = RealTime.Now + 2;\n\t\t\t_projectLoaded = EditorUtility.Projects.GetAll().Any( p =>\n\t\t\t{\n\t\t\t\tvar root = p.GetRootPath();\n\t\t\t\treturn !string.IsNullOrEmpty( root ) && (ProjectFiles.IsUnder( _root, root ) || ProjectFiles.IsUnder( root, _root ));\n\t\t\t} );\n\t\t}\n\n\t\tif ( !_projectLoaded )\n\t\t\t_buildStatus.Set( \"Not loaded in s&box - no live compile\", \"cloud_off\", Theme.TextLight );\n\t\telse if ( _output.IsBuilding )\n\t\t\t_buildStatus.Set( \"Building...\", \"sync\", Theme.Primary );\n\t\telse if ( _output.ErrorCount > 0 )\n\t\t\t_buildStatus.Set( $\"{_output.ErrorCount} error{(_output.ErrorCount == 1 ? \"\" : \"s\")}, {_output.WarningCount} warning{(_output.WarningCount == 1 ? \"\" : \"s\")}\", \"error\", Theme.Red );\n\t\telse if ( _output.WarningCount > 0 )\n\t\t\t_buildStatus.Set( $\"Build OK, {_output.WarningCount} warning{(_output.WarningCount == 1 ? \"\" : \"s\")}\", \"warning\", Theme.Yellow );\n\t\telse if ( _output.LastBuildSucceeded == true )\n\t\t\t_buildStatus.Set( \"Build succeeded\", \"check_circle\", Theme.Green );\n\t\telse\n\t\t\t_buildStatus.Set( \"Ready\", \"check\", Theme.TextLight );\n\n\t\tvar doc = _active;\n\t\tif ( doc is null )\n\t\t{\n\t\t\tforeach ( var item in new[] { _position, _indent, _lineEnding, _encoding, _language } )\n\t\t\t\titem.Set( \"\" );\n\t\t\treturn;\n\t\t}\n\n\t\tvar caret = doc.Editor.CaretPosition;\n\t\tvar selected = doc.Editor.SelectionLength;\n\t\t_position.Set( $\"Ln {caret.Line + 1}, Col {caret.Col + 1}\" + (selected > 0 ? $\" ({selected} selected)\" : \"\") );\n\t\t_indent.Set( doc.Editor.IndentDescription );\n\t\t_lineEnding.Set( doc.Editor.LineEndingName );\n\t\t_encoding.Set( doc.HasBom ? \"UTF-8 with BOM\" : \"UTF-8\" );\n\t\t_language.Set( doc.Editor.LanguageName, HotCodeStyle.FileIcon( doc.Path ), HotCodeStyle.FileIconColor( doc.Path ) );\n\t}\n\n\tinternal void RegisterNode( CodeFileNode node )\n\t{\n\t\tif ( node.IsFolder ) _nodes[Normalize( node.FullPath )] = node;\n\t}\n\n\tprivate static string Normalize( string path ) => Path.GetFullPath( path ).TrimEnd( '\\\\', '/' );\n\n\t/// <summary>\n\t/// Rebuilds a folder's children in the tree (and opens it, so new items are visible).\n\t/// </summary>\n\tprivate void RefreshFolder( string folder, bool open = false )\n\t{\n\t\tif ( !_nodes.TryGetValue( Normalize( folder ), out var node ) ) return;\n\n\t\tnode.Dirty();\n\t\tif ( open ) _tree.Open( node );\n\t}\n\n\tinternal void ShowFileContextMenu( CodeFileNode node )\n\t{\n\t\tvar path = node.FullPath;\n\t\tvar folder = node.IsFolder ? path : Path.GetDirectoryName( path );\n\t\tvar isRoot = string.Equals( Normalize( path ), Normalize( _root ), StringComparison.OrdinalIgnoreCase );\n\n\t\tvar menu = new ContextMenu( this );\n\n\t\tif ( !node.IsFolder )\n\t\t{\n\t\t\tmenu.AddOption( \"Open\", \"open_in_new\", () => OpenFile( path, preview: false ) );\n\t\t\tmenu.AddOption( \"Open in External Editor\", \"launch\", () => CodeEditor.OpenFile( path ) );\n\t\t\tmenu.AddSeparator();\n\t\t}\n\n\t\tmenu.AddOption( \"New File...\", \"note_add\", () => AskCreate( folder, \"New File\", \"File name:\", \"NewFile.cs\", name => ProjectFiles.CreateFile( folder, name ) ) );\n\t\tmenu.AddOption( \"New C# Component...\", \"code\", () => AskCreate( folder, \"New Component\", \"Component name:\", \"MyComponent\", name => ProjectFiles.CreateComponent( folder, name ) ) );\n\t\tmenu.AddOption( \"New Folder...\", \"create_new_folder\", () => AskCreate( folder, \"New Folder\", \"Folder name:\", \"NewFolder\", name => ProjectFiles.CreateFolder( folder, name ) ) );\n\t\tmenu.AddSeparator();\n\n\t\tif ( !isRoot )\n\t\t{\n\t\t\tmenu.AddOption( \"Rename...\", \"drive_file_rename_outline\", () => AskRename( node ) );\n\t\t\tmenu.AddOption( \"Delete\", \"delete\", () => AskDelete( node ) );\n\t\t\tmenu.AddSeparator();\n\t\t}\n\n\t\tmenu.AddOption( \"Copy Path\", \"content_copy\", () => EditorUtility.Clipboard.Copy( path ) );\n\t\tmenu.AddOption( \"Copy Relative Path\", \"content_copy\", () => EditorUtility.Clipboard.Copy( Path.GetRelativePath( _root, path ) ) );\n\t\tmenu.AddOption( \"Reveal in File Explorer\", \"folder_open\", () => EditorUtility.OpenFileFolder( path ) );\n\n\t\tif ( node.IsFolder )\n\t\t\tmenu.AddOption( \"Refresh\", \"refresh\", () => node.Dirty() );\n\n\t\tmenu.OpenAtCursor();\n\t}\n\n\tprivate void AskCreate( string folder, string title, string question, string suggestion, Func<string, string> create )\n\t{\n\t\tDialog.AskString( name =>\n\t\t{\n\t\t\tif ( !TryFileOp( () => create( name ), out var created ) ) return;\n\n\t\t\tRefreshFolder( folder, open: true );\n\t\t\tif ( File.Exists( created ) ) OpenFile( created, preview: false );\n\t\t}, question, okay: \"Create\", initialName: suggestion, title: title );\n\t}\n\n\tprivate void AskRename( CodeFileNode node )\n\t{\n\t\tvar oldPath = node.FullPath;\n\n\t\tDialog.AskString( name =>\n\t\t{\n\t\t\tif ( !TryFileOp( () => ProjectFiles.Rename( oldPath, name ), out var newPath ) ) return;\n\n\t\t\t// Open tabs follow the file (or everything inside a renamed folder)\n\t\t\tforeach ( var doc in _documents )\n\t\t\t{\n\t\t\t\tif ( string.Equals( doc.Path, oldPath, StringComparison.OrdinalIgnoreCase ) )\n\t\t\t\t\tdoc.Path = newPath;\n\t\t\t\telse if ( ProjectFiles.IsUnder( doc.Path, oldPath ) )\n\t\t\t\t\tdoc.Path = newPath + doc.Path[oldPath.Length..];\n\t\t\t}\n\n\t\t\t_tabs.Update();\n\t\t\tRefreshFolder( Path.GetDirectoryName( oldPath ) );\n\t\t}, \"New name:\", okay: \"Rename\", initialName: node.Name, title: \"Rename\" );\n\t}\n\n\tprivate void AskDelete( CodeFileNode node )\n\t{\n\t\tvar path = node.FullPath;\n\t\tvar affected = _documents.Where( d => ProjectFiles.IsUnder( d.Path, path ) ).ToList();\n\t\tvar warning = affected.Any( d => d.IsDirty ) ? \" Unsaved changes in open files will be lost.\" : \"\";\n\n\t\tDialog.AskConfirm( () =>\n\t\t{\n\t\t\tif ( !TryFileOp( () => { ProjectFiles.Delete( path ); return path; }, out _ ) ) return;\n\n\t\t\tforeach ( var doc in affected ) CloseDocument( doc );\n\t\t\tRefreshFolder( Path.GetDirectoryName( path ) );\n\t\t}, $\"Move '{node.Name}' to the Recycle Bin?{warning}\", \"Delete\", \"Delete\", \"Cancel\" );\n\t}\n\n\t/// <summary>\n\t/// Runs a file operation and shows any error instead of throwing.\n\t/// </summary>\n\tprivate bool TryFileOp( Func<string> op, out string result )\n\t{\n\t\ttry\n\t\t{\n\t\t\tresult = op();\n\t\t\treturn true;\n\t\t}\n\t\tcatch ( Exception e ) when ( e is InvalidOperationException or IOException or UnauthorizedAccessException or OperationCanceledException )\n\t\t{\n\t\t\tresult = null;\n\t\t\tLog.Warning( $\"[HotCode] {e.Message}\" );\n\t\t\tnew PopupWindow( \"HotCode\", e.Message, \"OK\", new Dictionary<string, Action>() ).Show();\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t// --- WATCHING THE DISK ---\n\n\tprivate void QueueChange( string path, bool structural )\n\t{\n\t\t// Build output churns constantly and is never shown; ignore it\n\t\tif ( CodeFileNode.IsInHiddenFolder( Path.GetRelativePath( _root, path ) ) ) return;\n\t\t_changedPaths.Enqueue( (path, structural) );\n\t}\n\n\t[EditorEvent.Frame]\n\tpublic void Frame()\n\t{\n\t\twhile ( _definitionResults.TryDequeue( out var definition ) )\n\t\t\tShowDefinitionResult( definition.Doc, definition.Result );\n\n\t\t_completions.Pump();\n\t\t_signatures.Pump();\n\t\tUpdateStatusBar();\n\t\tAutoSaveIfIdle();\n\n\t\tif ( _changedPaths.IsEmpty ) return;\n\n\t\tvar folders = new HashSet<string>( StringComparer.OrdinalIgnoreCase );\n\t\tvar files = new HashSet<string>( StringComparer.OrdinalIgnoreCase );\n\n\t\twhile ( _changedPaths.TryDequeue( out var change ) )\n\t\t{\n\t\t\tfiles.Add( change.Path );\n\t\t\tvar parent = Path.GetDirectoryName( change.Path );\n\t\t\tif ( change.Structural && parent is not null ) folders.Add( parent );\n\t\t}\n\n\t\tforeach ( var folder in folders )\n\t\t\tRefreshFolder( folder );\n\n\t\tforeach ( var file in files )\n\t\t\tReloadIfChangedOnDisk( file );\n\t}\n\n\t/// <summary>\n\t/// Picks up edits made by other programs to a file that's open but has no unsaved changes here.\n\t/// </summary>\n\tprivate void ReloadIfChangedOnDisk( string path )\n\t{\n\t\tvar doc = FindDocument( path );\n\t\tif ( doc is null || doc.IsDirty || !File.Exists( path ) || !HotCodeSettings.ReloadChangedFiles ) return;\n\t\tif ( !TryReadFile( path, out var contents, out _ ) ) return;\n\n\t\t// Our own saves land here too; only reload when the text really differs\n\t\tif ( TextBuffer.Normalize( contents ) == TextBuffer.Normalize( doc.Editor.Text ) ) return;\n\n\t\tdoc.Editor.ReloadText( contents );\n\t\tApplyMarkers( doc );\n\t\tLog.Info( $\"[HotCode] Reloaded {doc.Title} (changed outside HotCode)\" );\n\t}\n\n\t// --- CLOSING ---\n\n\t[Shortcut( \"hotcode.close-tab\", \"CTRL+W\" )]\n\tpublic void CloseActiveTab()\n\t{\n\t\tif ( _active is not null )\n\t\t\tRequestClose( _active );\n\t}\n\n\tprivate void RequestClose( EditorDocument doc )\n\t{\n\t\tif ( !doc.IsDirty )\n\t\t{\n\t\t\tCloseDocument( doc );\n\t\t\treturn;\n\t\t}\n\n\t\tvar confirm = new PopupWindow(\n\t\t\t\"Unsaved Changes\", $\"Save changes to {doc.Title}?\", \"Cancel\",\n\t\t\tnew Dictionary<string, Action>()\n\t\t\t{\n\t\t\t\t{ \"Don't Save\", () => CloseDocument( doc ) },\n\t\t\t\t{ \"Save\", () => { if ( SaveDocument( doc ) ) CloseDocument( doc ); } }\n\t\t\t}\n\t\t);\n\n\t\tconfirm.Show();\n\t}\n\n\tprivate void CloseDocument( EditorDocument doc )\n\t{\n\t\tvar index = _documents.IndexOf( doc );\n\t\tif ( index < 0 ) return;\n\t\tvar wasActive = doc == _active;\n\n\t\tRemoveDocument( doc );\n\n\t\tif ( wasActive )\n\t\t{\n\t\t\t// Same as most IDEs: move to the tab that slid into this spot, or the one before it\n\t\t\tvar next = _documents.Count == 0 ? null : _documents[Math.Min( index, _documents.Count - 1 )];\n\t\t\t_active = null;\n\t\t\tActivate( next );\n\t\t}\n\n\t\tUpdateTitle();\n\t}\n\n\t/// <summary>\n\t/// Drops a document without any prompts or activating anything else.\n\t/// </summary>\n\tprivate void RemoveDocument( EditorDocument doc )\n\t{\n\t\t_documents.Remove( doc );\n\t\t_tabs.Remove( doc );\n\t\tdoc.Editor.Destroy();\n\n\t\tif ( _active == doc )\n\t\t\t_active = null;\n\t}\n\n\t// --- SAVING ---\n\n\t[Shortcut( \"editor.save\", \"CTRL+S\" )]\n\tpublic void Save()\n\t{\n\t\tif ( _active is not null )\n\t\t\tSaveDocument( _active );\n\t}\n\n\tprivate bool SaveDocument( EditorDocument doc )\n\t{\n\t\tif ( !doc.IsDirty )\n\t\t\treturn true;\n\n\t\tif ( HotCodeSettings.TrimTrailingWhitespace || HotCodeSettings.InsertFinalNewline )\n\t\t\tdoc.Editor.FormatForSave( HotCodeSettings.TrimTrailingWhitespace, HotCodeSettings.InsertFinalNewline );\n\n\t\ttry\n\t\t{\n\t\t\tFile.WriteAllText( doc.Path, doc.Editor.Text, new UTF8Encoding( doc.HasBom ) );\n\t\t\tLog.Info( $\"[HotCode] Saved {doc.Title}\" );\n\t\t}\n\t\tcatch ( Exception e ) when ( e is IOException or UnauthorizedAccessException )\n\t\t{\n\t\t\tLog.Warning( $\"[HotCode] Couldn't save {doc.Path}: {e.Message}\" );\n\t\t\treturn false;\n\t\t}\n\n\t\tdoc.Editor.MarkSaved();\n\t\tdoc.IsDirty = false;\n\t\t_tabs.Update();\n\t\t_tree.Update();\n\t\tUpdateTitle();\n\t\treturn true;\n\t}\n\n\tprivate void UpdateTitle()\n\t{\n\t\tvar unsaved = _documents.Count( d => d.IsDirty );\n\n\t\tWindowTitle = unsaved > 0\n\t\t\t? $\"HotCode - {_entry.Title} ({unsaved} unsaved)\"\n\t\t\t: $\"HotCode - {_entry.Title}\";\n\t}\n\n\tprotected override bool OnClose()\n\t{\n\t\tvar dirty = _documents.Where( d => d.IsDirty ).ToList();\n\t\tif ( dirty.Count == 0 )\n\t\t\treturn true;\n\n\t\tvar confirm = new PopupWindow(\n\t\t\t\"Unsaved Changes\", $\"{dirty.Count} file(s) have unsaved changes. Save before closing?\", \"Cancel\",\n\t\t\tnew Dictionary<string, Action>()\n\t\t\t{\n\t\t\t\t{ \"Don't Save\", () => { dirty.ForEach( d => d.IsDirty = false ); Close(); } },\n\t\t\t\t{ \"Save\", () => { if ( dirty.All( SaveDocument ) ) Close(); } }\n\t\t\t}\n\t\t);\n\n\t\tconfirm.Show();\n\t\treturn false;\n\t}\n}\n"
},
{
"Ident": "chrinteractive.hotcode",
"Path": "Editor/Launcher/RecentEntries.cs",
"FileName": "RecentEntries.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 399294,
"IsPrivate": false,
"Code": "using Sandbox;\nusing Editor;\nusing System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Linq;\nusing System.Text.Json;\n\npublic enum HotCodeEntryKind\n{\n\tProject,\n\tSolution\n}\n\n/// <summary>\n/// Something the launcher can open: an s&box project (.sbproj) or a solution (.sln/.slnx).\n/// </summary>\npublic sealed record HotCodeEntry( string Path, HotCodeEntryKind Kind, string Title, string Type )\n{\n\t/// <summary>\n\t/// When it was last opened from HotCode, if it's in the recents list.\n\t/// </summary>\n\tpublic DateTime? LastOpened { get; init; }\n\n\t/// <summary>\n\t/// Pinned entries stay at the top and never drop off the recents list.\n\t/// </summary>\n\tpublic bool Pinned { get; init; }\n\n\tpublic string Directory => System.IO.Path.GetDirectoryName( Path );\n\n\tpublic string Icon => Kind == HotCodeEntryKind.Solution ? \"account_tree\" : Type switch\n\t{\n\t\t\"game\" => \"sports_esports\",\n\t\t\"library\" => \"local_library\",\n\t\t\"map\" => \"public\",\n\t\t\"content\" => \"perm_media\",\n\t\t\"tool\" => \"hardware\",\n\t\t\"addon\" => \"extension\",\n\t\t_ => \"folder_special\"\n\t};\n\n\t/// <summary>\n\t/// \"Game\", \"Library\", \"Solution\"...\n\t/// </summary>\n\tpublic string TypeName => Kind == HotCodeEntryKind.Solution ? \"Solution\"\n\t\t: string.IsNullOrEmpty( Type ) ? \"Project\" : char.ToUpperInvariant( Type[0] ) + Type[1..];\n\n\tpublic Color TypeColor => Kind == HotCodeEntryKind.Solution ? Theme.Pink : Type switch\n\t{\n\t\t\"game\" => Theme.Green,\n\t\t\"library\" => Theme.Blue,\n\t\t\"addon\" or \"content\" or \"map\" => Theme.Yellow,\n\t\t_ => Theme.TextLight\n\t};\n\n\tpublic static HotCodeEntry FromPath( string path )\n\t{\n\t\tvar ext = System.IO.Path.GetExtension( path ).ToLowerInvariant();\n\n\t\tif ( ext is \".sln\" or \".slnx\" )\n\t\t\treturn new HotCodeEntry( path, HotCodeEntryKind.Solution, System.IO.Path.GetFileNameWithoutExtension( path ), \"solution\" );\n\n\t\tvar title = System.IO.Path.GetFileNameWithoutExtension( path );\n\t\tvar type = \"\";\n\n\t\ttry\n\t\t{\n\t\t\tusing var doc = JsonDocument.Parse( File.ReadAllText( path ) );\n\t\t\tif ( doc.RootElement.TryGetProperty( \"Title\", out var t ) && t.GetString() is { Length: > 0 } s ) title = s;\n\t\t\tif ( doc.RootElement.TryGetProperty( \"Type\", out var ty ) ) type = ty.GetString() ?? \"\";\n\t\t}\n\t\tcatch ( Exception e )\n\t\t{\n\t\t\tLog.Warning( $\"[HotCode] Couldn't read {path}: {e.Message}\" );\n\t\t}\n\n\t\treturn new HotCodeEntry( path, HotCodeEntryKind.Project, title, type );\n\t}\n}\n\n/// <summary>\n/// One remembered project or solution. Stored in an editor cookie, so it's a plain class for JSON.\n/// </summary>\npublic class RecentRecord\n{\n\tpublic string Path { get; set; }\n\tpublic DateTime LastOpened { get; set; }\n\tpublic bool Pinned { get; set; }\n}\n\n/// <summary>\n/// Recently opened projects/solutions, persisted across editor sessions.\n/// </summary>\npublic static class RecentEntries\n{\n\tconst string CookieKey = \"hotcode.recent.v2\";\n\tconst string OldCookieKey = \"hotcode.recent\";\n\n\t/// <summary>\n\t/// How many unpinned entries to keep. Pinned ones don't count.\n\t/// </summary>\n\tconst int MaxEntries = 8;\n\n\tprivate static List<RecentRecord> Load()\n\t{\n\t\tvar records = EditorCookie.Get<List<RecentRecord>>( CookieKey, null );\n\t\tif ( records is not null ) return records;\n\n\t\t// Carry over the old list of plain paths\n\t\tvar old = EditorCookie.Get<List<string>>( OldCookieKey, null ) ?? new();\n\t\trecords = old.Select( ( p, i ) => new RecentRecord { Path = p, LastOpened = DateTime.Now.AddMinutes( -i ) } ).ToList();\n\t\tSave( records );\n\t\treturn records;\n\t}\n\n\tprivate static void Save( List<RecentRecord> records ) => EditorCookie.Set( CookieKey, records );\n\n\t/// <summary>\n\t/// Recent entries that still exist on disk: pinned first, then most recent.\n\t/// </summary>\n\tpublic static IEnumerable<HotCodeEntry> GetAll() => Load()\n\t\t.Where( r => File.Exists( r.Path ) )\n\t\t.OrderByDescending( r => r.Pinned )\n\t\t.ThenByDescending( r => r.LastOpened )\n\t\t.Select( r => HotCodeEntry.FromPath( r.Path ) with { LastOpened = r.LastOpened, Pinned = r.Pinned } );\n\n\t/// <summary>\n\t/// Marks a path as just opened (adding it if needed).\n\t/// </summary>\n\tpublic static void Touch( string path )\n\t{\n\t\tvar records = Load();\n\t\tvar record = Find( records, path );\n\t\tif ( record is null )\n\t\t{\n\t\t\trecord = new RecentRecord { Path = Normalize( path ) };\n\t\t\trecords.Add( record );\n\t\t}\n\n\t\trecord.LastOpened = DateTime.Now;\n\n\t\t// Drop the oldest unpinned ones past the limit\n\t\tvar extra = records.Where( r => !r.Pinned ).OrderByDescending( r => r.LastOpened ).Skip( MaxEntries ).ToList();\n\t\trecords.RemoveAll( extra.Contains );\n\n\t\tSave( records );\n\t}\n\n\tpublic static void SetPinned( string path, bool pinned )\n\t{\n\t\tvar records = Load();\n\t\tvar record = Find( records, path );\n\t\tif ( record is null )\n\t\t{\n\t\t\trecord = new RecentRecord { Path = Normalize( path ), LastOpened = DateTime.Now };\n\t\t\trecords.Add( record );\n\t\t}\n\n\t\trecord.Pinned = pinned;\n\t\tSave( records );\n\t}\n\n\tpublic static void Remove( string path )\n\t{\n\t\tvar records = Load();\n\t\trecords.RemoveAll( r => Normalize( r.Path ) == Normalize( path ) );\n\t\tSave( records );\n\t}\n\n\tprivate static RecentRecord Find( List<RecentRecord> records, string path ) => records.FirstOrDefault( r => Normalize( r.Path ) == Normalize( path ) );\n\n\tpublic static string Normalize( string path ) => System.IO.Path.GetFullPath( path ).Replace( '\\\\', '/' );\n\n\t/// <summary>\n\t/// \"just now\", \"5 minutes ago\", \"yesterday\", \"12 Mar\"...\n\t/// </summary>\n\tpublic static string Ago( DateTime time )\n\t{\n\t\tvar span = DateTime.Now - time;\n\t\tif ( span.TotalMinutes < 1 ) return \"just now\";\n\t\tif ( span.TotalMinutes < 60 ) return $\"{(int)span.TotalMinutes} min ago\";\n\t\tif ( span.TotalHours < 24 ) return $\"{(int)span.TotalHours} hour{((int)span.TotalHours == 1 ? \"\" : \"s\")} ago\";\n\t\tif ( span.TotalDays < 2 ) return \"yesterday\";\n\t\tif ( span.TotalDays < 7 ) return $\"{(int)span.TotalDays} days ago\";\n\t\treturn time.ToString( time.Year == DateTime.Now.Year ? \"d MMM\" : \"d MMM yyyy\" );\n\t}\n}\n"
},
{
"Ident": "chrinteractive.hotcode",
"Path": "Editor/HotCodeEditor/Navigation/DefinitionFinder.cs",
"FileName": "DefinitionFinder.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 399294,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing Microsoft.CodeAnalysis;\nusing Microsoft.CodeAnalysis.CSharp;\nusing Microsoft.CodeAnalysis.CSharp.Syntax;\n\n/// <summary>\n/// Where a symbol is declared. Line and Column are zero-based.\n/// </summary>\npublic record DefinitionLocation( string Path, int Line, int Column, string Preview );\n\n/// <summary>\n/// The answer to \"go to definition\": source locations, or the assembly a symbol comes from\n/// when there's no source for it (like s&box's own types).\n/// </summary>\npublic record DefinitionResult( string Symbol, IReadOnlyList<DefinitionLocation> Locations, string Assembly, string Error )\n{\n\tpublic static DefinitionResult Fail( string error ) => new( null, Array.Empty<DefinitionLocation>(), null, error );\n}\n\n/// <summary>\n/// Go to definition, using the compilation from <see cref=\"RoslynProject\"/>.\n/// </summary>\npublic static class DefinitionFinder\n{\n\t/// <summary>\n\t/// Finds the definition of whatever is at (line, column) in the snapshot's file. Safe to call off the main thread.\n\t/// </summary>\n\tpublic static DefinitionResult Find( RoslynProject.Snapshot snapshot, int line, int column )\n\t{\n\t\ttry\n\t\t{\n\t\t\treturn FindInternal( snapshot, line, column );\n\t\t}\n\t\tcatch ( Exception e )\n\t\t{\n\t\t\treturn DefinitionResult.Fail( $\"Couldn't look that up: {e.Message}\" );\n\t\t}\n\t}\n\n\tprivate static DefinitionResult FindInternal( RoslynProject.Snapshot snapshot, int line, int column )\n\t{\n\t\tvar (compilation, target) = RoslynProject.GetCompilation( snapshot );\n\t\tif ( target is null )\n\t\t\treturn DefinitionResult.Fail( \"Go to Definition works in C# files inside the project's code folders.\" );\n\n\t\tvar model = compilation.GetSemanticModel( target );\n\t\tvar symbol = SymbolAt( model, target, line, column );\n\n\t\tif ( symbol is null )\n\t\t\treturn DefinitionResult.Fail( \"Nothing to go to here.\" );\n\n\t\tsymbol = Unwrap( symbol );\n\t\tvar display = symbol.ToDisplayString( SymbolDisplayFormat.CSharpShortErrorMessageFormat );\n\n\t\tvar locations = symbol.Locations\n\t\t\t.Where( l => l.IsInSource && System.IO.File.Exists( l.SourceTree?.FilePath ) )\n\t\t\t.Select( l =>\n\t\t\t{\n\t\t\t\tvar span = l.GetLineSpan();\n\t\t\t\tvar source = l.SourceTree.GetText().Lines[span.StartLinePosition.Line].ToString().Trim();\n\t\t\t\treturn new DefinitionLocation( l.SourceTree.FilePath, span.StartLinePosition.Line, span.StartLinePosition.Character, source );\n\t\t\t} )\n\t\t\t.ToList();\n\n\t\treturn new DefinitionResult( display, locations, symbol.ContainingAssembly?.Name, null );\n\t}\n\n\t/// <summary>\n\t/// The symbol for the identifier at a position. Also works with the caret just after a word.\n\t/// </summary>\n\tprivate static ISymbol SymbolAt( SemanticModel model, SyntaxTree tree, int line, int column )\n\t{\n\t\tvar text = tree.GetText();\n\t\tif ( line < 0 || line >= text.Lines.Count ) return null;\n\n\t\tvar lineInfo = text.Lines[line];\n\t\tvar position = Math.Min( lineInfo.Start + column, lineInfo.End );\n\t\tvar root = tree.GetRoot();\n\n\t\tforeach ( var candidate in new[] { position, position - 1 } )\n\t\t{\n\t\t\tif ( candidate < lineInfo.Start ) continue;\n\n\t\t\tvar token = root.FindToken( candidate );\n\t\t\tif ( !token.Span.Contains( candidate ) && token.Span.End != candidate ) continue;\n\t\t\tif ( !token.IsKind( SyntaxKind.IdentifierToken ) && !SyntaxFacts.IsPredefinedType( token.Kind() ) && !token.IsKind( SyntaxKind.BaseKeyword ) && !token.IsKind( SyntaxKind.ThisKeyword ) )\n\t\t\t\tcontinue;\n\n\t\t\tvar symbol = SymbolFor( model, token );\n\t\t\tif ( symbol is not null ) return symbol;\n\t\t}\n\n\t\treturn null;\n\t}\n\n\tprivate static ISymbol SymbolFor( SemanticModel model, SyntaxToken token )\n\t{\n\t\tvar node = token.Parent;\n\t\tif ( node is null ) return null;\n\n\t\t// On a declaration's own name: go to that declaration (useful for partial classes and overrides)\n\t\tvar declared = model.GetDeclaredSymbol( node );\n\t\tif ( declared is not null && token == DeclarationIdentifier( node ) )\n\t\t{\n\t\t\t// An override's name jumps to what it overrides, since you're already at its definition\n\t\t\treturn declared switch\n\t\t\t{\n\t\t\t\tIMethodSymbol { OverriddenMethod: { } m } => m,\n\t\t\t\tIPropertySymbol { OverriddenProperty: { } p } => p,\n\t\t\t\tIEventSymbol { OverriddenEvent: { } ev } => ev,\n\t\t\t\t_ => declared\n\t\t\t};\n\t\t}\n\n\t\tvar info = model.GetSymbolInfo( node );\n\t\tvar symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault();\n\n\t\tif ( symbol is null && node.Parent is not null )\n\t\t{\n\t\t\tinfo = model.GetSymbolInfo( node.Parent );\n\t\t\tsymbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault();\n\t\t}\n\n\t\t// `new Foo()` with no constructor written lands on Foo itself\n\t\tif ( symbol is IMethodSymbol { MethodKind: MethodKind.Constructor, IsImplicitlyDeclared: true } ctor )\n\t\t\tsymbol = ctor.ContainingType;\n\n\t\t// `var` resolves to the type it stands for\n\t\tsymbol ??= model.GetTypeInfo( node ).Type;\n\n\t\treturn symbol;\n\t}\n\n\tprivate static SyntaxToken? DeclarationIdentifier( SyntaxNode node ) => node switch\n\t{\n\t\tBaseTypeDeclarationSyntax t => t.Identifier,\n\t\tDelegateDeclarationSyntax d => d.Identifier,\n\t\tMethodDeclarationSyntax m => m.Identifier,\n\t\tPropertyDeclarationSyntax p => p.Identifier,\n\t\tEventDeclarationSyntax e => e.Identifier,\n\t\tVariableDeclaratorSyntax v => v.Identifier,\n\t\tParameterSyntax p => p.Identifier,\n\t\tConstructorDeclarationSyntax c => c.Identifier,\n\t\tEnumMemberDeclarationSyntax e => e.Identifier,\n\t\t_ => null\n\t};\n\n\t/// <summary>\n\t/// From a use of a generic or extension method back to the thing that was declared.\n\t/// </summary>\n\tprivate static ISymbol Unwrap( ISymbol symbol )\n\t{\n\t\tif ( symbol is IMethodSymbol { ReducedFrom: { } reduced } ) symbol = reduced;\n\t\tif ( symbol is IAliasSymbol alias ) symbol = alias.Target;\n\t\treturn symbol.OriginalDefinition ?? symbol;\n\t}\n}\n"
},
{
"Ident": "chrinteractive.hotcode",
"Path": "Editor/HotCodeEditor/Settings/HotCodeSettingsWindow.cs",
"FileName": "HotCodeSettingsWindow.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 399294,
"IsPrivate": false,
"Code": "using Sandbox;\nusing Editor;\nusing System;\n\n/// <summary>\n/// HotCode's settings, laid out like s&box's own Editor Settings: pages down the left, and a\n/// ControlSheet of properties on each page. Changes save and apply straight away.\n/// </summary>\npublic class HotCodeSettingsWindow : Window\n{\n\tprivate static HotCodeSettingsWindow _open;\n\n\t/// <summary>\n\t/// Opens the settings, or brings the already-open window to the front.\n\t/// </summary>\n\tpublic static void Open()\n\t{\n\t\tif ( _open.IsValid() )\n\t\t{\n\t\t\t_open.Raise();\n\t\t\t_open.Focus( false );\n\t\t\treturn;\n\t\t}\n\n\t\t_open = new HotCodeSettingsWindow();\n\t}\n\n\t[Menu( \"Editor\", \"HotCode/Settings\", HotCodeLogo.IconName )]\n\tpublic static void OpenFromEditorMenu() => Open();\n\n\tprivate HotCodeSettingsWindow()\n\t{\n\t\tWindowTitle = \"HotCode Settings\";\n\t\tSetWindowIcon( HotCodeLogo.WindowIcon );\n\t\tDeleteOnClose = true;\n\t\tSize = new Vector2( 860, 620 );\n\t\tMinimumSize = new Vector2( 640, 420 );\n\n\t\tCanvas = new Widget( this );\n\t\tCanvas.Layout = Layout.Column();\n\t\tCanvas.Layout.Margin = 4;\n\t\tCanvas.Layout.Spacing = 4;\n\n\t\tvar nav = Canvas.Layout.Add( new NavigationView( Canvas ), 1 );\n\t\tnav.AddSectionHeader( \"HotCode\" );\n\t\tnav.AddPage( \"Editor\", \"edit_note\", Page( \"Editor\", sheet =>\n\t\t{\n\t\t\tsheet.AddProperty( () => HotCodeSettings.FontSize );\n\t\t\tsheet.AddProperty( () => HotCodeSettings.FontFamily );\n\t\t\tsheet.AddProperty( () => HotCodeSettings.LineSpacing );\n\t\t\tsheet.AddProperty( () => HotCodeSettings.TabSize );\n\t\t\tsheet.AddProperty( () => HotCodeSettings.IndentWithSpaces );\n\t\t\tsheet.AddProperty( () => HotCodeSettings.WordWrap );\n\t\t} ) );\n\t\tnav.AddPage( \"Appearance\", \"palette\", Page( \"Appearance\", sheet =>\n\t\t{\n\t\t\tsheet.AddProperty( () => HotCodeSettings.CodeColors );\n\t\t\tsheet.AddProperty( () => HotCodeSettings.ShowLineNumbers );\n\t\t\tsheet.AddProperty( () => HotCodeSettings.HighlightCurrentLine );\n\t\t\tsheet.AddProperty( () => HotCodeSettings.CursorBlink );\n\t\t} ) );\n\t\tnav.AddPage( \"Code Help\", \"auto_awesome\", Page( \"Code Help\", sheet =>\n\t\t{\n\t\t\tsheet.AddProperty( () => HotCodeSettings.SuggestWhileTyping );\n\t\t\tsheet.AddProperty( () => HotCodeSettings.ParameterHints );\n\t\t\tsheet.AddProperty( () => HotCodeSettings.AutoCloseBrackets );\n\t\t\tsheet.AddProperty( () => HotCodeSettings.UnderlineErrors );\n\t\t} ) );\n\t\tnav.AddPage( \"Files\", \"save\", Page( \"Files\", sheet =>\n\t\t{\n\t\t\tsheet.AddProperty( () => HotCodeSettings.AutoSave );\n\t\t\tsheet.AddProperty( () => HotCodeSettings.AutoSaveDelay );\n\t\t\tsheet.AddProperty( () => HotCodeSettings.TrimTrailingWhitespace );\n\t\t\tsheet.AddProperty( () => HotCodeSettings.InsertFinalNewline );\n\t\t\tsheet.AddProperty( () => HotCodeSettings.ReloadChangedFiles );\n\t\t} ) );\n\n\t\t// Footer\n\t\tCanvas.Layout.AddSeparator( 1, Theme.Border );\n\t\tvar footer = Canvas.Layout.AddRow();\n\t\tfooter.Margin = new Sandbox.UI.Margin( 8, 4, 8, 6 );\n\t\tfooter.Spacing = 8;\n\n\t\tvar note = footer.Add( new Label( \"Changes save and apply immediately.\", Canvas ) );\n\t\tnote.SetStyles( $\"color: {Theme.TextLight.Hex};\" );\n\t\tfooter.AddStretchCell();\n\n\t\tfooter.Add( new Button( \"Reset to Defaults\", \"settings_backup_restore\", Canvas )\n\t\t{\n\t\t\tClicked = () => Dialog.AskConfirm( () =>\n\t\t\t{\n\t\t\t\tHotCodeSettings.ResetToDefaults();\n\t\t\t\t_open = null;\n\t\t\t\tClose();\n\t\t\t\tOpen();\n\t\t\t}, \"Reset every HotCode setting to its default?\", \"Reset Settings\", \"Reset\", \"Cancel\" )\n\t\t} );\n\t\tfooter.Add( new Button.Primary( \"Done\", \"done\", Canvas ) { Clicked = () => Close() } );\n\n\t\tShow();\n\t}\n\n\t/// <summary>\n\t/// A settings page: a heading and a property sheet, padded like s&box's preference pages.\n\t/// </summary>\n\tprivate Widget Page( string title, Action<ControlSheet> fill )\n\t{\n\t\tvar page = new Widget( this );\n\t\tpage.Layout = Layout.Column();\n\t\tpage.Layout.Margin = 32;\n\t\tpage.Layout.Spacing = 8;\n\n\t\tvar heading = page.Layout.AddRow();\n\t\theading.Spacing = 10;\n\t\theading.Add( new LogoWidget( page, 22, badge: false ) );\n\t\theading.Add( new Label.Subtitle( title, page ) );\n\t\theading.AddStretchCell();\n\n\t\tvar sheet = new ControlSheet();\n\t\tfill( sheet );\n\t\tpage.Layout.Add( sheet );\n\t\tpage.Layout.AddStretchCell();\n\n\t\treturn page;\n\t}\n}\n"
},
{
"Ident": "chrinteractive.hotcode",
"Path": "Editor/HotCodeEditor/CodeView/SyntaxLanguage.cs",
"FileName": "SyntaxLanguage.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 399294,
"IsPrivate": false,
"Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.IO;\r\n\r\npublic enum TokenKind\r\n{\r\n\tText,\r\n\tKeyword,\r\n\tControlKeyword,\r\n\tType,\r\n\tMethod,\r\n\tString,\r\n\tNumber,\r\n\tComment,\r\n\tPreprocessor,\r\n\tProperty\r\n}\r\n\r\npublic readonly record struct Token( int Start, int Length, TokenKind Kind );\r\n\r\n/// <summary>\r\n/// What a line starts \"inside of\", for constructs that span lines.\r\n/// </summary>\r\npublic enum LexState\r\n{\r\n\tNormal,\r\n\tBlockComment,\r\n\tVerbatimString,\r\n\tRawString\r\n}\r\n\r\n/// <summary>\r\n/// A small, line-based tokenizer configured per language. It isn't a real parser: types and\r\n/// methods are guessed (PascalCase word = type, word followed by \"(\" = method), which is\r\n/// right most of the time for C# and cheap enough to run on every keystroke.\r\n/// </summary>\r\npublic class SyntaxLanguage\r\n{\r\n\tpublic string Name { get; init; }\r\n\tpublic HashSet<string> Keywords { get; init; } = new();\r\n\tpublic HashSet<string> ControlKeywords { get; init; } = new();\r\n\tpublic string LineComment { get; init; }\r\n\tpublic bool BlockComments { get; init; }\r\n\tpublic bool CSharpStrings { get; init; }\r\n\tpublic bool SingleQuoteStrings { get; init; }\r\n\tpublic bool HashPreprocessor { get; init; }\r\n\tpublic bool GuessTypesAndMethods { get; init; }\r\n\r\n\t/// <summary>\r\n\t/// CSS-style: identifiers may contain '-', and \"name:\" is a property.\r\n\t/// </summary>\r\n\tpublic bool CssIdentifiers { get; init; }\r\n\r\n\t/// <summary>\r\n\t/// No highlighting at all.\r\n\t/// </summary>\r\n\tpublic bool IsPlain { get; init; }\r\n\r\n\tpublic static readonly SyntaxLanguage Plain = new() { Name = \"Plain Text\", IsPlain = true };\r\n\r\n\tpublic static readonly SyntaxLanguage CSharp = new()\r\n\t{\r\n\t\tName = \"C#\",\r\n\t\tLineComment = \"//\",\r\n\t\tBlockComments = true,\r\n\t\tCSharpStrings = true,\r\n\t\tHashPreprocessor = true,\r\n\t\tGuessTypesAndMethods = true,\r\n\t\tKeywords = new()\r\n\t\t{\r\n\t\t\t\"abstract\", \"as\", \"base\", \"bool\", \"byte\", \"char\", \"checked\", \"class\", \"const\", \"decimal\", \"default\", \"delegate\",\r\n\t\t\t\"double\", \"enum\", \"event\", \"explicit\", \"extern\", \"false\", \"fixed\", \"float\", \"implicit\", \"in\", \"int\", \"interface\",\r\n\t\t\t\"internal\", \"is\", \"lock\", \"long\", \"namespace\", \"new\", \"null\", \"object\", \"operator\", \"out\", \"override\", \"params\",\r\n\t\t\t\"private\", \"protected\", \"public\", \"readonly\", \"ref\", \"sbyte\", \"sealed\", \"short\", \"sizeof\", \"stackalloc\", \"static\",\r\n\t\t\t\"string\", \"struct\", \"this\", \"true\", \"typeof\", \"uint\", \"ulong\", \"unchecked\", \"unsafe\", \"ushort\", \"using\", \"virtual\",\r\n\t\t\t\"void\", \"volatile\", \"var\", \"dynamic\", \"async\", \"get\", \"set\", \"init\", \"value\", \"partial\", \"where\", \"record\", \"nameof\",\r\n\t\t\t\"required\", \"global\", \"with\", \"not\", \"and\", \"or\", \"nint\", \"nuint\", \"file\", \"scoped\", \"field\", \"add\", \"remove\", \"let\",\r\n\t\t\t\"from\", \"select\", \"orderby\", \"group\", \"into\", \"on\", \"equals\", \"by\", \"ascending\", \"descending\", \"join\", \"alias\"\r\n\t\t},\r\n\t\tControlKeywords = new()\r\n\t\t{\r\n\t\t\t\"if\", \"else\", \"switch\", \"case\", \"for\", \"foreach\", \"while\", \"do\", \"break\", \"continue\", \"return\", \"goto\", \"try\",\r\n\t\t\t\"catch\", \"finally\", \"throw\", \"yield\", \"await\", \"when\"\r\n\t\t}\r\n\t};\r\n\r\n\tpublic static readonly SyntaxLanguage Json = new()\r\n\t{\r\n\t\tName = \"JSON\",\r\n\t\tLineComment = \"//\",\r\n\t\tBlockComments = true,\r\n\t\tKeywords = new() { \"true\", \"false\", \"null\" }\r\n\t};\r\n\r\n\tpublic static readonly SyntaxLanguage Css = new()\r\n\t{\r\n\t\tName = \"SCSS\",\r\n\t\tLineComment = \"//\",\r\n\t\tBlockComments = true,\r\n\t\tSingleQuoteStrings = true,\r\n\t\tCssIdentifiers = true,\r\n\t\tKeywords = new() { \"!important\", \"@import\", \"@use\", \"@media\", \"@keyframes\", \"@mixin\", \"@include\", \"@extend\", \"@if\", \"@else\", \"@each\", \"@for\", \"@function\", \"@return\" }\r\n\t};\r\n\r\n\tpublic static SyntaxLanguage FromPath( string path ) => Path.GetExtension( path ).ToLowerInvariant() switch\r\n\t{\r\n\t\t\".cs\" => CSharp,\r\n\t\t\".json\" or \".sbproj\" => Json,\r\n\t\t\".scss\" or \".css\" => Css,\r\n\t\t_ => Plain\r\n\t};\r\n\r\n\t/// <summary>\r\n\t/// Appends the colored tokens of <paramref name=\"line\"/> to <paramref name=\"tokens\"/> (plain text gets no token)\r\n\t/// and returns the state the next line starts in.\r\n\t/// </summary>\r\n\tpublic LexState Tokenize( string line, LexState state, List<Token> tokens )\r\n\t{\r\n\t\tif ( IsPlain ) return LexState.Normal;\r\n\r\n\t\tint i = 0;\r\n\t\tint n = line.Length;\r\n\r\n\t\t// Continue whatever the previous line left open\r\n\t\tswitch ( state )\r\n\t\t{\r\n\t\t\tcase LexState.BlockComment:\r\n\t\t\t{\r\n\t\t\t\tvar end = line.IndexOf( \"*/\", StringComparison.Ordinal );\r\n\t\t\t\tif ( end < 0 ) { Add( tokens, 0, n, TokenKind.Comment ); return state; }\r\n\t\t\t\tAdd( tokens, 0, end + 2, TokenKind.Comment );\r\n\t\t\t\ti = end + 2;\r\n\t\t\t\tbreak;\r\n\t\t\t}\r\n\t\t\tcase LexState.VerbatimString:\r\n\t\t\t{\r\n\t\t\t\tvar end = FindVerbatimEnd( line, 0 );\r\n\t\t\t\tif ( end < 0 ) { Add( tokens, 0, n, TokenKind.String ); return state; }\r\n\t\t\t\tAdd( tokens, 0, end, TokenKind.String );\r\n\t\t\t\ti = end;\r\n\t\t\t\tbreak;\r\n\t\t\t}\r\n\t\t\tcase LexState.RawString:\r\n\t\t\t{\r\n\t\t\t\tvar end = line.IndexOf( \"\\\"\\\"\\\"\", StringComparison.Ordinal );\r\n\t\t\t\tif ( end < 0 ) { Add( tokens, 0, n, TokenKind.String ); return state; }\r\n\t\t\t\tAdd( tokens, 0, end + 3, TokenKind.String );\r\n\t\t\t\ti = end + 3;\r\n\t\t\t\tbreak;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tif ( HashPreprocessor && i == 0 )\r\n\t\t{\r\n\t\t\tvar first = 0;\r\n\t\t\twhile ( first < n && char.IsWhiteSpace( line[first] ) ) first++;\r\n\t\t\tif ( first < n && line[first] == '#' )\r\n\t\t\t{\r\n\t\t\t\tAdd( tokens, first, n - first, TokenKind.Preprocessor );\r\n\t\t\t\treturn LexState.Normal;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\twhile ( i < n )\r\n\t\t{\r\n\t\t\tvar c = line[i];\r\n\r\n\t\t\tif ( char.IsWhiteSpace( c ) ) { i++; continue; }\r\n\r\n\t\t\t// Comments\r\n\t\t\tif ( LineComment is not null && string.CompareOrdinal( line, i, LineComment, 0, LineComment.Length ) == 0 )\r\n\t\t\t{\r\n\t\t\t\tAdd( tokens, i, n - i, TokenKind.Comment );\r\n\t\t\t\treturn LexState.Normal;\r\n\t\t\t}\r\n\r\n\t\t\tif ( BlockComments && c == '/' && i + 1 < n && line[i + 1] == '*' )\r\n\t\t\t{\r\n\t\t\t\tvar end = line.IndexOf( \"*/\", i + 2, StringComparison.Ordinal );\r\n\t\t\t\tif ( end < 0 ) { Add( tokens, i, n - i, TokenKind.Comment ); return LexState.BlockComment; }\r\n\t\t\t\tAdd( tokens, i, end + 2 - i, TokenKind.Comment );\r\n\t\t\t\ti = end + 2;\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\t// Strings\r\n\t\t\tif ( CSharpStrings && TryStartCSharpString( line, i, out var prefix, out var verbatim, out var raw ) )\r\n\t\t\t{\r\n\t\t\t\tvar bodyStart = i + prefix;\r\n\t\t\t\tif ( raw )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar end = line.IndexOf( \"\\\"\\\"\\\"\", bodyStart, StringComparison.Ordinal );\r\n\t\t\t\t\tif ( end < 0 ) { Add( tokens, i, n - i, TokenKind.String ); return LexState.RawString; }\r\n\t\t\t\t\tAdd( tokens, i, end + 3 - i, TokenKind.String );\r\n\t\t\t\t\ti = end + 3;\r\n\t\t\t\t}\r\n\t\t\t\telse if ( verbatim )\r\n\t\t\t\t{\r\n\t\t\t\t\tvar end = FindVerbatimEnd( line, bodyStart );\r\n\t\t\t\t\tif ( end < 0 ) { Add( tokens, i, n - i, TokenKind.String ); return LexState.VerbatimString; }\r\n\t\t\t\t\tAdd( tokens, i, end - i, TokenKind.String );\r\n\t\t\t\t\ti = end;\r\n\t\t\t\t}\r\n\t\t\t\telse\r\n\t\t\t\t{\r\n\t\t\t\t\tvar end = FindQuotedEnd( line, bodyStart, '\"' );\r\n\t\t\t\t\tAdd( tokens, i, end - i, TokenKind.String );\r\n\t\t\t\t\ti = end;\r\n\t\t\t\t}\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\tif ( c == '\"' || (c == '\\'' && (CSharpStrings || SingleQuoteStrings)) )\r\n\t\t\t{\r\n\t\t\t\tvar end = FindQuotedEnd( line, i + 1, c );\r\n\t\t\t\tAdd( tokens, i, end - i, TokenKind.String );\r\n\t\t\t\ti = end;\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\t// Numbers\r\n\t\t\tif ( char.IsDigit( c ) || (c == '.' && i + 1 < n && char.IsDigit( line[i + 1] )) )\r\n\t\t\t{\r\n\t\t\t\tvar start = i;\r\n\t\t\t\twhile ( i < n && (char.IsLetterOrDigit( line[i] ) || line[i] == '.' || line[i] == '_') ) i++;\r\n\t\t\t\tif ( CssIdentifiers && i < n && line[i] == '%' ) i++;\r\n\t\t\t\tAdd( tokens, start, i - start, TokenKind.Number );\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\t// CSS hex colours: #fff, #a0b1c2\r\n\t\t\tif ( CssIdentifiers && c == '#' && i + 1 < n && Uri.IsHexDigit( line[i + 1] ) )\r\n\t\t\t{\r\n\t\t\t\tvar start = i++;\r\n\t\t\t\twhile ( i < n && char.IsLetterOrDigit( line[i] ) ) i++;\r\n\t\t\t\tAdd( tokens, start, i - start, TokenKind.Number );\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\t// Words\r\n\t\t\tif ( IsIdentStart( c ) || (c == '@' && i + 1 < n && IsIdentStart( line[i + 1] )) || (CssIdentifiers && (c == '$' || c == '!')) )\r\n\t\t\t{\r\n\t\t\t\tvar start = i++;\r\n\t\t\t\twhile ( i < n && IsIdentPart( line[i] ) ) i++;\r\n\t\t\t\tvar word = line.Substring( start, i - start );\r\n\t\t\t\tvar kind = ClassifyWord( line, word, i );\r\n\t\t\t\tif ( kind != TokenKind.Text ) Add( tokens, start, i - start, kind );\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\ti++;\r\n\t\t}\r\n\r\n\t\treturn LexState.Normal;\r\n\t}\r\n\r\n\tprivate TokenKind ClassifyWord( string line, string word, int after )\r\n\t{\r\n\t\tif ( ControlKeywords.Contains( word ) ) return TokenKind.ControlKeyword;\r\n\t\tif ( Keywords.Contains( word ) ) return TokenKind.Keyword;\r\n\r\n\t\tif ( CssIdentifiers )\r\n\t\t{\r\n\t\t\tif ( word[0] == '$' ) return TokenKind.Type;\r\n\r\n\t\t\t// \"color: red\" is a property, but \"a:hover {\" is a selector\r\n\t\t\tvar isFirstOnLine = string.IsNullOrWhiteSpace( line[..(after - word.Length)] );\r\n\t\t\treturn isFirstOnLine && NextNonSpace( line, after ) == ':' && !line.Contains( '{' ) ? TokenKind.Property : TokenKind.Text;\r\n\t\t}\r\n\r\n\t\tif ( !GuessTypesAndMethods ) return TokenKind.Text;\r\n\r\n\t\tvar nextChar = NextNonSpace( line, after );\r\n\t\tif ( nextChar == '(' ) return TokenKind.Method;\r\n\r\n\t\tvar bare = word[0] == '@' ? word[1..] : word;\r\n\t\tif ( bare.Length > 0 && char.IsUpper( bare[0] ) ) return TokenKind.Type;\r\n\r\n\t\treturn TokenKind.Text;\r\n\t}\r\n\r\n\tprivate bool IsIdentStart( char c ) => char.IsLetter( c ) || c == '_';\r\n\tprivate bool IsIdentPart( char c ) => char.IsLetterOrDigit( c ) || c == '_' || (CssIdentifiers && c == '-');\r\n\r\n\tprivate static char SafeChar( string s, int i ) => i < s.Length ? s[i] : '\\0';\r\n\r\n\tprivate static char NextNonSpace( string line, int i )\r\n\t{\r\n\t\twhile ( i < line.Length && line[i] == ' ' ) i++;\r\n\t\treturn SafeChar( line, i );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Recognises the C# string openers: \" $\" @\" $@\" @$\" \"\"\" $\"\"\" (and $$\"\"\" etc).\r\n\t/// </summary>\r\n\tprivate static bool TryStartCSharpString( string line, int i, out int prefixLength, out bool verbatim, out bool raw )\r\n\t{\r\n\t\tprefixLength = 0;\r\n\t\tverbatim = false;\r\n\t\traw = false;\r\n\r\n\t\tvar j = i;\r\n\t\twhile ( j < line.Length && (line[j] == '$' || line[j] == '@') )\r\n\t\t{\r\n\t\t\tif ( line[j] == '@' ) verbatim = true;\r\n\t\t\tj++;\r\n\t\t}\r\n\r\n\t\tif ( j >= line.Length || line[j] != '\"' )\r\n\t\t\treturn false;\r\n\r\n\t\t// Plain \"...\" is handled by the generic quote branch\r\n\t\tif ( j == i && !(j + 2 < line.Length && line[j + 1] == '\"' && line[j + 2] == '\"') )\r\n\t\t\treturn false;\r\n\r\n\t\tif ( !verbatim && j + 2 < line.Length && line[j + 1] == '\"' && line[j + 2] == '\"' )\r\n\t\t{\r\n\t\t\traw = true;\r\n\t\t\tvar k = j;\r\n\t\t\twhile ( k < line.Length && line[k] == '\"' ) k++;\r\n\t\t\tprefixLength = k - i;\r\n\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\tprefixLength = j + 1 - i;\r\n\t\treturn true;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Index just past the closing quote of a verbatim string (\"\" is an escaped quote), or -1 if it runs off the line.\r\n\t/// </summary>\r\n\tprivate static int FindVerbatimEnd( string line, int i )\r\n\t{\r\n\t\twhile ( i < line.Length )\r\n\t\t{\r\n\t\t\tif ( line[i] == '\"' )\r\n\t\t\t{\r\n\t\t\t\tif ( i + 1 < line.Length && line[i + 1] == '\"' ) { i += 2; continue; }\r\n\t\t\t\treturn i + 1;\r\n\t\t\t}\r\n\t\t\ti++;\r\n\t\t}\r\n\r\n\t\treturn -1;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Index just past the closing quote, honouring backslash escapes. Unterminated strings end at the line end.\r\n\t/// </summary>\r\n\tprivate static int FindQuotedEnd( string line, int i, char quote )\r\n\t{\r\n\t\twhile ( i < line.Length )\r\n\t\t{\r\n\t\t\tif ( line[i] == '\\\\' ) { i += 2; continue; }\r\n\t\t\tif ( line[i] == quote ) return i + 1;\r\n\t\t\ti++;\r\n\t\t}\r\n\r\n\t\treturn line.Length;\r\n\t}\r\n\r\n\tprivate static void Add( List<Token> tokens, int start, int length, TokenKind kind )\r\n\t{\r\n\t\tif ( length > 0 ) tokens.Add( new Token( start, length, kind ) );\r\n\t}\r\n}\r\n"
},
{
"Ident": "chrinteractive.hotcode",
"Path": "Editor/Launcher/HotCodeInstallUi.cs",
"FileName": "HotCodeInstallUi.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 399294,
"IsPrivate": false,
"Code": "using Sandbox;\nusing Editor;\nusing System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Linq;\n\n/// <summary>\n/// The confirmations and messages around <see cref=\"HotCodeInstaller\"/>, shared by the launcher and the editor.\n/// </summary>\npublic static class HotCodeInstallUi\n{\n\t/// <summary>\n\t/// Where this copy of HotCode lives: the loaded project whose .sbproj is HotCode's.\n\t/// </summary>\n\tpublic static string SourceRoot\n\t{\n\t\tget\n\t\t{\n\t\t\tvar loaded = EditorUtility.Projects.GetAll()\n\t\t\t\t.Where( p => p.ConfigFilePath is not null && HotCodeInstaller.IsHotCode( p.ConfigFilePath ) )\n\t\t\t\t.Select( p => Path.GetDirectoryName( p.ConfigFilePath ) )\n\t\t\t\t.FirstOrDefault();\n\t\t\tif ( loaded is not null ) return loaded;\n\n\t\t\t// Libraries might not be in the project list: look in the open project's Libraries folder\n\t\t\tvar current = Project.Current?.GetRootPath();\n\t\t\treturn string.IsNullOrEmpty( current ) ? null : HotCodeInstaller.FindInstalled( current );\n\t\t}\n\t}\n\n\tpublic enum State\n\t{\n\t\t/// <summary>Not a project HotCode can go in (a solution, or HotCode's own source).</summary>\n\t\tNotApplicable,\n\t\tNotInstalled,\n\t\tInstalled\n\t}\n\n\tpublic static State StateOf( HotCodeEntry entry )\n\t{\n\t\tif ( entry.Kind != HotCodeEntryKind.Project ) return State.NotApplicable;\n\n\t\tvar source = SourceRoot;\n\t\tif ( source is not null && (ProjectFiles.IsUnder( entry.Directory, source ) || ProjectFiles.IsUnder( source, entry.Directory )) )\n\t\t\treturn State.NotApplicable;\n\n\t\ttry\n\t\t{\n\t\t\treturn HotCodeInstaller.FindInstalled( entry.Directory ) is null ? State.NotInstalled : State.Installed;\n\t\t}\n\t\tcatch ( Exception e ) when ( e is IOException or UnauthorizedAccessException )\n\t\t{\n\t\t\treturn State.NotApplicable;\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// Adds the install/update/remove options for a project to a menu.\n\t/// </summary>\n\tpublic static void AddMenuOptions( Menu menu, HotCodeEntry entry, Action changed )\n\t{\n\t\tswitch ( StateOf( entry ) )\n\t\t{\n\t\t\tcase State.NotInstalled:\n\t\t\t\tmenu.AddOption( \"Add HotCode to This Project\", HotCodeLogo.IconName, () => AskInstall( entry, changed ) );\n\t\t\t\tbreak;\n\n\t\t\tcase State.Installed:\n\t\t\t\tmenu.AddOption( \"Update HotCode in This Project\", \"system_update_alt\", () => AskInstall( entry, changed ) );\n\t\t\t\tmenu.AddOption( \"Remove HotCode from This Project\", \"remove_circle_outline\", () => AskUninstall( entry, changed ) );\n\t\t\t\tbreak;\n\t\t}\n\t}\n\n\tpublic static void AskInstall( HotCodeEntry entry, Action changed = null )\n\t{\n\t\tvar source = SourceRoot;\n\t\tif ( source is null )\n\t\t{\n\t\t\tShow( \"Add HotCode\", \"Couldn't find HotCode's own files. Open HotCode's project (or one that has it) in s&box first.\" );\n\t\t\treturn;\n\t\t}\n\n\t\tvar updating = StateOf( entry ) == State.Installed;\n\t\tvar question = updating\n\t\t\t? $\"Update the copy of HotCode in '{entry.Title}' to this version?\\n\\nThe old copy goes to the Recycle Bin.\"\n\t\t\t: $\"Add HotCode to '{entry.Title}'?\\n\\nThis copies HotCode into its Libraries folder, the same place the Library Manager puts libraries. Next time you open that project in s&box, HotCode will be there.\";\n\n\t\tDialog.AskConfirm( () =>\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tvar folder = HotCodeInstaller.Install( source, entry.Directory, ProjectFiles.Delete );\n\t\t\t\tLog.Info( $\"[HotCode] {(updating ? \"Updated\" : \"Added\")} HotCode in {folder}\" );\n\t\t\t\tShow( updating ? \"HotCode Updated\" : \"HotCode Added\",\n\t\t\t\t\t$\"HotCode is now in '{entry.Title}'.\\n\\n{folder}\\n\\nOpen (or reopen) that project in s&box to use it.\" );\n\t\t\t\tchanged?.Invoke();\n\t\t\t}\n\t\t\tcatch ( Exception e ) when ( e is InvalidOperationException or IOException or UnauthorizedAccessException )\n\t\t\t{\n\t\t\t\tShow( \"Couldn't Add HotCode\", e.Message );\n\t\t\t}\n\t\t}, question, updating ? \"Update HotCode\" : \"Add HotCode\", updating ? \"Update\" : \"Add\", \"Cancel\" );\n\t}\n\n\tpublic static void AskUninstall( HotCodeEntry entry, Action changed = null )\n\t{\n\t\tvar source = SourceRoot ?? \"\";\n\n\t\tDialog.AskConfirm( () =>\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tif ( HotCodeInstaller.Uninstall( source, entry.Directory, ProjectFiles.Delete ) )\n\t\t\t\t\tLog.Info( $\"[HotCode] Removed HotCode from {entry.Directory}\" );\n\t\t\t\tchanged?.Invoke();\n\t\t\t}\n\t\t\tcatch ( Exception e ) when ( e is InvalidOperationException or IOException or UnauthorizedAccessException )\n\t\t\t{\n\t\t\t\tShow( \"Couldn't Remove HotCode\", e.Message );\n\t\t\t}\n\t\t}, $\"Remove HotCode from '{entry.Title}'?\\n\\nIts copy goes to the Recycle Bin.\", \"Remove HotCode\", \"Remove\", \"Cancel\" );\n\t}\n\n\tprivate static void Show( string title, string message ) =>\n\t\tnew PopupWindow( title, message, \"OK\", new Dictionary<string, Action>() ).Show();\n}\n"
},
{
"Ident": "chrinteractive.hotcode",
"Path": "Editor/HotCodeEditor/CodeView/TextBuffer.cs",
"FileName": "TextBuffer.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 399294,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\nusing System.Text;\n\n/// <summary>\n/// A position in a <see cref=\"TextBuffer\"/>: zero-based line and character index within that line.\n/// </summary>\npublic readonly record struct TextPos( int Line, int Col ) : IComparable<TextPos>\n{\n\tpublic int CompareTo( TextPos other ) => Line != other.Line ? Line.CompareTo( other.Line ) : Col.CompareTo( other.Col );\n\n\tpublic static bool operator <( TextPos a, TextPos b ) => a.CompareTo( b ) < 0;\n\tpublic static bool operator >( TextPos a, TextPos b ) => a.CompareTo( b ) > 0;\n\tpublic static bool operator <=( TextPos a, TextPos b ) => a.CompareTo( b ) <= 0;\n\tpublic static bool operator >=( TextPos a, TextPos b ) => a.CompareTo( b ) >= 0;\n\n\tpublic static TextPos Min( TextPos a, TextPos b ) => a <= b ? a : b;\n\tpublic static TextPos Max( TextPos a, TextPos b ) => a >= b ? a : b;\n}\n\n/// <summary>\n/// Plain text stored as a list of lines. Knows nothing about carets, undo or drawing.\n/// </summary>\npublic class TextBuffer\n{\n\tpublic List<string> Lines { get; } = new() { \"\" };\n\n\t/// <summary>\n\t/// Line ending used when joining lines back into text. Detected from the loaded text.\n\t/// </summary>\n\tpublic string LineEnding { get; set; } = \"\\n\";\n\n\tpublic int LineCount => Lines.Count;\n\n\tpublic string Text\n\t{\n\t\tget => string.Join( LineEnding, Lines );\n\t\tset\n\t\t{\n\t\t\tvalue ??= \"\";\n\t\t\tLineEnding = value.Contains( \"\\r\\n\" ) ? \"\\r\\n\" : \"\\n\";\n\t\t\tLines.Clear();\n\t\t\tLines.AddRange( Normalize( value ).Split( '\\n' ) );\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// Converts any line endings to \\n.\n\t/// </summary>\n\tpublic static string Normalize( string text ) => text.Replace( \"\\r\\n\", \"\\n\" ).Replace( '\\r', '\\n' );\n\n\tpublic TextPos Clamp( TextPos p )\n\t{\n\t\tvar line = Math.Clamp( p.Line, 0, Lines.Count - 1 );\n\t\treturn new TextPos( line, Math.Clamp( p.Col, 0, Lines[line].Length ) );\n\t}\n\n\tpublic TextPos End => new( Lines.Count - 1, Lines[^1].Length );\n\n\t/// <summary>\n\t/// Where text inserted at <paramref name=\"start\"/> would end.\n\t/// </summary>\n\tpublic static TextPos EndOf( TextPos start, string text )\n\t{\n\t\tvar lastNewline = text.LastIndexOf( '\\n' );\n\t\tif ( lastNewline < 0 )\n\t\t\treturn new TextPos( start.Line, start.Col + text.Length );\n\n\t\tvar newlines = 0;\n\t\tforeach ( var c in text ) if ( c == '\\n' ) newlines++;\n\t\treturn new TextPos( start.Line + newlines, text.Length - lastNewline - 1 );\n\t}\n\n\tpublic string GetText( TextPos a, TextPos b )\n\t{\n\t\tvar start = Clamp( TextPos.Min( a, b ) );\n\t\tvar end = Clamp( TextPos.Max( a, b ) );\n\n\t\tif ( start.Line == end.Line )\n\t\t\treturn Lines[start.Line].Substring( start.Col, end.Col - start.Col );\n\n\t\tvar sb = new StringBuilder();\n\t\tsb.Append( Lines[start.Line], start.Col, Lines[start.Line].Length - start.Col );\n\t\tfor ( int i = start.Line + 1; i < end.Line; i++ )\n\t\t\tsb.Append( '\\n' ).Append( Lines[i] );\n\t\tsb.Append( '\\n' ).Append( Lines[end.Line], 0, end.Col );\n\t\treturn sb.ToString();\n\t}\n\n\tpublic void Delete( TextPos a, TextPos b )\n\t{\n\t\tvar start = Clamp( TextPos.Min( a, b ) );\n\t\tvar end = Clamp( TextPos.Max( a, b ) );\n\t\tif ( start == end ) return;\n\n\t\tvar head = Lines[start.Line][..start.Col];\n\t\tvar tail = Lines[end.Line][end.Col..];\n\t\tLines.RemoveRange( start.Line + 1, end.Line - start.Line );\n\t\tLines[start.Line] = head + tail;\n\t}\n\n\t/// <summary>\n\t/// Inserts text (which may contain newlines) and returns the position just after it.\n\t/// </summary>\n\tpublic TextPos Insert( TextPos at, string text )\n\t{\n\t\tat = Clamp( at );\n\t\ttext = Normalize( text );\n\t\tif ( text.Length == 0 ) return at;\n\n\t\tvar line = Lines[at.Line];\n\t\tvar head = line[..at.Col];\n\t\tvar tail = line[at.Col..];\n\t\tvar parts = text.Split( '\\n' );\n\n\t\tif ( parts.Length == 1 )\n\t\t{\n\t\t\tLines[at.Line] = head + text + tail;\n\t\t\treturn new TextPos( at.Line, at.Col + text.Length );\n\t\t}\n\n\t\tLines[at.Line] = head + parts[0];\n\t\tfor ( int i = 1; i < parts.Length - 1; i++ )\n\t\t\tLines.Insert( at.Line + i, parts[i] );\n\t\tLines.Insert( at.Line + parts.Length - 1, parts[^1] + tail );\n\n\t\treturn new TextPos( at.Line + parts.Length - 1, parts[^1].Length );\n\t}\n}\n"
},
{
"Ident": "chrinteractive.hotcode",
"Path": "Editor/HotCodeEditor/CodeView/UndoHistory.cs",
"FileName": "UndoHistory.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 399294,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\n\n/// <summary>\n/// One change to a <see cref=\"TextBuffer\"/>: at <see cref=\"Start\"/>, <see cref=\"Removed\"/> was replaced by <see cref=\"Inserted\"/>.\n/// </summary>\npublic readonly record struct BufferEdit( TextPos Start, string Removed, string Inserted );\n\n/// <summary>\n/// What kind of edit this was, which decides whether it can merge with the one before it.\n/// </summary>\npublic enum EditKind\n{\n\t/// <summary>Paste, cut, indent, new line... always its own undo step.</summary>\n\tOther,\n\tTyping,\n\tDeleting\n}\n\n/// <summary>\n/// One undo step. Usually a single edit, but typing a word, backspacing, or a \"replace all\"\n/// becomes one group.\n/// </summary>\npublic class UndoGroup\n{\n\tpublic int Id { get; init; }\n\tpublic EditKind Kind { get; init; }\n\tpublic List<BufferEdit> Edits { get; } = new();\n\tpublic TextPos CaretBefore { get; init; }\n\tpublic TextPos? AnchorBefore { get; init; }\n\tpublic TextPos CaretAfter { get; set; }\n\tpublic TextPos? AnchorAfter { get; set; }\n\tpublic double Time { get; set; }\n}\n\n/// <summary>\n/// Undo/redo stacks with editor-style merging:\n/// <list type=\"bullet\">\n/// <item>typing merges per word (\"hello world\" undoes as \"world\", then \"hello \")</item>\n/// <item>backspace / delete runs merge</item>\n/// <item>moving the caret, saving, pausing, or doing anything else starts a new step</item>\n/// </list>\n/// Also tracks the save point, so undoing back to the saved text counts as unmodified.\n/// </summary>\npublic class UndoHistory\n{\n\t/// <summary>\n\t/// A pause longer than this starts a new undo step.\n\t/// </summary>\n\tpublic const double MergeTimeout = 3.0;\n\n\tconst int MaxSteps = 1000;\n\n\tprivate readonly List<UndoGroup> _undo = new();\n\tprivate readonly List<UndoGroup> _redo = new();\n\tprivate int _nextId = 1;\n\tprivate int _savedId;\n\tprivate bool _breakNext = true;\n\n\tprivate UndoGroup _transaction;\n\tprivate int _transactionDepth;\n\n\t/// <summary>\n\t/// Id of the text as it is now; 0 is the text as loaded.\n\t/// </summary>\n\tpublic int CurrentId => _undo.Count > 0 ? _undo[^1].Id : 0;\n\n\t/// <summary>\n\t/// Differs from what was last loaded or saved.\n\t/// </summary>\n\tpublic bool IsModified => CurrentId != _savedId;\n\n\tpublic bool CanUndo => _undo.Count > 0;\n\tpublic bool CanRedo => _redo.Count > 0;\n\n\tpublic void Clear()\n\t{\n\t\t_undo.Clear();\n\t\t_redo.Clear();\n\t\t_savedId = 0;\n\t\t_breakNext = true;\n\t}\n\n\tpublic void MarkSaved()\n\t{\n\t\t_savedId = CurrentId;\n\t\t_breakNext = true;\n\t}\n\n\t/// <summary>\n\t/// The next edit starts a new undo step (call when the caret moves, etc).\n\t/// </summary>\n\tpublic void Break() => _breakNext = true;\n\n\t/// <summary>\n\t/// Everything recorded until the matching <see cref=\"EndTransaction\"/> becomes one undo step.\n\t/// </summary>\n\tpublic void BeginTransaction( TextPos caret, TextPos? anchor, double now )\n\t{\n\t\tif ( _transactionDepth++ > 0 ) return;\n\t\t_transaction = new UndoGroup { Id = _nextId++, Kind = EditKind.Other, CaretBefore = caret, AnchorBefore = anchor, CaretAfter = caret, Time = now };\n\t}\n\n\tpublic void EndTransaction( TextPos caret, TextPos? anchor )\n\t{\n\t\tif ( _transactionDepth == 0 || --_transactionDepth > 0 ) return;\n\n\t\tvar group = _transaction;\n\t\t_transaction = null;\n\t\tif ( group.Edits.Count == 0 ) return;\n\n\t\tgroup.CaretAfter = caret;\n\t\tgroup.AnchorAfter = anchor;\n\t\tPush( group );\n\t\t_breakNext = true;\n\t}\n\n\tpublic void Record( BufferEdit edit, EditKind kind, TextPos caretBefore, TextPos? anchorBefore, TextPos caretAfter, double now )\n\t{\n\t\tif ( _transaction is not null )\n\t\t{\n\t\t\t_transaction.Edits.Add( edit );\n\t\t\t_transaction.CaretAfter = caretAfter;\n\t\t\treturn;\n\t\t}\n\n\t\tvar top = _undo.Count > 0 ? _undo[^1] : null;\n\t\tif ( top is not null && CanMerge( top, edit, kind, now ) )\n\t\t{\n\t\t\ttop.Edits.Add( edit );\n\t\t\ttop.CaretAfter = caretAfter;\n\t\t\ttop.AnchorAfter = null;\n\t\t\ttop.Time = now;\n\t\t\t_redo.Clear();\n\t\t\treturn;\n\t\t}\n\n\t\tvar group = new UndoGroup { Id = _nextId++, Kind = kind, CaretBefore = caretBefore, AnchorBefore = anchorBefore, CaretAfter = caretAfter, Time = now };\n\t\tgroup.Edits.Add( edit );\n\t\tPush( group );\n\n\t\t// Typing and deleting stay open for merging; anything else stands alone\n\t\t_breakNext = kind == EditKind.Other;\n\t}\n\n\tprivate void Push( UndoGroup group )\n\t{\n\t\t_undo.Add( group );\n\t\t_redo.Clear();\n\t\tif ( _undo.Count > MaxSteps ) _undo.RemoveAt( 0 );\n\t}\n\n\tprivate bool CanMerge( UndoGroup top, BufferEdit edit, EditKind kind, double now )\n\t{\n\t\tif ( _breakNext || kind == EditKind.Other || top.Kind != kind ) return false;\n\t\tif ( now - top.Time > MergeTimeout ) return false;\n\n\t\t// Never merge into the saved state, or undo would step past it\n\t\tif ( top.Id == _savedId ) return false;\n\n\t\tvar last = top.Edits[^1];\n\n\t\tif ( kind == EditKind.Typing )\n\t\t{\n\t\t\tif ( edit.Removed.Length != 0 || edit.Inserted.Contains( '\\n' ) ) return false;\n\t\t\tif ( edit.Start != TextBuffer.EndOf( last.Start, last.Inserted ) ) return false;\n\n\t\t\t// Start a new step at each new word: \"hello |world\"\n\t\t\tvar lastChar = last.Inserted.Length > 0 ? last.Inserted[^1] : ' ';\n\t\t\treturn !(char.IsWhiteSpace( lastChar ) && !char.IsWhiteSpace( edit.Inserted[0] ));\n\t\t}\n\n\t\tif ( kind == EditKind.Deleting )\n\t\t{\n\t\t\tif ( edit.Inserted.Length != 0 || edit.Removed.Contains( '\\n' ) ) return false;\n\n\t\t\tvar isBackspace = TextBuffer.EndOf( edit.Start, edit.Removed ) == last.Start;\n\t\t\tvar isForwardDelete = edit.Start == last.Start;\n\t\t\treturn isBackspace || isForwardDelete;\n\t\t}\n\n\t\treturn false;\n\t}\n\n\t/// <summary>\n\t/// Reverts the last step. Returns it so the caller can restore the caret, or null if there's nothing to undo.\n\t/// </summary>\n\tpublic UndoGroup Undo( TextBuffer buffer )\n\t{\n\t\tif ( _undo.Count == 0 ) return null;\n\n\t\tvar group = _undo[^1];\n\t\t_undo.RemoveAt( _undo.Count - 1 );\n\n\t\tfor ( int i = group.Edits.Count - 1; i >= 0; i-- )\n\t\t{\n\t\t\tvar e = group.Edits[i];\n\t\t\tbuffer.Delete( e.Start, TextBuffer.EndOf( e.Start, e.Inserted ) );\n\t\t\tbuffer.Insert( e.Start, e.Removed );\n\t\t}\n\n\t\t_redo.Add( group );\n\t\t_breakNext = true;\n\t\treturn group;\n\t}\n\n\tpublic UndoGroup Redo( TextBuffer buffer )\n\t{\n\t\tif ( _redo.Count == 0 ) return null;\n\n\t\tvar group = _redo[^1];\n\t\t_redo.RemoveAt( _redo.Count - 1 );\n\n\t\tforeach ( var e in group.Edits )\n\t\t{\n\t\t\tbuffer.Delete( e.Start, TextBuffer.EndOf( e.Start, e.Removed ) );\n\t\t\tbuffer.Insert( e.Start, e.Inserted );\n\t\t}\n\n\t\t_undo.Add( group );\n\t\t_breakNext = true;\n\t\treturn group;\n\t}\n}\n"
},
{
"Ident": "chrinteractive.hotcode",
"Path": "Editor/HotCodeEditor/Navigation/XmlDocs.cs",
"FileName": "XmlDocs.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 399294,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\nusing System.Globalization;\nusing System.IO;\nusing System.Threading;\nusing System.Xml;\nusing System.Xml.Linq;\nusing Microsoft.CodeAnalysis;\n\n/// <summary>\n/// Hands Roslyn the doc comments from a compiler-generated XML doc file (Foo.xml next to Foo.dll).\n/// Roslyn's own XmlDocumentationProvider is in an assembly HotCode doesn't reference, and this is all it does.\n/// The file is read on first use, not when the reference is created.\n/// </summary>\npublic sealed class XmlDocs : DocumentationProvider\n{\n\tprivate readonly string _path;\n\tprivate readonly string _text;\n\tprivate Dictionary<string, string> _members;\n\n\tprivate XmlDocs( string path, string text )\n\t{\n\t\t_path = path;\n\t\t_text = text;\n\t}\n\n\tpublic static XmlDocs FromFile( string path ) => new( path, null );\n\tpublic static XmlDocs FromText( string xml ) => new( null, xml );\n\n\tprotected override string GetDocumentationForSymbol( string documentationMemberID, CultureInfo preferredCulture, CancellationToken cancellationToken = default )\n\t{\n\t\tvar members = LazyInitializer.EnsureInitialized( ref _members, Load );\n\t\treturn members.TryGetValue( documentationMemberID, out var xml ) ? xml : \"\";\n\t}\n\n\t/// <summary>\n\t/// Member id (\"M:Sandbox.GameObject.Destroy\") -> its <member> element as XML.\n\t/// </summary>\n\tprivate Dictionary<string, string> Load()\n\t{\n\t\tvar members = new Dictionary<string, string>( StringComparer.Ordinal );\n\n\t\ttry\n\t\t{\n\t\t\tvar doc = _text is not null ? XDocument.Parse( _text ) : XDocument.Load( _path );\n\t\t\tforeach ( var member in doc.Descendants( \"member\" ) )\n\t\t\t{\n\t\t\t\tvar name = (string)member.Attribute( \"name\" );\n\t\t\t\tif ( !string.IsNullOrEmpty( name ) ) members[name] = member.ToString( SaveOptions.DisableFormatting );\n\t\t\t}\n\t\t}\n\t\tcatch ( Exception e ) when ( e is XmlException or IOException or UnauthorizedAccessException )\n\t\t{\n\t\t\t// No docs is fine; hints just show without them\n\t\t}\n\n\t\treturn members;\n\t}\n\n\tpublic override bool Equals( object obj ) => obj is XmlDocs other && other._path == _path && ReferenceEquals( other._text, _text );\n\tpublic override int GetHashCode() => HashCode.Combine( _path, _text );\n}\n"
}
]
}