Editor/HotCodeEditor/Output/OutputPanel.cs
using Sandbox;
using Editor;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using Microsoft.CodeAnalysis;
/// <summary>
/// A compile error or warning, flattened out of Roslyn's Diagnostic.
/// Line and Column are zero-based.
/// </summary>
public record ProblemItem( DiagnosticSeverity Severity, string Id, string Message, string Path, int Line, int Column, int EndLine, int EndColumn );
/// <summary>
/// The bottom panel: "Problems" (compile errors/warnings for this project) and "Output" (the s&box log).
/// Watches the project's compile group, so it updates every time a save triggers a hotload.
/// </summary>
public class OutputPanel : Widget
{
private readonly string _root;
private readonly PanelTabBar _tabs;
private readonly ProblemsList _problems;
private readonly OutputLog _output;
private readonly Widget _outputTools;
private bool _wasBuilding;
private DateTime _buildStarted;
/// <summary>
/// Problems were refreshed after a compile. HotCode uses it to underline errors in open files.
/// </summary>
public Action<IReadOnlyList<ProblemItem>> ProblemsChanged;
/// <summary>
/// A problem was clicked.
/// </summary>
public Action<ProblemItem> ProblemActivated;
public IReadOnlyList<ProblemItem> Problems => _problems.Items;
/// <summary>
/// Build state for the status bar.
/// </summary>
public bool IsBuilding => _wasBuilding;
public int ErrorCount { get; private set; }
public int WarningCount { get; private set; }
/// <summary>
/// False when the last build failed, null before any build has been seen.
/// </summary>
public bool? LastBuildSucceeded { get; private set; }
public OutputPanel( Widget parent, string root ) : base( parent )
{
_root = root;
MinimumHeight = 80;
Layout = Layout.Column();
OnPaintOverride = () =>
{
Paint.ClearPen();
Paint.SetBrush( Theme.TabBarBackground );
Paint.DrawRect( LocalRect );
return true;
};
// Header: [Problems 3] [Output] [errors only] [clear]
var header = Layout.AddRow();
header.Spacing = 2;
_tabs = header.Add( new PanelTabBar( this ), 1 );
_tabs.AddTab( "problems", "Problems", "error_outline" );
_tabs.AddTab( "output", "Output", "terminal" );
_tabs.OnSelected = key => ShowPage( problems: key == "problems" );
_outputTools = header.Add( new Widget( this ) );
_outputTools.Layout = Layout.Row();
_outputTools.Layout.Spacing = 2;
_outputTools.Layout.Margin = new Sandbox.UI.Margin( 0, 0, 4, 0 );
var errorsOnly = _outputTools.Layout.Add( new IconButton( "filter_alt" ) { ToolTip = "Only show warnings and errors", IsToggle = true } );
var clear = _outputTools.Layout.Add( new IconButton( "delete_sweep" ) { ToolTip = "Clear" } );
Layout.AddSeparator( 1, Theme.Border );
// Pages
_problems = Layout.Add( new ProblemsList( this ), 1 );
_problems.ItemActivated = item => ProblemActivated?.Invoke( item );
_output = Layout.Add( new OutputLog( this ), 1 );
errorsOnly.OnToggled = on => _output.WarningsOnly = on;
clear.OnClick = _output.Clear;
ShowPage( problems: true );
// Pick up whatever the last compile left behind
RefreshProblems( announce: false );
}
private void ShowPage( bool problems )
{
_problems.Visible = problems;
_output.Visible = !problems;
_outputTools.Visible = !problems;
_tabs.Select( problems ? "problems" : "output" );
}
public void ShowProblems()
{
Visible = true;
ShowPage( problems: true );
}
public void ShowOutput()
{
Visible = true;
ShowPage( problems: false );
}
[EditorEvent.Frame]
public void Frame()
{
_output.DrainQueue();
var group = Project.CompileGroup;
if ( group is null ) return;
var building = group.IsBuilding;
if ( building && !_wasBuilding )
{
_buildStarted = DateTime.Now;
_output.AddLine( LogLevel.Info, "HotCode", $"Compiling {group.Name}..." );
}
else if ( !building && _wasBuilding )
{
RefreshProblems( announce: true );
}
_wasBuilding = building;
}
public void RefreshProblems( bool announce )
{
var group = Project.CompileGroup;
if ( group is null ) return;
var items = new List<ProblemItem>();
var failed = new List<string>();
foreach ( var compiler in group.Compilers )
{
if ( compiler.Output?.Successful == false ) failed.Add( compiler.Name );
if ( compiler.Diagnostics is null ) continue;
foreach ( var d in compiler.Diagnostics )
{
if ( d.Severity < DiagnosticSeverity.Warning ) continue;
var span = d.Location.GetLineSpan();
if ( string.IsNullOrEmpty( span.Path ) || !ProjectFiles.IsUnder( span.Path, _root ) ) continue;
items.Add( new ProblemItem( d.Severity, d.Id, d.GetMessage(), span.Path,
span.StartLinePosition.Line, span.StartLinePosition.Character,
span.EndLinePosition.Line, span.EndLinePosition.Character ) );
}
}
// Errors first, then by file and line; drop duplicates (the same file can be in several compilers)
items = items
.DistinctBy( x => (x.Path, x.Line, x.Column, x.Id) )
.OrderByDescending( x => x.Severity )
.ThenBy( x => x.Path, StringComparer.OrdinalIgnoreCase )
.ThenBy( x => x.Line )
.ToList();
_problems.SetItems( items, _root );
var errors = items.Count( x => x.Severity == DiagnosticSeverity.Error );
var warnings = items.Count - errors;
ErrorCount = errors;
WarningCount = warnings;
if ( announce || failed.Count > 0 || errors > 0 ) LastBuildSucceeded = errors == 0 && failed.Count == 0;
_tabs.SetBadge( "problems", items.Count == 0 ? null : items.Count.ToString(), errors > 0 ? Theme.Red : Theme.Yellow );
if ( announce )
{
var seconds = (DateTime.Now - _buildStarted).TotalSeconds;
if ( errors > 0 || failed.Count > 0 )
_output.AddLine( LogLevel.Error, "HotCode", $"Build failed: {errors} error(s), {warnings} warning(s) ({seconds:0.0}s)" );
else
_output.AddLine( LogLevel.Info, "HotCode", $"Build succeeded{(warnings > 0 ? $" with {warnings} warning(s)" : "")} ({seconds:0.0}s)" );
// Jump to the errors when a build breaks
if ( errors > 0 ) ShowPage( problems: true );
}
ProblemsChanged?.Invoke( items );
}
}
/// <summary>
/// Log view with a console command box underneath. Messages can arrive on any thread,
/// so they're queued and drawn on the next frame.
/// </summary>
public class OutputLog : Widget
{
const int MaxEntries = 5000;
private record struct Entry( LogLevel Level, string Logger, string Message, DateTime Time );
private readonly TextEdit _text;
private readonly List<Entry> _entries = new();
private readonly ConcurrentQueue<Entry> _incoming = new();
private readonly Action<LogEvent> _logger;
public OutputLog( Widget parent ) : base( parent )
{
Layout = Layout.Column();
_text = Layout.Add( new TextEdit( this ), 1 );
_text.ReadOnly = true;
_text.MaximumBlockCount = MaxEntries;
_text.SetStyles( $"font-family: '{Theme.MonospaceFont}'; font-size: 12px; background-color: {Theme.TabBackground.Hex}; border: none;" );
// > [command.................]
var inputRow = Layout.AddRow();
inputRow.Margin = 2;
inputRow.Spacing = 4;
var prompt = inputRow.Add( new Label( ">", this ) );
prompt.SetStyles( $"font-family: '{Theme.MonospaceFont}'; color: #569cd6; padding-left: 4px;" );
var input = inputRow.Add( new ConsoleInput( this ), 1 );
input.CommandEntered = RunCommand;
_logger = e => _incoming.Enqueue( new Entry( e.Level, e.Logger, e.Message, e.Time ) );
EditorUtility.AddLogger( _logger );
}
private void RunCommand( string command )
{
AddLine( LogLevel.Info, "console", $"> {command}" );
try
{
Editor.ConsoleSystem.Run( command );
}
catch ( Exception e )
{
AddLine( LogLevel.Error, "console", e.Message );
}
}
public override void OnDestroyed()
{
EditorUtility.RemoveLogger( _logger );
base.OnDestroyed();
}
private bool _warningsOnly;
public bool WarningsOnly
{
get => _warningsOnly;
set
{
_warningsOnly = value;
Rebuild();
}
}
public void AddLine( LogLevel level, string logger, string message ) => _incoming.Enqueue( new Entry( level, logger, message, DateTime.Now ) );
public void Clear()
{
_entries.Clear();
_text.Clear();
}
public void DrainQueue()
{
if ( _incoming.IsEmpty ) return;
while ( _incoming.TryDequeue( out var e ) )
{
_entries.Add( e );
if ( Passes( e ) ) Append( e );
}
if ( _entries.Count > MaxEntries ) _entries.RemoveRange( 0, _entries.Count - MaxEntries );
_text.ScrollToBottom();
}
private bool Passes( Entry e ) => !_warningsOnly || e.Level >= LogLevel.Warn;
private void Rebuild()
{
_text.Clear();
foreach ( var e in _entries.Where( Passes ) ) Append( e );
_text.ScrollToBottom();
}
private void Append( Entry e )
{
var color = e.Level switch
{
LogLevel.Error => "#f48771",
LogLevel.Warn => "#cca700",
LogLevel.Trace => "#808080",
_ => "#cccccc"
};
var message = System.Net.WebUtility.HtmlEncode( e.Message ?? "" ).Replace( "\n", "<br>" );
var logger = System.Net.WebUtility.HtmlEncode( e.Logger ?? "" );
_text.AppendHtml( $"<span style=\"color:#6e7681\">{e.Time:HH:mm:ss}</span> <span style=\"color:#569cd6\">[{logger}]</span> <span style=\"color:{color}\">{message}</span>" );
}
}
/// <summary>
/// The list of problems. Click one to jump to it.
/// </summary>
public class ProblemsList : ScrollArea
{
private readonly ProblemsCanvas _canvas;
public Action<ProblemItem> ItemActivated;
public IReadOnlyList<ProblemItem> Items => _canvas.Items;
public ProblemsList( Widget parent ) : base( parent )
{
_canvas = new ProblemsCanvas( this );
Canvas = _canvas;
}
public void SetItems( List<ProblemItem> items, string root ) => _canvas.SetItems( items, root );
internal void Activate( ProblemItem item ) => ItemActivated?.Invoke( item );
protected override void OnResize()
{
base.OnResize();
_canvas.UpdateSize();
}
internal class ProblemsCanvas : Widget
{
const float RowHeight = 22;
private readonly ProblemsList _list;
private List<ProblemItem> _items = new();
private string _root = "";
private int _hovered = -1;
public IReadOnlyList<ProblemItem> Items => _items;
public ProblemsCanvas( ProblemsList list ) : base( list )
{
_list = list;
MouseTracking = true;
Cursor = CursorShape.Finger;
}
public void SetItems( List<ProblemItem> items, string root )
{
_items = items;
_root = root;
_hovered = -1;
UpdateSize();
Update();
}
public void UpdateSize()
{
var height = MathF.Max( _items.Count * RowHeight + 4, _list.Height - 2 );
MinimumSize = new Vector2( _list.Width - 16, height );
Size = MinimumSize;
}
private int RowAt( Vector2 pos )
{
var i = (int)(pos.y / RowHeight);
return i >= 0 && i < _items.Count ? i : -1;
}
protected override void OnMouseMove( MouseEvent e )
{
base.OnMouseMove( e );
var row = RowAt( e.LocalPosition );
if ( row == _hovered ) return;
_hovered = row;
ToolTip = row >= 0 ? _items[row].Message : null;
Update();
}
protected override void OnMouseLeave()
{
base.OnMouseLeave();
_hovered = -1;
Update();
}
protected override void OnMousePress( MouseEvent e )
{
base.OnMousePress( e );
var row = RowAt( e.LocalPosition );
if ( e.LeftMouseButton && row >= 0 ) _list.Activate( _items[row] );
}
protected override void OnPaint()
{
Paint.ClearPen();
Paint.SetBrush( Theme.TabBackground );
Paint.DrawRect( LocalRect );
if ( _items.Count == 0 )
{
Paint.SetDefaultFont( HotCodeStyle.FontSize );
Paint.SetPen( Theme.TextLight );
Paint.DrawText( new Rect( 10, 0, Width, RowHeight ), "No problems found.", TextFlag.LeftCenter );
return;
}
for ( int i = 0; i < _items.Count; i++ )
{
var item = _items[i];
var rect = new Rect( 0, i * RowHeight, Width, RowHeight );
if ( i == _hovered )
{
Paint.ClearPen();
Paint.SetBrush( Theme.SurfaceLightBackground );
Paint.DrawRect( rect );
}
var isError = item.Severity == DiagnosticSeverity.Error;
Paint.SetPen( isError ? Theme.Red : Theme.Yellow );
Paint.DrawIcon( new Rect( rect.Left + 6, rect.Top, 18, RowHeight ), isError ? "error" : "warning", 14, TextFlag.Center );
// "Player.cs(12,5) CS0103"
var location = $"{System.IO.Path.GetRelativePath( _root, item.Path )}({item.Line + 1},{item.Column + 1}) {item.Id}";
Paint.SetDefaultFont( 8 );
var locationWidth = MathF.Min( Paint.MeasureText( location ).x + 16, Width * 0.45f );
var locationRect = new Rect( rect.Right - locationWidth, rect.Top, locationWidth - 8, RowHeight );
Paint.SetPen( Theme.Text );
var messageRect = new Rect( rect.Left + 30, rect.Top, locationRect.Left - rect.Left - 38, RowHeight );
Paint.DrawText( messageRect, Paint.GetElidedText( item.Message, messageRect.Width, ElideMode.Right ), TextFlag.LeftCenter );
Paint.SetPen( Theme.TextLight );
Paint.DrawText( locationRect, Paint.GetElidedText( location, locationRect.Width, ElideMode.Left ), TextFlag.RightCenter );
}
}
}
}