Editor/Prism/Ui/InspectorPanel.cs

Editor UI for the Inspector dock. Defines InspectorHeader (draws icon, title, category chip and description) and InspectorPanel (the inspector sheet that shows graph settings or selected node(s), builds controls, docs, handles selection changes and routes property edits through the undo/mutation system).

File AccessNetworking
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Serialization;
using Editor.Prism.Undo;
using Margin = Sandbox.UI.Margin;

namespace Editor.Prism.Ui;

/// <summary>
/// The card at the top of the Inspector: the selected node's icon, title, category and description,
/// or the document's own identity when nothing is selected.
/// </summary>
internal sealed class InspectorHeader : Widget
{
	/// <summary>Build the header.</summary>
	public InspectorHeader( Widget parent ) : base( parent )
	{
		FixedHeight = 52f;
	}

	/// <summary>Material icon drawn on the left.</summary>
	public string Icon { get; set; } = PrismIcons.Parameter;

	/// <summary>Bold title line.</summary>
	public string Title { get; set; } = "Nothing selected";

	/// <summary>Category path, drawn as a chip under the title.</summary>
	public string Category { get; set; }

	/// <summary>One-line description.</summary>
	public string Description { get; set; }

	/// <summary>Colour of the left accent bar, normally the node's category colour.</summary>
	public Color Accent { get; set; } = PrismTheme.CategoryUtility;

	/// <inheritdoc/>
	protected override void OnPaint()
	{
		Paint.Antialiasing = true;

		var rect = LocalRect;

		Paint.ClearPen();
		Paint.SetBrush( PrismTheme.PanelAlt );
		Paint.DrawRect( rect );

		Paint.SetBrush( Accent );
		Paint.DrawRect( new Rect( rect.Left, rect.Top, 3f, rect.Height ) );

		Paint.SetPen( PrismTheme.BorderSubtle, 1f );
		Paint.DrawLine( new Vector2( rect.Left, rect.Bottom - 0.5f ), new Vector2( rect.Right, rect.Bottom - 0.5f ) );

		var iconRect = new Rect( rect.Left + 10f, rect.Top + 10f, 22f, 22f );

		Paint.SetPen( Accent.WithAlpha( 0.85f ) );
		Paint.DrawIcon( iconRect, Icon ?? PrismIcons.Parameter, 19f, TextFlag.Center );

		var left = iconRect.Right + 9f;
		var width = MathF.Max( 30f, rect.Right - left - 10f );

		PrismPaint.Text( new Rect( left, rect.Top + 8f, width, 17f ), Title,
			PrismTheme.TextPrimary, 13, 600 );

		var subtitle = string.IsNullOrEmpty( Description ) ? Category : Description;

		if ( !string.IsNullOrEmpty( subtitle ) )
		{
			PrismPaint.Text( new Rect( left, rect.Top + 25f, width, 15f ), subtitle,
				PrismTheme.TextMuted, 11, 400 );
		}

		if ( string.IsNullOrEmpty( Category ) || string.IsNullOrEmpty( Description ) ) return;

		Paint.SetFont( PrismTheme.FontFamily, 10, 500, false, true );

		var chipWidth = Paint.MeasureText( Category ).x + 12f;

		if ( chipWidth > width * 0.6f ) return;

		var chip = new Rect( rect.Right - chipWidth - 10f, rect.Top + 8f, chipWidth, 16f );

		PrismPaint.Pill( chip, Accent.WithAlpha( 0.14f ), PrismTheme.RadiusChip );

		Paint.SetFont( PrismTheme.FontFamily, 10, 500, false, true );
		Paint.SetPen( Accent.WithAlpha( 0.9f ) );
		Paint.DrawText( chip, Category, TextFlag.Center | TextFlag.SingleLine );
	}
}

/// <summary>
/// The Inspector dock: a control sheet over whatever is selected.
/// <para>
/// One node shows that node's serialized properties; several nodes show the subset they all declare
/// with the same type, and an edit is applied to every one of them; nothing selected shows the
/// document's own settings. Ports are deliberately excluded — a port is edited on the card, not here.
/// </para>
/// <para>
/// The control sheet writes straight into the object, which would be invisible to undo. So every
/// property is captured before it changes, the direct write is rolled back, and the same value is
/// re-applied through <see cref="GraphMutations"/>. The user sees one edit; the History panel sees
/// one step; nothing is ever changed behind the undo stack's back.
/// </para>
/// </summary>
public sealed class InspectorPanel : Widget
{
	/// <summary>The dock name this panel registers under. Frozen.</summary>
	public const string DockName = "Inspector";

