Editor/Prism/Ui/Adapters/PrismNodeAdapter.cs

Adapter wrapping a PrismNode for the editor node-graph framework. It exposes node properties, ports, diagnostics and UI actions, synchronises plugs, handles painting guard, context menus, double-click behaviour and specialised adapters for reroute and comment nodes.

Native Interop
using Editor.Prism.Core;
using Editor.Prism.Model;

namespace Editor.Prism.Ui.Adapters;

/// <summary>
/// One <see cref="PrismNode"/>, as the node-graph framework sees it.
/// <para>
/// The model layer deliberately knows nothing about <c>Editor.NodeEditor</c> — <see cref="PrismNode"/>
/// has no <c>INode</c> in its inheritance chain and never will, because that would drag the whole Qt
/// widget stack into the compiler's dependency graph. This adapter is the entire seam, and it is
/// one-to-one and cached: exactly one adapter exists per node for the lifetime of the document, so the
/// framework's reference-equality checks on plugs and nodes hold.
/// </para>
/// </summary>
public class PrismNodeAdapter : INode
{
	readonly List<PrismPlugIn> _inputs = new();
	readonly List<PrismPlugOut> _outputs = new();
	readonly Dictionary<PortId, PrismPlugIn> _inputById = new();
	readonly Dictionary<PortId, PrismPlugOut> _outputById = new();

	readonly List<Diagnostic> _diagnostics = new();

	string _errorMessage;
	string _signature = "\0";
	int _errorCount;
	int _warningCount;
	bool _hooked;
	bool _paintFaulted;

	/// <summary>Wrap a node for a document adapter.</summary>
	public PrismNodeAdapter( PrismGraphAdapter adapter, PrismNode node )
	{
		Adapter = adapter;
		PrismNode = node;

		Hook();
	}

	/// <summary>The document adapter that owns this node adapter.</summary>
	public PrismGraphAdapter Adapter { get; }

	/// <summary>The model node this adapter stands for.</summary>
	public PrismNode PrismNode { get; }

	/// <summary>Cached reflection metadata for the node's concrete type.</summary>
	public NodeDescriptor Descriptor => PrismNode?.Descriptor;

	/// <inheritdoc/>
	public event Action Changed;

	/// <inheritdoc/>
	public string Identifier => PrismNode?.Id.Value ?? string.Empty;

	/// <inheritdoc/>
	public DisplayInfo DisplayInfo
	{
		get
		{
			var descriptor = Descriptor;

			return new DisplayInfo
			{
				Name = descriptor?.Title ?? PrismNode?.GetType().Name ?? "Node",
				Description = descriptor?.Description,
				Group = descriptor?.Category,
				Icon = string.IsNullOrEmpty( descriptor?.Icon ) ? PrismIcons.Graph : descriptor.Icon
			};
		}
	}

	/// <summary>The accent colour of the node's category, which drives the header bar.</summary>
	public Color CategoryColor => PrismTheme.ForCategory( Descriptor?.Category );

	/// <inheritdoc/>
	public bool CanClone => true;

	/// <inheritdoc/>
	public bool CanRemove
	{
		get
		{
			var document = Adapter?.Document;

			if ( document is null || PrismNode is null ) return true;

			// The terminal node is the one thing a graph cannot be without.
			return !ReferenceEquals( document.OutputNode, PrismNode );
		}
	}

	/// <inheritdoc/>
	public Vector2 Position
	{
		get => PrismNode?.Position ?? Vector2.Zero;
		set
		{
			var node = PrismNode;

			if ( node is null || node.Position == value ) return;

			var document = Adapter?.Document;
			var mutations = Adapter?.Mutations;

			// A node that is not in the document yet — the drag-and-drop ghost — has nothing to record.
			if ( document is null || mutations is null || document.FindNode( node.Id ) is null )
			{
				node.Position = value;
				return;
			}

			mutations.Move( node.Id, value, "Move Item" );
		}
	}

	/// <inheritdoc/>
	public Vector2 ExpandSize => Vector2.Zero;

