Editor/Prism/Ui/PrismSession.cs

Editor session for a Prism document. Manages a single PrismGraph instance, undo stack, compile service and selection, raises events for UI panels, handles loading/saving state, debounced compile requests and lifecycle (hooking/unhooking).

NetworkingFile Access
using Editor.Prism.Compiler;
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Serialization;
using Editor.Prism.Toolchain;
using Editor.Prism.Undo;

namespace Editor.Prism.Ui;

/// <summary>
/// One open document: its graph, undo stack, compiler and selection.
/// <para>
/// Every panel binds to this and never to another panel. That is the whole point: the Blackboard does
/// not know the Inspector exists, the Diagnostics list does not hold a reference to the canvas, and a
/// panel that fails to construct cannot break the ones beside it. Anything a panel needs to know is an
/// event here; anything a panel wants to do is a method here.
/// </para>
/// <para>
/// The document object identity is deliberately stable for the whole life of the session. Opening a
/// file reconciles the new content into the live <see cref="PrismGraph"/> through
/// <see cref="PrismSerializer.Restore"/> rather than swapping the reference, so the undo stack, the
/// mutation API and every subscription survive a New or an Open.
/// <see cref="DocumentReplaced"/> still fires, because the content genuinely did change wholesale.
/// </para>
/// </summary>
public sealed class PrismSession : IDisposable
{
	readonly List<PrismNode> _selection = new();
	readonly string _sessionId;

	bool _dirty;
	bool _disposed;
	bool _loading;

	/// <summary>Create a session with its own compiler scratch space.</summary>
	public PrismSession( string sessionId )
	{
		_sessionId = string.IsNullOrWhiteSpace( sessionId ) ? Ids.NewShortId() : sessionId;

		Graph = new PrismGraph();
		Undo = new PrismUndoStack( Graph );
		Mutations = new GraphMutations( Graph, Undo );
		Compiler = PrismLog.Guard<ShaderCompileService>( "Create the compile service",
			() => new ShaderCompileService( _sessionId ) );

		Hook();
	}

	// ---------------------------------------------------------------- state ----

	/// <summary>The document being edited. The same instance for the life of the session.</summary>
	public PrismGraph Graph { get; }

	/// <summary>The undo stack recording edits to <see cref="Graph"/>.</summary>
	public PrismUndoStack Undo { get; }

	/// <summary>The compile service that turns the graph into a shader.</summary>
	public ShaderCompileService Compiler { get; }

	/// <summary>Where the document lives on disk. Null when it has never been saved.</summary>
	public string FilePath { get; private set; }

	/// <summary>True when there are edits the user has not saved.</summary>
	public bool IsDirty => _dirty;

	/// <summary>True when this document is a shader function rather than a shader.</summary>
	public bool IsSubgraph => Graph is not null && Graph.IsSubgraph;

	/// <summary>The nodes currently selected, in selection order.</summary>
	public IReadOnlyList<PrismNode> Selection => _selection;

	/// <summary>The most recent compile result, or null before the first compile.</summary>
	public CompileResult LastCompile { get; private set; }

	/// <summary>Everything the last compile had to say.</summary>
	public IReadOnlyList<Diagnostic> Diagnostics { get; private set; } = Array.Empty<Diagnostic>();

	/// <summary>
	/// The only sanctioned way to change the document. Exposed here so panels never construct their own
	/// and accidentally record into a different undo stack.
	/// </summary>
	public GraphMutations Mutations { get; }

	/// <summary>The document title as it should appear in a tab or a window caption.</summary>
	public string DisplayName
	{
		get
		{
			var title = Graph?.Meta?.Title;

			if ( !string.IsNullOrWhiteSpace( title ) ) return title;

			if ( !string.IsNullOrWhiteSpace( FilePath ) )
			{
				return System.IO.Path.GetFileNameWithoutExtension( FilePath );
			}

			return "untitled";
		}
	}

	/// <summary>
	/// Set by the canvas while a wire is being dragged. Picking up a connected wire reports the input as
	/// momentarily disconnected, and compiling in that window produces a shader nobody asked for.
	/// </summary>
	internal Func<bool> CompileSuppressed { get; set; }

	// ---------------------------------------------------------------- events ----

	/// <summary>Nodes, edges, parameters or keywords were added or removed.</summary>
	public event Action GraphStructureChanged;

	/// <summary>A literal or a property changed, but the shape of the graph did not.</summary>
	public event Action GraphValuesChanged;

	/// <summary>The selection changed.</summary>
	public event Action SelectionChanged;

	/// <summary>A compile finished, successfully or not.</summary>
	public event Action<CompileResult> Compiled;

	/// <summary>A compile started, after the debounce.</summary>
	public event Action CompileStarted;

	/// <summary>The unsaved-changes state changed.</summary>
	public event Action DirtyChanged;

	/// <summary>New, Open or an undo restore replaced the document wholesale.</summary>
	public event Action DocumentReplaced;

	/// <summary>Focus a node, and optionally a port, in the graph view. The window subscribes.</summary>
	public event Action<NodeId, PortId> FocusRequested;

