Editor/Prism/Ui/Adapters/PrismPlugs.cs

Editor UI adapter for Prism node graph plugs. Maps shader types to CLR marker/engine types, provides color/name helpers, and implements PrismPlug base plus PrismPlugIn and PrismPlugOut which manage input/output port state, connection acceptance, inline values, deferred detach logic, handle offsets, and context menus.

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

namespace Editor.Prism.Ui.Adapters;

/// <summary>
/// The bridge between Prism's <see cref="ShaderType"/> lattice and the CLR <see cref="Type"/> the
/// node-graph framework keys its handle configuration and its wire-drag compatibility filter on.
/// <para>
/// The framework has exactly one notion of "what flows down this wire" and it is a <c>System.Type</c>.
/// Rather than fight that, we map every shader type onto a stable CLR type — real engine types where
/// one exists, so a dragged <c>Vector3</c> behaves the way a user expects, and tiny marker structs
/// where the shader world has no engine equivalent at all.
/// </para>
/// </summary>
public static class PrismShaderTypes
{
	/// <summary>Marker for a boolean vector, which has no engine equivalent.</summary>
	public readonly struct BoolVector { }

	/// <summary>Marker for an integer vector, which has no engine equivalent.</summary>
	public readonly struct IntVector { }

	/// <summary>Marker for a matrix of any shape.</summary>
	public readonly struct MatrixValue { }

	/// <summary>Marker for a sampler state.</summary>
	public readonly struct SamplerValue { }

	/// <summary>Marker for a buffer or unordered-access resource.</summary>
	public readonly struct BufferValue { }

	/// <summary>Marker for a user struct.</summary>
	public readonly struct StructValue { }

	/// <summary>
	/// The CLR type that stands for a shader type. <paramref name="isColor"/> promotes a four-component
	/// float to <see cref="Color"/>, which is what gives colour ports their own handle colour.
	/// </summary>
	public static Type Clr( ShaderType type, bool isColor = false )
	{
		if ( isColor ) return typeof( Color );
		if ( type.IsVoid ) return typeof( object );
		if ( type.IsMatrix ) return typeof( MatrixValue );
		if ( type.IsSampler ) return typeof( SamplerValue );
		if ( type.IsTexture ) return typeof( Texture );
		if ( type.IsBuffer || type.IsWritable ) return typeof( BufferValue );
		if ( type.IsStruct ) return typeof( StructValue );

		if ( type.IsBoolean ) return type.Components <= 1 ? typeof( bool ) : typeof( BoolVector );
		if ( type.IsIntegral ) return type.Components <= 1 ? typeof( int ) : typeof( IntVector );

		return type.Components switch
		{
			<= 1 => typeof( float ),
			2 => typeof( Vector2 ),
			3 => typeof( Vector3 ),
			_ => typeof( Vector4 )
		};
	}

	/// <summary>
	/// The shader type a CLR type stands for. The inverse of <see cref="Clr"/>, lossy by design: it only
	/// has to be good enough to answer "could this node accept the value on the wire I am dragging?".
	/// </summary>
	public static ShaderType Shader( Type type )
	{
		if ( type is null || type == typeof( object ) ) return ShaderType.Void;
		if ( type == typeof( float ) || type == typeof( double ) ) return ShaderType.Float;
		if ( type == typeof( Vector2 ) ) return ShaderType.Float2;
		if ( type == typeof( Vector3 ) ) return ShaderType.Float3;
		if ( type == typeof( Vector4 ) || type == typeof( Color ) ) return ShaderType.Float4;
		if ( type == typeof( bool ) ) return ShaderType.Bool;
		if ( type == typeof( BoolVector ) ) return ShaderType.Bool4;
		if ( type == typeof( int ) || type == typeof( uint ) ) return ShaderType.Int;
		if ( type == typeof( IntVector ) ) return ShaderType.Int4;
		if ( type == typeof( MatrixValue ) ) return ShaderType.Float4x4;
		if ( type == typeof( SamplerValue ) ) return ShaderType.Sampler;
		if ( type == typeof( Texture ) ) return ShaderType.Texture2D;
		if ( type == typeof( BufferValue ) ) return ShaderType.Obj( ObjectKind.StructuredBuffer );
		if ( type == typeof( StructValue ) ) return ShaderType.Struct( "struct" );

		return ShaderType.Void;
	}

	/// <summary>The colour a CLR type reads as on a handle, a wire or a value pill.</summary>
	public static Color ColorFor( Type type )
	{
		if ( type == typeof( Color ) ) return PrismTheme.TypeColor;
		if ( type is null || type == typeof( object ) ) return PrismTheme.TypeGeneric;

		return PrismTheme.ForType( Shader( type ) );
	}

