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.
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;
}
}
}