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.
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 );
}