	readonly Dictionary<string, object> _before = new();

	/// <summary>
	/// True while we are writing to the sheet ourselves, so the callbacks that write raises can be
	/// ignored. See <see cref="OnPropertyChanged"/> for why this has to exist.
	/// </summary>
	bool _applying;

	PrismSession _session;
	InspectorHeader _header;
	ScrollArea _scroll;
	Widget _canvas;
	PrismEmptyState _empty;
	Widget _docs;
	Button _docsButton;

	SerializedObject _serialized;
	PrismNode[] _targets = Array.Empty<PrismNode>();
	bool _showingSettings;
	bool _docsExpanded;
	bool _rebuildQueued;

	/// <summary>Build the panel. A null session is legal and shows the empty state.</summary>
	public InspectorPanel( PrismSession session ) : base( null )
	{
		Name = "PrismInspector";
		WindowTitle = DockName;

		Layout = Layout.Column();
		Layout.Margin = 0;
		Layout.Spacing = 0;

		_header = new InspectorHeader( this );
		Layout.Add( _header );

		var strip = new Widget( this );
		strip.Layout = Layout.Row();
		strip.Layout.Margin = new Margin( 8, 2, 8, 2 );
		strip.Layout.Spacing = 4;
		strip.Layout.AddStretchCell();

		_docsButton = new Button.Clear( "Documentation", "menu_book", strip );
		_docsButton.Clicked = ToggleDocs;
		strip.Layout.Add( _docsButton );

		Layout.Add( strip );

		_docs = new Widget( this );
		_docs.Layout = Layout.Column();
		_docs.Layout.Margin = new Margin( 10, 4, 10, 8 );
		_docs.Layout.Spacing = 2;
		_docs.Visible = false;
		Layout.Add( _docs );

		_scroll = new ScrollArea( this );
		_canvas = new Widget( _scroll );
		_canvas.Layout = Layout.Column();
		_canvas.Layout.Margin = new Margin( 8, 8, 8, 8 );
		_canvas.Layout.Spacing = 2;
		_scroll.Canvas = _canvas;

		Layout.Add( _scroll, 1 );

		// The panel falls back to graph settings when the selection is empty, so the only state that can
		// actually reach the user is "there is no document at all" — and the action for that is to make
		// one, routed through the window so it goes down the same path as File ▸ New.
		_empty = new PrismEmptyState( this, PrismIcons.Graph, "No document",
			"Create or open a graph to inspect it.", "New graph", NewGraph )
		{
			Detail = "Ctrl+N for a shader graph, Ctrl+Shift+N for a shader function."
		};

		Layout.Add( _empty, 1 );

		Bind( session );
	}

	/// <summary>Material icon shown on the dock tab.</summary>
	public string DockIcon => "tune";

	/// <summary>The session this panel is bound to. Null is legal.</summary>
	public PrismSession Session => _session;

	// ---------------------------------------------------------------- binding ----

	void Bind( PrismSession session )
	{
		Unbind();

		_session = session;

		if ( _session is not null )
		{
			_session.SelectionChanged += QueueRebuild;
			_session.GraphStructureChanged += QueueRebuild;
			_session.DocumentReplaced += QueueRebuild;
		}

		Rebuild();
	}

	void Unbind()
	{
		if ( _session is null ) return;

		_session.SelectionChanged -= QueueRebuild;
		_session.GraphStructureChanged -= QueueRebuild;
		_session.DocumentReplaced -= QueueRebuild;
		_session = null;
	}

	/// <inheritdoc/>
	public override void OnDestroyed()
	{
		Detach();
		Unbind();
		base.OnDestroyed();
	}

	void QueueRebuild()
	{
		if ( _rebuildQueued ) return;

		_rebuildQueued = true;

		MainThread.Queue( () =>
		{
			_rebuildQueued = false;

			if ( !this.IsValid() ) return;

			Rebuild();
		} );
	}

