Editor/Prism/Ui/GraphPanel.cs

Editor UI component for the Prism graph editor. Hosts the canvas, breadcrumb, overlay toolbar and minimap, binds to a PrismSession, synchronises selection and diagnostics, handles view and layout commands, and routes context-menu gestures to the node search palette.

File AccessNative Interop
using Editor.Prism.Compiler;
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Undo;

namespace Editor.Prism.Ui;

/// <summary>One clickable step in the canvas breadcrumb.</summary>
public sealed record GraphCrumb( string Title, string Icon, Action Activate );

/// <summary>
/// The dock that hosts the canvas: breadcrumb, the graph view itself, the floating overlay toolbar and
/// the minimap.
/// <para>
/// The panel owns the wiring between the session and the canvas in both directions — a document swap
/// rebinds and reframes the view, a compile paints diagnostics onto the cards, a focus request scrolls
/// a node into view, and a selection made with the mouse is published back to the session so the
/// Inspector and the Blackboard can follow along without ever knowing this class exists.
/// </para>
/// </summary>
public sealed class GraphPanel : Widget
{
	/// <summary>The dock name. Frozen; the saved layout is keyed on it.</summary>
	public const string DockName = "Graph";

	readonly List<GraphCrumb> _trail = new();

	const float FirstRunWidth = 380f;
	const float FirstRunHeight = 190f;

	Breadcrumb _breadcrumb;
	PrismEmptyState _firstRun;
	PrismSession _session;
	NodeId[] _lastSelection = Array.Empty<NodeId>();
	Vector2 _canvasSize = -1f;
	int _lastSelectionVersion = -1;
	bool _pendingSync;
	bool _pushingSelection;
	bool _snapToGrid = true;

	/// <summary>Build the graph dock for a session. Survives a null session as an empty canvas.</summary>
	public GraphPanel( PrismSession session ) : base( null )
	{
		Name = DockName;
		WindowTitle = DockName;

		SetWindowIcon( DockIcon );

		Layout = Layout.Column();

		_breadcrumb = Layout.Add( new Breadcrumb( this ) );

		GraphView = new PrismCanvas( this );

		Layout.Add( GraphView, 1 );

		Overlay = new GraphOverlayToolbar( this, GraphView );
		Minimap = new Minimap( GraphView, GraphView );

		_firstRun = new PrismEmptyState( GraphView, PrismIcons.Graph, "This graph is empty",
			"Add a node and wire it into the output to start building a shader.",
			"Browse nodes", OpenNodeSearch )
		{
			Detail = "Press Space or Tab on the canvas, or drag a node in from the Node Library.",
			Card = true,
			FixedWidth = FirstRunWidth,
			FixedHeight = FirstRunHeight,
			Visible = false
		};

		ShowMinimap = PrismLog.Guard( "Read the minimap cookie",
			() => EditorCookie.Get( CookieMinimap, true ), true );

		Bind( session );
	}

	/// <summary>The Material icon shown on the dock tab.</summary>
	public string DockIcon => PrismIcons.Graph;

	/// <summary>The canvas.</summary>
	public PrismGraphView GraphView { get; }

	/// <summary>The floating viewport toolbar.</summary>
	public GraphOverlayToolbar Overlay { get; }

	/// <summary>The overview map in the bottom-right corner.</summary>
	public Minimap Minimap { get; }

	/// <summary>The document this panel is showing.</summary>
	public PrismSession Session => _session;

	/// <summary>Whether dragged cards land on the grid.</summary>
	public bool SnapToGrid
	{
		get => _snapToGrid;
		set
		{
			if ( _snapToGrid == value ) return;

			_snapToGrid = value;

			// GridSize is what the framework's drag code snaps to. One pixel is "off": zero would be
			// divided by inside Vector2.SnapToGrid and put every card at NaN.
			if ( GraphView.IsValid() ) GraphView.GridSize = value ? PrismTheme.GridSize : 1f;

			Overlay?.Refresh();
		}
	}

