Editor/Prism/Integration/PrismDocumentation.cs

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.

File Access
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;
		}
	}
}