	/// <summary>A short human name for a type, used in tooltips and the handle configuration.</summary>
	public static string Name( ShaderType type, bool isColor = false )
	{
		if ( isColor ) return "color";

		return type.IsVoid ? "T" : type.ToString();
	}
}

/// <summary>
/// One socket of a node, as the node-graph framework sees it.
/// <para>
/// A plug adapter is long-lived and identified by its <see cref="PortId"/>, not by the
/// <see cref="Port"/> object: <c>RebuildPorts</c> replaces port instances whenever a node reshapes
/// itself, and <c>NodeUI.UpdatePlugs</c> matches plugs by <em>reference</em>. Holding a port instance
/// would therefore make every dynamic node destroy and re-create its sockets — and with them their
/// connections — on every property change.
/// </para>
/// </summary>
public abstract class PrismPlug : IPlug
{
	string _error;
	RealTimeSince _flash = 1000f;

	/// <summary>Bind a plug adapter to a port of a node.</summary>
	protected PrismPlug( PrismNodeAdapter owner, PortId id )
	{
		Owner = owner;
		PortId = id;
	}

	/// <summary>The node adapter that owns this plug.</summary>
	public PrismNodeAdapter Owner { get; }

	/// <summary>The id of the port this plug stands for. Stable across port rebuilds.</summary>
	public PortId PortId { get; }

	/// <summary>The live port, or null when the node reshaped itself and dropped this id.</summary>
	public abstract Port ModelPort { get; }

	/// <summary>The port declaration, or null when the port no longer exists.</summary>
	public PortDef Def => ModelPort?.Def;

	/// <summary>The node this plug belongs to.</summary>
	public INode Node => Owner;

	/// <inheritdoc/>
	public string Identifier => PortId.Value;

	/// <summary>The solver-resolved type, falling back to the declared one.</summary>
	public ShaderType EffectiveType => ModelPort?.EffectiveType ?? ShaderType.Void;

	/// <summary>True when the port is declared as a colour rather than a bare four-component float.</summary>
	public bool IsColor
	{
		get
		{
			var declared = ModelPort?.DeclaredType;

			return !string.IsNullOrEmpty( declared )
				&& string.Equals( declared, "color", StringComparison.OrdinalIgnoreCase );
		}
	}

	/// <summary>True when the port's type is still a type variable.</summary>
	public bool IsGeneric => ModelPort?.Def?.IsGeneric ?? false;

	/// <summary>True when leaving this port unconnected is an error.</summary>
	public bool IsRequired => ModelPort?.Required ?? false;

	/// <summary>The port's declared flags.</summary>
	public PortFlags Flags => ModelPort?.Flags ?? PortFlags.None;

	/// <summary>The label drawn beside the handle.</summary>
	public string Label => ModelPort?.DisplayName ?? PortId.Value;

	/// <summary>The optional group heading this port sits under.</summary>
	public string GroupName => ModelPort?.Group;

	/// <summary>The colour of this port's handle, wire and value pill.</summary>
	public Color TypeColor => IsColor ? PrismTheme.TypeColor : PrismTheme.ForType( EffectiveType );

	/// <inheritdoc/>
	public Type Type => PrismShaderTypes.Clr( EffectiveType, IsColor );

	/// <inheritdoc/>
	public DisplayInfo DisplayInfo => new()
	{
		Name = Label,
		Description = ModelPort?.Tooltip,
		Group = string.IsNullOrEmpty( GroupName ) ? null : GroupName,
		Icon = null
	};

	/// <inheritdoc/>
	public virtual bool ShowLabel => false;

	/// <inheritdoc/>
	public virtual bool AllowStretch => false;

	/// <inheritdoc/>
	public virtual bool ShowConnection => true;

	/// <inheritdoc/>
	public virtual bool InTitleBar => false;

	/// <inheritdoc/>
	public bool IsReachable => Owner?.IsReachable ?? true;

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

	/// <summary>True while this handle is flashing to reject something the user tried to drop on it.</summary>
	public bool IsFlashing => _flash < 0.45f;

	/// <summary>How far through the rejection flash we are, from one down to zero.</summary>
	public float FlashAmount => IsFlashing ? 1f - ( (float)_flash / 0.45f ) : 0f;

	/// <summary>Flash this handle in the error colour, without any other consequence.</summary>
	public void Flash()
	{
		_flash = 0f;
		Owner?.Invalidate();
	}

