Utility class that centralizes all editable mutations on a PrismGraph and optionally records them into a PrismUndoStack. It provides methods to add/remove/move/resize nodes, manage connections, parameters, keywords, groups, notes, document settings, clipboard operations and wraps each change in an undo scope and diagnostics reporting.
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Serialization;
namespace Editor.Prism.Undo;
/// <summary>
/// The sanctioned way to change a document.
/// <para>
/// Every method here brackets its work in an <see cref="UndoScope"/>, so one call produces exactly
/// one entry in the History panel — no more, and never zero. UI code calls these and never touches
/// <see cref="PrismGraph"/>'s raw mutators, because a change made outside a scope is a change the
/// user cannot undo, and one of those is enough to make the whole editor feel untrustworthy.
/// </para>
/// <para>
/// Instances are cheap; the window owns one per open document. An instance with no undo stack still
/// works — it just records nothing, which is what a headless import or a fixture wants.
/// </para>
/// </summary>
public sealed class GraphMutations
{
/// <summary>Bind a mutation API to a document and, optionally, an undo stack.</summary>
public GraphMutations( PrismGraph graph, PrismUndoStack undo = null )
{
Graph = graph;
Undo = undo;
}
/// <summary>Bind a mutation API to a document and, optionally, an undo stack.</summary>
public static GraphMutations For( PrismGraph graph, PrismUndoStack undo = null ) => new( graph, undo );
/// <summary>The document being edited.</summary>
public PrismGraph Graph { get; }
/// <summary>Where edits are recorded. Null means nothing is recorded.</summary>
public PrismUndoStack Undo { get; }
/// <summary>Optional sink for problems raised while mutating, e.g. a rejected connection.</summary>
public DiagnosticSink Diagnostics { get; set; }
/// <summary>
/// The node type inserted by <see cref="InsertReroute"/>. Settable so the UI can point it at
/// whatever the node library actually registered without this package depending on it.
/// </summary>
public static string RerouteTypeId { get; set; } = "prism.util.reroute";
/// <summary>
/// Open a scope by hand, for a compound edit this class does not cover. Everything done inside it
/// becomes one undo entry. Always dispose it.
/// </summary>
public IDisposable Begin( string name ) => (IDisposable)Undo?.Scope( name ) ?? UndoScope.None;
// ---------------------------------------------------------------- nodes ----
/// <summary>Add an existing node instance at a position.</summary>
public PrismNode AddNode( PrismNode node, Vector2 position, string label = "Add Node" )
{
if ( Graph is null || node is null ) return null;
using ( Begin( label ) )
{
node.Position = position;
return Graph.AddNode( node );
}
}
/// <summary>Create a node by stable type id and add it. Returns null when the id is unknown.</summary>
public PrismNode AddNode( string typeId, Vector2 position, string label = "Add Node" )
{
if ( Graph is null ) return null;
var node = NodeRegistry.Create( typeId );
if ( node is null )
{
Diagnostics?.Error( DiagnosticCode.UnknownNodeType, $"There is no node type '{typeId}'" );
return null;
}
return AddNode( node, position, label );
}
/// <summary>Remove a node and everything wired to it.</summary>
public bool RemoveNode( NodeId id, string label = "Delete Node" )
{
if ( Graph is null ) return false;
var node = Graph.FindNode( id );
if ( node is null ) return false;
using ( Begin( label ) )
{
return Graph.RemoveNode( node );
}
}
/// <summary>Remove several nodes as one step.</summary>
public int RemoveNodes( IEnumerable<NodeId> ids, string label = "Delete Selection" )
{
if ( Graph is null || ids is null ) return 0;
var targets = ids.ToArray();
if ( targets.Length == 0 ) return 0;
using ( Begin( label ) )
{
var removed = 0;
foreach ( var id in targets )
{
if ( Graph.RemoveNode( id ) ) removed++;
}
return removed;
}
}
/// <summary>Move one node. Repeated moves with the same label coalesce into a single drag step.</summary>
public bool Move( NodeId id, Vector2 position, string label = "Move Node" )
{
if ( Graph is null ) return false;
var node = Graph.FindNode( id );
if ( node is null || node.Position == position ) return false;
using ( Begin( label ) )
{
node.Position = position;
Graph.OnNodeChanged( node, NodeChangeKind.Position );
return true;
}
}
/// <summary>Move several nodes as one step, as a marquee drag does.</summary>
public int Move( IReadOnlyDictionary<NodeId, Vector2> positions, string label = "Move Items" )
{
if ( Graph is null || positions is null || positions.Count == 0 ) return 0;
using ( Begin( label ) )
{
var moved = 0;
foreach ( var pair in positions )
{
var node = Graph.FindNode( pair.Key );
if ( node is null || node.Position == pair.Value ) continue;
node.Position = pair.Value;
Graph.OnNodeChanged( node, NodeChangeKind.Position );
moved++;
}
return moved;
}
}
/// <summary>Offset several nodes by a delta as one step.</summary>
public int Nudge( IEnumerable<NodeId> ids, Vector2 delta, string label = "Move Items" )
{
if ( Graph is null || ids is null ) return 0;
var positions = new Dictionary<NodeId, Vector2>();
foreach ( var id in ids )
{
var node = Graph.FindNode( id );
if ( node is null ) continue;
positions[id] = node.Position + delta;
}
return Move( positions, label );
}
/// <summary>Set a node's card size, as a manual resize does. Null restores auto-sizing.</summary>
public bool Resize( NodeId id, Vector2? size, string label = "Resize Node" )
{
if ( Graph is null || Graph.FindNode( id ) is null ) return false;
using ( Begin( label ) )
{
Graph.SetNodeSize( id, size );
return true;
}
}
/// <summary>Replace a node's flags.</summary>
public bool SetFlags( NodeId id, NodeFlags flags, string label = "Change Node" )
{
if ( Graph is null ) return false;
var node = Graph.FindNode( id );
if ( node is null || node.Flags == flags ) return false;
using ( Begin( label ) )
{
node.Flags = flags;
Graph.OnNodeChanged( node, NodeChangeKind.Flags );
return true;
}
}
/// <summary>Turn one flag on or off.</summary>
public bool SetFlag( NodeId id, NodeFlags flag, bool value, string label = null )
{
if ( Graph is null ) return false;
var node = Graph.FindNode( id );
if ( node is null ) return false;
var flags = value ? node.Flags | flag : node.Flags & ~flag;
return SetFlags( id, flags, label ?? $"{( value ? "Enable" : "Disable" )} {flag}" );
}
/// <summary>
/// Set a serialized property on a node and tell the document what kind of change it was, so the
/// compile service can decide between re-pushing a uniform and regenerating the shader.
/// </summary>
public bool SetProperty( NodeId id, string property, object value,
NodeChangeKind kind = NodeChangeKind.Properties, string label = null )
{
if ( Graph is null || string.IsNullOrEmpty( property ) ) return false;
var node = Graph.FindNode( id );
if ( node is null ) return false;
var current = NodeProperties.Get( node, property );
if ( ValueCodec.Equal( current, value ) ) return false;
using ( Begin( label ?? $"Set {property}" ) )
{
if ( !NodeProperties.Set( node, property, value ) ) return false;
NodeProperties.RebuildPorts( node );
// An [InlineValue] property backs a port's unconnected literal, and the emitter reads the
// slot first. Without this, editing e.g. an output node's DefaultOpacity through the
// Inspector would change the property and leave the generated shader alone.
NodeProperties.SyncInlineFor( node, property );
Graph.OnNodeChanged( node, kind );
return true;
}
}
/// <summary>Set the literal an input port uses when nothing is connected to it.</summary>
public bool SetInlineValue( NodeId id, PortId port, object value, string label = null )
{
if ( Graph is null ) return false;
var node = Graph.FindNode( id );
var input = node?.FindInput( port );
if ( input is null ) return false;
var current = NodeProperties.GetInline( node, input );
if ( ValueCodec.Equal( current, value ) ) return false;
using ( Begin( label ?? $"Set {input.DisplayName}" ) )
{
NodeProperties.SetInline( node, input, value );
Graph.OnNodeChanged( node, NodeChangeKind.Value );
return true;
}
}
// ---------------------------------------------------------------- connections ----
/// <summary>
/// Connect two ports, replacing whatever already fed the target input. Returns null and reports
/// when the connection would be illegal — an incompatible type, or a cycle.
/// </summary>
public Edge Connect( PortRef from, PortRef to, string label = "Create Connection" )
{
if ( Graph is null ) return null;
if ( !Graph.CanConnect( from, to, out var reason ) )
{
Diagnostics?.Warn( DiagnosticCode.IllegalConversion, reason,
GraphRef.ForPort( to.Node, to.Port ) );
return null;
}
using ( Begin( label ) )
{
return Graph.Connect( from, to );
}
}
/// <summary>Remove one connection.</summary>
public bool Disconnect( EdgeId id, string label = "Delete Connection" )
{
if ( Graph is null ) return false;
if ( Graph.FindEdge( id ) is null ) return Graph.RemoveBrokenEdge( id );
using ( Begin( label ) )
{
return Graph.RemoveEdge( id );
}
}
/// <summary>Remove every connection touching a port.</summary>
public int DisconnectPort( PortRef port, string label = "Disconnect" )
{
if ( Graph is null ) return 0;
using ( Begin( label ) )
{
return Graph.DisconnectPort( port );
}
}
/// <summary>Override the value a padding conversion writes into the components it invents.</summary>
public bool SetEdgeFill( EdgeId id, float? fill, string label = "Set Pad Fill" )
{
if ( Graph is null ) return false;
var edge = Graph.FindEdge( id );
if ( edge is null || Nullable.Equals( edge.Fill, fill ) ) return false;
using ( Begin( label ) )
{
return Graph.ReplaceEdge( edge with { Fill = fill } ) is not null;
}
}
/// <summary>Persist the routing waypoints a user dragged a connection through.</summary>
public bool SetEdgeVia( EdgeId id, Vector2[] via, string label = "Route Connection" )
{
if ( Graph is null ) return false;
var edge = Graph.FindEdge( id );
if ( edge is null ) return false;
using ( Begin( label ) )
{
return Graph.ReplaceEdge( edge with { Via = via } ) is not null;
}
}
/// <summary>
/// Split a connection with a reroute node placed at a point, rewiring both halves. One step.
/// </summary>
public PrismNode InsertReroute( EdgeId id, Vector2 position, string label = "Reroute Connection" )
{
if ( Graph is null ) return null;
var edge = Graph.FindEdge( id );
if ( edge is null ) return null;
var reroute = NodeRegistry.Create( RerouteTypeId );
if ( reroute is null )
{
Diagnostics?.Error( DiagnosticCode.UnknownNodeType,
$"The reroute node type '{RerouteTypeId}' is not registered" );
return null;
}
using ( Begin( label ) )
{
reroute.Position = position;
Graph.AddNode( reroute );
var input = reroute.Inputs.FirstOrDefault();
var output = reroute.Outputs.FirstOrDefault();
if ( input is null || output is null )
{
Graph.RemoveNode( reroute );
return null;
}
Graph.RemoveEdge( edge.Id );
Graph.AddEdge( Edge.Between( Graph.NewEdgeId(), edge.From, new PortRef( reroute.Id, input.Id ) ) );
Graph.AddEdge( Edge.Between( Graph.NewEdgeId(), new PortRef( reroute.Id, output.Id ), edge.To ) );
return reroute;
}
}
// ---------------------------------------------------------------- parameters ----
/// <summary>Add a blackboard parameter.</summary>
public Parameter AddParameter( Parameter parameter, int index = -1, string label = "Add Parameter" )
{
if ( Graph is null || parameter is null ) return null;
using ( Begin( label ) )
{
return Graph.AddParameter( parameter, index );
}
}
/// <summary>Create and add a blackboard parameter of a given name and type.</summary>
public Parameter AddParameter( string name, ShaderType type, string label = "Add Parameter" ) =>
AddParameter( new Parameter( name, type ) { Default = ValueCodec.Default( type ) }, -1, label );
/// <summary>Remove a blackboard parameter. Nodes referencing it report an unresolved reference.</summary>
public bool RemoveParameter( ParamId id, string label = "Delete Parameter" )
{
if ( Graph is null || Graph.FindParameter( id ) is null ) return false;
using ( Begin( label ) )
{
return Graph.RemoveParameter( id );
}
}
/// <summary>Rename a parameter. The name is made unique; the id never changes.</summary>
public bool RenameParameter( ParamId id, string name, string label = "Rename Parameter" )
{
if ( Graph is null ) return false;
var parameter = Graph.FindParameter( id );
if ( parameter is null ) return false;
var unique = Graph.UniqueParameterName( name, id );
if ( string.Equals( parameter.Name, unique, StringComparison.Ordinal ) ) return false;
using ( Begin( label ) )
{
parameter.Name = unique;
Graph.NotifyParameterChanged( parameter );
return true;
}
}
/// <summary>Change a parameter's type, reshaping its default into the new type.</summary>
public bool SetParameterType( ParamId id, ShaderType type, string label = "Change Parameter Type" )
{
if ( Graph is null ) return false;
var parameter = Graph.FindParameter( id );
if ( parameter is null || parameter.Type == type ) return false;
using ( Begin( label ) )
{
parameter.Type = type;
parameter.Default = ValueCodec.Coerce( parameter.Default, type );
Graph.NotifyParameterChanged( parameter );
return true;
}
}
/// <summary>Change a parameter's default value.</summary>
public bool SetParameterDefault( ParamId id, object value, string label = null )
{
if ( Graph is null ) return false;
var parameter = Graph.FindParameter( id );
if ( parameter is null ) return false;
var coerced = ValueCodec.Coerce( value, parameter.Type );
if ( ValueCodec.Equal( parameter.Default, coerced ) ) return false;
using ( Begin( label ?? $"Set {parameter.Name}" ) )
{
parameter.Default = coerced;
Graph.NotifyParameterChanged( parameter );
return true;
}
}
/// <summary>Edit a parameter in place through a callback, as one undo step.</summary>
public bool EditParameter( ParamId id, Action<Parameter> edit, string label = "Edit Parameter" )
{
if ( Graph is null || edit is null ) return false;
var parameter = Graph.FindParameter( id );
if ( parameter is null ) return false;
using ( Begin( label ) )
{
if ( !PrismLog.Guard( label, () => edit( parameter ) ) ) return false;
parameter.Name = Graph.UniqueParameterName( parameter.Name, id );
Graph.NotifyParameterChanged( parameter );
return true;
}
}
/// <summary>Move a parameter within the blackboard order.</summary>
public bool ReorderParameter( ParamId id, int index, string label = "Reorder Parameters" )
{
if ( Graph is null ) return false;
using ( Begin( label ) )
{
return Graph.ReorderParameter( id, index );
}
}
// ---------------------------------------------------------------- keywords ----
/// <summary>Add a keyword / combo declaration.</summary>
public Keyword AddKeyword( Keyword keyword, int index = -1, string label = "Add Keyword" )
{
if ( Graph is null || keyword is null ) return null;
using ( Begin( label ) )
{
return Graph.AddKeyword( keyword, index );
}
}
/// <summary>Create and add a keyword of a given name and kind.</summary>
public Keyword AddKeyword( string name, ComboKind kind, string label = "Add Keyword" ) =>
AddKeyword( new Keyword( name, kind ), -1, label );
/// <summary>Remove a keyword.</summary>
public bool RemoveKeyword( ParamId id, string label = "Delete Keyword" )
{
if ( Graph is null || Graph.FindKeyword( id ) is null ) return false;
using ( Begin( label ) )
{
return Graph.RemoveKeyword( id );
}
}
/// <summary>Edit a keyword in place through a callback, as one undo step.</summary>
public bool EditKeyword( ParamId id, Action<Keyword> edit, string label = "Edit Keyword" )
{
if ( Graph is null || edit is null ) return false;
var keyword = Graph.FindKeyword( id );
if ( keyword is null ) return false;
using ( Begin( label ) )
{
if ( !PrismLog.Guard( label, () => edit( keyword ) ) ) return false;
// The blackboard renames a keyword through here, and two combos that lower to one name are a
// block-header parse failure with no diagnostic behind it.
keyword.Name = Graph.UniqueKeywordName( keyword.Name, keyword.Kind, id );
Graph.NotifyKeywordChanged( keyword );
return true;
}
}
// ---------------------------------------------------------------- groups + notes ----
/// <summary>Add a group box.</summary>
public GraphGroup AddGroup( GraphGroup group, string label = "Add Group" )
{
if ( Graph is null || group is null ) return null;
using ( Begin( label ) )
{
return Graph.AddGroup( group );
}
}
/// <summary>Remove a group box. Nodes inside it are untouched.</summary>
public bool RemoveGroup( string id, string label = "Delete Group" )
{
if ( Graph is null ) return false;
using ( Begin( label ) )
{
return Graph.RemoveGroup( id );
}
}
/// <summary>Edit a group in place through a callback, as one undo step.</summary>
public bool EditGroup( string id, Action<GraphGroup> edit, string label = "Edit Group" )
{
if ( Graph is null || edit is null ) return false;
var group = Graph.FindGroup( id );
if ( group is null ) return false;
using ( Begin( label ) )
{
if ( !PrismLog.Guard( label, () => edit( group ) ) ) return false;
Graph.NotifyGroupsChanged();
return true;
}
}
/// <summary>Add a sticky note.</summary>
public StickyNote AddNote( StickyNote note, string label = "Add Note" )
{
if ( Graph is null || note is null ) return null;
using ( Begin( label ) )
{
return Graph.AddNote( note );
}
}
/// <summary>Remove a sticky note.</summary>
public bool RemoveNote( string id, string label = "Delete Note" )
{
if ( Graph is null ) return false;
using ( Begin( label ) )
{
return Graph.RemoveNote( id );
}
}
/// <summary>Edit a note in place through a callback, as one undo step.</summary>
public bool EditNote( string id, Action<StickyNote> edit, string label = "Edit Note" )
{
if ( Graph is null || edit is null ) return false;
var note = Graph.FindNote( id );
if ( note is null ) return false;
using ( Begin( label ) )
{
if ( !PrismLog.Guard( label, () => edit( note ) ) ) return false;
Graph.NotifyNotesChanged();
return true;
}
}
// ---------------------------------------------------------------- document ----
/// <summary>Edit graph settings in place through a callback, as one undo step.</summary>
public bool UpdateSettings( Action<GraphSettings> edit, string label = "Change Settings" )
{
if ( Graph?.Settings is null || edit is null ) return false;
using ( Begin( label ) )
{
if ( !PrismLog.Guard( label, () => edit( Graph.Settings ) ) ) return false;
Graph.Settings.Normalize( Diagnostics );
Graph.NotifySettingsChanged();
return true;
}
}
/// <summary>Edit preview state in place through a callback, as one undo step.</summary>
public bool UpdatePreview( Action<PreviewState> edit, string label = "Change Preview" )
{
if ( Graph?.Preview is null || edit is null ) return false;
using ( Begin( label ) )
{
if ( !PrismLog.Guard( label, () => edit( Graph.Preview ) ) ) return false;
Graph.NotifyPreviewChanged();
return true;
}
}
/// <summary>Edit document metadata in place through a callback, as one undo step.</summary>
public bool UpdateMeta( Action<GraphMeta> edit, string label = "Change Details" )
{
if ( Graph?.Meta is null || edit is null ) return false;
using ( Begin( label ) )
{
if ( !PrismLog.Guard( label, () => edit( Graph.Meta ) ) ) return false;
Graph.NotifyMetaChanged();
return true;
}
}
// ---------------------------------------------------------------- clipboard ----
/// <summary>Paste a clipboard payload centred on a point. One undo step.</summary>
public PasteResult Paste( string payload, Vector2 origin, string label = "Paste" )
{
if ( Graph is null ) return PasteResult.Failed( "No document" );
using ( Begin( label ) )
{
return ClipboardCodec.Paste( Graph, payload, origin, Diagnostics );
}
}
/// <summary>Duplicate a selection in place, offset by a delta. One undo step.</summary>
public PasteResult Duplicate( IEnumerable<NodeId> ids, Vector2 offset, string label = "Duplicate" )
{
if ( Graph is null ) return PasteResult.Failed( "No document" );
var nodes = ids?.Select( Graph.FindNode ).Where( x => x is not null ).ToArray()
?? Array.Empty<PrismNode>();
if ( nodes.Length == 0 ) return PasteResult.Failed( "Nothing to duplicate" );
using ( Begin( label ) )
{
return ClipboardCodec.Duplicate( Graph, nodes, offset, Diagnostics );
}
}
/// <summary>Serialize a selection as a clipboard payload. Reads only, so nothing is recorded.</summary>
public string Copy( IEnumerable<NodeId> ids )
{
if ( Graph is null || ids is null ) return null;
var nodes = ids.Select( Graph.FindNode ).Where( x => x is not null ).ToArray();
return nodes.Length == 0 ? null : ClipboardCodec.Encode( Graph, nodes );
}
/// <summary>Copy a selection and then delete it. One undo step.</summary>
public string Cut( IEnumerable<NodeId> ids, string label = "Cut" )
{
if ( Graph is null || ids is null ) return null;
var targets = ids.ToArray();
var payload = Copy( targets );
if ( payload is null ) return null;
using ( Begin( label ) )
{
foreach ( var id in targets )
{
Graph.RemoveNode( id );
}
}
return payload;
}
}