Graph canvas UI component for the Prism editor. It binds to a PrismGraph document via an adapter, manages node cards, wires/connections, node type registry, drag-and-drop of assets, undo/redo integration, per-frame lightweight hover/preview handling, background grid rendering, context menus and layout operations (align, frame, relayout).
using Editor.Prism.Core;
using Editor.Prism.Integration;
using Editor.Prism.Model;
using Editor.Prism.Serialization;
using Editor.Prism.Ui.Adapters;
using Editor.Prism.Undo;
using System.Reflection;
using Connection = Editor.NodeEditor.Connection;
namespace Editor.Prism.Ui;
/// <summary>
/// The graph canvas.
/// <para>
/// A <c>GraphView</c> subclass rather than a fork: everything worth keeping — marquee select, copy and
/// paste, drag-and-drop, reroute insertion, the move-undo bracket — is inherited, and everything worth
/// replacing is an override. The five that are not optional are called out in the contracts and all
/// five are here: <see cref="GetRelevantNodes"/> (empty by default, which leaves the create-node menu
/// permanently blank), <see cref="RerouteNodeType"/> and <see cref="CommentNodeType"/> (null by
/// default, which throws the moment a user presses R), <see cref="ClipboardIdent"/> (shared by default,
/// which makes pasting from another graph editor throw), and the <see cref="PushUndo"/> /
/// <see cref="PushRedo"/> pair, which the framework calls in a strict order and asserts on.
/// </para>
/// </summary>
public class PrismGraphView : GraphView
{
static int s_registryVersion;
static PrismGraphView()
{
// The registry rebuilds on hotload and can come back with the same number of types, so a count
// comparison alone would leave every open canvas offering stale node types.
PrismLog.Guard( "Watch node registry", () => NodeRegistry.Refreshed += () => s_registryVersion++ );
}
readonly Dictionary<string, PrismNodeTypeAdapter> _nodeTypes = new( StringComparer.Ordinal );
readonly List<PrismNodeUi> _cards = new();
Pixmap _grid;
Pixmap _flat;
INodeType _rerouteType;
INodeType _commentType;
ConnectionStyle _lastStyle;
PrismWireStyle _wireStyle;
int _nodeTypeVersion = -1;
int _registryVersion = -1;
int _selectionVersion;
bool _ready;
bool _hasPreviewWire;
bool _wasHot;
/// <summary>Build a canvas.</summary>
public PrismGraphView( Widget parent ) : base( parent )
{
// GridSize must be assigned after the base constructor, which already built a background using
// its own default. The grid pixmap is rebuilt here for the same reason.
GridSize = PrismTheme.GridSize;
AcceptDrops = true;
// The canvas has to be focusable by tab as well as by click, because the canvas-scoped shortcuts
// on PrismWindow — copy, paste, select all, frame, ESC — gate on ContainsFocus. Without this a
// user who has only ever clicked a dock has no keyboard route back to the graph.
FocusMode = FocusMode.TabOrClickOrWheel;
BilinearFiltering = false;
Antialiasing = true;
TextAntialiasing = true;
_wireStyle = ReadWireStyleCookie();
_lastStyle = ConnectionStyle;
// GraphicsView exposes selection change as an Action property rather than an event, so it is
// assigned with += and nothing else in the framework loses its handler. A version counter is
// what lets the panel answer "did the selection change?" without projecting it every frame.
OnSelectionChanged += () => _selectionVersion++;
RebuildBackground();
_ready = true;
}
/// <summary>
/// Bumped every time the selection changes. Compare it instead of projecting
/// <see cref="SelectedNodes"/> in a per-frame hook — that walk allocates five objects and an array
/// every frame, to discover nothing changed.
/// </summary>
public int SelectionVersion => _selectionVersion;
/// <summary>The document adapter the canvas is bound to.</summary>
public PrismGraphAdapter Adapter { get; private set; }
/// <summary>The document being edited.</summary>
public PrismGraph Document => Adapter?.Document;
/// <summary>The only sanctioned way to change the document.</summary>
public GraphMutations Mutations => Adapter?.Mutations;
/// <summary>The undo stack the framework's undo bracket writes into.</summary>
public PrismUndoStack UndoStack { get; private set; }
/// <summary>
/// True while a wire is being dragged. The compile service must ignore mutations while this is set:
/// picking up a connected wire momentarily reports the input as disconnected, and recompiling in
/// that window produces a shader nobody asked for.
/// <para>
/// The expensive half of the answer — "is there a connection in the scene that is not in the
/// connection list?" — is computed at most once per editor frame in <see cref="OnPrismFrame"/> and
/// cached here, because this property is polled every frame by the window and by the compile
/// service's suppression hook, and answering it from scratch each time was quadratic in the number
/// of wires.
/// </para>
/// </summary>
public bool IsDraggingWire => Adapter is { HasPendingDetach: true } || _hasPreviewWire;
/// <summary>How wires are routed. Persisted in an editor cookie.</summary>
public PrismWireStyle WireStyle
{
get => _wireStyle;
set
{
if ( _wireStyle == value ) return;
_wireStyle = value;
PrismLog.Guard( "Save wire style", () => PrismCookies.WireStyle = value );
ReflowConnections();
}
}
/// <summary>The cookie key pan and zoom are persisted under. Set to the document's asset path.</summary>
public string ViewCookieName { get; set; }
/// <inheritdoc/>
protected override string ViewCookie => ViewCookieName;
/// <inheritdoc/>
protected override string ClipboardIdent => ClipboardCodec.Prefix.TrimEnd( ':' );
/// <inheritdoc/>
public override ConnectionStyle ConnectionStyle => PrismConnectionStyles.For( _wireStyle );
/// <summary>
/// The base swaps in a one-pixel <c>Theme.WindowBackground</c> tile below half zoom, which is not
/// the Prism canvas colour. We do the fade ourselves in <see cref="OnMouseWheel"/> instead.
/// </summary>
public override bool FadeOutBackground => false;
// ---------------------------------------------------------------- document ----
/// <summary>Bind the canvas to a document, its mutation API and its undo stack.</summary>
public void SetDocument( PrismGraph document, GraphMutations mutations = null, PrismUndoStack undo = null )
{
UndoStack = undo;
// The previous adapter is subscribed to the previous document's events; dropping the reference
// without unhooking would keep a closed document alive and repainting.
Adapter?.Dispose();
if ( document is null )
{
Adapter = null;
Graph = null;
return;
}
Adapter = new PrismGraphAdapter( document, mutations ?? new GraphMutations( document, undo ) );
ViewCookieName ??= document.AssetPath;
Graph = Adapter;
}
/// <summary>Rebuild every card and wire from the document. Use after a load.</summary>
public void Reload()
{
Adapter?.Clear();
RebuildFromGraph();
}
/// <summary>
/// Reconcile the canvas against the document without tearing it down: cards whose node survived are
/// reused, cards whose node vanished are destroyed, and wires are diffed.
/// <para>
/// This is what an undo or redo should call. <see cref="Reload"/> also works but discards selection,
/// and destroying and rebuilding several hundred graphics items to move four of them is exactly the
/// mistake that makes an undo feel like a reload.
/// </para>
/// </summary>
public void SyncFromDocument()
{
if ( Adapter is null ) return;
var nodes = Adapter.Nodes.ToArray();
BuildFromNodes( nodes, false );
// BuildFromNodes reuses surviving cards, and a reused card keeps the position it had before the
// restore, so positions have to be pushed back explicitly.
foreach ( var item in Items )
{
if ( item is not NodeUI card || card.Node is not PrismNodeAdapter adapter ) continue;
card.Position = adapter.Position;
card.Update();
}
foreach ( var connection in Items.OfType<Connection>() )
{
PrismLog.Guard( "Relayout connection", connection.Layout );
}
}
/// <summary>The ids of the nodes currently selected, in selection order.</summary>
public IReadOnlyList<NodeId> SelectedNodes => SelectedItems
.OfType<NodeUI>()
.Select( x => x.Node as PrismNodeAdapter )
.Where( x => x?.PrismNode is not null )
.Select( x => x.PrismNode.Id )
.ToArray();
/// <summary>Select a node by id and scroll it into view.</summary>
public void FocusNode( NodeId id )
{
var adapter = Adapter?.Find( id );
if ( adapter is null ) return;
var card = SelectNode( adapter );
if ( card.IsValid() ) CenterOn( card.SceneRect.Center );
}
// ---------------------------------------------------------------- undo ----
/// <inheritdoc/>
public override void PushUndo( string name ) => UndoStack?.Push( name );
/// <inheritdoc/>
public override void PushRedo() => UndoStack?.Commit();
// ---------------------------------------------------------------- node types ----
/// <inheritdoc/>
protected override INodeType RerouteNodeType
{
get
{
_rerouteType ??= Resolve( GraphMutations.RerouteTypeId );
return _rerouteType;
}
}
/// <inheritdoc/>
protected override INodeType CommentNodeType
{
get
{
_commentType ??= Resolve( PrismCommentAdapter.TypeId );
return _commentType;
}
}
/// <summary>
/// The node types the create-node menu offers for a query. The base returns nothing at all, which
/// leaves the menu permanently empty and is the single easiest way to lose a day on this framework.
/// </summary>
protected override IEnumerable<INodeType> GetRelevantNodes( NodeQuery query )
{
RefreshNodeTypes();
return _nodeTypes.Values.Filter( query );
}
/// <summary>Every registered node type, wrapped for the framework. Rebuilt when the registry changes.</summary>
public IReadOnlyCollection<PrismNodeTypeAdapter> NodeTypes
{
get
{
RefreshNodeTypes();
return _nodeTypes.Values;
}
}
/// <summary>The framework wrapper for a registered node type id, or null when it is not registered.</summary>
public PrismNodeTypeAdapter Resolve( string typeId )
{
if ( string.IsNullOrEmpty( typeId ) ) return null;
RefreshNodeTypes();
return _nodeTypes.TryGetValue( typeId, out var type ) ? type : null;
}
void RefreshNodeTypes()
{
var types = PrismLog.Guard<IReadOnlyList<PrismNodeType>>( "Read node registry",
() => NodeRegistry.Types, Array.Empty<PrismNodeType>() );
if ( types is null ) return;
if ( _registryVersion == s_registryVersion && _nodeTypeVersion == types.Count && _nodeTypes.Count > 0 )
{
return;
}
_registryVersion = s_registryVersion;
_nodeTypes.Clear();
foreach ( var type in types )
{
if ( type is null || string.IsNullOrEmpty( type.Id ) ) continue;
_nodeTypes[type.Id] = new PrismNodeTypeAdapter( type );
}
_nodeTypeVersion = types.Count;
_rerouteType = null;
_commentType = null;
}
// ---------------------------------------------------------------- handles ----
/// <inheritdoc/>
protected override HandleConfig OnGetHandleConfig( Type type ) =>
new( PrismShaderTypes.Shader( type ).ToString(), PrismShaderTypes.ColorFor( type ), HandleShape.Circle );
/// <summary>
/// Put the canvas back in step with <see cref="PrismTheme"/> after a theme file has been loaded.
/// <para>
/// A repaint on its own is not enough. A theme may change <c>GridSize</c>, <c>PortPitch</c>,
/// <c>NodeHeaderHeight</c> or a type size — all of which are <em>geometry</em>, baked into each
/// card's measured layout and into the background tile, so without a re-measure the cards keep the
/// old palette's proportions and only their colours move.
/// </para>
/// </summary>
public void ApplyTheme()
{
// Grid snapping is a live setting the panel also drives; only take the theme's value back when
// snapping is on, so a retheme cannot silently re-enable it.
if ( GridSize > 1f ) GridSize = PrismTheme.GridSize;
RebuildBackground();
FlushHandleConfigs();
RelayoutCards();
}
/// <summary>Re-measure and repaint every card. Use after anything that changes card geometry.</summary>
public void RelayoutCards()
{
for ( int i = _cards.Count - 1; i >= 0; i-- )
{
var card = _cards[i];
if ( !card.IsValid() )
{
_cards.RemoveAt( i );
continue;
}
PrismLog.Guard( "Relayout a card", card.Relayout );
}
ReflowConnections();
}
/// <summary>
/// Drop the framework's handle-configuration cache, which is keyed on <see cref="Type"/> and never
/// invalidated. Without this a retheme leaves every wire in the old palette until the window is
/// closed and reopened.
/// </summary>
public void FlushHandleConfigs()
{
PrismLog.Guard( "Flush handle configs", () =>
{
var property = typeof( GraphView ).GetProperty( "HandleConfigCache",
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public );
if ( property?.GetValue( this ) is not System.Collections.IDictionary cache ) return;
cache.Clear();
} );
foreach ( var node in Items.OfType<NodeUI>() ) node.Update();
ReflowConnections();
}
// ---------------------------------------------------------------- connections ----
/// <inheritdoc/>
protected override Connection CreateConnection( PlugOut nodeOutput, PlugIn dropTarget, bool uiOnly = false )
{
if ( nodeOutput is null || dropTarget is null ) return null;
if ( !uiOnly )
{
dropTarget.Inner.ConnectedOutput = nodeOutput.Inner;
// Our input plug refuses illegal conversions by silently not applying them. When that
// happens there must be no wire either, or the canvas would show a connection the document
// does not have.
if ( dropTarget.Inner.ConnectedOutput != nodeOutput.Inner ) return null;
}
if ( !nodeOutput.Inner.ShowConnection || !dropTarget.Inner.ShowConnection ) return null;
var connection = new PrismConnection( nodeOutput, dropTarget );
Add( connection );
connection.Layout();
Connections.Add( connection );
return connection;
}
/// <inheritdoc/>
public override void RemoveConnection( Connection c )
{
var plug = c is not null && c.Input.IsValid() ? c.Input.Inner as PrismPlugIn : null;
// The framework nulls ConnectedOutput inside base.RemoveConnection when the wire is still
// attached, and that is our deferred-detach path; commit on both sides of the call so either
// order resolves.
if ( plug is not null ) Adapter?.CommitDetached( plug );
base.RemoveConnection( c );
if ( plug is not null ) Adapter?.CommitDetached( plug );
}
/// <summary>
/// Create a node and, when the user dragged a wire out to get here, connect it up.
/// <para>
/// Reimplemented rather than extended because the base dereferences the result of node creation
/// without checking it: an unregistered node type — which is exactly what happens when a package is
/// missing or a hotload is half-finished — takes the whole editor down with a null reference instead
/// of doing nothing.
/// </para>
/// </summary>
public override void CreateNewNode( INodeType type, Vector2 position, Plug targetPlug, bool selected = true )
{
if ( type is null ) return;
PushUndo( "Add Node" );
try
{
var nodeUI = CreateNewNode( type, position );
if ( !nodeUI.IsValid() ) return;
nodeUI.Selected = selected;
if ( !targetPlug.IsValid() ) return;
if ( targetPlug is PlugIn plugIn )
{
if ( !type.TryGetOutput( plugIn.Inner.Type, out var name ) ) return;
if ( nodeUI.Outputs.FirstOrDefault( x => x.Inner.Identifier == name ) is { } match )
{
// An input takes one wire. The framework's own drop path clears the old one first;
// this path has to as well or the canvas keeps a wire the document no longer has.
foreach ( var existing in Connections.Where( x => x.Input == plugIn ).ToArray() )
{
RemoveConnection( existing );
existing.Destroy();
}
CreateConnection( match, plugIn );
}
}
else if ( targetPlug is PlugOut plugOut )
{
if ( !type.TryGetInput( plugOut.Inner.Type, out var name ) ) return;
if ( nodeUI.Inputs.FirstOrDefault( x => x.Inner.Identifier == name ) is { } match )
{
foreach ( var existing in Connections.Where( x => x.Input == match ).ToArray() )
{
RemoveConnection( existing );
existing.Destroy();
}
CreateConnection( plugOut, match );
}
}
}
finally
{
// PushUndo and PushRedo are asserted as a strictly balanced pair.
PushRedo();
}
}
/// <summary>Re-run layout on every wire, after a style change or a theme flush.</summary>
public void ReflowConnections()
{
_lastStyle = ConnectionStyle;
foreach ( var connection in Items.OfType<Connection>() )
{
PrismLog.Guard( "Reflow connection", connection.Layout );
}
}
/// <summary>
/// True when the scene holds a wire the connection list does not.
/// <para>
/// The in-flight preview wire is added to the scene but never to <c>Connections</c>, so counting is
/// enough and is what this does — asking <c>Connections.Contains</c> per scene item is quadratic in
/// the wire count, and on a several-hundred-node graph that was the single most expensive thing the
/// editor did every frame.
/// </para>
/// </summary>
bool HasPreviewConnection
{
get
{
var scene = 0;
foreach ( var item in Items )
{
if ( item is Connection ) scene++;
}
return scene > Connections.Count;
}
}
/// <summary>
/// The plug an in-flight wire is anchored to, or null when no wire is being dragged.
/// <para>
/// One end of a preview wire is a real plug and the other is following the cursor, so whichever end
/// is valid is the one the user started from. The window reads this to narrow the node library to
/// types that could actually accept the connection while the drag is in progress.
/// </para>
/// </summary>
public Plug DraggingPlug
{
get
{
foreach ( var item in Items )
{
if ( item is not Connection connection || Connections.Contains( connection ) ) continue;
if ( connection.Output.IsValid() ) return connection.Output;
if ( connection.Input.IsValid() ) return connection.Input;
}
return null;
}
}
// ---------------------------------------------------------------- background ----
/// <inheritdoc/>
protected override Pixmap CreateBackgroundPixmap() => BuildGrid();
/// <summary>Rebuild the grid tile and re-apply it. Safe to call after a retheme.</summary>
public void RebuildBackground()
{
PrismLog.Guard( "Build canvas grid", () =>
{
_grid = BuildGrid();
_flat = new Pixmap( 1, 1 );
_flat.Clear( PrismTheme.Canvas );
SetBackgroundImage( Scale.x < 0.5f ? _flat : _grid );
} );
}
static Pixmap BuildGrid()
{
const int tile = 128;
// Not a constant: the grid size is a theme token, so a theme file can change it and the tile has
// to be rebuilt around whatever it now says.
var minor = Math.Clamp( (int)PrismTheme.GridSize, 2, tile );
var pixmap = new Pixmap( tile, tile );
pixmap.Clear( PrismTheme.Canvas );
using ( Paint.ToPixmap( pixmap ) )
{
Paint.Antialiasing = false;
Paint.SetPen( PrismTheme.GridMinor, 1f );
for ( int i = minor; i < tile; i += minor )
{
Paint.DrawLine( new Vector2( i, 0f ), new Vector2( i, tile ) );
Paint.DrawLine( new Vector2( 0f, i ), new Vector2( tile, i ) );
}
Paint.SetPen( PrismTheme.GridMajor, 1f );
Paint.DrawLine( new Vector2( 0f, 0f ), new Vector2( 0f, tile ) );
Paint.DrawLine( new Vector2( 0f, 0f ), new Vector2( tile, 0f ) );
}
return pixmap;
}
/// <inheritdoc/>
protected override void OnMouseWheel( WheelEvent e )
{
base.OnMouseWheel( e );
if ( !_ready ) return;
PrismLog.Guard( "Swap canvas background",
() => SetBackgroundImage( Scale.x < 0.5f ? _flat : _grid ) );
}
// ---------------------------------------------------------------- interaction ----
/// <inheritdoc/>
protected override void OnKeyPress( KeyEvent e )
{
base.OnKeyPress( e );
if ( e.Key == KeyCode.Tab )
{
OpenContextMenu( Application.CursorPosition, ToScene( FromScreen( Application.CursorPosition ) ) );
e.Accepted = true;
return;
}
if ( e.HasCtrl || e.HasAlt ) return;
// 1..9 toggle the preview flag on the selection. Read from the digit rather than from the key
// code so a numeric keypad and the number row behave the same, and so a layout that puts the
// digits behind a modifier still produces the digit the user actually typed.
var text = e.Text;
if ( text is not { Length: 1 } ) return;
var digit = text[0];
if ( digit is < '1' or > '9' ) return;
if ( TogglePreview( digit - '0' ) ) e.Accepted = true;
}
/// <summary>
/// Toggle the preview flag on the nth selected node, or on every selected node when there are fewer
/// than <paramref name="index"/> of them. Returns false when there was nothing to toggle.
/// </summary>
public bool TogglePreview( int index )
{
var mutations = Mutations;
if ( mutations is null ) return false;
var selected = SelectedNodes;
if ( selected.Count == 0 ) return false;
// One node selected: any digit toggles it. Several: the digit picks one, and a digit past the
// end toggles the lot, which is the gesture people reach for after a marquee select.
IReadOnlyList<NodeId> targets = index <= selected.Count && selected.Count > 1
? new[] { selected[index - 1] }
: selected;
var document = Document;
if ( document is null ) return false;
var wanted = document.FindNode( targets[0] ) is { } first
&& ( first.Flags & NodeFlags.Preview ) == 0;
var label = wanted ? "Show Preview" : "Hide Preview";
using ( mutations.Begin( label ) )
{
foreach ( var id in targets )
{
mutations.SetFlag( id, NodeFlags.Preview, wanted, label );
FindCard( id )?.Relayout();
}
}
return true;
}
/// <inheritdoc/>
protected override void OnPopulateNodeMenuSpecialOptions( Menu menu, Vector2 clickPos, Plug targetPlug,
string filter )
{
if ( CanPasteSelection() )
{
menu.AddOption( "Paste", PrismIcons.Paste, PasteSelection );
}
if ( !targetPlug.IsValid() )
{
if ( CommentNodeType is not null )
{
menu.AddOption( "Add Group", PrismIcons.Comment,
() => CreateNewComment( "Untitled", CommentColor.Blue, clickPos, new Vector2( 360f, 220f ) ) );
}
if ( RerouteNodeType is not null )
{
menu.AddOption( "Add Reroute", PrismIcons.Reroute, () => CreateNewReroute( clickPos ) );
}
return;
}
if ( RerouteNodeType is not null )
{
menu.AddOption( "Add Reroute", PrismIcons.Reroute,
() => CreateNewNode( RerouteNodeType, clickPos, targetPlug ) );
}
}
/// <inheritdoc/>
protected override void OnOpenContextMenu( Menu menu, Plug targetPlug )
{
base.OnOpenContextMenu( menu, targetPlug );
if ( targetPlug.IsValid() ) return;
var selected = SelectedItems.OfType<NodeUI>().Count();
if ( selected > 1 )
{
var align = menu.AddMenu( "Align", PrismIcons.Align );
align.AddOption( "Left", null, () => Align( true, 0f ) );
align.AddOption( "Centre", null, () => Align( true, 0.5f ) );
align.AddOption( "Right", null, () => Align( true, 1f ) );
align.AddSeparator();
align.AddOption( "Top", null, () => Align( false, 0f ) );
align.AddOption( "Middle", null, () => Align( false, 0.5f ) );
align.AddOption( "Bottom", null, () => Align( false, 1f ) );
}
var view = menu.AddMenu( "View", PrismIcons.Fit );
view.AddOption( "Frame All", PrismIcons.Fit, FrameAll );
view.AddOption( "Frame Selection", PrismIcons.Frame, CenterOnSelection );
view.AddSeparator();
var bezier = view.AddOption( "Curved Wires", PrismIcons.WireStyle, () => WireStyle = PrismWireStyle.Bezier );
var ortho = view.AddOption( "Angular Wires", PrismIcons.WireStyle, () => WireStyle = PrismWireStyle.Orthogonal );
bezier.Checkable = true;
bezier.Checked = WireStyle == PrismWireStyle.Bezier;
ortho.Checkable = true;
ortho.Checked = WireStyle == PrismWireStyle.Orthogonal;
}
/// <summary>Align the selection on an axis: 0 is left/top, 0.5 centre/middle, 1 right/bottom.</summary>
public void Align( bool horizontal, float anchor )
{
var nodes = SelectedItems.OfType<NodeUI>().ToArray();
if ( nodes.Length < 2 || Mutations is null ) return;
var min = float.MaxValue;
var max = float.MinValue;
foreach ( var node in nodes )
{
var rect = node.SceneRect;
min = MathF.Min( min, horizontal ? rect.Left : rect.Top );
max = MathF.Max( max, horizontal ? rect.Right : rect.Bottom );
}
var target = min + ( max - min ) * anchor;
var positions = new Dictionary<NodeId, Vector2>();
// One scope, so aligning eleven nodes is one entry in the History panel rather than eleven.
using ( Mutations.Begin( "Align" ) )
{
foreach ( var node in nodes )
{
if ( node.Node is not PrismNodeAdapter adapter || adapter.PrismNode is null ) continue;
var size = node.Size;
var position = node.Position;
if ( horizontal ) position.x = target - size.x * anchor;
else position.y = target - size.y * anchor;
position = position.SnapToGrid( GridSize );
node.Position = position;
positions[adapter.PrismNode.Id] = position;
}
Mutations.Move( positions, "Align" );
}
}
/// <summary>Fit every card in the view, with a margin.</summary>
public void FrameAll()
{
var bounds = new Rect();
var any = false;
foreach ( var node in Items.OfType<NodeUI>() )
{
if ( !any ) bounds = node.SceneRect;
else bounds.Add( node.SceneRect );
any = true;
}
if ( !any ) return;
FitInView( bounds.Grow( 80f ) );
}
// ---------------------------------------------------------------- drag and drop ----
/// <inheritdoc/>
protected override INodeType NodeTypeFromDragEvent( DragEvent ev )
{
if ( ev?.Data is null ) return null;
var asset = ev.Data.Assets?.FirstOrDefault();
if ( asset is not null && !string.IsNullOrWhiteSpace( asset.AssetPath ) )
{
var path = asset.AssetPath;
var extension = System.IO.Path.GetExtension( path );
if ( string.Equals( extension, "." + PrismConstants.SubgraphExtension, StringComparison.OrdinalIgnoreCase ) )
{
return Configure( "prism.subgraph.instance", path );
}
if ( LooksLikeImage( extension ) )
{
return Configure( "prism.texture.sample2d", path );
}
}
var text = ev.Data.Text;
if ( string.IsNullOrWhiteSpace( text ) ) return null;
// The node library and the blackboard both drag a plain string: a node type id, or a parameter
// reference in the form "param:<id>".
if ( text.StartsWith( "param:", StringComparison.Ordinal ) )
{
var id = text["param:".Length..];
var type = Resolve( LegacyShaderGraphImporter.ParameterRefTypeId );
return type is null ? null : new PrismNodeTypeAdapter( type.Type,
node => NodeProperties.Set( node, "Parameter", new ParamId( id ) ) );
}
return Resolve( text );
}
static bool LooksLikeImage( string extension )
{
// Deliberately extension-based: resolving the real Asset means awaiting a download for a cloud
// asset, and a drag-hover handler that blocks is a drag-hover handler that stutters.
if ( string.IsNullOrEmpty( extension ) ) return false;
return extension.ToLowerInvariant()
is ".vtex" or ".png" or ".jpg" or ".jpeg" or ".tga" or ".psd" or ".exr" or ".hdr" or ".bmp" or ".gif";
}
PrismNodeTypeAdapter Configure( string typeId, string path )
{
var type = Resolve( typeId );
if ( type is null ) return null;
return new PrismNodeTypeAdapter( type.Type, node => ApplyAssetPath( node, path ) );
}
/// <summary>
/// Point a freshly dropped node at an asset. The concrete property differs per node type and those
/// types belong to another package, so this tries the port first — a texture-typed input is the
/// canonical shape — and then a short list of conventional property names.
/// </summary>
static void ApplyAssetPath( PrismNode node, string path )
{
if ( node is null || string.IsNullOrWhiteSpace( path ) ) return;
// The property comes first: a texture node keeps the image it samples in a plain string
// (DefaultTexture) and only reads its Texture port when something is actually wired into it, so
// writing the port's literal instead would leave the drop silently doing nothing.
foreach ( var name in s_assetProperties )
{
if ( NodeProperties.Find( node.GetType(), name ) is not { } property ) continue;
if ( property.PropertyType == typeof( TextureValue ) )
{
if ( NodeProperties.Set( node, name, new TextureValue { Path = path } ) ) return;
continue;
}
if ( property.PropertyType != typeof( string ) ) continue;
if ( NodeProperties.Set( node, name, path ) ) return;
}
foreach ( var input in node.Inputs )
{
if ( !input.EffectiveType.IsTexture ) continue;
NodeProperties.SetInline( node, input, new TextureValue { Path = path } );
return;
}
}
/// <summary>
/// Property names a dropped asset path is written to, most specific first. <c>DefaultTexture</c> is
/// what every node deriving from the texture base reads; <c>Subgraph</c> resolves through
/// <c>[FormerlyKnownAs]</c> onto the instance node's path.
/// </summary>
static readonly string[] s_assetProperties =
["DefaultTexture", "SubgraphPath", "Texture", "Image", "Asset", "Subgraph", "Path", "Source"];
// ---------------------------------------------------------------- compile feedback ----
/// <summary>
/// Attach compiler diagnostics to the cards and ports they name.
/// <para>
/// No blanket repaint: <c>PrismNodeAdapter.SetDiagnostics</c> compares a signature and invalidates
/// only the nodes whose diagnostics actually moved, so repainting every card here would undo that
/// and make a clean recompile of a large graph as expensive as a rebuild.
/// </para>
/// </summary>
public void ApplyDiagnostics( IEnumerable<Diagnostic> diagnostics ) => Adapter?.ApplyDiagnostics( diagnostics );
/// <summary>
/// Push the solver's inferred types back onto the canvas after a compile, so handles and wires
/// recolour by <em>resolved</em> type rather than by declared type.
/// <para>
/// Only the cards whose resolved types actually changed are repainted. On a graph of any size most
/// of a recompile changes nothing — the whole point of the structure/value hash split — and the
/// cheapest repaint is the one that does not happen.
/// </para>
/// </summary>
public void RefreshTypes()
{
var changed = false;
for ( int i = _cards.Count - 1; i >= 0; i-- )
{
var card = _cards[i];
if ( !card.IsValid() )
{
_cards.RemoveAt( i );
continue;
}
var dirty = false;
foreach ( var plug in card.Inputs ) dirty |= Retype( plug );
foreach ( var plug in card.Outputs ) dirty |= Retype( plug );
if ( !dirty ) continue;
changed = true;
card.Update();
}
if ( !changed ) return;
foreach ( var connection in Items.OfType<Connection>() ) connection.Update();
}
static bool Retype( Plug plug )
{
if ( plug is null ) return false;
var resolved = plug.Inner?.Type;
if ( resolved is null || plug.PropertyType == resolved ) return false;
plug.PropertyType = resolved;
return true;
}
// ---------------------------------------------------------------- frame ----
/// <summary>
/// Per-frame housekeeping the framework gives us no events for.
/// <para>
/// <b>This must cost nothing when the user is not touching the canvas.</b> A 265-node graph has well
/// over five hundred graphics items, and walking them every frame to ask each card whether its hover
/// state changed is a per-frame allocation and a per-frame cache miss for a question whose answer is
/// "no" every frame the pointer is somewhere else. So the walk is gated on the pointer actually
/// being over the canvas, plus a one-frame tail so the last hover is cleanly painted out, plus the
/// two states that genuinely need polling — a pending detach and an in-flight wire.
/// </para>
/// </summary>
[EditorEvent.Frame]
public void OnPrismFrame()
{
if ( !IsValid || !_ready ) return;
var pendingDetach = Adapter is { HasPendingDetach: true };
var hot = IsUnderMouse || pendingDetach || _hasPreviewWire || _wasHot;
if ( !hot )
{
// Cheapest possible idle frame: one bool, no enumeration, no allocation.
if ( _lastStyle != ConnectionStyle ) ReflowConnections();
return;
}
_hasPreviewWire = HasPreviewConnection;
if ( pendingDetach && !_hasPreviewWire )
{
// A wire was picked up and dropped back where it came from. The document was never changed,
// so all that is left to do is forget that we were expecting a change.
Adapter.ResolveDetached();
}
_wasHot = IsUnderMouse || _hasPreviewWire;
SyncCards();
if ( _lastStyle != ConnectionStyle ) ReflowConnections();
}
// ---------------------------------------------------------------- cards ----
/// <summary>
/// Remember a card so per-frame work does not have to walk the whole scene.
/// <para>
/// Called by <see cref="PrismNodeUi"/> from its own constructor. Cards are never explicitly
/// unregistered: a destroyed graphics item reports itself invalid, which is both cheaper and safer
/// than trying to catch every path that can delete one.
/// </para>
/// </summary>
internal void RegisterCard( PrismNodeUi card )
{
if ( card is null ) return;
_cards.Add( card );
}
void SyncCards()
{
for ( int i = _cards.Count - 1; i >= 0; i-- )
{
var card = _cards[i];
if ( !card.IsValid() )
{
_cards.RemoveAt( i );
continue;
}
card.SyncHoverState();
}
}
/// <summary>The card drawing a node, or null when the node has none.</summary>
public PrismNodeUi FindCard( NodeId id )
{
for ( int i = _cards.Count - 1; i >= 0; i-- )
{
var card = _cards[i];
if ( !card.IsValid() )
{
_cards.RemoveAt( i );
continue;
}
if ( card.Model?.Id == id ) return card;
}
return null;
}
/// <summary>How many cards the canvas is currently drawing. Drives the empty-state overlay.</summary>
public int CardCount
{
get
{
var count = 0;
for ( int i = _cards.Count - 1; i >= 0; i-- )
{
if ( !_cards[i].IsValid() )
{
_cards.RemoveAt( i );
continue;
}
count++;
}
return count;
}
}
/// <summary>
/// Hand a freshly rendered thumbnail to the card that asked for it, and relayout it — the image
/// changes the card's height, so a bare repaint would draw it outside the card's own bounds.
/// </summary>
public void SetThumbnail( NodeId id, Pixmap pixmap )
{
var card = FindCard( id );
if ( card is null || card.Adapter is null ) return;
var had = card.Adapter.Thumbnail is not null;
card.Adapter.Thumbnail = pixmap;
if ( had && pixmap is not null )
{
// Same geometry, new pixels.
card.Update();
return;
}
card.Relayout();
}
/// <summary>
/// The nodes currently inside the viewport, nearest the centre first. The thumbnail service renders
/// these before anything else, so what the user is looking at fills in first.
/// </summary>
public IReadOnlyList<NodeId> VisibleNodes()
{
var local = LocalRect;
var view = new Rect( ToScene( local.TopLeft ) ).AddPoint( ToScene( local.BottomRight ) );
var centre = view.Center;
var visible = new List<(NodeId Id, float Distance)>();
for ( int i = _cards.Count - 1; i >= 0; i-- )
{
var card = _cards[i];
if ( !card.IsValid() )
{
_cards.RemoveAt( i );
continue;
}
var model = card.Model;
if ( model is null ) continue;
var rect = card.SceneRect;
if ( !Overlaps( view, rect ) ) continue;
visible.Add( (model.Id, ( rect.Center - centre ).LengthSquared) );
}
visible.Sort( static ( a, b ) => a.Distance.CompareTo( b.Distance ) );
var result = new NodeId[visible.Count];
for ( int i = 0; i < visible.Count; i++ ) result[i] = visible[i].Id;
return result;
}
static bool Overlaps( Rect a, Rect b ) =>
a.Left < b.Right && b.Left < a.Right && a.Top < b.Bottom && b.Top < a.Bottom;
/// <inheritdoc/>
protected override void OnClear()
{
base.OnClear();
Adapter?.ResolveDetached();
}
/// <inheritdoc/>
public override void OnDestroyed()
{
Adapter?.Dispose();
Adapter = null;
base.OnDestroyed();
}
// Read and written through PrismCookies rather than EditorCookie directly. Both spell the same key,
// but PrismCookies stores the enum by name and this used to store it by ordinal, so the two
// disagreed about what the cookie meant and whichever wrote last made the other read a default.
// Going through the one owner also means the preferences page and the View menu stay in step.
static PrismWireStyle ReadWireStyleCookie() =>
PrismLog.Guard( "Read wire style", () => PrismCookies.WireStyle, PrismWireStyle.Bezier );
}