	/// <inheritdoc/>
	public bool AutoSize => true;

	/// <inheritdoc/>
	public bool HasTitleBar => true;

	/// <inheritdoc/>
	public Pixmap Thumbnail { get; set; }

	/// <summary>Per-node flags: preview, collapsed, disabled, pinned.</summary>
	public NodeFlags Flags => PrismNode?.Flags ?? NodeFlags.None;

	/// <summary>True when the card is drawn as a header-only strip.</summary>
	public bool IsCollapsed => ( Flags & NodeFlags.Collapsed ) != 0;

	/// <summary>True when the node is excluded from compilation.</summary>
	public bool IsDisabled => ( Flags & NodeFlags.Disabled ) != 0;

	/// <summary>True when the node asks for a preview thumbnail.</summary>
	public bool WantsPreview => ( Flags & NodeFlags.Preview ) != 0;

	/// <summary>True when the node is pinned.</summary>
	public bool IsPinned => ( Flags & NodeFlags.Pinned ) != 0;

	/// <summary>True when this node is a reroute, which is drawn as a bare dot rather than a card.</summary>
	public virtual bool IsReroute => false;

	/// <summary>True when this node is a comment / group box rather than a value-producing node.</summary>
	public virtual bool IsComment => false;

	/// <inheritdoc/>
	public string ErrorMessage => _errorMessage;

	/// <summary>How many errors are attached to this node.</summary>
	public int ErrorCount => _errorCount;

	/// <summary>How many warnings are attached to this node.</summary>
	public int WarningCount => _warningCount;

	/// <summary>Every diagnostic attached to this node, in report order.</summary>
	public IReadOnlyList<Diagnostic> Diagnostics => _diagnostics;

	/// <summary>The first line worth showing in the card's inline diagnostic strip.</summary>
	public Diagnostic PrimaryDiagnostic
	{
		get
		{
			Diagnostic best = null;

			foreach ( var diagnostic in _diagnostics )
			{
				if ( best is null || diagnostic.Severity > best.Severity ) best = diagnostic;
			}

			return best;
		}
	}

	/// <inheritdoc/>
	public bool IsReachable => Adapter?.IsReachable( PrismNode ) ?? true;

	/// <inheritdoc/>
	public IEnumerable<IPlugIn> Inputs
	{
		get
		{
			SyncPlugs();
			return _inputs;
		}
	}

	/// <inheritdoc/>
	public IEnumerable<IPlugOut> Outputs
	{
		get
		{
			SyncPlugs();
			return _outputs;
		}
	}

	/// <summary>The input plug adapters, in socket order.</summary>
	public IReadOnlyList<PrismPlugIn> InputPlugs
	{
		get
		{
			SyncPlugs();
			return _inputs;
		}
	}

	/// <summary>The output plug adapters, in socket order.</summary>
	public IReadOnlyList<PrismPlugOut> OutputPlugs
	{
		get
		{
			SyncPlugs();
			return _outputs;
		}
	}

	/// <summary>Find an input plug adapter by port id.</summary>
	public PrismPlugIn FindInput( PortId id )
	{
		SyncPlugs();
		return _inputById.TryGetValue( id, out var plug ) ? plug : null;
	}

	/// <summary>Find an output plug adapter by port id.</summary>
	public PrismPlugOut FindOutput( PortId id )
	{
		SyncPlugs();
		return _outputById.TryGetValue( id, out var plug ) ? plug : null;
	}

	/// <inheritdoc/>
	public virtual NodeUI CreateUI( GraphView view ) => new PrismNodeUi( view, this );

	/// <inheritdoc/>
	public Color GetPrimaryColor( GraphView view ) => CategoryColor;

	/// <inheritdoc/>
	public void OnPaint( Rect rect ) => PaintBody( rect, default );

