Editor/Prism/Ui/PrismGraphView.cs

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).

ReflectionFile AccessExternal Download
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 );
}