	// ---------------------------------------------------------------- selection ----

	/// <summary>Replace or extend the selection.</summary>
	public void Select( IEnumerable<PrismNode> nodes, bool additive = false )
	{
		if ( _disposed ) return;

		var wanted = additive ? new List<PrismNode>( _selection ) : new List<PrismNode>();

		if ( nodes is not null )
		{
			foreach ( var node in nodes )
			{
				if ( node is null || wanted.Contains( node ) ) continue;

				wanted.Add( node );
			}
		}

		// Re-selecting exactly what is already selected is extremely common — clicking a card that is
		// already selected — and raising would rebuild the Inspector for nothing.
		if ( Same( wanted, _selection ) ) return;

		_selection.Clear();
		_selection.AddRange( wanted );

		RaiseSelectionChanged();
	}

	/// <summary>Select exactly one node by id.</summary>
	public void SelectNode( NodeId id )
	{
		var node = Graph?.FindNode( id );

		Select( node is null ? Array.Empty<PrismNode>() : new[] { node } );
	}

	/// <summary>Select nothing.</summary>
	public void ClearSelection()
	{
		if ( _selection.Count == 0 ) return;

		_selection.Clear();

		RaiseSelectionChanged();
	}

	/// <summary>Ask the canvas to scroll a node — and optionally one of its ports — into view.</summary>
	public void RequestFocus( NodeId node, PortId port = default )
	{
		if ( _disposed || !node.IsValid ) return;

		PrismLog.Guard( "Raise FocusRequested", () => FocusRequested?.Invoke( node, port ) );
	}

	// ---------------------------------------------------------------- lifecycle ----

	/// <summary>Flag the document as having unsaved changes.</summary>
	public void MarkDirty()
	{
		if ( _disposed || _dirty ) return;

		_dirty = true;

		PrismLog.Guard( "Raise DirtyChanged", () => DirtyChanged?.Invoke() );
	}

	/// <summary>Notify listeners of a value-level change and ask for a debounced recompile.</summary>
	public void Touch()
	{
		if ( _disposed ) return;

		MarkDirty();

		PrismLog.Guard( "Raise GraphValuesChanged", () => GraphValuesChanged?.Invoke() );

		RequestCompile();
	}

	/// <summary>Ask for a compile. Debounced and coalesced by the compile service.</summary>
	public void RequestCompile( CompileMode mode = CompileMode.Preview )
	{
		if ( _disposed || Compiler is null || Graph is null ) return;

		if ( CompileSuppressed is not null && PrismLog.Guard( "Test compile suppression", CompileSuppressed, false ) )
		{
			return;
		}

		PrismLog.Guard( "Request a compile", () => Compiler.Request( Graph, mode ) );
	}

	/// <summary>
	/// Replace the document's content with another graph's, and point the session at a file.
	/// <para>
	/// The live graph object is reconciled against the incoming one rather than swapped, so the undo
	/// stack and every panel subscription survive. Undo history is cleared, because history from the
	/// previous document would restore that document.
	/// </para>
	/// </summary>
	public void LoadFrom( PrismGraph graph, string filePath )
	{
		if ( _disposed || Graph is null ) return;

		_loading = true;

		try
		{
			_selection.Clear();

			LastCompile = null;
			Diagnostics = Array.Empty<Diagnostic>();

			if ( graph is not null && !ReferenceEquals( graph, Graph ) )
			{
				var text = PrismLog.Guard<string>( "Serialize the incoming document",
					() => PrismSerializer.Write( graph ) );

				Graph.DocumentId = graph.DocumentId;
				Graph.IsSubgraph = graph.IsSubgraph;

				if ( string.IsNullOrWhiteSpace( text ) || !PrismLog.Guard( "Load the document",
						() => PrismSerializer.Restore( Graph, text ) ) )
				{
					// A failed reconcile must not leave half a document behind.
					PrismLog.Guard( "Clear the document", Graph.Clear );
				}
			}
			else if ( graph is null )
			{
				PrismLog.Guard( "Clear the document", Graph.Clear );
			}

			Graph.AssetPath = filePath;
			Graph.IsDirty = false;

			FilePath = filePath;
			_dirty = false;

			PrismLog.Guard( "Reset the undo stack", Undo.Clear );
		}
		finally
		{
			_loading = false;
		}

		PrismLog.Guard( "Raise DocumentReplaced", () => DocumentReplaced?.Invoke() );
		PrismLog.Guard( "Raise DirtyChanged", () => DirtyChanged?.Invoke() );

		RaiseSelectionChanged();
		RaiseStructureChanged();

		RequestCompile();
	}

	/// <summary>Record that the document was written to a path, clearing the dirty marker.</summary>
	internal void MarkSaved( string filePath )
	{
		if ( _disposed ) return;

		if ( !string.IsNullOrWhiteSpace( filePath ) )
		{
			FilePath = filePath;

			if ( Graph is not null ) Graph.AssetPath = filePath;
		}

		if ( Graph is not null ) Graph.IsDirty = false;

		if ( !_dirty ) return;

		_dirty = false;

		PrismLog.Guard( "Raise DirtyChanged", () => DirtyChanged?.Invoke() );
	}