	/// <summary>
	/// Start a new document, through the window that owns this dock. Resolved on demand rather than
	/// captured, because a dock can be floated and re-parented and the window it belongs to is not
	/// guaranteed to be the one it was built under.
	/// </summary>
	void NewGraph() => PrismLog.Guard( "Start a new graph from the Inspector",
		() => GetAncestor<PrismWindow>()?.NewGraph() );

	// ---------------------------------------------------------------- content ----

	GraphMutations Mutations =>
		_session?.Graph is null ? null : GraphMutations.For( _session.Graph, _session.Undo );

	void Rebuild()
	{
		Detach();

		_canvas.Layout.Clear( true );
		_canvas.DestroyChildren();
		_docs.Layout.Clear( true );
		_docs.DestroyChildren();
		_docs.Visible = false;

		if ( _session?.Graph is null )
		{
			_targets = Array.Empty<PrismNode>();
			_showingSettings = false;

			_header.Icon = PrismIcons.Graph;
			_header.Title = "No document";
			_header.Category = null;
			_header.Description = "Create or open a graph.";
			_header.Accent = PrismTheme.CategoryUtility;
			_header.Update();

			_docsButton.Visible = false;
			_empty.Set( "No document", "Create or open a graph to inspect it." );
			_empty.Visible = true;
			_scroll.Visible = false;
			return;
		}

		_targets = ( _session.Selection ?? Array.Empty<PrismNode>() ).Where( x => x is not null ).ToArray();
		_showingSettings = _targets.Length == 0;
		_empty.Visible = false;
		_scroll.Visible = true;

		if ( _showingSettings ) BuildSettings();
		else if ( _targets.Length == 1 ) BuildSingle( _targets[0] );
		else BuildMultiple();

		_canvas.Layout.AddStretchCell();
	}

	void BuildSettings()
	{
		var settings = _session.Graph.Settings;

		_header.Icon = _session.IsSubgraph ? PrismIcons.Subgraph : PrismIcons.Graph;
		_header.Title = string.IsNullOrWhiteSpace( _session.Graph.Meta?.Title )
			? "Graph Settings"
			: _session.Graph.Meta.Title;
		_header.Category = _session.IsSubgraph ? "Subgraph" : settings.Domain.ToString();
		_header.Description = "Document settings — nothing is selected.";
		_header.Accent = PrismTheme.CategoryOutput;
		_header.Update();

		_docsButton.Visible = false;

		_serialized = PrismLog.Guard<SerializedObject>( "Serialize graph settings", settings.GetSerialized );

		if ( _serialized is null ) return;

		Attach();

		var sheet = new ControlSheet();
		sheet.AddObject( _serialized, ShouldShowSetting );
		_canvas.Layout.Add( sheet );
	}

	void BuildSingle( PrismNode node )
	{
		var descriptor = node.Descriptor;

		_header.Icon = string.IsNullOrEmpty( descriptor?.Icon ) ? PrismIcons.Parameter : descriptor.Icon;
		_header.Title = descriptor?.Title ?? node.GetType().Name;
		_header.Category = descriptor?.Category;
		_header.Description = descriptor?.Description;
		_header.Accent = PrismTheme.ForCategory( descriptor?.Category );
		_header.Update();

		_docsButton.Visible = true;
		BuildDocs( node, descriptor );
		SyncDocs();

		_serialized = PrismLog.Guard<SerializedObject>( "Serialize node", node.GetSerialized );

		if ( _serialized is null )
		{
			_canvas.Layout.Add( new Label( "This node exposes no editable properties." )
			{
				Color = PrismTheme.TextMuted
			} );

			return;
		}

		Attach();

		var sheet = new ControlSheet();
		sheet.AddObject( _serialized, property => ShouldShowNodeProperty( node, property ) );
		_canvas.Layout.Add( sheet );

		if ( node.Inputs.Count == 0 && node.Outputs.Count == 0 ) return;

		_canvas.Layout.AddSpacingCell( 6f );
		_canvas.Layout.Add( new Label( $"{node.Inputs.Count} in · {node.Outputs.Count} out · {descriptor?.Id}" )
		{
			Color = PrismTheme.TextDisabled
		} );
	}

