Adapter that wraps a PrismGraph document to implement the IGraph interface used by a node-graph UI. It creates and caches PrismNodeAdapter instances, routes document mutations through GraphMutations, manages detached input plugs during drag/drop, computes reachability for drawing, and attaches diagnostics to nodes.
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Serialization;
using Editor.Prism.Undo;
namespace Editor.Prism.Ui.Adapters;
/// <summary>
/// A <see cref="PrismGraph"/> document, as the node-graph framework sees it.
/// <para>
/// <c>IGraph</c> is five members wide, and three of them are copy/paste. Mapping them onto Prism's own
/// serializer rather than inventing a second format is what makes the framework's clipboard, duplicate
/// and drag-drop paths produce documents that are byte-identical to what a save would have written —
/// including stable ids, unknown-node round-tripping and per-node fault isolation.
/// </para>
/// <para>
/// Everything that mutates goes through <see cref="GraphMutations"/> so that the framework's own undo
/// bracketing (<c>PushUndo</c>/<c>PushRedo</c>) and ours are the same stack rather than two stacks that
/// disagree.
/// </para>
/// </summary>
public sealed class PrismGraphAdapter : IGraph
{
readonly Dictionary<NodeId, PrismNodeAdapter> _adapters = new();
readonly List<PrismPlugIn> _detached = new();
IReadOnlyCollection<NodeId> _reachable;
int _reachableVersion = -1;
int _topologyVersion;
/// <summary>Wrap a document for the canvas.</summary>
public PrismGraphAdapter( PrismGraph document, GraphMutations mutations = null )
{
Document = document;
Mutations = mutations ?? new GraphMutations( document );
if ( Document is null ) return;
Document.NodeRemoved += OnNodeRemoved;
// Reachability only changes when the topology does. Recomputing it against the document's
// version counter instead would re-walk the whole graph on every frame of a node drag.
Document.NodeAdded += OnTopologyChanged;
Document.NodeRemoved += OnTopologyChanged;
Document.EdgeAdded += OnTopologyChanged;
Document.EdgeRemoved += OnTopologyChanged;
}
/// <summary>
/// Bumped whenever a node or an edge is added or removed. Anything that caches a projection of the
/// graph's shape — reachability, the minimap model — compares this instead of rebuilding on a timer.
/// </summary>
public int TopologyVersion => _topologyVersion;
/// <summary>Stop listening to the document. Call when the canvas is torn down.</summary>
public void Dispose()
{
if ( Document is null ) return;
Document.NodeRemoved -= OnNodeRemoved;
Document.NodeAdded -= OnTopologyChanged;
Document.NodeRemoved -= OnTopologyChanged;
Document.EdgeAdded -= OnTopologyChanged;
Document.EdgeRemoved -= OnTopologyChanged;
Clear();
}
/// <summary>The document being edited.</summary>
public PrismGraph Document { get; }
/// <summary>The only sanctioned way to change the document.</summary>
public GraphMutations Mutations { get; set; }
/// <summary>Where problems raised while adapting are reported. Optional.</summary>
public DiagnosticSink Diagnostics { get; set; }
/// <summary>Raised after nodes arrive from the clipboard, so the view can frame them.</summary>
public event Action<IReadOnlyList<PrismNode>> NodesPasted;
/// <inheritdoc/>
public IEnumerable<INode> Nodes
{
get
{
if ( Document is null ) yield break;
foreach ( var node in Document.Nodes )
{
var adapter = Wrap( node );
if ( adapter is not null ) yield return adapter;
}
}
}
/// <summary>Every node adapter currently alive, in document order.</summary>
public IEnumerable<PrismNodeAdapter> NodeAdapters => Nodes.OfType<PrismNodeAdapter>();
/// <summary>Input plugs the framework believes are disconnected while the document still is not.</summary>
public IReadOnlyList<PrismPlugIn> DetachedPlugs => _detached;
/// <summary>True while at least one wire has been picked up but not yet dropped.</summary>
public bool HasPendingDetach => _detached.Count > 0;
// ---------------------------------------------------------------- IGraph ----
/// <inheritdoc/>
public void AddNode( INode node )
{
if ( node is not PrismNodeAdapter adapter || Document is null ) return;
var model = adapter.PrismNode;
if ( model is null || Document.FindNode( model.Id ) is not null ) return;
if ( Mutations is not null ) Mutations.AddNode( model, model.Position );
else Document.AddNode( model );
_adapters[model.Id] = adapter;
}
/// <inheritdoc/>
public void RemoveNode( INode node )
{
if ( node is not PrismNodeAdapter adapter || Document is null ) return;
var model = adapter.PrismNode;
if ( model is null ) return;
if ( Document.FindNode( model.Id ) is null )
{
// The drag-and-drop ghost is created but never added; removing it must not be an error.
Forget( model.Id );
return;
}
if ( Mutations is not null ) Mutations.RemoveNode( model.Id );
else Document.RemoveNode( model.Id );
Forget( model.Id );
}
/// <inheritdoc/>
public string SerializeNodes( IEnumerable<INode> nodes )
{
if ( Document is null || nodes is null ) return "{}";
var models = nodes
.OfType<PrismNodeAdapter>()
.Select( x => x.PrismNode )
.Where( x => x is not null )
.ToArray();
return PrismLog.Guard( "Serialize nodes", () => PrismSerializer.WriteNodes( Document, models ), "{}" );
}
/// <inheritdoc/>
public IEnumerable<INode> DeserializeNodes( string serialized )
{
if ( Document is null || string.IsNullOrWhiteSpace( serialized ) ) return Array.Empty<INode>();
var added = PrismLog.Guard( "Deserialize nodes",
() => PrismSerializer.ReadNodes( Document, serialized, true, Diagnostics ),
Array.Empty<PrismNode>() );
if ( added is null || added.Count == 0 ) return Array.Empty<INode>();
PrismLog.Guard( "Nodes pasted", () => NodesPasted?.Invoke( added ) );
return added.Select( Wrap ).Where( x => x is not null ).Cast<INode>().ToArray();
}
// ---------------------------------------------------------------- lookups ----
/// <summary>
/// The adapter for a node, creating it if this is the first time we have seen it. Exactly one
/// adapter exists per node, because the framework compares plugs and nodes by reference.
/// </summary>
public PrismNodeAdapter Wrap( PrismNode node )
{
if ( node is null ) return null;
if ( _adapters.TryGetValue( node.Id, out var existing ) )
{
// An undo restore can replace a node object while keeping its id. Reusing the stale adapter
// would leave the card pointing at a node the document no longer contains.
if ( ReferenceEquals( existing.PrismNode, node ) ) return existing;
existing.Detach();
}
var created = Create( node );
_adapters[node.Id] = created;
return created;
}
/// <summary>The adapter for a node id, or null when there is no such node.</summary>
public PrismNodeAdapter Find( NodeId id )
{
var node = Document?.FindNode( id );
return node is null ? null : Wrap( node );
}
/// <summary>The input plug adapter a port reference points at, or null.</summary>
public PrismPlugIn FindPlugIn( PortRef reference ) => Find( reference.Node )?.FindInput( reference.Port );
/// <summary>The output plug adapter a port reference points at, or null.</summary>
public PrismPlugOut FindPlugOut( PortRef reference ) => Find( reference.Node )?.FindOutput( reference.Port );
/// <summary>Drop the cached adapter for a node id.</summary>
public void Forget( NodeId id )
{
if ( !_adapters.Remove( id, out var adapter ) ) return;
adapter.Detach();
for ( int i = _detached.Count - 1; i >= 0; i-- )
{
if ( _detached[i].Owner == adapter ) _detached.RemoveAt( i );
}
}
/// <summary>Drop every cached adapter. Called when the document is reloaded wholesale.</summary>
public void Clear()
{
foreach ( var adapter in _adapters.Values ) adapter.Detach();
_adapters.Clear();
_detached.Clear();
_reachableVersion = -1;
}
// ---------------------------------------------------------------- state ----
/// <summary>
/// True when a node contributes to something the shader actually outputs. Unreachable nodes are
/// drawn dimmed, which is the cheapest possible answer to "why is my change doing nothing?".
/// </summary>
public bool IsReachable( PrismNode node )
{
if ( node is null || Document is null ) return true;
if ( _reachable is null || _reachableVersion != _topologyVersion )
{
_reachable = PrismLog.Guard<IReadOnlyCollection<NodeId>>( "Reachability",
() => GraphQueries.ReachableFromOutputs( Document ), null );
_reachableVersion = _topologyVersion;
}
// A document with no output node has no reachable set at all; dimming every single card in it
// would be technically true and completely useless.
if ( _reachable is null || _reachable.Count == 0 ) return true;
return _reachable.Contains( node.Id );
}
/// <summary>Attach compiler diagnostics to the nodes and ports they name.</summary>
public void ApplyDiagnostics( IEnumerable<Diagnostic> diagnostics )
{
var byNode = new Dictionary<NodeId, List<Diagnostic>>();
if ( diagnostics is not null )
{
foreach ( var diagnostic in diagnostics )
{
if ( diagnostic?.Graph is not { } graph || !graph.Node.IsValid ) continue;
if ( !byNode.TryGetValue( graph.Node, out var list ) )
{
byNode[graph.Node] = list = new List<Diagnostic>();
}
list.Add( diagnostic );
}
}
foreach ( var adapter in _adapters.Values )
{
var id = adapter.PrismNode?.Id ?? default;
adapter.SetDiagnostics( byNode.TryGetValue( id, out var list ) ? list : null );
}
}
/// <summary>Record that a wire has been picked up off an input but not yet dropped anywhere.</summary>
public void NoteDetached( PrismPlugIn plug )
{
if ( plug is null || _detached.Contains( plug ) ) return;
_detached.Add( plug );
}
/// <summary>
/// Resolve every pending detach. A wire that was dropped somewhere has already resolved itself
/// through the connection setter or through connection removal; anything still pending here was
/// dropped back where it came from, so the document is left exactly as it was.
/// </summary>
public void ResolveDetached()
{
if ( _detached.Count == 0 ) return;
var pending = _detached.ToArray();
_detached.Clear();
foreach ( var plug in pending )
{
plug.CancelDetach();
}
}
/// <summary>Commit a specific pending detach, because the wire really was removed.</summary>
public void CommitDetached( PrismPlugIn plug )
{
if ( plug is null ) return;
_detached.Remove( plug );
plug.CommitDetach();
}
PrismNodeAdapter Create( PrismNode node )
{
var id = node.Descriptor?.Id;
if ( string.Equals( id, GraphMutations.RerouteTypeId, StringComparison.Ordinal ) )
return new PrismRerouteAdapter( this, node );
if ( string.Equals( id, PrismCommentAdapter.TypeId, StringComparison.Ordinal ) )
return new PrismCommentAdapter( this, node );
return new PrismNodeAdapter( this, node );
}
void OnNodeRemoved( PrismNode node )
{
if ( node is null ) return;
Forget( node.Id );
}
void OnTopologyChanged( PrismNode node ) => _topologyVersion++;
void OnTopologyChanged( Edge edge ) => _topologyVersion++;
}