Editor UI code for Prism documentation and reference. Defines PrismNodeHelp (node doc model), PrismDocumentation (lookup, windows management), PrismWelcomeWindow (first-run UI), PrismGlyph (icon widget), and PrismNodeReferenceWindow (searchable list and detail pane showing node descriptors). It reads NodeRegistry/NodeDescriptor to build UI and clipboard copy support.
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Ui;
using System.Text;
namespace Editor.Prism.Integration;
/// <summary>
/// Everything Prism knows how to explain about one node type, assembled from the same metadata the
/// graph and the node library already use — never a second, drifting copy.
/// </summary>
public sealed record PrismNodeHelp(
string Id, string Title, string Category, string Icon, string Summary,
IReadOnlyList<string> Keywords, NodeTier Tier, string Since, string DeprecatedBy,
IReadOnlyList<PortDef> Inputs, IReadOnlyList<PortDef> Outputs )
{
/// <summary>True when there is nothing useful to show.</summary>
public bool IsEmpty => string.IsNullOrEmpty( Id );
/// <summary>A one-line status for the header: tier, availability and replacement.</summary>
public string Status
{
get
{
var parts = new List<string>();
if ( Tier != NodeTier.Common ) parts.Add( Tier.ToString() );
if ( !string.IsNullOrWhiteSpace( Since ) ) parts.Add( $"since {Since}" );
if ( !string.IsNullOrWhiteSpace( DeprecatedBy ) ) parts.Add( $"replaced by {DeprecatedBy}" );
return string.Join( " · ", parts );
}
}
/// <summary>Plain-text rendering, for a tooltip or the clipboard.</summary>
public string ToPlainText()
{
var builder = new StringBuilder();
builder.AppendLine( Title );
if ( !string.IsNullOrWhiteSpace( Category ) ) builder.AppendLine( Category );
builder.AppendLine();
if ( !string.IsNullOrWhiteSpace( Summary ) )
{
builder.AppendLine( Summary );
builder.AppendLine();
}
Append( builder, "Inputs", Inputs );
Append( builder, "Outputs", Outputs );
builder.AppendLine( $"Type id: {Id}" );
return builder.ToString();
}
static void Append( StringBuilder builder, string heading, IReadOnlyList<PortDef> ports )
{
if ( ports is null || ports.Count == 0 ) return;
builder.AppendLine( heading );
foreach ( var port in ports )
{
builder.Append( " " ).Append( port.DisplayName ).Append( " " ).Append( port.DeclaredType );
if ( !string.IsNullOrWhiteSpace( port.Tooltip ) ) builder.Append( " — " ).Append( port.Tooltip );
builder.AppendLine();
}
builder.AppendLine();
}
}
/// <summary>
/// In-editor help: the orientation panel a new user sees once, and the per-node reference every user
/// reaches from the node library, the inspector or the <c>Prism</c> menu.
/// </summary>
public static class PrismDocumentation
{
static PrismWelcomeWindow s_welcome;
static PrismNodeReferenceWindow s_reference;
/// <summary>Documentation for one registered node type, or an empty record when it is unknown.</summary>
public static PrismNodeHelp Lookup( string typeId )
{
if ( string.IsNullOrWhiteSpace( typeId ) ) return Empty;
return PrismLog.Guard( "Looking up node documentation", () =>
{
NodeRegistry.EnsureBuilt();
return NodeRegistry.TryResolve( typeId, out var descriptor ) ? For( descriptor ) : Empty;
}, Empty );
}
/// <summary>Documentation for a node instance.</summary>
public static PrismNodeHelp For( PrismNode node ) => node is null ? Empty : For( node.Descriptor );
/// <summary>Documentation built from a descriptor.</summary>
public static PrismNodeHelp For( NodeDescriptor descriptor )
{
if ( descriptor is null ) return Empty;
return new PrismNodeHelp(
descriptor.Id,
string.IsNullOrWhiteSpace( descriptor.Title ) ? descriptor.Id : descriptor.Title,
descriptor.Category,
string.IsNullOrWhiteSpace( descriptor.Icon ) ? "extension" : descriptor.Icon,
descriptor.Description,
descriptor.Keywords ?? Array.Empty<string>(),
descriptor.Tier,
descriptor.Since,
descriptor.DeprecatedBy,
descriptor.Inputs ?? Array.Empty<PortDef>(),
descriptor.Outputs ?? Array.Empty<PortDef>() );
}
/// <summary>The "nothing to show" record.</summary>
public static PrismNodeHelp Empty { get; } = new( null, null, null, null, null,
Array.Empty<string>(), NodeTier.Common, null, null,
Array.Empty<PortDef>(), Array.Empty<PortDef>() );
// ---- windows -----------------------------------------------------------
/// <summary>Open the node reference, optionally scrolled to one node.</summary>
public static void ShowNodeReference( string typeId = null )
{
PrismLog.Guard( "Opening the Prism node reference", () =>
{
if ( s_reference is null || !s_reference.IsValid )
{
s_reference = new PrismNodeReferenceWindow();
}
s_reference.Show();
s_reference.Focus();
if ( !string.IsNullOrWhiteSpace( typeId ) ) s_reference.SelectNode( typeId );
} );
}
/// <summary>Open the orientation panel on demand.</summary>
public static void ShowWelcome()
{
PrismLog.Guard( "Opening the Prism welcome panel", () =>
{
if ( s_welcome is null || !s_welcome.IsValid )
{
s_welcome = new PrismWelcomeWindow();
}
s_welcome.Show();
s_welcome.Focus();
} );
}
/// <summary>
/// Show the orientation panel the very first time Prism is opened, and never again unless it is
/// asked for. Called from every path that opens a window.
/// </summary>
public static void ShowWelcomeIfFirstRun()
{
if ( PrismCookies.WelcomeShown ) return;
PrismCookies.WelcomeShown = true;
ShowWelcome();
}
/// <summary>Drop the cached windows outright.</summary>
public static void Reset()
{
s_welcome = null;
s_reference = null;
}
/// <summary>
/// What hotload calls. A window that is still on screen is kept — the hotload system migrates the
/// instance rather than destroying it, and dropping the reference here would leave the user with a
/// second copy the next time they asked for one.
/// </summary>
public static void Revalidate()
{
if ( s_welcome is not null && !s_welcome.IsValid ) s_welcome = null;
if ( s_reference is not null && !s_reference.IsValid ) s_reference = null;
PrismLog.Guard( "Reloading the Prism node reference", () =>
{
if ( s_reference is not null && s_reference.IsValid ) s_reference.Reload();
} );
}
}
/// <summary>
/// The first-run orientation panel: what Prism is, what makes it different from the built-in shader
/// graph, and the six things worth knowing before the first graph.
/// </summary>
public sealed class PrismWelcomeWindow : BaseWindow
{
/// <summary>Build the panel.</summary>
public PrismWelcomeWindow()
{
WindowTitle = "What Is Prism?";
SetWindowIcon( "gradient" );
Size = new Vector2( 720f, 660f );
MinimumSize = new Vector2( 560f, 420f );
Layout = Layout.Column();
Layout.Margin = 0f;
var scroll = new ScrollArea( this );
scroll.Canvas = new Widget( scroll );
scroll.Canvas.Layout = Layout.Column();
scroll.Canvas.Layout.Margin = 28f;
scroll.Canvas.Layout.Spacing = 10f;
Build( scroll.Canvas.Layout );
Layout.Add( scroll, 1 );
var footer = Layout.AddRow();
footer.Margin = new Sandbox.UI.Margin( 28f, 0f, 28f, 20f );
footer.Spacing = 8f;
var reference = footer.Add( new Button( "Node Reference", "menu_book", this ) );
reference.Clicked = () => PrismDocumentation.ShowNodeReference();
footer.AddStretchCell();
var close = footer.Add( new Button.Primary( "Start Building", "arrow_forward", this ) );
close.Clicked = Close;
}
void Build( Layout layout )
{
var title = layout.Add( new Label.Title( "Prism" ) );
title.Color = PrismTheme.TextPrimary;
var lead = layout.Add( new Label.Subtitle(
"A node-based shader editor for s&box that treats generated code as something you are meant to read." ) );
lead.Color = PrismTheme.TextSecondary;
lead.WordWrap = true;
layout.AddSpacingCell( 8f );
Section( layout, "gradient", "Graphs compile to real HLSL",
"Everything you wire up becomes a readable .shader beside the document, with the same block "
+ "structure a hand-written one has. The Code panel shows it live, and clicking a line "
+ "selects the node that produced it." );
Section( layout, "rule", "Connections are type-checked",
"Free conversions connect silently. A lossy or padded one connects, warns, and draws a marker "
+ "on the wire telling you exactly what it did — the built-in editor pads float2 to float3 with "
+ "zero and never says so. Illegal connections are refused at the drop." );
Section( layout, "history", "Nothing is quietly destroyed",
"Node ids are minted once and never renumbered. A node whose plugin is missing survives as a "
+ "placeholder and re-saves byte-identically. A connection that cannot resolve stays as a "
+ "visible ghost instead of vanishing." );
Section( layout, "bolt", "The preview is the shader",
"There is no separate preview path. Edits are debounced and recompiled with the minimum combo "
+ "set, so the sphere shows the same code the material will use. The status strip tells you "
+ "how long each compile took." );
Section( layout, "functions", "Subgraphs and custom code are first class",
"A .prismfn is a reusable function with its own inputs and outputs. When a node does not exist "
+ "yet, the Custom Code node takes HLSL directly — a missing node is an inconvenience, not a wall." );
Section( layout, "keyboard", "Worth learning early",
"Space or double-click on empty canvas opens the node search. Dragging a wire into empty space "
+ "opens it filtered by type. Ctrl+Z and Ctrl+Y are per-document. Ctrl+S saves the document and "
+ "regenerates the shader beside it." );
layout.AddSpacingCell( 8f );
var footnote = layout.Add( new Label.Small(
"Prism never registers the built-in .shdrgrph or .shdrfunc extensions. To bring an existing graph "
+ "across, right-click it and choose Import into Prism." ) );
footnote.Color = PrismTheme.TextMuted;
footnote.WordWrap = true;
layout.AddStretchCell();
}
void Section( Layout layout, string icon, string heading, string body )
{
layout.AddSpacingCell( 12f );
var row = layout.AddRow();
row.Spacing = 12f;
row.Add( new PrismGlyph( this, icon, PrismTheme.Accent ) );
var column = row.AddColumn( 1 );
column.Spacing = 3f;
var header = column.Add( new Label.Header( heading ) );
header.Color = PrismTheme.TextPrimary;
var text = column.Add( new Label.Body( body ) );
text.Color = PrismTheme.TextSecondary;
text.WordWrap = true;
}
}
/// <summary>
/// A fixed-size material icon as a layout item. Qt labels cannot render one, and a whole
/// <c>IconButton</c> would bring click behaviour and hover states nobody asked for.
/// </summary>
internal sealed class PrismGlyph : Widget
{
readonly string _icon;
readonly Color _color;
readonly float _size;
/// <summary>Build a glyph of the given size, in the given colour.</summary>
public PrismGlyph( Widget parent, string icon, Color color, float size = 20f ) : base( parent )
{
_icon = string.IsNullOrWhiteSpace( icon ) ? "circle" : icon;
_color = color;
_size = size;
FixedSize = new Vector2( size + 6f, size + 6f );
}
/// <summary>Draw the glyph, centred.</summary>
protected override void OnPaint()
{
Paint.Antialiasing = true;
Paint.SetPen( _color );
Paint.DrawIcon( LocalRect, _icon, _size, TextFlag.Center );
}
}
/// <summary>
/// Every registered node type, searchable, with the ports and description the compiler and the node
/// library read from the same metadata.
/// </summary>
public sealed class PrismNodeReferenceWindow : BaseWindow
{
readonly List<PrismNodeType> _all = new();
ListView _list;
LineEdit _search;
Widget _detail;
Label _count;
/// <summary>Build the window and load the registry.</summary>
public PrismNodeReferenceWindow()
{
WindowTitle = "Prism Node Reference";
SetWindowIcon( "menu_book" );
Size = new Vector2( 1040f, 700f );
MinimumSize = new Vector2( 720f, 460f );
Layout = Layout.Column();
Layout.Margin = 16f;
Layout.Spacing = 10f;
BuildHeader();
BuildBody();
Reload();
}
void BuildHeader()
{
var row = Layout.AddRow();
row.Spacing = 8f;
_search = row.Add( new LineEdit( this ), 1 );
_search.PlaceholderText = "Search nodes by name, category or keyword";
_search.TextEdited += _ => Populate();
var refresh = row.Add( new Button( "", "refresh", this ) );
refresh.Clicked = Reload;
refresh.StatusTip = "Rebuild the node registry";
_count = Layout.Add( new Label.Small( "" ) );
_count.Color = PrismTheme.TextMuted;
}
void BuildBody()
{
var row = Layout.AddRow( 1 );
row.Spacing = 12f;
_list = row.Add( new ListView( this ), 1 );
_list.ItemSize = new Vector2( -1f, 34f );
_list.ItemSpacing = new Vector2( 0f, 2f );
_list.Margin = 2f;
_list.ItemPaint = PaintRow;
_list.ItemSelected = item => ShowDetail( item as PrismNodeType );
var scroll = new ScrollArea( this );
scroll.Canvas = new Widget( scroll );
scroll.Canvas.Layout = Layout.Column();
scroll.Canvas.Layout.Margin = 4f;
scroll.Canvas.Layout.Spacing = 6f;
_detail = scroll.Canvas;
row.Add( scroll, 2 );
ShowDetail( null );
}
/// <summary>Rebuild from the registry — useful after a hotload adds node types.</summary>
public void Reload()
{
PrismLog.Guard( "Loading the Prism node registry", () =>
{
NodeRegistry.EnsureBuilt();
_all.Clear();
_all.AddRange( NodeRegistry.Types
.OrderBy( x => x.Category ?? string.Empty, StringComparer.OrdinalIgnoreCase )
.ThenBy( x => x.Title ?? string.Empty, StringComparer.OrdinalIgnoreCase ) );
} );
Populate();
}
/// <summary>Select and reveal one node type by its stable id.</summary>
public void SelectNode( string typeId )
{
PrismLog.Guard( "Selecting a node in the reference", () =>
{
var match = _all.FirstOrDefault( x => string.Equals( x.Id, typeId, StringComparison.Ordinal ) );
if ( match is null ) return;
_list?.ScrollTo( match );
ShowDetail( match );
} );
}
void Populate()
{
PrismLog.Guard( "Filtering the Prism node reference", () =>
{
var text = _search?.Text ?? string.Empty;
var matches = string.IsNullOrWhiteSpace( text )
? _all
: NodeRegistry.Search( text ).ToList();
_list?.SetItems( matches.Cast<object>() );
if ( _count is not null )
{
_count.Text = matches.Count == _all.Count
? $"{_all.Count} node types"
: $"{matches.Count} of {_all.Count} node types";
}
} );
}
void PaintRow( VirtualWidget item )
{
if ( item?.Object is not PrismNodeType type ) return;
var rect = item.Rect;
Paint.Antialiasing = true;
Paint.ClearPen();
if ( item.Selected ) Paint.SetBrush( PrismTheme.AccentSoft );
else if ( item.Hovered ) Paint.SetBrush( PrismTheme.PanelAlt );
else Paint.ClearBrush();
if ( item.Selected || item.Hovered ) Paint.DrawRect( rect, PrismTheme.RadiusChip );
var iconRect = new Rect( rect.Left + 8f, rect.Top + ( rect.Height - 16f ) * 0.5f, 16f, 16f );
Paint.SetPen( item.Selected ? PrismTheme.Accent : PrismTheme.TextMuted );
Paint.DrawIcon( iconRect, string.IsNullOrWhiteSpace( type.Icon ) ? "extension" : type.Icon, 15f );
var textRect = new Rect( rect.Left + 32f, rect.Top, rect.Width - 40f, rect.Height );
Paint.SetPen( item.Selected ? PrismTheme.TextPrimary : PrismTheme.TextSecondary );
Paint.SetFont( PrismTheme.FontFamily, PrismTheme.BodySize, 500, false, false );
Paint.DrawText( textRect, type.Title ?? type.Id, TextFlag.LeftCenter );
Paint.SetPen( PrismTheme.TextDisabled );
Paint.SetFont( PrismTheme.FontFamily, PrismTheme.PortLabelSize, 400, false, false );
Paint.DrawText( textRect, type.Category ?? string.Empty, TextFlag.RightCenter );
}
void ShowDetail( PrismNodeType type )
{
if ( _detail is null ) return;
_detail.Layout.Clear( true );
if ( type is null )
{
var empty = _detail.Layout.Add( new Label.Body(
"Pick a node on the left to see what it does, what it takes and what it returns." ) );
empty.Color = PrismTheme.TextMuted;
empty.WordWrap = true;
_detail.Layout.AddStretchCell();
return;
}
var help = PrismDocumentation.For( type.Descriptor );
var title = _detail.Layout.Add( new Label.Title( help.Title ) );
title.Color = PrismTheme.TextPrimary;
var subtitle = _detail.Layout.Add( new Label.Small(
string.Join( " · ", new[] { help.Category, help.Status }.Where( x => !string.IsNullOrWhiteSpace( x ) ) ) ) );
subtitle.Color = PrismTheme.TextMuted;
if ( !string.IsNullOrWhiteSpace( help.Summary ) )
{
_detail.Layout.AddSpacingCell( 6f );
var summary = _detail.Layout.Add( new Label.Body( help.Summary ) );
summary.Color = PrismTheme.TextSecondary;
summary.WordWrap = true;
}
Ports( "Inputs", help.Inputs );
Ports( "Outputs", help.Outputs );
if ( help.Keywords.Count > 0 )
{
_detail.Layout.AddSpacingCell( 8f );
var keywords = _detail.Layout.Add( new Label.Small( "Also found by: " + string.Join( ", ", help.Keywords ) ) );
keywords.Color = PrismTheme.TextDisabled;
keywords.WordWrap = true;
}
_detail.Layout.AddSpacingCell( 8f );
var id = _detail.Layout.Add( new Label.Small( $"Type id {help.Id}" ) );
id.Color = PrismTheme.TextDisabled;
id.TextSelectable = true;
var copy = _detail.Layout.Add( new Button( "Copy Documentation", "content_copy", this ) );
copy.Clicked = () => PrismLog.Guard( "Copying node documentation",
() => EditorUtility.Clipboard.Copy( help.ToPlainText() ) );
_detail.Layout.AddStretchCell();
}
void Ports( string heading, IReadOnlyList<PortDef> ports )
{
if ( ports is null || ports.Count == 0 ) return;
_detail.Layout.AddSpacingCell( 10f );
var header = _detail.Layout.Add( new Label.Header( heading ) );
header.Color = PrismTheme.TextPrimary;
foreach ( var port in ports )
{
var row = _detail.Layout.AddRow();
row.Spacing = 8f;
var name = row.Add( new Label( port.DisplayName ?? port.Id.ToString(), this ) );
name.Color = PrismTheme.TextSecondary;
name.MinimumWidth = 130f;
var declared = row.Add( new Label( port.DeclaredType ?? "float", this ) );
declared.Color = PrismTheme.TypeGeneric;
declared.MinimumWidth = 70f;
var tooltip = row.Add( new Label( port.Tooltip ?? string.Empty, this ), 1 );
tooltip.Color = PrismTheme.TextMuted;
tooltip.WordWrap = true;
}
}
}