	void BuildMultiple()
	{
		var common = CommonProperties( _targets );

		_header.Icon = "select_all";
		_header.Title = $"{_targets.Length} nodes selected";
		_header.Category = null;
		_header.Description = common.Count == 0
			? "These nodes share no editable properties."
			: $"Editing the {common.Count} propert{( common.Count == 1 ? "y" : "ies" )} they share.";
		_header.Accent = PrismTheme.Accent;
		_header.Update();

		_docsButton.Visible = false;

		if ( common.Count == 0 )
		{
			_canvas.Layout.Add( new Label( "Select nodes of the same type to edit them together." )
			{
				Color = PrismTheme.TextMuted
			} );

			return;
		}

		_serialized = PrismLog.Guard<SerializedObject>( "Serialize node", _targets[0].GetSerialized );

		if ( _serialized is null ) return;

		Attach();

		var sheet = new ControlSheet();
		sheet.AddObject( _serialized, property => common.Contains( property.Name ) );
		_canvas.Layout.Add( sheet );
	}

	void BuildDocs( PrismNode node, NodeDescriptor descriptor )
	{
		if ( !string.IsNullOrWhiteSpace( descriptor?.Description ) )
		{
			_docs.Layout.Add( new Label( descriptor.Description ) { Color = PrismTheme.TextSecondary, WordWrap = true } );
			_docs.Layout.AddSpacingCell( 4f );
		}

		AddDocSection( "Inputs", node.Inputs.Select( x =>
			$"{x.DisplayName} : {Describe( x )}{Suffix( x.Tooltip )}" ) );

		AddDocSection( "Outputs", node.Outputs.Select( x =>
			$"{x.DisplayName} : {Describe( x )}{Suffix( x.Tooltip )}" ) );

		var facts = new List<string>();

		if ( !string.IsNullOrEmpty( descriptor?.Id ) ) facts.Add( descriptor.Id );
		if ( descriptor is not null ) facts.Add( $"tier {descriptor.Tier}" );
		if ( !string.IsNullOrEmpty( descriptor?.Since ) ) facts.Add( $"since {descriptor.Since}" );
		if ( !string.IsNullOrEmpty( descriptor?.DeprecatedBy ) ) facts.Add( $"replaced by {descriptor.DeprecatedBy}" );

		if ( facts.Count == 0 ) return;

		_docs.Layout.AddSpacingCell( 4f );
		_docs.Layout.Add( new Label( string.Join( "  ·  ", facts ) ) { Color = PrismTheme.TextDisabled } );
	}

	void AddDocSection( string title, IEnumerable<string> lines )
	{
		var rows = lines.ToList();

		if ( rows.Count == 0 ) return;

		_docs.Layout.Add( new Label( title.ToUpperInvariant() ) { Color = PrismTheme.TextMuted } );

		foreach ( var row in rows )
		{
			_docs.Layout.Add( new Label( row ) { Color = PrismTheme.TextSecondary } );
		}
	}

	static string Describe( Port port )
	{
		var type = port.EffectiveType;

		return type.IsVoid ? port.DeclaredType ?? "T" : type.Hlsl;
	}

	static string Suffix( string tooltip ) =>
		string.IsNullOrWhiteSpace( tooltip ) ? string.Empty : $" — {tooltip}";

	void ToggleDocs()
	{
		_docsExpanded = !_docsExpanded;
		SyncDocs();
	}

	/// <summary>Keep the documentation block and its toggle in agreement after a rebuild.</summary>
	void SyncDocs()
	{
		var any = _docs.Children.Any();

		_docs.Visible = _docsExpanded && any;
		_docsButton.Text = _docsExpanded ? "Hide Documentation" : "Documentation";
		_docsButton.Enabled = any;
	}

	// ---------------------------------------------------------------- filters ----

	static readonly HashSet<string> s_hiddenNodeProperties = new( StringComparer.Ordinal )
	{
		"Id", "Graph", "Position", "Flags", "Descriptor", "Inputs", "Outputs"
	};

	static bool ShouldShowNodeProperty( PrismNode node, SerializedProperty property )
	{
		if ( property is null ) return false;
		if ( s_hiddenNodeProperties.Contains( property.Name ) ) return false;
		if ( property.PropertyType == typeof( PortRef ) ) return false;
		if ( !property.IsEditable ) return false;

		return property.ShouldShow();
	}

