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&amp;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 );
			}
		}
	}
}