	/// <summary>
	/// Let the node draw its own body decoration — a curve, a gradient ramp, a swatch. Called by the
	/// card painter with the rectangle left over after ports and thumbnails.
	/// <para>
	/// Written as a bare <c>try</c> rather than through <c>PrismLog.Guard</c> on purpose: this runs once
	/// per visible card per repaint, and the guard would allocate a closure, a delegate and an
	/// interpolated string every time — several hundred objects per frame while panning a large graph,
	/// to describe a fault that almost never happens.
	/// </para>
	/// <para>
	/// A node that throws is reported once and then skipped for the rest of the session. The alternative
	/// is a console filling at frame rate, which hides the one line that actually explains the problem.
	/// </para>
	/// </summary>
	public void PaintBody( Rect rect, NodePaintState state )
	{
		var node = PrismNode;

		if ( node is null || _paintFaulted ) return;

		try
		{
			node.OnPaintBody( rect, state );
		}
		catch ( Exception e )
		{
			_paintFaulted = true;

			PrismLog.Error( e, $"Painting the body of node '{Identifier}' failed; it will not be drawn again" );
		}
	}

	/// <inheritdoc/>
	public virtual void OnDoubleClick( MouseEvent e )
	{
		var node = PrismNode;
		var mutations = Adapter?.Mutations;

		if ( node is null || mutations is null ) return;

		mutations.SetFlag( node.Id, NodeFlags.Collapsed, !IsCollapsed,
			IsCollapsed ? "Expand Node" : "Collapse Node" );

		Invalidate();

		e.Accepted = true;
	}

	/// <inheritdoc/>
	public virtual Menu CreateContextMenu( NodeUI node )
	{
		var model = PrismNode;
		var mutations = Adapter?.Mutations;

		if ( model is null ) return null;

		var menu = new Menu( DisplayInfo.Name );

		if ( mutations is not null )
		{
			menu.AddOption( WantsPreview ? "Hide Preview" : "Show Preview",
				WantsPreview ? PrismIcons.PreviewOff : PrismIcons.Preview,
				() =>
				{
					mutations.SetFlag( model.Id, NodeFlags.Preview, !WantsPreview,
						WantsPreview ? "Hide Preview" : "Show Preview" );
					Invalidate();
				} );

			menu.AddOption( IsCollapsed ? "Expand" : "Collapse",
				IsCollapsed ? PrismIcons.Expanded : PrismIcons.Collapsed,
				() =>
				{
					mutations.SetFlag( model.Id, NodeFlags.Collapsed, !IsCollapsed,
						IsCollapsed ? "Expand Node" : "Collapse Node" );
					Invalidate();
				} );

			menu.AddOption( IsDisabled ? "Enable" : "Disable", PrismIcons.Disabled,
				() =>
				{
					mutations.SetFlag( model.Id, NodeFlags.Disabled, !IsDisabled,
						IsDisabled ? "Enable Node" : "Disable Node" );
					Invalidate();
				} );

			menu.AddOption( IsPinned ? "Unpin" : "Pin", PrismIcons.Pinned,
				() =>
				{
					mutations.SetFlag( model.Id, NodeFlags.Pinned, !IsPinned,
						IsPinned ? "Unpin Node" : "Pin Node" );
					Invalidate();
				} );
		}

		PrismLog.Guard( $"Node context menu '{Identifier}'", () => model.OnContextMenu( menu ) );

		return menu;
	}