	static bool ShouldShowSetting( SerializedProperty property )
	{
		if ( property is null ) return false;
		if ( property.Name is "X" or "Modes" or "Targets" ) return false;
		if ( !property.IsEditable ) return false;

		return property.ShouldShow();
	}

	/// <summary>
	/// The property names every selected node declares with the same CLR type. Matching on the type as
	/// well as the name matters: two unrelated nodes can both have a <c>Mode</c> and mean different
	/// enums, and writing one into the other would silently corrupt it.
	/// </summary>
	static HashSet<string> CommonProperties( IReadOnlyList<PrismNode> nodes )
	{
		var common = new HashSet<string>( StringComparer.Ordinal );

		if ( nodes.Count == 0 ) return common;

		var first = NodeProperties.Structural( nodes[0].GetType() );

		foreach ( var property in first )
		{
			if ( s_hiddenNodeProperties.Contains( property.Name ) ) continue;
			if ( property.PropertyType == typeof( PortRef ) ) continue;

			var everywhere = true;

			for ( var i = 1; i < nodes.Count && everywhere; i++ )
			{
				var other = NodeProperties.Find( nodes[i].GetType(), property.Name );

				everywhere = other is not null && other.PropertyType == property.PropertyType;
			}

			if ( everywhere ) common.Add( property.Name );
		}

		return common;
	}

	// ---------------------------------------------------------------- undo routing ----

	void Attach()
	{
		if ( _serialized is null ) return;

		_serialized.OnPropertyPreChange = OnPropertyPreChange;
		_serialized.OnPropertyChanged = OnPropertyChanged;
	}

	void Detach()
	{
		_before.Clear();
		_applying = false;

		if ( _serialized is null ) return;

		_serialized.OnPropertyPreChange = null;
		_serialized.OnPropertyChanged = null;
		_serialized = null;
	}

	void OnPropertyPreChange( SerializedProperty property )
	{
		if ( _applying || property is null ) return;

		_before[property.Name] = PrismLog.Guard<object>( $"Read '{property.Name}'",
			() => property.GetValue<object>() );
	}

	/// <summary>
	/// The sheet has already written the value straight into the object and is now telling us. We answer
	/// by writing to it twice more — once to put the old value back, so the mutation API sees a real
	/// change and captures a correct "before" snapshot, and once to re-apply the new one through undo.
	/// <remarks>
	/// Both of those go through <c>SerializedProperty.SetValue</c>, which calls <c>NotePreChange</c> and
	/// <c>NoteChanged</c>, which call straight back into here. Left alone that is unbounded mutual
	/// recursion with no base case — the two writes flip the property between old and new forever — and
	/// the resulting <c>StackOverflowException</c> cannot be caught, so it kills the whole editor, not
	/// just Prism. Hence <see cref="_applying"/>: callbacks raised by our own writes are ignored,
	/// because they carry nothing we do not already know.
	/// <para>
	/// The two-argument <c>SetValue</c> overload is documented as the non-notifying one, but its base
	/// implementation just forwards to the notifying one, so only <c>TypeSerializedProperty</c> actually
	/// honours it. It is used below where it helps, but the flag is what guarantees termination.
	/// </para>
	/// </remarks>
	/// </summary>
	void OnPropertyChanged( SerializedProperty property )
	{
		if ( _applying || property is null ) return;

		var mutations = Mutations;

		if ( mutations is null ) return;

		var value = PrismLog.Guard<object>( $"Read '{property.Name}'", () => property.GetValue<object>() );

		_applying = true;

		try
		{
			if ( _before.TryGetValue( property.Name, out var old ) )
			{
				PrismLog.Guard( $"Restore '{property.Name}'", () => property.SetValue( old, property ) );
			}

			var label = $"Set {property.DisplayName ?? property.Name}";

			if ( _showingSettings )
			{
				mutations.UpdateSettings( _ => property.SetValue( value, property ), label );
			}
			else
			{
				using ( mutations.Begin( label ) )
				{
					foreach ( var node in _targets )
					{
						mutations.SetProperty( node.Id, property.Name, value, NodeChangeKind.Properties, label );
					}
				}
			}

			_before[property.Name] = value;
		}
		finally
		{
			_applying = false;
		}

		_session.Touch();
	}
}