An editor UI panel that lists compiler and graph diagnostics, groups them by severity, filters and mutes severities, allows selecting/framing nodes, copying messages, and jumping to generated code lines via the session compile artifacts and source map.
using Editor.Prism.Compiler;
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Text;
using Margin = Sandbox.UI.Margin;
using System.Text;
namespace Editor.Prism.Ui;
/// <summary>One row of the diagnostics list: a severity group header or a single diagnostic.</summary>
internal sealed class DiagnosticEntry
{
/// <summary>The severity this row belongs to.</summary>
public DiagnosticSeverity Severity { get; init; }
/// <summary>Header text when this row is a group header.</summary>
public string Header { get; init; }
/// <summary>The diagnostic this row presents.</summary>
public Diagnostic Diagnostic { get; init; }
/// <summary>Number of diagnostics under a header.</summary>
public int Count { get; init; }
/// <summary>Display name of the node this diagnostic points at, when it points at one.</summary>
public string NodeName { get; init; }
/// <summary>True when the row is a group header.</summary>
public bool IsHeader => Diagnostic is null;
/// <inheritdoc/>
public override string ToString() => Header ?? Diagnostic?.Message ?? "(row)";
}
/// <summary>
/// The Diagnostics dock: everything wrong with the graph, grouped by severity and clickable.
/// <para>
/// A single click selects and frames the node — and the port — a diagnostic names. A double click
/// jumps to the generated line that produced it, resolved through the compile's
/// <see cref="SourceMap"/>. That two-hop mapping from a compiler line back to a node is the whole
/// reason this editor exists, so it gets the primary gesture rather than a context-menu item.
/// </para>
/// </summary>
public sealed class DiagnosticsPanel : Widget
{
/// <summary>The dock name this panel registers under. Frozen.</summary>
public const string DockName = "Diagnostics";
readonly List<DiagnosticEntry> _entries = new();
readonly HashSet<DiagnosticSeverity> _muted = new();
PrismSession _session;
LineEdit _filter;
ListView _list;
PrismEmptyState _empty;
Button _errorToggle;
Button _warningToggle;
Button _infoToggle;
Label _status;
bool _rebuildQueued;
/// <summary>Build the panel. A null session is legal and shows the empty state.</summary>
public DiagnosticsPanel( PrismSession session ) : base( null )
{
Name = "PrismDiagnostics";
WindowTitle = DockName;
Layout = Layout.Column();
Layout.Margin = 0;
Layout.Spacing = 0;
BuildToolbar();
_list = new ListView( this )
{
ItemSize = new Vector2( -1, PrismPanelChrome.RowHeight ),
ItemPaint = PaintRow,
ItemClicked = OnRowClicked,
ItemActivated = OnRowActivated,
ItemContextMenu = OnRowContextMenu,
MultiSelect = false
};
Layout.Add( _list, 1 );
_empty = new PrismEmptyState( this, "check_circle", "No problems",
"The graph compiles cleanly." );
Layout.Add( _empty, 1 );
Bind( session );
}
/// <summary>Material icon shown on the dock tab.</summary>
public string DockIcon => "error_outline";
/// <summary>The session this panel is bound to. Null is legal.</summary>
public PrismSession Session => _session;
/// <summary>
/// Where a "go to the generated line" request is sent. The window points this at the Code panel;
/// when nothing sets it, the panel opens a standalone code window instead so the gesture always
/// does something.
/// </summary>
public Action<Diagnostic> CodeNavigationRequested { get; set; }
// ---------------------------------------------------------------- binding ----
void Bind( PrismSession session )
{
Unbind();
_session = session;
if ( _session is not null )
{
_session.Compiled += OnCompiled;
_session.CompileStarted += OnCompileStarted;
_session.DocumentReplaced += QueueRebuild;
}
Rebuild();
}
void Unbind()
{
if ( _session is null ) return;
_session.Compiled -= OnCompiled;
_session.CompileStarted -= OnCompileStarted;
_session.DocumentReplaced -= QueueRebuild;
_session = null;
}
/// <inheritdoc/>
public override void OnDestroyed()
{
Unbind();
base.OnDestroyed();
}
void OnCompiled( CompileResult result ) => QueueRebuild();
void OnCompileStarted()
{
if ( _status is null ) return;
_status.Text = "Compiling…";
_status.Color = PrismTheme.TextMuted;
}
void QueueRebuild()
{
if ( _rebuildQueued ) return;
_rebuildQueued = true;
MainThread.Queue( () =>
{
_rebuildQueued = false;
if ( !this.IsValid() ) return;
Rebuild();
} );
}
// ---------------------------------------------------------------- toolbar ----
void BuildToolbar()
{
var bar = new Widget( this );
bar.Layout = Layout.Row();
bar.Layout.Margin = new Margin( 6, 6, 6, 4 );
bar.Layout.Spacing = 4;
_errorToggle = CreateToggle( bar, PrismIcons.Error, PrismTheme.Error, DiagnosticSeverity.Error );
_warningToggle = CreateToggle( bar, PrismIcons.Warning, PrismTheme.Warning, DiagnosticSeverity.Warning );
_infoToggle = CreateToggle( bar, PrismIcons.Info, PrismTheme.Info, DiagnosticSeverity.Info );
_filter = PrismPanelChrome.CreateSearchField( bar, "Filter problems" );
_filter.TextEdited += _ => Rebuild();
bar.Layout.Add( _filter, 1 );
var copy = new IconButton( PrismIcons.Copy, CopyAll, bar )
{
ToolTip = "Copy every problem to the clipboard",
FixedWidth = 24f,
FixedHeight = 24f
};
bar.Layout.Add( copy );
Layout.Add( bar );
_status = new Label( "Idle" ) { Color = PrismTheme.TextMuted };
_status.ContentMargins = new Margin( 8, 0, 8, 4 );
Layout.Add( _status );
}
Button CreateToggle( Widget parent, string icon, Color tint, DiagnosticSeverity severity )
{
var button = new Button( "0", icon, parent )
{
IsToggle = true,
IsChecked = true,
Tint = tint.WithAlpha( 0.28f ),
FixedHeight = 24f,
ToolTip = $"Show {severity.ToString().ToLowerInvariant()}s"
};
button.Toggled = () =>
{
if ( button.IsChecked ) _muted.Remove( severity );
else _muted.Add( severity );
Rebuild();
};
parent.Layout.Add( button );
return button;
}
/// <summary>Move keyboard focus to the filter field.</summary>
[Shortcut( "prism.diagnostics.search", "CTRL+F" )]
public void FocusSearch()
{
_filter?.Focus();
_filter?.SelectAll();
}
// ---------------------------------------------------------------- model ----
void Rebuild()
{
_entries.Clear();
var all = _session?.Diagnostics ?? Array.Empty<Diagnostic>();
var graph = _session?.Graph;
var filter = _filter?.Text?.Trim() ?? string.Empty;
var errors = all.Count( x => x.Severity == DiagnosticSeverity.Error );
var warnings = all.Count( x => x.Severity == DiagnosticSeverity.Warning );
var infos = all.Count( x => x.Severity == DiagnosticSeverity.Info );
if ( _errorToggle is not null ) _errorToggle.Text = errors.ToString();
if ( _warningToggle is not null ) _warningToggle.Text = warnings.ToString();
if ( _infoToggle is not null ) _infoToggle.Text = infos.ToString();
foreach ( var severity in s_order )
{
if ( _muted.Contains( severity ) ) continue;
var matching = all
.Where( x => x.Severity == severity )
.Where( x => Matches( x, filter ) )
.ToList();
if ( matching.Count == 0 ) continue;
_entries.Add( new DiagnosticEntry
{
Severity = severity,
Header = Plural( severity, matching.Count ),
Count = matching.Count
} );
foreach ( var diagnostic in matching )
{
_entries.Add( new DiagnosticEntry
{
Severity = severity,
Diagnostic = diagnostic,
NodeName = NameOf( graph, diagnostic )
} );
}
}
_empty.Visible = _entries.Count == 0;
_list.Visible = _entries.Count > 0;
if ( _entries.Count == 0 ) SetEmptyState( all.Count );
_list.SetItems( _entries );
UpdateStatus( errors, warnings, infos );
}
/// <summary>
/// Say the right thing when the list is empty, and offer the one action that resolves it.
/// <para>
/// "No problems" is a lie before the first compile — the graph has not been checked, it has merely
/// not been checked <em>yet</em> — and "nothing shown" without a way to unhide is a dead end. The
/// three states are distinguished so the panel is never quietly reassuring about something it does
/// not know.
/// </para>
/// </summary>
void SetEmptyState( int total )
{
if ( _empty is null ) return;
if ( _session is null )
{
_empty.Set( "No document", "Open a graph to see its problems." );
SetEmptyAction( null, null );
return;
}
if ( total > 0 )
{
_empty.Set( "Nothing shown", "Every problem is filtered out by the toggles above." );
SetEmptyAction( "Show everything", ShowAllSeverities );
return;
}
if ( _session.LastCompile is null )
{
_empty.Set( "Not compiled yet", "Compile the graph to hear what the shader compiler says." );
SetEmptyAction( "Compile now", () => _session?.RequestCompile( CompileMode.Preview ) );
return;
}
_empty.Set( "No problems", "The graph compiles cleanly." );
SetEmptyAction( null, null );
}
void SetEmptyAction( string text, Action action )
{
_empty.ActionText = text;
_empty.Action = action;
_empty.Update();
}
/// <summary>Un-mute every severity and put the toolbar toggles back in step.</summary>
public void ShowAllSeverities()
{
if ( _muted.Count == 0 ) return;
_muted.Clear();
if ( _errorToggle is not null ) _errorToggle.IsChecked = true;
if ( _warningToggle is not null ) _warningToggle.IsChecked = true;
if ( _infoToggle is not null ) _infoToggle.IsChecked = true;
Rebuild();
}
void UpdateStatus( int errors, int warnings, int infos )
{
if ( _status is null ) return;
var result = _session?.LastCompile;
if ( result is null )
{
_status.Text = _session is null ? "No document" : "Not compiled yet";
_status.Color = PrismTheme.TextMuted;
return;
}
var parts = new List<string>();
if ( errors > 0 ) parts.Add( errors == 1 ? "1 error" : $"{errors} errors" );
if ( warnings > 0 ) parts.Add( warnings == 1 ? "1 warning" : $"{warnings} warnings" );
if ( infos > 0 ) parts.Add( infos == 1 ? "1 note" : $"{infos} notes" );
if ( parts.Count == 0 ) parts.Add( "no problems" );
var stats = result.Stats;
_status.Text = stats is null
? string.Join( " · ", parts )
: $"{string.Join( " · ", parts )} · {stats.NodeCount} nodes · {stats.StatementCount} statements · {stats.TotalMs:0} ms";
_status.Color = errors > 0 ? PrismTheme.Error : warnings > 0 ? PrismTheme.Warning : PrismTheme.Success;
}
static string Plural( DiagnosticSeverity severity, int count ) => severity switch
{
DiagnosticSeverity.Error => count == 1 ? "Error" : "Errors",
DiagnosticSeverity.Warning => count == 1 ? "Warning" : "Warnings",
_ => count == 1 ? "Note" : "Notes"
};
static bool Matches( Diagnostic diagnostic, string filter )
{
if ( string.IsNullOrEmpty( filter ) ) return true;
return ( diagnostic.Message?.Contains( filter, StringComparison.OrdinalIgnoreCase ) ?? false )
|| ( diagnostic.Code?.Contains( filter, StringComparison.OrdinalIgnoreCase ) ?? false )
|| ( diagnostic.Detail?.Contains( filter, StringComparison.OrdinalIgnoreCase ) ?? false );
}
static string NameOf( PrismGraph graph, Diagnostic diagnostic )
{
if ( graph is null || diagnostic.Graph is not { } reference || !reference.Node.IsValid ) return null;
var node = graph.FindNode( reference.Node );
if ( node is null ) return null;
var title = node.Descriptor?.Title ?? node.GetType().Name;
return reference.Port is { } port ? $"{title}.{port.Value}" : title;
}
// ---------------------------------------------------------------- painting ----
void PaintRow( VirtualWidget item )
{
if ( item.Object is not DiagnosticEntry entry ) return;
var rect = item.Rect;
if ( entry.IsHeader )
{
PrismPanelChrome.PaintSectionHeader( rect, entry.Header, entry.Count.ToString() );
return;
}
var index = _entries.IndexOf( entry );
PrismPanelChrome.PaintRow( rect, index, item.Hovered, item.Selected );
var color = PrismTheme.ForSeverity( entry.Severity );
var inner = rect.Shrink( PrismPanelChrome.Pad, 0f, PrismPanelChrome.Pad, 0f );
Paint.SetPen( color );
Paint.DrawIcon( new Rect( inner.Left, inner.Top, 16f, inner.Height ),
IconFor( entry.Severity ), 13f, TextFlag.Center );
var left = inner.Left + 20f;
if ( !string.IsNullOrEmpty( entry.Diagnostic.Code ) && inner.Width > 260f )
{
Paint.SetFont( PrismTheme.FontFamily, 10, 500, false, true );
Paint.SetPen( PrismTheme.TextDisabled );
Paint.DrawText( new Rect( left, inner.Top, 44f, inner.Height ), entry.Diagnostic.Code,
TextFlag.LeftCenter | TextFlag.SingleLine );
left += 48f;
}
var right = inner.Right;
var location = LocationOf( entry );
if ( !string.IsNullOrEmpty( location ) && inner.Width > 220f )
{
Paint.SetFont( PrismTheme.FontFamily, 10, 400, false, true );
var width = MathF.Min( 170f, Paint.MeasureText( location ).x + 4f );
Paint.SetPen( PrismTheme.TextMuted );
Paint.DrawText( new Rect( right - width, inner.Top, width, inner.Height ),
Paint.GetElidedText( location, width, ElideMode.Left, TextFlag.RightCenter ),
TextFlag.RightCenter | TextFlag.SingleLine );
right -= width + 8f;
}
PrismPaint.Text( new Rect( left, inner.Top, MathF.Max( 20f, right - left ), inner.Height ),
entry.Diagnostic.Message, item.Selected ? PrismTheme.TextPrimary : PrismTheme.TextSecondary,
PrismTheme.BodySize, 400 );
}
static string LocationOf( DiagnosticEntry entry )
{
if ( !string.IsNullOrEmpty( entry.NodeName ) ) return entry.NodeName;
var span = entry.Diagnostic.Span;
return span is { IsValid: true } ? $"line {span.Value.Line}" : null;
}
static string IconFor( DiagnosticSeverity severity ) => severity switch
{
DiagnosticSeverity.Error => PrismIcons.Error,
DiagnosticSeverity.Warning => PrismIcons.Warning,
_ => PrismIcons.Info
};
/// <inheritdoc/>
protected override void OnPaint()
{
Paint.ClearPen();
Paint.SetBrush( PrismTheme.Panel );
Paint.DrawRect( LocalRect );
}
// ---------------------------------------------------------------- interaction ----
void OnRowClicked( object item )
{
if ( item is not DiagnosticEntry entry || entry.IsHeader ) return;
Focus( entry.Diagnostic );
}
void OnRowActivated( object item )
{
if ( item is not DiagnosticEntry entry || entry.IsHeader ) return;
Focus( entry.Diagnostic );
JumpToCode( entry.Diagnostic );
}
void OnRowContextMenu( object item )
{
var menu = new Menu( this );
if ( item is DiagnosticEntry entry && !entry.IsHeader )
{
menu.AddOption( "Select Node", PrismIcons.Frame, () => Focus( entry.Diagnostic ) );
menu.AddOption( "Go To Generated Line", PrismIcons.Code, () => JumpToCode( entry.Diagnostic ) );
menu.AddOption( "Copy Message", PrismIcons.Copy, () => EditorUtility.Clipboard.Copy( Format( entry.Diagnostic ) ) );
menu.AddSeparator();
}
menu.AddOption( "Copy All", PrismIcons.Copy, CopyAll );
menu.AddOption( "Recompile", "refresh", () => _session?.RequestCompile( CompileMode.Preview ) );
menu.OpenAtCursor( false );
}
void Focus( Diagnostic diagnostic )
{
if ( _session is null || diagnostic.Graph is not { } reference || !reference.Node.IsValid ) return;
_session.SelectNode( reference.Node );
_session.RequestFocus( reference.Node, reference.Port ?? default );
}
/// <summary>
/// Jump to the generated line a diagnostic came from. A compiler diagnostic already carries the
/// line; a graph diagnostic is resolved through the compile's source map, which is what turns
/// "this node is wrong" into "this is the line it produced".
/// </summary>
void JumpToCode( Diagnostic diagnostic )
{
if ( CodeNavigationRequested is not null )
{
PrismLog.Guard( "Diagnostics: go to code", () => CodeNavigationRequested( diagnostic ) );
return;
}
var result = _session?.LastCompile;
var artifact = result?.Artifact( PrismConstants.BackendHlsl );
if ( artifact is null || string.IsNullOrEmpty( artifact.Text ) ) return;
var line = diagnostic.Span is { IsValid: true } span ? span.Line : 0;
if ( line <= 0 && diagnostic.Graph is { } reference && artifact.SourceMap is not null &&
artifact.SourceMap.TryGetLines( reference.Node, out var range ) )
{
line = range.Start;
}
PrismLog.Guard( "Diagnostics: open generated code", () =>
{
var window = CodeWindow.Open();
if ( window is null ) return;
var tab = window.OpenText( "Generated", artifact.Text, "vfx", true );
if ( tab?.Editor is not null && line > 0 ) tab.Editor.GoToLine( line );
} );
}
void CopyAll()
{
var all = _session?.Diagnostics;
if ( all is null || all.Count == 0 ) return;
var sb = new StringBuilder();
foreach ( var diagnostic in all )
{
sb.AppendLine( Format( diagnostic ) );
}
EditorUtility.Clipboard.Copy( sb.ToString() );
}
static string Format( Diagnostic diagnostic )
{
var sb = new StringBuilder();
sb.Append( diagnostic.Severity.ToString().ToLowerInvariant() ).Append( ' ' )
.Append( diagnostic.Code ).Append( ": " ).Append( diagnostic.Message );
if ( diagnostic.Span is { IsValid: true } span ) sb.Append( " (" ).Append( span ).Append( ')' );
if ( diagnostic.Graph is { IsValid: true } reference ) sb.Append( " [" ).Append( reference ).Append( ']' );
if ( !string.IsNullOrWhiteSpace( diagnostic.Detail ) ) sb.Append( "\n\t" ).Append( diagnostic.Detail );
return sb.ToString();
}
static readonly DiagnosticSeverity[] s_order =
{
DiagnosticSeverity.Error, DiagnosticSeverity.Warning, DiagnosticSeverity.Info
};
}