Editor/Prism/Ui/CodePanel.cs

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.

File AccessNetworking
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&amp;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 );
	}
}