	/// <summary>Whether the minimap is shown. Persisted per user.</summary>
	public bool ShowMinimap
	{
		get => Minimap.IsValid() && Minimap.Visible;
		set
		{
			if ( !Minimap.IsValid() ) return;

			Minimap.Visible = value;

			PrismLog.Guard( "Save the minimap cookie", () => EditorCookie.Set( CookieMinimap, value ) );

			Overlay?.Refresh();
		}
	}

	/// <summary>The nodes the user has selected on the canvas.</summary>
	public IReadOnlyList<PrismNode> SelectedNodes
	{
		get
		{
			if ( !GraphView.IsValid() || _session?.Graph is null ) return Array.Empty<PrismNode>();

			return GraphView.SelectedNodes
				.Select( x => _session.Graph.FindNode( x ) )
				.Where( x => x is not null )
				.ToArray();
		}
	}

	/// <summary>The mutation API for the bound document, or null when there is no document.</summary>
	public GraphMutations Mutations => _session?.Mutations;

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

	/// <summary>Point the panel at a session. Pass null to show an empty canvas.</summary>
	public void Bind( PrismSession session )
	{
		Unhook();

		_session = session;

		if ( _session is not null )
		{
			_session.DocumentReplaced += OnDocumentReplaced;
			_session.GraphStructureChanged += OnStructureChanged;
			_session.SelectionChanged += OnSessionSelectionChanged;
			_session.Compiled += OnCompiled;
			_session.CompileSuppressed = () => GraphView.IsValid() && GraphView.IsDraggingWire;
		}

		Reload( true );
	}

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

