UI panel for showing generated shader code and related artifacts. It contains a read-only code editor, a node ribbon that maps generated lines to graph nodes, tabs for different outputs (HLSL, Slang, Preprocessed, IR), copy/save/open actions, and logic to keep the ribbon in sync with editor scrolling and session compile results.
using Editor.Prism.Compiler;
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Text;
using Margin = Sandbox.UI.Margin;
using System.IO;
namespace Editor.Prism.Ui;
/// <summary>
/// The thin strip beside the generated code that says which node produced which line.
/// <para>
/// The engine's own code editor cannot show this, because nothing upstream of it knows where a line
/// came from. Prism's backends emit a <see cref="SourceMap"/>, so every generated line can be traced
/// to the node that wrote it — and that is the single most legible way to prove it. Bands are drawn in
/// the node's category colour; clicking one selects the node.
/// </para>
/// </summary>
internal sealed class CodeNodeRibbon : Widget
{
/// <summary>Build the ribbon.</summary>
public CodeNodeRibbon( Widget parent ) : base( parent )
{
FixedWidth = 10f;
MouseTracking = true;
Cursor = CursorShape.Finger;
}
/// <summary>The editor whose rows this ribbon annotates.</summary>
public CodeEditorWidget Editor { get; set; }
/// <summary>The map from generated line to node.</summary>
public SourceMap Map { get; set; }
/// <summary>The graph the nodes belong to, for names and categories.</summary>
public PrismGraph Graph { get; set; }
/// <summary>The node currently highlighted, normally the selection.</summary>
public NodeId Highlight { get; set; }
/// <summary>Raised when a band is clicked.</summary>
public Action<NodeId> NodePicked { get; set; }
/// <inheritdoc/>
protected override void OnPaint()
{
Paint.ClearPen();
Paint.SetBrush( PrismTheme.Code.Gutter );
Paint.DrawRect( LocalRect );
var editor = Editor;
if ( editor is null || !editor.IsValid() || Map is null || Map.Count == 0 ) return;
var lineHeight = editor.LineHeight;
if ( lineHeight <= 0f ) return;
var first = editor.FirstVisibleRow;
var last = first + editor.VisibleRowCount;
for ( var row = first; row <= last; row++ )
{
var line = editor.Folding is null ? row : editor.Folding.ToDocument( row );
if ( line < 0 ) continue;
if ( !Map.TryGetEntry( line + 1, out var entry ) ) continue;
var node = Graph?.FindNode( entry.Node );
var color = PrismTheme.ForCategory( node?.Descriptor?.Category );
var top = editor.RowTop( row );
if ( top > Height ) break;
var selected = entry.Node == Highlight;
Paint.ClearPen();
Paint.SetBrush( selected ? color : color.WithAlpha( 0.45f ) );
Paint.DrawRect( new Rect( 3f, top + 1f, selected ? 5f : 3f, MathF.Max( 1f, lineHeight - 2f ) ), 1f );
}
}
/// <inheritdoc/>
protected override void OnMouseMove( MouseEvent e )
{
base.OnMouseMove( e );
ToolTip = DescribeAt( e.LocalPosition.y );
}
/// <inheritdoc/>
protected override void OnMouseClick( MouseEvent e )
{
base.OnMouseClick( e );
if ( !e.LeftMouseButton ) return;
var id = NodeAt( e.LocalPosition.y );
if ( !id.IsValid ) return;
NodePicked?.Invoke( id );
}
NodeId NodeAt( float y )
{
var editor = Editor;
if ( editor is null || !editor.IsValid() || Map is null || editor.LineHeight <= 0f ) return NodeId.None;
// RowTop( row ) is row * LineHeight - ScrollY, so this is exactly its inverse.
var row = (int)MathF.Floor( ( y + editor.ScrollY ) / editor.LineHeight );
var line = editor.Folding is null ? row : editor.Folding.ToDocument( row );
if ( line < 0 ) return NodeId.None;
return Map.TryGetNode( line + 1, out var node ) ? node : NodeId.None;
}
string DescribeAt( float y )
{
var id = NodeAt( y );
if ( !id.IsValid ) return null;
var node = Graph?.FindNode( id );
return node is null ? id.Value : $"{node.Descriptor?.Title ?? node.GetType().Name} — click to select";
}
}
/// <summary>
/// The Code dock: what the graph actually compiles to.
/// <para>
/// Four views of the same compile — the s&box <c>.shader</c>, the portable <c>.slang</c> module,
/// the preprocessed text the engine handed its compiler, and the IR both backends were generated from.
/// All read-only, all syntax-highlighted by the same lexers the text editor uses, all annotated with
/// the node each line came from.
/// </para>
/// </summary>
public sealed class CodePanel : Widget
{
/// <summary>The dock name this panel registers under. Frozen.</summary>
public const string DockName = "Code";
/// <summary>The tabs this panel offers, in order.</summary>
static readonly string[] s_tabs = { "HLSL", "Slang", "Preprocessed", "IR" };
readonly List<Button> _tabButtons = new();
PrismSession _session;
CodeEditorWidget _editor;
CodeNodeRibbon _ribbon;
PrismEmptyState _empty;
Widget _body;
Label _status;
int _tab;
float _lastScroll = -1f;
bool _rebuildQueued;
/// <summary>Build the panel. A null session is legal and shows the empty state.</summary>
public CodePanel( PrismSession session ) : base( null )
{
Name = "PrismCode";
WindowTitle = DockName;
Layout = Layout.Column();
Layout.Margin = 0;
Layout.Spacing = 0;
BuildToolbar();
_body = new Widget( this );
_body.Layout = Layout.Row();
_body.Layout.Margin = 0;
_body.Layout.Spacing = 0;
_ribbon = new CodeNodeRibbon( _body ) { NodePicked = OnNodePicked };
_body.Layout.Add( _ribbon );
_editor = new CodeEditorWidget( _body )
{
ReadOnly = true,
ShowFoldMargin = true,
ShowIndentGuides = true,
HighlightCurrentLine = true,
HighlightOccurrences = true
};
_ribbon.Editor = _editor;
_body.Layout.Add( _editor, 1 );
Layout.Add( _body, 1 );
_empty = new PrismEmptyState( this, PrismIcons.Code, "Nothing generated yet",
"Compile the graph to see the shader it produces.", "Compile now",
() => _session?.RequestCompile( CompileMode.Preview ) );
Layout.Add( _empty, 1 );
_status = new Label( string.Empty ) { Color = PrismTheme.TextMuted };
_status.ContentMargins = new Margin( 8, 2, 8, 4 );
Layout.Add( _status );
Bind( session );
}
/// <summary>Material icon shown on the dock tab.</summary>
public string DockIcon => "code";
/// <summary>The session this panel is bound to. Null is legal.</summary>
public PrismSession Session => _session;
/// <summary>The read-only editor showing the current tab. Exposed so the window can drive it.</summary>
public CodeEditorWidget Editor => _editor;
// ---------------------------------------------------------------- binding ----
void Bind( PrismSession session )
{
Unbind();
_session = session;
if ( _session is not null )
{
_session.Compiled += OnCompiled;
_session.SelectionChanged += OnSelectionChanged;
_session.DocumentReplaced += QueueRefresh;
}
Refresh();
}
void Unbind()
{
if ( _session is null ) return;
_session.Compiled -= OnCompiled;
_session.SelectionChanged -= OnSelectionChanged;
_session.DocumentReplaced -= QueueRefresh;
_session = null;
}
/// <inheritdoc/>
public override void OnDestroyed()
{
Unbind();
base.OnDestroyed();
}
void OnCompiled( CompileResult result ) => QueueRefresh();
void QueueRefresh()
{
if ( _rebuildQueued ) return;
_rebuildQueued = true;
MainThread.Queue( () =>
{
_rebuildQueued = false;
if ( !this.IsValid() ) return;
Refresh();
} );
}
/// <summary>
/// Keeps the node ribbon in step with the editor's viewport. The editor is sealed, so there is no
/// scroll event to hook; comparing the scroll offset once a frame costs nothing and is exact.
/// </summary>
[EditorEvent.Frame]
public void OnFrame()
{
if ( !this.IsValid() || !Visible ) return;
if ( _editor is null || !_editor.IsValid() || _ribbon is null ) return;
if ( _editor.ScrollY.AlmostEqual( _lastScroll ) ) return;
_lastScroll = _editor.ScrollY;
_ribbon.Update();
}
// ---------------------------------------------------------------- toolbar ----
void BuildToolbar()
{
var bar = new Widget( this );
bar.Layout = Layout.Row();
bar.Layout.Margin = new Margin( 6, 6, 6, 4 );
bar.Layout.Spacing = 2;
for ( var i = 0; i < s_tabs.Length; i++ )
{
var index = i;
var button = new Button( s_tabs[i], bar )
{
IsToggle = true,
IsChecked = i == 0,
FixedHeight = 24f,
Tint = PrismTheme.Accent.WithAlpha( 0.25f )
};
button.Clicked = () => ShowTab( index );
_tabButtons.Add( button );
bar.Layout.Add( button );
}
bar.Layout.AddStretchCell();
var copy = new IconButton( PrismIcons.Copy, CopyAll, bar )
{
ToolTip = "Copy this tab to the clipboard",
FixedWidth = 24f,
FixedHeight = 24f
};
bar.Layout.Add( copy );
var save = new IconButton( "save_as", SaveAs, bar )
{
ToolTip = "Save this tab to a file",
FixedWidth = 24f,
FixedHeight = 24f
};
bar.Layout.Add( save );
var open = new IconButton( "open_in_new", OpenInCodeWindow, bar )
{
ToolTip = "Open this tab in the code window",
FixedWidth = 24f,
FixedHeight = 24f
};
bar.Layout.Add( open );
Layout.Add( bar );
}
/// <summary>Switch to a tab by index. Out-of-range values are clamped.</summary>
public void ShowTab( int index )
{
_tab = Math.Clamp( index, 0, s_tabs.Length - 1 );
for ( var i = 0; i < _tabButtons.Count; i++ )
{
_tabButtons[i].IsChecked = i == _tab;
}
Refresh();
}
/// <summary>Switch to a tab by name: <c>HLSL</c>, <c>Slang</c>, <c>Preprocessed</c> or <c>IR</c>.</summary>
public void ShowTab( string name )
{
var index = Array.FindIndex( s_tabs, x => string.Equals( x, name, StringComparison.OrdinalIgnoreCase ) );
if ( index >= 0 ) ShowTab( index );
}
// ---------------------------------------------------------------- content ----
void Refresh()
{
var result = _session?.LastCompile;
var text = TextFor( result, out var language, out var map );
_ribbon.Graph = _session?.Graph;
_ribbon.Map = map;
var hasText = !string.IsNullOrEmpty( text );
_body.Visible = hasText;
_empty.Visible = !hasText;
if ( !hasText )
{
_empty.Set( EmptyTitle( result ), EmptyMessage( result ) );
_status.Text = string.Empty;
return;
}
PrismLog.Guard( "Code panel: set text", () => _editor.SetText( text, language ) );
if ( _tab == 0 && _session?.Diagnostics is { Count: > 0 } diagnostics )
{
var spanned = diagnostics.Where( x => x.Span is { IsValid: true } ).ToList();
PrismLog.Guard( "Code panel: diagnostics", () => _editor.SetDiagnostics( spanned ) );
}
else
{
_editor.ClearDiagnostics();
}
_lastScroll = -1f;
_ribbon.Update();
var lines = _editor.Document?.LineCount ?? 0;
var mapped = map?.Count ?? 0;
_status.Text = mapped > 0
? $"{s_tabs[_tab]} · {lines} lines · {mapped} mapped to nodes"
: $"{s_tabs[_tab]} · {lines} lines";
ScrollToSelection();
}
string TextFor( CompileResult result, out string language, out SourceMap map )
{
language = "hlsl";
map = null;
if ( result is null ) return null;
switch ( _tab )
{
case 0:
{
var artifact = result.Artifact( PrismConstants.BackendHlsl );
language = "vfx";
map = artifact?.SourceMap;
return artifact?.Text;
}
case 1:
{
var artifact = result.Artifact( PrismConstants.BackendSlang );
language = "slang";
map = artifact?.SourceMap;
return artifact?.Text;
}
case 2:
{
language = "hlsl";
return Preprocessed( result );
}
default:
{
language = "text";
return result.Module is null
? null
: PrismLog.Guard<string>( "Print IR", () => IrPrinter.Print( result.Module ) );
}
}
}
/// <summary>
/// The preprocessed text, when a backend produced one as an extra artifact. The engine keeps its
/// own preprocessed source inside the compile results and does not hand it back, so this tab is
/// honest about being empty rather than showing the same text as the HLSL tab.
/// </summary>
static string Preprocessed( CompileResult result )
{
if ( result.Artifacts is null ) return null;
foreach ( var pair in result.Artifacts )
{
if ( pair.Value?.Extra is null ) continue;
foreach ( var extra in pair.Value.Extra )
{
if ( extra?.FileName is null ) continue;
if ( !extra.FileName.Contains( "preprocess", StringComparison.OrdinalIgnoreCase ) ) continue;
return extra.Text;
}
}
return null;
}
string EmptyTitle( CompileResult result )
{
if ( _session is null ) return "No document";
if ( result is null ) return "Nothing generated yet";
return _tab switch
{
1 => "Slang is not a target",
2 => "No preprocessed source",
3 => "No IR",
_ => "Nothing generated yet"
};
}
string EmptyMessage( CompileResult result )
{
if ( _session is null ) return "Open a graph to see the code it generates.";
if ( result is null ) return "Compile the graph to see the shader it produces.";
return _tab switch
{
1 => "Enable the Slang target in the graph's settings to emit a .slang module.",
2 => "The preprocessed text comes from the engine compiler and is only kept when a backend emits it.",
3 => "The compile produced no IR module — check the Diagnostics panel.",
_ => "The compile produced no shader text — check the Diagnostics panel."
};
}
// ---------------------------------------------------------------- navigation ----
void OnSelectionChanged() => ScrollToSelection();
void ScrollToSelection()
{
if ( _session is null || _ribbon?.Map is null || !_body.Visible ) return;
var selection = _session.Selection;
if ( selection is null || selection.Count != 1 ) return;
var node = selection[0];
_ribbon.Highlight = node.Id;
_ribbon.Update();
if ( !_ribbon.Map.TryGetLines( node.Id, out var range ) || !range.IsValid ) return;
PrismLog.Guard( "Code panel: reveal node", () => _editor.GoToLine( range.Start ) );
}
/// <summary>
/// Show the generated line a diagnostic came from. Compiler diagnostics carry the line directly;
/// graph diagnostics are resolved through the source map. The window wires the Diagnostics panel
/// to this.
/// </summary>
public bool ShowDiagnostic( Diagnostic diagnostic )
{
if ( diagnostic is null ) return false;
ShowTab( 0 );
var line = diagnostic.Span is { IsValid: true } span ? span.Line : 0;
if ( line <= 0 && diagnostic.Graph is { } reference && _ribbon?.Map is not null &&
_ribbon.Map.TryGetLines( reference.Node, out var range ) )
{
line = range.Start;
}
if ( line <= 0 ) return false;
PrismLog.Guard( "Code panel: reveal diagnostic", () => _editor.GoToLine( line ) );
return true;
}
void OnNodePicked( NodeId id )
{
if ( _session is null || !id.IsValid ) return;
_session.SelectNode( id );
_session.RequestFocus( id );
}
// ---------------------------------------------------------------- commands ----
void CopyAll()
{
var text = _editor?.Document?.Text;
if ( string.IsNullOrEmpty( text ) ) return;
EditorUtility.Clipboard.Copy( text );
}
void SaveAs()
{
var text = _editor?.Document?.Text;
if ( string.IsNullOrEmpty( text ) ) return;
PrismLog.Guard( "Code panel: save as", () =>
{
var dialog = new FileDialog( this ) { Title = $"Save generated {s_tabs[_tab]}" };
dialog.SetModeSave();
dialog.SetNameFilter( "Shader source (*.shader *.slang *.hlsl *.txt)" );
dialog.DefaultSuffix = SuffixForTab();
if ( !dialog.Execute() ) return;
var path = dialog.SelectedFile;
if ( string.IsNullOrWhiteSpace( path ) ) return;
File.WriteAllText( path, text );
} );
}
string SuffixForTab() => _tab switch
{
1 => PrismConstants.SlangExtension,
3 => "txt",
_ => PrismConstants.ShaderExtension
};
void OpenInCodeWindow()
{
var text = _editor?.Document?.Text;
if ( string.IsNullOrEmpty( text ) ) return;
PrismLog.Guard( "Code panel: open in code window", () =>
{
var window = CodeWindow.Open();
if ( window is null ) return;
var name = _session?.Graph?.Meta?.Title;
var title = string.IsNullOrWhiteSpace( name ) ? s_tabs[_tab] : $"{name} · {s_tabs[_tab]}";
window.OpenText( title, text, LanguageForTab(), true );
} );
}
string LanguageForTab() => _tab switch
{
0 => "vfx",
1 => "slang",
3 => "text",
_ => "hlsl"
};
/// <inheritdoc/>
protected override void OnPaint()
{
Paint.ClearPen();
Paint.SetBrush( PrismTheme.Panel );
Paint.DrawRect( LocalRect );
}
}