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