Editor/Prism/Ui/StatusStrip.cs

A UI widget for the editor status bar. Shows compile state (including an animated compiling indicator), error/warning counts, instruction/temp/global counts, shader target, Slang toolchain status, and an unsaved marker, and raises callbacks when Diagnostics or Slang chips are clicked.

Native Interop
using Editor.Prism.Compiler;
using Editor.Prism.Core;
using Editor.Prism.Toolchain;

namespace Editor.Prism.Ui;

/// <summary>
/// The window's status line: compile state, timings, graph and instruction counts, the compile target,
/// the Slang toolchain state and the unsaved marker.
/// <para>
/// Two of these chips are clickable, because a status bar that reports a problem and then makes you go
/// and find it is only half a status bar: the compile chip raises the Diagnostics dock, and the Slang
/// chip opens the toolchain installer when nothing is installed.
/// </para>
/// </summary>
public sealed class StatusStrip : Widget
{
	const float BarHeight = 24f;
	const float Gap = 10f;

	readonly List<Chip> _chips = new();

	PrismSession _session;
	CompileResult _result;
	bool _compiling;
	double _elapsedMs;
	int _hovered = -1;
	RealTimeSince _sinceStart;

	/// <summary>Build a status strip for a session. Survives a null session.</summary>
	public StatusStrip( PrismSession session, Widget parent = null ) : base( parent )
	{
		FixedHeight = BarHeight;
		MinimumWidth = 320f;
		MouseTracking = true;

		Bind( session );
	}

	/// <summary>Raised when the user clicks the compile chip.</summary>
	public Action DiagnosticsRequested { get; set; }

	/// <summary>Raised when the user clicks the Slang chip.</summary>
	public Action SlangRequested { get; set; }

	/// <summary>Point the strip at a session.</summary>
	public void Bind( PrismSession session )
	{
		Unhook();

		_session = session;

		if ( _session is not null )
		{
			_session.CompileStarted += OnCompileStarted;
			_session.Compiled += OnCompiled;
			_session.DirtyChanged += OnDirtyChanged;
			_session.DocumentReplaced += OnDirtyChanged;

			_result = _session.LastCompile;
		}
		else
		{
			_result = null;
		}

		_compiling = false;

		Update();
	}

	void Unhook()
	{
		if ( _session is null ) return;

		_session.CompileStarted -= OnCompileStarted;
		_session.Compiled -= OnCompiled;
		_session.DirtyChanged -= OnDirtyChanged;
		_session.DocumentReplaced -= OnDirtyChanged;
		_session = null;
	}

	void OnCompileStarted()
	{
		_compiling = true;
		_sinceStart = 0f;

		Update();
	}

	void OnCompiled( CompileResult result )
	{
		_compiling = false;
		_result = result;
		_elapsedMs = _session?.Compiler?.LastCompileMs ?? result?.Stats?.TotalMs ?? 0d;

		Update();
	}

	void OnDirtyChanged() => Update();

	/// <summary>
	/// The animated ellipsis needs a tick, and only while something is actually compiling — plus one
	/// reconcile against the service, which is the authority.
	/// <para>
	/// <c>Started</c> and <c>Completed</c> are not a matched pair: a compile cancelled after it started
	/// throws before it reaches <c>Publish</c>, so no <c>Completed</c> arrives and a spinner driven by
	/// the events alone runs for the rest of the session. Half a second of grace keeps this from
	/// fighting the normal case, where <c>IsCompiling</c> is briefly false between the event and the
	/// work starting.
	/// </para>
	/// </summary>
	[EditorEvent.Frame]
	public void OnStatusFrame()
	{
		if ( !this.IsValid() ) return;

		if ( _compiling && _sinceStart > 0.5f && _session?.Compiler is { IsCompiling: false } )
		{
			_compiling = false;

			Update();
			return;
		}

		if ( !_compiling ) return;

		SetContentHash( (int)( _sinceStart * 3f ), 0.1f );
	}