	/// <summary>Replace the diagnostic text attached to this port. Null clears it.</summary>
	public void SetError( string message ) => _error = string.IsNullOrWhiteSpace( message ) ? null : message;

	/// <inheritdoc/>
	public virtual ValueEditor CreateEditor( NodeUI node, Plug plug ) => null;

	/// <inheritdoc/>
	public virtual Menu CreateContextMenu( NodeUI node, Plug plug ) => null;

	/// <inheritdoc/>
	public virtual void OnDoubleClick( NodeUI node, Plug plug, MouseEvent e ) { }

	/// <inheritdoc/>
	public override string ToString() => $"{Owner?.PrismNode?.Id}.{PortId} : {EffectiveType}";
}

/// <summary>
/// An input socket.
/// <para>
/// <b>This is where connection type-checking lives.</b> <c>GraphView</c> performs none at all — it
/// simply assigns <see cref="ConnectedOutput"/> when a wire is dropped — so the setter is the only
/// hook the framework offers, and an illegal conversion is rejected by returning without applying it
/// and flashing the handle.
/// </para>
/// <para>
/// The setter is also deliberately <em>deferred</em> when it is asked to disconnect. Clicking a
/// connected input immediately nulls <c>ConnectedOutput</c> and starts re-dragging the wire, so an
/// eager disconnect would delete the edge — and push an undo step — before the user has done anything
/// at all, and dropping the wire back where it came from would silently destroy it. Instead the plug
/// remembers that the framework wants it detached, reports itself as disconnected, and the document is
/// only changed once the drag resolves into a real removal or a real reconnection.
/// </para>
/// </summary>
public sealed class PrismPlugIn : PrismPlug, IPlugIn
{
	static readonly string[] s_handleNames = { "x", "y", "a", "b" };

	readonly Dictionary<string, float> _handleOffsets = new( StringComparer.Ordinal );

	bool _detached;

	/// <summary>Bind an input plug adapter to a port of a node.</summary>
	public PrismPlugIn( PrismNodeAdapter owner, PortId id ) : base( owner, id ) { }

	/// <inheritdoc/>
	public override Port ModelPort => Owner?.PrismNode?.FindInput( PortId );

	/// <summary>The input port, or null when the node dropped this id.</summary>
	public InputPort Input => Owner?.PrismNode?.FindInput( PortId );

	/// <summary>True while the framework believes this input is disconnected but the document still is not.</summary>
	public bool IsDetachPending => _detached;

	/// <summary>The literal used when nothing is connected.</summary>
	/// <summary>
	/// The literal this port uses when nothing is connected.
	/// <remarks>
	/// Resolved through <see cref="NodeProperties.GetInline"/> rather than off the port slot, because a
	/// port can be backed by a property — <c>[InlineValue]</c> — and then the property is the value.
	/// Reading the slot directly showed an empty pill on the card for anything so bound, while the
	/// compiler, the Inspector and the preview all read the property and saw the real value. A texture
	/// node was the visible case: it holds its image in <c>DefaultTexture</c>, so the card claimed "None"
	/// over a node that was sampling a texture perfectly well.
	/// </remarks>
	/// </summary>
	public object InlineValue => NodeProperties.GetInline( Owner?.PrismNode, Input );

	/// <summary>True when the document has an edge terminating on this port.</summary>
	public bool IsConnectedInDocument => !_detached && Input is { IsConnected: true };

	/// <inheritdoc/>
	public IPlugOut ConnectedOutput
	{
		get
		{
			if ( _detached ) return null;

			var graph = Owner?.Adapter;
			var node = Owner?.PrismNode;

			if ( graph is null || node is null ) return null;
			if ( !graph.Document.TryGetIncomingEdge( node.Id, PortId, out var edge ) ) return null;

			return graph.FindPlugOut( edge.From );
		}
		set
		{
			if ( value is null )
			{
				BeginDetach();
				return;
			}

			Apply( value as PrismPlugOut );
		}
	}

	/// <summary>
	/// Mark the input as detached without touching the document. Committed by
	/// <see cref="CommitDetach"/> when the drag really removes the wire, or abandoned by
	/// <see cref="CancelDetach"/> when it does not.
	/// </summary>
	public void BeginDetach()
	{
		if ( _detached ) return;

		_detached = true;

		Owner?.Adapter?.NoteDetached( this );
		Owner?.Invalidate();
	}