	/// <summary>Publish a compile result the session did not request itself, e.g. an explicit save build.</summary>
	internal void Publish( CompileResult result )
	{
		if ( _disposed || result is null ) return;

		OnCompiled( result );
	}

	/// <summary>Tear down subscriptions and the compile service.</summary>
	public void Dispose()
	{
		if ( _disposed ) return;

		_disposed = true;

		Unhook();

		PrismLog.Guard( "Dispose the compile service", () => Compiler?.Dispose() );

		GraphStructureChanged = null;
		GraphValuesChanged = null;
		SelectionChanged = null;
		Compiled = null;
		CompileStarted = null;
		DirtyChanged = null;
		DocumentReplaced = null;
		FocusRequested = null;
	}

	// ---------------------------------------------------------------- plumbing ----

	void Hook()
	{
		if ( Graph is not null ) Graph.Changed += OnGraphChanged;
		if ( Undo is not null ) Undo.Restored += OnUndoRestored;

		if ( Compiler is null ) return;

		Compiler.Started += OnCompileStarted;
		Compiler.Completed += OnCompiled;
	}

	void Unhook()
	{
		if ( Graph is not null ) Graph.Changed -= OnGraphChanged;
		if ( Undo is not null ) Undo.Restored -= OnUndoRestored;

		if ( Compiler is null ) return;

		Compiler.Started -= OnCompileStarted;
		Compiler.Completed -= OnCompiled;
	}

	void OnGraphChanged( GraphChange change )
	{
		if ( _disposed || _loading ) return;

		// Panning is persisted with the document but is not an edit anybody wants to be warned about
		// when closing a window.
		if ( change.Kind != GraphChangeKind.ViewChanged ) MarkDirty();

		if ( IsStructural( change ) ) RaiseStructureChanged();
		else if ( change.Kind != GraphChangeKind.ViewChanged ) RaiseValuesChanged();

		if ( change.AffectsCompilation ) RequestCompile();
	}

	void OnUndoRestored()
	{
		if ( _disposed ) return;

		// A restore reconciles rather than rebuilds — PrismSerializer.Restore keeps a node that survived
		// the undo as the same object precisely so its card, its selection state and its widget tree are
		// reused. Clearing the selection outright threw that away and made every step of a multi-node
		// edit cost a re-selection. Re-resolve by id instead: nodes that still exist stay selected, and
		// the ones the undo removed drop out.
		var selected = _selection.Select( x => x?.Id ?? NodeId.None ).Where( x => x.IsValid ).ToArray();

		_selection.Clear();

		foreach ( var id in selected )
		{
			var node = Graph?.FindNode( id );

			if ( node is not null ) _selection.Add( node );
		}

		MarkDirty();

		PrismLog.Guard( "Raise DocumentReplaced", () => DocumentReplaced?.Invoke() );

		RaiseSelectionChanged();
		RaiseStructureChanged();

		RequestCompile();
	}

	void OnCompileStarted()
	{
		if ( _disposed ) return;

		PrismLog.Guard( "Raise CompileStarted", () => CompileStarted?.Invoke() );
	}

	void OnCompiled( CompileResult result )
	{
		if ( _disposed ) return;

		LastCompile = result;
		Diagnostics = result?.Diagnostics ?? Array.Empty<Diagnostic>();

		PrismLog.Guard( "Raise Compiled", () => Compiled?.Invoke( result ) );
	}

	void RaiseSelectionChanged() =>
		PrismLog.Guard( "Raise SelectionChanged", () => SelectionChanged?.Invoke() );

	void RaiseStructureChanged() =>
		PrismLog.Guard( "Raise GraphStructureChanged", () => GraphStructureChanged?.Invoke() );

	void RaiseValuesChanged() =>
		PrismLog.Guard( "Raise GraphValuesChanged", () => GraphValuesChanged?.Invoke() );

	/// <summary>Whether two selections hold the same nodes in the same order.</summary>
	static bool Same( IReadOnlyList<PrismNode> a, IReadOnlyList<PrismNode> b )
	{
		if ( a.Count != b.Count ) return false;

		for ( int i = 0; i < a.Count; i++ )
		{
			if ( !ReferenceEquals( a[i], b[i] ) ) return false;
		}

		return true;
	}

	/// <summary>Whether a change altered the shape of the document rather than a value inside it.</summary>
	static bool IsStructural( GraphChange change ) => change.Kind switch
	{
		GraphChangeKind.NodeAdded or GraphChangeKind.NodeRemoved => true,
		GraphChangeKind.EdgeAdded or GraphChangeKind.EdgeRemoved or GraphChangeKind.EdgeBroken => true,
		GraphChangeKind.ParameterAdded or GraphChangeKind.ParameterRemoved => true,
		GraphChangeKind.KeywordAdded or GraphChangeKind.KeywordRemoved => true,
		GraphChangeKind.SettingsChanged or GraphChangeKind.Reloaded => true,
		GraphChangeKind.NodeChanged => change.NodeChange == NodeChangeKind.Ports,
		_ => false
	};
}