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