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