	/// <summary>Replace the diagnostics attached to this node and its ports.</summary>
	public void SetDiagnostics( IEnumerable<Diagnostic> diagnostics )
	{
		// Every compile calls this for every node. Relaying out a card whose diagnostics did not change
		// would make a clean recompile as expensive as a full rebuild.
		var signature = Signature( diagnostics );

		if ( string.Equals( signature, _signature, StringComparison.Ordinal ) ) return;

		_signature = signature;

		_diagnostics.Clear();
		_errorCount = 0;
		_warningCount = 0;
		_errorMessage = null;

		foreach ( var plug in _inputs ) plug.SetError( null );
		foreach ( var plug in _outputs ) plug.SetError( null );

		if ( diagnostics is not null )
		{
			foreach ( var diagnostic in diagnostics )
			{
				if ( diagnostic is null ) continue;

				_diagnostics.Add( diagnostic );

				if ( diagnostic.Severity == DiagnosticSeverity.Error ) _errorCount++;
				else if ( diagnostic.Severity == DiagnosticSeverity.Warning ) _warningCount++;

				if ( diagnostic.Graph?.Port is not { } port ) continue;

				var plug = (PrismPlug)FindInput( port ) ?? FindOutput( port );

				plug?.SetError( diagnostic.Message );
			}
		}

		if ( _errorCount > 0 )
		{
			var lines = _diagnostics
				.Where( x => x.Severity == DiagnosticSeverity.Error )
				.Select( x => x.Message );

			_errorMessage = string.Join( Environment.NewLine, lines );
		}

		Invalidate();
	}

	/// <summary>Raise <see cref="Changed"/> so the card re-syncs its plugs, relayouts and repaints.</summary>
	public void Invalidate() => PrismLog.Guard( $"Invalidate node '{Identifier}'", () => Changed?.Invoke() );

	static string Signature( IEnumerable<Diagnostic> diagnostics )
	{
		if ( diagnostics is null ) return string.Empty;

		var builder = new System.Text.StringBuilder();

		foreach ( var diagnostic in diagnostics )
		{
			if ( diagnostic is null ) continue;

			builder.Append( (int)diagnostic.Severity ).Append( '|' )
				.Append( diagnostic.Code ).Append( '|' )
				.Append( diagnostic.Graph?.Port?.Value ).Append( '|' )
				.Append( diagnostic.Message ).Append( '\n' );
		}

		return builder.ToString();
	}

	/// <summary>Stop listening to the model node. Called when the node leaves the document.</summary>
	public void Detach()
	{
		if ( !_hooked || PrismNode is null ) return;

		PrismNode.Changed -= OnModelChanged;
		_hooked = false;
	}

	/// <inheritdoc/>
	public override string ToString() => $"{Identifier} ({Descriptor?.Id})";

	void Hook()
	{
		if ( _hooked || PrismNode is null ) return;

		PrismNode.Changed += OnModelChanged;
		_hooked = true;
	}

	void OnModelChanged( PrismNode node, NodeChangeKind kind )
	{
		// A position change is the framework moving the card the user is already dragging. Feeding it
		// back would re-sync plugs and re-run layout on every pixel of every drag, and move nothing:
		// the card's own position is authoritative until something calls Rebuild.
		if ( kind == NodeChangeKind.Position ) return;

		if ( kind == NodeChangeKind.Ports ) SyncPlugs( true );

		Invalidate();
	}

	void SyncPlugs( bool force = false )
	{
		var node = PrismNode;

		if ( node is null ) return;

		if ( !force && IsInSync( node ) ) return;

		_inputs.Clear();
		_outputs.Clear();

		foreach ( var port in node.Inputs )
		{
			if ( port is null || port.Def is null || port.Def.Hidden ) continue;

			if ( !_inputById.TryGetValue( port.Id, out var plug ) )
			{
				plug = new PrismPlugIn( this, port.Id );
				_inputById[port.Id] = plug;
			}

			_inputs.Add( plug );
		}

		foreach ( var port in node.Outputs )
		{
			if ( port is null || port.Def is null || port.Def.Hidden ) continue;

			if ( !_outputById.TryGetValue( port.Id, out var plug ) )
			{
				plug = new PrismPlugOut( this, port.Id );
				_outputById[port.Id] = plug;
			}

			_outputs.Add( plug );
		}
	}

	bool IsInSync( PrismNode node )
	{
		var index = 0;

		foreach ( var port in node.Inputs )
		{
			if ( port?.Def is null || port.Def.Hidden ) continue;
			if ( index >= _inputs.Count || _inputs[index].PortId != port.Id ) return false;

			index++;
		}

		if ( index != _inputs.Count ) return false;

		index = 0;

		foreach ( var port in node.Outputs )
		{
			if ( port?.Def is null || port.Def.Hidden ) continue;
			if ( index >= _outputs.Count || _outputs[index].PortId != port.Id ) return false;

			index++;
		}

		return index == _outputs.Count;
	}
}