	/// <summary>Apply a pending detach to the document, as one undo step.</summary>
	public bool CommitDetach()
	{
		if ( !_detached ) return false;

		_detached = false;

		var graph = Owner?.Adapter;
		var node = Owner?.PrismNode;

		if ( graph is null || node is null ) return false;
		if ( !graph.Document.TryGetIncomingEdge( node.Id, PortId, out var edge ) ) return false;

		graph.Mutations?.Disconnect( edge.Id, "Disconnect" );
		Owner?.Invalidate();

		return true;
	}

	/// <summary>Abandon a pending detach. The document was never changed, so nothing has to be undone.</summary>
	public void CancelDetach()
	{
		if ( !_detached ) return;

		_detached = false;
		Owner?.Invalidate();
	}

	/// <summary>
	/// Would a wire from this output to this input be legal? Consults the same conversion table the
	/// compiler does, so what the canvas refuses is exactly what the compiler would have rejected.
	/// </summary>
	public bool CanAccept( PrismPlugOut source, out string reason )
	{
		reason = null;

		if ( source is null )
		{
			reason = "There is nothing to connect";
			return false;
		}

		var graph = Owner?.Adapter;
		var target = Owner?.PrismNode;
		var origin = source.Owner?.PrismNode;

		if ( graph is null || target is null || origin is null )
		{
			reason = "One end of this connection no longer exists";
			return false;
		}

		var from = new PortRef( origin.Id, source.PortId );
		var to = new PortRef( target.Id, PortId );

		return graph.Document.CanConnect( from, to, out reason );
	}

	void Apply( PrismPlugOut source )
	{
		var graph = Owner?.Adapter;
		var target = Owner?.PrismNode;

		if ( graph is null || target is null ) return;

		if ( !CanAccept( source, out var reason ) )
		{
			// The only rejection channel the framework gives us: refuse silently, and say so visually.
			PrismLog.Trace( $"Prism refused a connection: {reason}" );

			Flash();
			CancelDetach();

			return;
		}

		var from = new PortRef( source.Owner.PrismNode.Id, source.PortId );
		var to = new PortRef( target.Id, PortId );

		_detached = false;

		using ( graph.Mutations?.Begin( "Connect" ) ?? Undo.UndoScope.None )
		{
			if ( graph.Document.TryGetIncomingEdge( target.Id, PortId, out var existing ) )
			{
				graph.Mutations?.Disconnect( existing.Id, "Connect" );
			}

			graph.Mutations?.Connect( from, to, "Connect" );
		}

		Owner?.Invalidate();
	}

	/// <inheritdoc/>
	public float? GetHandleOffset( string name )
	{
		if ( string.IsNullOrEmpty( name ) ) return null;

		LoadHandleOffsets();

		return _handleOffsets.TryGetValue( name, out var value ) ? value : null;
	}

	/// <inheritdoc/>
	public void SetHandleOffset( string name, float? value )
	{
		if ( string.IsNullOrEmpty( name ) ) return;

		LoadHandleOffsets();

		if ( value is null ) _handleOffsets.Remove( name );
		else _handleOffsets[name] = value.Value;

		SaveHandleOffsets();
	}

	/// <inheritdoc/>
	public override ValueEditor CreateEditor( NodeUI node, Plug plug ) =>
		InlineEditors.InlineEditorFactory.Create( node, plug, this );

	/// <inheritdoc/>
	public override Menu CreateContextMenu( NodeUI node, Plug plug )
	{
		var graph = Owner?.Adapter;
		var owner = Owner?.PrismNode;

		if ( graph is null || owner is null ) return null;

		var menu = new Menu();

		menu.AddHeading( $"{Label} : {PrismShaderTypes.Name( EffectiveType, IsColor )}" );

		if ( graph.Document.TryGetIncomingEdge( owner.Id, PortId, out var edge ) )
		{
			menu.AddOption( "Disconnect", PrismIcons.Disconnect,
				() => graph.Mutations?.Disconnect( edge.Id ) );

			var fill = menu.AddMenu( "Pad Fill", PrismIcons.Conversion );

			fill.AddOption( "Automatic", null, () => graph.Mutations?.SetEdgeFill( edge.Id, null ) );
			fill.AddOption( "Zero", null, () => graph.Mutations?.SetEdgeFill( edge.Id, 0f ) );
			fill.AddOption( "One", null, () => graph.Mutations?.SetEdgeFill( edge.Id, 1f ) );
		}
		else
		{
			menu.AddOption( "Reset Value", PrismIcons.Reset, ResetInline );
		}

		return menu;
	}

	/// <inheritdoc/>
	public override void OnDoubleClick( NodeUI node, Plug plug, MouseEvent e )
	{
		ResetInline();
		e.Accepted = true;
	}

