Editor/Prism/Undo/GraphMutations.cs

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.

File AccessNetworking
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;
	}
}