/// <summary>
/// A reroute node. The framework special-cases <see cref="IRerouteNode"/> when it walks upstream to
/// find the real type behind an untyped wire, so declaring it is what keeps the create-node menu
/// filtered correctly when a user drags out of a chain of reroutes.
/// </summary>
public sealed class PrismRerouteAdapter : PrismNodeAdapter, IRerouteNode
{
	/// <summary>Wrap a reroute node.</summary>
	public PrismRerouteAdapter( PrismGraphAdapter adapter, PrismNode node ) : base( adapter, node ) { }

	/// <inheritdoc/>
	public override bool IsReroute => true;

	/// <inheritdoc/>
	public string Comment
	{
		get => Serialization.NodeProperties.Get( PrismNode, "Comment" ) as string;
		set
		{
			var node = PrismNode;

			if ( node is null ) return;

			Adapter?.Mutations?.SetProperty( node.Id, "Comment", value, NodeChangeKind.Properties, "Label Reroute" );
			Invalidate();
		}
	}

	/// <inheritdoc/>
	public override void OnDoubleClick( MouseEvent e )
	{
		// A reroute has no body to collapse; double-clicking one should do nothing rather than
		// silently toggling an invisible flag.
		e.Accepted = true;
	}
}

/// <summary>
/// A comment / group box.
/// <para>
/// The framework's comment support is driven entirely by <see cref="ICommentNode"/>, and it buys real
/// behaviour: eight-direction resizing, nested layering, and click-to-select-everything-inside. The
/// node's own properties are read and written by name through the serializer's property helper, so this
/// adapter does not need to know the concrete comment node class — which belongs to the node library
/// package, not to this one.
/// </para>
/// </summary>
public sealed class PrismCommentAdapter : PrismNodeAdapter, ICommentNode
{
	/// <summary>The stable node type id a comment box is expected to have.</summary>
	public const string TypeId = "prism.util.comment";

	/// <summary>Wrap a comment node.</summary>
	public PrismCommentAdapter( PrismGraphAdapter adapter, PrismNode node ) : base( adapter, node ) { }

	/// <inheritdoc/>
	public override bool IsComment => true;

	/// <inheritdoc/>
	public int Layer
	{
		get => Read( "Layer" ) is int value ? value : 5;
		set => Write( "Layer", value, "Reorder Comment" );
	}

	/// <inheritdoc/>
	public Vector2 Size
	{
		get
		{
			var stored = Adapter?.Document?.GetNodeSize( PrismNode?.Id ?? default );

			return stored ?? new Vector2( 320f, 200f );
		}
		set
		{
			var node = PrismNode;

			if ( node is null ) return;

			Adapter?.Mutations?.Resize( node.Id, value, "Resize Comment" );
		}
	}

	/// <inheritdoc/>
	public CommentColor Color
	{
		get => Read( "Color" ) is CommentColor value ? value : CommentColor.Blue;
		set => Write( "Color", value, "Recolour Comment" );
	}

	/// <inheritdoc/>
	public string Title
	{
		get => Read( "Title" ) as string ?? "Untitled";
		set => Write( "Title", value, "Rename Comment" );
	}

	/// <inheritdoc/>
	public string Description
	{
		get => Read( "Description" ) as string ?? string.Empty;
		set => Write( "Description", value, "Describe Comment" );
	}

	/// <inheritdoc/>
	public override NodeUI CreateUI( GraphView view ) => new PrismCommentUi( view, this );

	object Read( string property ) =>
		PrismNode is null ? null : Serialization.NodeProperties.Get( PrismNode, property );

	void Write( string property, object value, string label )
	{
		var node = PrismNode;

		if ( node is null ) return;

		Adapter?.Mutations?.SetProperty( node.Id, property, value, NodeChangeKind.Properties, label );
		Invalidate();
	}
}