		_session.DocumentReplaced -= OnDocumentReplaced;
		_session.GraphStructureChanged -= OnStructureChanged;
		_session.SelectionChanged -= OnSessionSelectionChanged;
		_session.Compiled -= OnCompiled;
		_session.CompileSuppressed = null;
		_session = null;
	}

	void Reload( bool frame )
	{
		if ( !GraphView.IsValid() ) return;

		// Force one selection publish after the rebuild: destroying every card may or may not raise the
		// framework's selection-changed action, and an editor that silently keeps showing the previous
		// document's node in the Inspector is worse than one extra projection.
		_lastSelectionVersion = -1;

		PrismLog.Guard( "Rebind the canvas", () =>
		{
			GraphView.ViewCookieName = _session?.FilePath;
			GraphView.SetDocument( _session?.Graph, _session?.Mutations, _session?.Undo );
			GraphView.GridSize = _snapToGrid ? PrismTheme.GridSize : 1f;
			GraphView.Reload();
		} );

		RefreshTrail();
		RefreshFirstRun();

		if ( !frame ) return;

		// A freshly opened document with no saved view is otherwise looking at empty space.
		PrismLog.Guard( "Frame the document", () =>
		{
			GraphView.Scale = 1f;
			GraphView.FrameAll();
			GraphView.RestoreViewFromCookie();
		} );
	}

	void OnDocumentReplaced() => Reload( true );

	void OnStructureChanged() => _pendingSync = true;

	void OnCompiled( CompileResult result )
	{
		if ( !GraphView.IsValid() ) return;

		PrismLog.Guard( "Apply diagnostics to the canvas",
			() => GraphView.ApplyDiagnostics( result?.Diagnostics ) );

		PrismLog.Guard( "Refresh inferred types", GraphView.RefreshTypes );
	}

	void OnSessionSelectionChanged()
	{
		if ( _pushingSelection || !GraphView.IsValid() || _session is null ) return;

		var wanted = _session.Selection.Select( x => x.Id ).ToArray();

		if ( SameSelection( wanted, GraphView.SelectedNodes ) ) return;

		PrismLog.Guard( "Mirror the selection onto the canvas", () =>
		{
			GraphView.ClearSelection();

			NodeUI first = null;

			foreach ( var node in _session.Selection )
			{
				var adapter = GraphView.Adapter?.Find( node.Id );

				if ( adapter is null ) continue;

				var card = GraphView.FindNode( adapter );

				if ( !card.IsValid() ) continue;

				// Selecting each card directly rather than through FocusNode, which recentres the view
				// on every call and would leave a multi-node selection parked on whichever came last.
				card.Selected = true;
				first ??= card;
			}

			if ( first.IsValid() ) GraphView.CenterOn( first.SceneRect.Center );
		} );

		_lastSelection = wanted;
	}

	/// <summary>Focus a node — and optionally one of its ports — on the canvas.</summary>
	public void Focus( NodeId node, PortId port = default )
	{
		if ( !GraphView.IsValid() ) return;

		PrismLog.Guard( "Focus a node", () => GraphView.FocusNode( node ) );
	}

	// ---------------------------------------------------------------- frame ----

	/// <summary>
	/// Deferred reconciliation. Structural changes arrive in the middle of a mutation, and rebuilding
	/// cards from inside the mutation that is creating them is how a graph editor gets a reentrancy bug.
	/// </summary>
	[EditorEvent.Frame]
	public void OnGraphPanelFrame()
	{
		if ( !this.IsValid() || !GraphView.IsValid() ) return;

		if ( _pendingSync )
		{
			_pendingSync = false;

			PrismLog.Guard( "Reconcile the canvas", GraphView.SyncFromDocument );

			RefreshFirstRun();
		}

		PositionFirstRun();
		PublishSelection();
	}

	/// <summary>
	/// Show or hide the first-run hint. Driven by structural change rather than polled, because
	/// answering "how many cards are there?" every frame on a several-hundred-node graph is exactly the
	/// kind of idle cost this pass exists to remove.
	/// </summary>
	void RefreshFirstRun()
	{
		if ( _firstRun is null || !_firstRun.IsValid() || !GraphView.IsValid() ) return;

		// One card is the seeded output node, which on its own is still an empty graph as far as the
		// user is concerned: there is nothing wired to it and nothing to compile.
		var empty = _session?.Graph is not null && GraphView.CardCount <= 1;

		if ( _firstRun.Visible == empty ) return;

		_firstRun.Visible = empty;

		if ( empty ) PositionFirstRun( true );
	}

	void PositionFirstRun( bool force = false )
	{
		if ( _firstRun is null || !_firstRun.IsValid() || !_firstRun.Visible ) return;
		if ( !GraphView.IsValid() ) return;

		var size = GraphView.Size;

		if ( !force && size == _canvasSize ) return;

		_canvasSize = size;

		_firstRun.Position = new Vector2(
			MathF.Max( 8f, ( size.x - FirstRunWidth ) * 0.5f ),
			MathF.Max( 8f, ( size.y - FirstRunHeight ) * 0.5f - 24f ) );
	}

	void PublishSelection()
	{
		if ( _session is null || _session.Graph is null ) return;

		// The framework tells us when the selection changed, so the projection below — five LINQ
		// allocations and an array — only runs when it did, instead of every frame forever.
		if ( GraphView.SelectionVersion == _lastSelectionVersion ) return;

		_lastSelectionVersion = GraphView.SelectionVersion;

		var current = GraphView.SelectedNodes;

		if ( SameSelection( current, _lastSelection ) ) return;

		_lastSelection = current.ToArray();
		_pushingSelection = true;

		try
		{
			var nodes = _lastSelection
				.Select( x => _session.Graph.FindNode( x ) )
				.Where( x => x is not null )
				.ToArray();

			_session.Select( nodes );
		}
		finally
		{
			_pushingSelection = false;
		}
	}

	static bool SameSelection( IReadOnlyList<NodeId> a, IReadOnlyList<NodeId> b )
	{
		if ( a is null || b is null ) return ReferenceEquals( a, b );
		if ( a.Count != b.Count ) return false;

		for ( int i = 0; i < a.Count; i++ )
		{
			if ( a[i] != b[i] ) return false;
		}

		return true;
	}

	// ---------------------------------------------------------------- view commands ----

	/// <summary>Multiply the zoom, clamped to the view's own limits.</summary>
	public void ZoomBy( float factor )
	{
		if ( !GraphView.IsValid() || factor <= 0f ) return;

		var scale = Math.Clamp( GraphView.Scale.x * factor, GraphView.MinZoom, GraphView.MaxZoom );

		PrismLog.Guard( "Zoom the canvas", () => GraphView.Scale = scale );

		Overlay?.Refresh();
	}

	/// <summary>Return to 1:1.</summary>
	public void ResetZoom()
	{
		if ( !GraphView.IsValid() ) return;

		PrismLog.Guard( "Reset the zoom", () => GraphView.Scale = 1f );

		Overlay?.Refresh();
	}

	/// <summary>Fit every card in the view.</summary>
	public void FrameAll()
	{
		if ( !GraphView.IsValid() ) return;

		PrismLog.Guard( "Frame the graph", GraphView.FrameAll );
	}

	/// <summary>Centre on the selection, or on everything when nothing is selected.</summary>
	public void FrameSelection()
	{
		if ( !GraphView.IsValid() ) return;

		if ( !GraphView.SelectedItems.Any() )
		{
			FrameAll();
			return;
		}

		PrismLog.Guard( "Frame the selection", GraphView.CenterOnSelection );
	}

	/// <summary>Swap between curved and angular wires.</summary>
	public void ToggleWireStyle()
	{
		if ( !GraphView.IsValid() ) return;

		GraphView.WireStyle = GraphView.WireStyle == PrismWireStyle.Bezier
			? PrismWireStyle.Orthogonal
			: PrismWireStyle.Bezier;

		Overlay?.Refresh();
	}

	// ---------------------------------------------------------------- layout commands ----

	/// <summary>Align the selection.</summary>
	public void Align( AlignEdge edge )
	{
		if ( AlignTools.Align( Mutations, SelectedNodes, edge, MeasureCard ) > 0 ) AfterLayout();
	}

	/// <summary>Distribute the selection evenly.</summary>
	public void Distribute( bool horizontal )
	{
		if ( AlignTools.Distribute( Mutations, SelectedNodes, horizontal, MeasureCard ) > 0 ) AfterLayout();
	}

	/// <summary>Snap the selection — or the whole graph when nothing is selected — to the grid.</summary>
	public void SnapSelectionToGrid()
	{
		var nodes = SelectedNodes;

		if ( nodes.Count == 0 ) nodes = _session?.Graph?.Nodes?.ToArray() ?? Array.Empty<PrismNode>();

		if ( AlignTools.SnapToGrid( Mutations, nodes ) > 0 ) AfterLayout();
	}

	/// <summary>Lay the selection out by data flow, or the whole graph when nothing is selected.</summary>
	public void AutoLayout()
	{
		var nodes = SelectedNodes;
		var subset = nodes.Count > 1 ? nodes : null;

		if ( AlignTools.AutoLayout( Mutations, _session?.Graph, subset, AutoLayoutOptions.Default,
				MeasureCard ) > 0 )
		{
			AfterLayout();
		}
	}

	void AfterLayout()
	{
		if ( !GraphView.IsValid() ) return;

		PrismLog.Guard( "Reconcile after a layout change", GraphView.SyncFromDocument );

		// Cards moved without the pointer touching the canvas and without the graph's shape changing,
		// which is the one case the minimap's idle gate cannot see for itself.
		if ( Minimap.IsValid() ) Minimap.Invalidate();

		_session?.MarkDirty();
	}

	/// <summary>The real on-screen size of a card, so layout maths uses what the user can see.</summary>
	Vector2 MeasureCard( PrismNode node )
	{
		if ( node is null ) return AlignTools.Measure( null );

		if ( GraphView.IsValid() )
		{
			foreach ( var item in GraphView.Items )
			{
				if ( item is not NodeUI card ) continue;
				if ( card.Node is not Adapters.PrismNodeAdapter adapter ) continue;
				if ( adapter.PrismNode != node ) continue;

				var size = card.Size;

				if ( size.x > 1f && size.y > 1f ) return size;

				break;
			}
		}

		return AlignTools.Measure( node );
	}

	// ---------------------------------------------------------------- palette ----

	/// <summary>Open the node palette in the middle of the canvas.</summary>
	public void OpenNodeSearch()
	{
		if ( !GraphView.IsValid() ) return;

		var centre = GraphView.LocalRect.Center;

		OpenNodeSearch( GraphView.ToScreen( centre ), GraphView.ToScene( centre ) );
	}

	/// <summary>
	/// Open the node palette. Called by the canvas for every gesture that creates a node: Space, Tab,
	/// a right-click on empty canvas, and dropping a wire into space — the last of which arrives with
	/// the plug it came from, which is what makes the results type-filtered.
	/// </summary>
	public void OpenNodeSearch( Vector2 screenPosition, Vector2 scenePosition, Plug targetPlug = null,
		Action onClose = null )
	{
		if ( !GraphView.IsValid() )
		{
			onClose?.Invoke();
			return;
		}

		PrismLog.Guard( "Open the node palette",
			() => NodeSearchPopup.Open( GraphView, screenPosition, scenePosition, targetPlug, onClose ) );
	}

	// ---------------------------------------------------------------- breadcrumb ----

	/// <summary>Replace the breadcrumb trail. The last entry is rendered as the current document.</summary>
	public void SetTrail( IEnumerable<GraphCrumb> crumbs )
	{
		_trail.Clear();

		if ( crumbs is not null ) _trail.AddRange( crumbs.Where( x => x is not null ) );

		_breadcrumb?.SetCrumbs( _trail );
	}

	void RefreshTrail()
	{
		if ( _trail.Count > 0 )
		{
			_breadcrumb?.SetCrumbs( _trail );
			return;
		}

		var title = _session?.DisplayName ?? "No document";
		var icon = _session is { IsSubgraph: true } ? PrismIcons.Subgraph : PrismIcons.Graph;

		_breadcrumb?.SetCrumbs( new[] { new GraphCrumb( title, icon, null ) } );
	}

	/// <inheritdoc/>
	public override void OnDestroyed()
	{
		Unhook();

		base.OnDestroyed();
	}

	const string CookieMinimap = "prism.graph.minimap";

	// ---------------------------------------------------------------- canvas ----

	/// <summary>
	/// The canvas, with the create-node gesture rerouted to Prism's own palette.
	/// <para>
	/// <see cref="PrismGraphView"/> exposes no public hook for replacing the create-node menu — the
	/// framework's <c>OpenContextMenu</c> is <c>protected virtual</c> — so the panel supplies a thin
	/// subclass rather than reimplementing four gestures. Overriding that one method captures all of
	/// them at once: Space, Tab, right-click on empty canvas, and a wire dropped into space, which is
	/// the only path that carries the plug the results have to be compatible with.
	/// </para>
	/// </summary>
	sealed class PrismCanvas : PrismGraphView
	{
		readonly GraphPanel _panel;

		bool _fromContextMenu;

		public PrismCanvas( GraphPanel panel ) : base( panel )
		{
			_panel = panel;
		}

		protected override void OnContextMenu( ContextMenuEvent e )
		{
			_fromContextMenu = true;

			try
			{
				base.OnContextMenu( e );
			}
			finally
			{
				_fromContextMenu = false;
			}
		}

		protected override void OpenContextMenu( Vector2 pos, Vector2 clickPos, Plug targetPlug = null,
			Action onClose = null )
		{
			// A right-click that lands on something is a context menu — cut, copy, align, node options.
			// A right-click on empty canvas, and every keyboard or wire gesture, wants the palette.
			var contextual = _fromContextMenu && !targetPlug.IsValid()
				&& ( SelectedItems.Any() || GetNodeAt( clickPos ) is not null );

			if ( contextual )
			{
				base.OpenContextMenu( pos, clickPos, targetPlug, onClose );
				return;
			}

			_panel?.OpenNodeSearch( pos, clickPos, targetPlug, onClose );
		}
	}

	// ---------------------------------------------------------------- breadcrumb widget ----

	/// <summary>The thin trail above the canvas. Each segment except the last is clickable.</summary>
	sealed class Breadcrumb : Widget
	{
		const float BarHeight = 26f;
		const float IconSize = 13f;
		const float Gap = 6f;

		readonly List<GraphCrumb> _crumbs = new();
		readonly List<Rect> _rects = new();

		int _hovered = -1;

		public Breadcrumb( Widget parent ) : base( parent )
		{
			FixedHeight = BarHeight;
			MouseTracking = true;
		}

		public void SetCrumbs( IReadOnlyList<GraphCrumb> crumbs )
		{
			_crumbs.Clear();

			if ( crumbs is not null ) _crumbs.AddRange( crumbs );

			_hovered = -1;

			Update();
		}

		protected override void OnPaint()
		{
			var rect = LocalRect;

			Paint.Antialiasing = true;
			Paint.SetBrushAndPen( PrismTheme.PanelAlt );
			Paint.DrawRect( rect );

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

			_rects.Clear();

			var x = rect.Left + PrismTheme.Rhythm;

			for ( int i = 0; i < _crumbs.Count; i++ )
			{
				var crumb = _crumbs[i];
				var last = i == _crumbs.Count - 1;
				var hot = _hovered == i && !last && crumb.Activate is not null;
				var color = last ? PrismTheme.TextPrimary : hot ? PrismTheme.Accent : PrismTheme.TextSecondary;

				if ( !string.IsNullOrEmpty( crumb.Icon ) )
				{
					Paint.SetPen( color.WithAlpha( 0.8f ) );
					Paint.DrawIcon( new Rect( x, rect.Top, IconSize + 2f, rect.Height ), crumb.Icon, IconSize );

					x += IconSize + Gap;
				}

				var width = PrismPaint.MeasureText( crumb.Title, PrismTheme.BodySize,
					last ? PrismTheme.InlineValueWeight : PrismTheme.PortLabelWeight );

				var slot = new Rect( x, rect.Top, width + 2f, rect.Height );

				PrismPaint.Text( slot, crumb.Title, color, PrismTheme.BodySize,
					last ? PrismTheme.InlineValueWeight : PrismTheme.PortLabelWeight,
					TextFlag.LeftCenter );

				_rects.Add( slot );

				x += width + Gap;

				if ( last ) continue;

				Paint.SetPen( PrismTheme.TextDisabled );
				Paint.DrawIcon( new Rect( x, rect.Top, 12f, rect.Height ), "chevron_right", 12f );

				x += 12f + Gap;
			}
		}

		protected override void OnMouseMove( MouseEvent e )
		{
			base.OnMouseMove( e );

			var index = IndexAt( e.LocalPosition );

			if ( index == _hovered ) return;

			_hovered = index;
			Cursor = index >= 0 && index < _crumbs.Count - 1 ? CursorShape.Finger : CursorShape.Arrow;

			Update();
		}

		protected override void OnMouseLeave()
		{
			base.OnMouseLeave();

			_hovered = -1;

			Update();
		}

		protected override void OnMouseClick( MouseEvent e )
		{
			base.OnMouseClick( e );

			var index = IndexAt( e.LocalPosition );

			if ( index < 0 || index >= _crumbs.Count - 1 ) return;

			PrismLog.Guard( "Follow a breadcrumb", () => _crumbs[index].Activate?.Invoke() );
		}

		int IndexAt( Vector2 local )
		{
			for ( int i = 0; i < _rects.Count; i++ )
			{
				if ( _rects[i].IsInside( local ) ) return i;
			}

			return -1;
		}
	}
}