	/// <summary>Put the inline literal back to the default for the port's type.</summary>
	public void ResetInline()
	{
		var graph = Owner?.Adapter;
		var owner = Owner?.PrismNode;
		var input = Input;

		if ( graph is null || owner is null || input is null ) return;

		var value = Serialization.ValueCodec.Default( input.EffectiveType );

		graph.Mutations?.SetInlineValue( owner.Id, PortId, value, $"Reset {Label}" );
		Owner?.Invalidate();
	}

	/// <summary>Write a new inline literal through the mutation API, so the edit is undoable.</summary>
	public void SetInlineValue( object value, string label = null )
	{
		var graph = Owner?.Adapter;
		var owner = Owner?.PrismNode;

		if ( graph is null || owner is null ) return;

		graph.Mutations?.SetInlineValue( owner.Id, PortId, value, label ?? $"Set {Label}" );
		Owner?.Invalidate();
	}

	/// <summary>
	/// Write a node property through the mutation API, for a port whose value is kept in one rather than
	/// in an inline slot. Structural, not a value edit: a texture path changes the shader's declarations,
	/// so the compile service has to regenerate rather than re-push a uniform.
	/// </summary>
	public void SetBoundProperty( string property, object value, string label = null )
	{
		var graph = Owner?.Adapter;
		var owner = Owner?.PrismNode;

		if ( graph is null || owner is null || string.IsNullOrEmpty( property ) ) return;

		graph.Mutations?.SetProperty( owner.Id, property, value, NodeChangeKind.Properties,
			label ?? $"Set {Label}" );

		Owner?.Invalidate();
	}

	void LoadHandleOffsets()
	{
		if ( _handleOffsets.Count > 0 ) return;

		var graph = Owner?.Adapter;
		var owner = Owner?.PrismNode;

		if ( graph is null || owner is null ) return;
		if ( !graph.Document.TryGetIncomingEdge( owner.Id, PortId, out var edge ) ) return;
		if ( edge.Via is not { Length: > 0 } ) return;

		// The document stores routing handles as sparse (index, value) pairs so a style that only uses
		// one of them does not have to invent values for the rest.
		foreach ( var pair in edge.Via )
		{
			var index = (int)MathF.Round( pair.x );

			if ( index < 0 || index >= s_handleNames.Length ) continue;

			_handleOffsets[s_handleNames[index]] = pair.y;
		}
	}

	void SaveHandleOffsets()
	{
		var graph = Owner?.Adapter;
		var owner = Owner?.PrismNode;

		if ( graph is null || owner is null ) return;
		if ( !graph.Document.TryGetIncomingEdge( owner.Id, PortId, out var edge ) ) return;

		var via = new List<Vector2>();

		for ( int i = 0; i < s_handleNames.Length; i++ )
		{
			if ( !_handleOffsets.TryGetValue( s_handleNames[i], out var value ) ) continue;

			via.Add( new Vector2( i, value ) );
		}

		graph.Mutations?.SetEdgeVia( edge.Id, via.Count == 0 ? null : via.ToArray() );
	}
}

/// <summary>An output socket. Fans out to any number of inputs; the framework tracks that for us.</summary>
public sealed class PrismPlugOut : PrismPlug, IPlugOut
{
	/// <summary>Bind an output plug adapter to a port of a node.</summary>
	public PrismPlugOut( PrismNodeAdapter owner, PortId id ) : base( owner, id ) { }

	/// <inheritdoc/>
	public override Port ModelPort => Owner?.PrismNode?.FindOutput( PortId );

	/// <summary>The output port, or null when the node dropped this id.</summary>
	public OutputPort Output => Owner?.PrismNode?.FindOutput( PortId );

	/// <summary>True when at least one edge leaves this port.</summary>
	public bool IsConnectedInDocument => Output is { IsConnected: true };

	/// <inheritdoc/>
	public override Menu CreateContextMenu( NodeUI node, Plug plug )
	{
		var graph = Owner?.Adapter;
		var owner = Owner?.PrismNode;

		if ( graph is null || owner is null ) return null;

		var menu = new Menu();

		menu.AddHeading( $"{Label} : {PrismShaderTypes.Name( EffectiveType, IsColor )}" );

		var edges = graph.Document.GetOutgoingEdges( owner.Id, PortId ).ToArray();

		if ( edges.Length > 0 )
		{
			menu.AddOption( edges.Length == 1 ? "Disconnect" : $"Disconnect {edges.Length} Wires",
				PrismIcons.Disconnect,
				() => graph.Mutations?.DisconnectPort( new PortRef( owner.Id, PortId ) ) );
		}

		return menu;
	}
}