	// ---------------------------------------------------------------- painting ----

	/// <inheritdoc/>
	protected override void OnPaint()
	{
		var rect = LocalRect;

		Paint.Antialiasing = true;
		Paint.SetBrushAndPen( PrismTheme.Panel );
		Paint.DrawRect( rect );

		Paint.SetPen( PrismTheme.BorderSubtle, 1f );
		Paint.DrawLine( new Vector2( rect.Left, rect.Top + 0.5f ), new Vector2( rect.Right, rect.Top + 0.5f ) );

		_chips.Clear();

		var x = rect.Left + Gap;

		x = PaintState( rect, x );
		x = PaintCounts( rect, x );
		x = PaintTarget( rect, x );

		PaintSlang( rect, x );

		PaintDirty( rect );
	}

	float PaintState( Rect rect, float x )
	{
		string text;
		Color color;
		string icon;

		if ( _compiling )
		{
			var dots = new string( '.', 1 + (int)( _sinceStart * 3f ) % 3 );

			text = $"Compiling{dots}";
			color = PrismTheme.Accent;
			icon = "sync";
		}
		else if ( _result is null )
		{
			text = "Idle";
			color = PrismTheme.TextMuted;
			icon = "radio_button_unchecked";
		}
		else if ( _result.ErrorCount > 0 )
		{
			text = _result.ErrorCount == 1 ? "1 error" : $"{_result.ErrorCount} errors";
			color = PrismTheme.Error;
			icon = PrismIcons.Error;
		}
		else if ( _result.WarningCount > 0 )
		{
			text = _result.WarningCount == 1 ? "1 warning" : $"{_result.WarningCount} warnings";
			color = PrismTheme.Warning;
			icon = PrismIcons.Warning;
		}
		else
		{
			text = _elapsedMs > 0d ? $"Compiled  {_elapsedMs:0} ms" : "Compiled";
			color = PrismTheme.Success;
			icon = "check_circle";
		}

		return PaintChip( rect, x, icon, text, color, ChipAction.Diagnostics,
			"Click to show the Diagnostics panel" );
	}

	float PaintCounts( Rect rect, float x )
	{
		var stats = _result?.Stats;
		var nodes = _session?.Graph?.Nodes?.Count ?? 0;

		var text = stats is null
			? $"{nodes} nodes"
			: $"{nodes} nodes  ·  {stats.StatementCount} instr  ·  {stats.TempCount} temps";

		return PaintChip( rect, x, null, text, PrismTheme.TextSecondary, ChipAction.None,
			stats is null
				? "Nothing has been compiled yet"
				: $"{stats.GlobalCount} globals · {stats.VaryingCount} varyings · " +
				  $"{stats.HelperCount} helpers · {stats.OptimizedAway} expressions folded away" );
	}

	float PaintTarget( Rect rect, float x )
	{
		var model = ShaderModel.Target;

		return PaintChip( rect, x, "memory", $"SM {model} · Vulkan", PrismTheme.TextSecondary, ChipAction.None,
			"s&box compiles every shader at Shader Model 6.0 targeting SPIR-V 1.5" );
	}

	void PaintSlang( Rect rect, float x )
	{
		var available = PrismLog.Guard( "Read the Slang toolchain", () => SlangToolchain.IsAvailable, false );
		var version = available
			? PrismLog.Guard<string>( "Read the Slang version", () => SlangToolchain.Version )
			: null;

		var text = available
			? string.IsNullOrWhiteSpace( version ) ? "Slang: ok" : $"Slang: ok {version}"
			: "Slang: not validated";

		PaintChip( rect, x, "verified", text,
			available ? PrismTheme.Success : PrismTheme.TextDisabled,
			ChipAction.Slang,
			available
				? "The Slang toolchain is installed and validating generated modules"
				: "No Slang toolchain found. Generation still works; only the second-opinion validation " +
				  "is unavailable. Click to install." );
	}

	void PaintDirty( Rect rect )
	{
		if ( _session is null || !_session.IsDirty ) return;

		var text = "Unsaved changes";
		var width = PrismPaint.MeasureText( text, PrismTheme.PortLabelSize, PrismTheme.InlineValueWeight );
		var slot = new Rect( rect.Right - width - Gap - 14f, rect.Top, width + 14f, rect.Height );

		PrismPaint.Dot( new Vector2( slot.Left + 4f, rect.Center.y ), 3f, PrismTheme.Warning );

		PrismPaint.Text( new Rect( slot.Left + 12f, rect.Top, width + 2f, rect.Height ), text,
			PrismTheme.Warning, PrismTheme.PortLabelSize, PrismTheme.InlineValueWeight );
	}

	float PaintChip( Rect rect, float x, string icon, string text, Color color, ChipAction action, string tooltip )
	{
		var hasIcon = !string.IsNullOrEmpty( icon );
		var textWidth = PrismPaint.MeasureText( text, PrismTheme.PortLabelSize, PrismTheme.InlineValueWeight );
		var width = textWidth + ( hasIcon ? 20f : 0f ) + 12f;
		var slot = new Rect( x, rect.Top + 3f, width, rect.Height - 6f );

		var index = _chips.Count;
		var interactive = action != ChipAction.None;

		if ( interactive && _hovered == index )
		{
			Paint.SetBrushAndPen( PrismTheme.BorderSubtle );
			Paint.DrawRect( slot, PrismTheme.RadiusChip );
		}

		var textLeft = slot.Left + 6f;

		if ( hasIcon )
		{
			Paint.SetPen( color );
			Paint.DrawIcon( new Rect( textLeft, slot.Top, 16f, slot.Height ), icon, 13f );

			textLeft += 20f;
		}

		PrismPaint.Text( new Rect( textLeft, slot.Top, textWidth + 2f, slot.Height ), text, color,
			PrismTheme.PortLabelSize, PrismTheme.InlineValueWeight );

		_chips.Add( new Chip( slot, action, tooltip ) );

		return x + width + Gap;
	}

	// ---------------------------------------------------------------- interaction ----

	/// <inheritdoc/>
	protected override void OnMouseMove( MouseEvent e )
	{
		base.OnMouseMove( e );

		var index = IndexAt( e.LocalPosition );

		if ( index == _hovered ) return;

		_hovered = index;
		ToolTip = index >= 0 ? _chips[index].Tooltip : null;
		Cursor = index >= 0 && _chips[index].Action != ChipAction.None
			? CursorShape.Finger
			: CursorShape.Arrow;

		Update();
	}

	/// <inheritdoc/>
	protected override void OnMouseLeave()
	{
		base.OnMouseLeave();

		_hovered = -1;

		Update();
	}

	/// <inheritdoc/>
	protected override void OnMouseClick( MouseEvent e )
	{
		base.OnMouseClick( e );

		var index = IndexAt( e.LocalPosition );

		if ( index < 0 ) return;

		switch ( _chips[index].Action )
		{
			case ChipAction.Diagnostics:
				PrismLog.Guard( "Show diagnostics", () => DiagnosticsRequested?.Invoke() );
				break;

			case ChipAction.Slang:
				PrismLog.Guard( "Show the Slang toolchain", () => SlangRequested?.Invoke() );
				break;
		}
	}

	int IndexAt( Vector2 local )
	{
		for ( int i = 0; i < _chips.Count; i++ )
		{
			if ( _chips[i].Rect.IsInside( local ) ) return i;
		}

		return -1;
	}

	/// <inheritdoc/>
	public override void OnDestroyed()
	{
		Unhook();

		base.OnDestroyed();
	}

	enum ChipAction
	{
		None,
		Diagnostics,
		Slang
	}

	readonly record struct Chip( Rect Rect, ChipAction Action, string Tooltip );
}