Editor/Prism/Ui/PrismWindow.cs

Editor window class for the Prism shader editor. It constructs the UI (toolbars, menus, docks, status bar), manages a PrismSession (document, undo, compile), handles opening/saving/importing/exporting graphs, thumbnail preview syncing, recent files, subgraph collapse/expand, and a variety of user commands and shortcuts.

File AccessNetworking
using Editor.Prism.Compiler;
using Editor.Prism.Compiler.Backends;
using Editor.Prism.Core;
using Editor.Prism.Integration;
using Editor.Prism.Model;
using Editor.Prism.Nodes;
using Editor.Prism.Serialization;
using Editor.Prism.Text;
using Editor.Prism.Toolchain;
using Editor.Prism.Ui.Adapters;
using Editor.Prism.Undo;

using System.IO;
using System.Text;

namespace Editor.Prism.Ui;

/// <summary>
/// The Prism editor window.
/// <para>
/// Construction order is load-bearing and matches the framework's own requirements exactly: toolbars,
/// then the menu bar, then every dock widget, then <c>AddDock</c>, then <c>Show()</c>, and only then
/// <c>StateCookie</c> — whose setter immediately restores the saved layout and silently drops any dock
/// name it does not already know about. The document is loaded last, so panels are alive to receive the
/// events it raises.
/// </para>
/// </summary>
public class PrismWindow : DockWindow
{
	static readonly List<PrismWindow> s_windows = new();

	readonly Dictionary<string, Widget> _panels = new( StringComparer.Ordinal );
	readonly List<string> _recent = new();

	Option _undoToolbar;
	Option _redoToolbar;
	Option _undoMenu;
	Option _redoMenu;
	Option _saveToolbar;
	Menu _recentMenu;
	StatusStrip _status;

	int _lastUndoLevel = -1;
	int _lastPreviewFlags = -1;
	RealTimeSince _sincePrioritise;
	RealTimeSince _sincePreviewScan;
	bool _closing;
	bool _wasDraggingWire;

	/// <summary>Build and show a Prism window with an empty document.</summary>
	public PrismWindow()
	{
		DeleteOnClose = true;

		Title = $"{PrismConstants.ProductName} Shader Editor";
		Size = new Vector2( 1840f, 1120f );

		Session = new PrismSession( Ids.NewShortId() );
		Session.DirtyChanged += UpdateTitle;
		Session.DocumentReplaced += UpdateTitle;
		Session.FocusRequested += OnFocusRequested;

		ApplyPreferences();

		PrismCookies.Changed += ApplyPreferences;
		PrismTheme.Changed += OnThemeChanged;

		// External-change detection. Nothing reloads automatically — the copy in memory may hold
		// unsaved work, and a shader graph is not something to swap out from under an edit — but the
		// user is told, which is the difference between "my change vanished" and a decision.
		AssetHooks.DocumentChangedOnDisk += OnDocumentChangedOnDisk;

		s_windows.Add( this );

		LoadRecentFiles();

		CreateToolBar();
		BuildMenuBar();

		PrismWindowLayout.CreateDocks( this );

		WirePanels();

		CreateStatusBar();

		Show();

		// The setter restores geometry and the saved dock layout, so every dock must already exist.
		StateCookie = PrismConstants.WindowStateCookie;

		CascadeOverExistingWindows();

		CreateDocument( new NewGraphChoice() );

		UpdateTitle();

		// Autosave and crash recovery track windows, not sessions. Registering here rather than only in
		// PrismLauncher is what covers a window opened by File ▸ Open, by a second OpenOrFocus, or by
		// anything else that did not come through the launcher.
		PrismLog.Guard( "Track the window for autosave", () => PrismAutosave.Track( this ) );
	}

	/// <summary>
	/// The document this window has open was rewritten by something else — another Prism window, a
	/// version-control checkout, a text editor.
	/// <para>
	/// A clean document reloads: there is nothing to lose and continuing to show the old graph would
	/// mean the next save silently reverts whatever landed. A dirty one is left alone and offers the
	/// choice, because discarding unsaved work without asking is never the right default.
	/// </para>
	/// </summary>
	void OnDocumentChangedOnDisk( string absolutePath )
	{
		if ( string.IsNullOrWhiteSpace( absolutePath ) || Session is null ) return;
		if ( !SamePath( Session.FilePath, absolutePath ) ) return;

		PrismLog.Guard( "Handling an external document change", () =>
		{
			if ( !Session.IsDirty )
			{
				OpenFile( absolutePath );
				Status( $"{Path.GetFileName( absolutePath )} changed on disk and was reloaded" );

				return;
			}

			Status( $"{Path.GetFileName( absolutePath )} changed on disk — you have unsaved changes here" );

			PrismDialogs.PromptReloadChangedOnDisk( Path.GetFileName( absolutePath ),
				() => OpenFile( absolutePath ) );
		} );
	}

	/// <summary>
	/// Nudge this window off any Prism window that already occupies the same place.
	/// <para>
	/// Every Prism window shares one <c>StateCookie</c>, which is what makes them all restore the dock
	/// arrangement the user last arranged — but it also means a second document restores <em>exactly</em>
	/// the first one's geometry and covers it completely, so opening it looks like nothing happened.
	/// Cascading is the smallest fix that keeps the shared layout and still leaves both windows
	/// reachable, and it is clamped to the screen so a fifth window cannot walk off the edge.
	/// </para>
	/// </summary>
	void CascadeOverExistingWindows()
	{
		PrismLog.Guard( "Cascade the window off its siblings", () =>
		{
			const float step = 32f;

			var rect = Position;

			for ( int attempt = 0; attempt < s_windows.Count; attempt++ )
			{
				var clash = false;

				foreach ( var other in s_windows )
				{
					if ( ReferenceEquals( other, this ) || !other.IsValid() ) continue;
					if ( other.Position.Distance( rect ) > step * 0.5f ) continue;

					clash = true;
					break;
				}

				if ( !clash ) break;

				rect += new Vector2( step, step );
			}

			if ( rect == Position ) return;

			var screen = ScreenGeometry;

			// Never push the title bar past the bottom-right of the screen it started on.
			rect.x = Math.Clamp( rect.x, screen.Left, Math.Max( screen.Left, screen.Right - 240f ) );
			rect.y = Math.Clamp( rect.y, screen.Top, Math.Max( screen.Top, screen.Bottom - 160f ) );

			Position = rect;
		} );
	}

	/// <summary>
	/// Connect the panels that have to know about each other.
	/// <para>
	/// Panels bind to the session and never to a sibling, which is what keeps one failing to construct
	/// from taking the others with it. The handful of genuinely cross-panel gestures are therefore
	/// brokered here, by the one object that is allowed to know every dock exists — and every one of
	/// them degrades to something sensible when the panel it points at was not built.
	/// </para>
	/// </summary>
	void WirePanels()
	{
		PrismLog.Guard( "Wire the panels together", () =>
		{
			var diagnostics = Panel( PrismWindowLayout.DockDiagnostics ) as DiagnosticsPanel;
			var code = Panel( PrismWindowLayout.DockCode ) as CodePanel;

			// Without this, double-clicking a diagnostic still works but opens a standalone code window
			// instead of the Code dock that is already showing the very file it is talking about.
			if ( diagnostics is not null && code is not null )
			{
				diagnostics.CodeNavigationRequested = d =>
				{
					ShowDock( PrismWindowLayout.DockCode );

					code.ShowDiagnostic( d );
				};
			}

			// A rendered thumbnail has to reach the card that asked for it. The preview dock owns the
			// renderer and the graph dock owns the cards, and neither may know the other exists, so the
			// window is what joins them — and does nothing at all when either dock failed to build.
			if ( Panel( PrismWindowLayout.DockPreview ) is Preview.PreviewPanel preview )
			{
				preview.ThumbnailReady += OnThumbnailReady;

				// The other half of per-node preview: with Node Preview on, the viewport shows the
				// selected node's value on the real mesh. That is one attribute write and zero
				// compiles, but only if something tells the preview what is selected.
				if ( Session is not null ) Session.SelectionChanged += OnSelectionChangedForPreview;
			}

			// The library records what the user picks from its own list, but a node can arrive from the
			// canvas palette, a paste or a drop, and the Recently Used shelf is a lie if it only knows
			// about one of those. The graph is the one place every route passes through.
			if ( Session?.Graph is not null ) Session.Graph.Changed += OnGraphChangedForLibrary;
		} );
	}

	/// <summary>Point the preview's stage switch at whatever the user just selected.</summary>
	void OnSelectionChangedForPreview()
	{
		if ( Panel( PrismWindowLayout.DockPreview ) is not Preview.PreviewPanel preview ) return;

		var first = Session?.Selection is { Count: > 0 } selection ? selection[0] : null;

		PrismLog.Guard( "Focus the preview on the selection",
			() => preview.SetFocusNode( first?.Id ?? NodeId.None ) );
	}

	/// <summary>Hand a finished thumbnail to the card that wanted it.</summary>
	void OnThumbnailReady( NodeId node )
	{
		var view = Graph?.GraphView;

		if ( !view.IsValid() ) return;
		if ( Panel( PrismWindowLayout.DockPreview ) is not Preview.PreviewPanel preview ) return;

		PrismLog.Guard( "Show a node thumbnail", () => view.SetThumbnail( node, preview.Thumbnail( node ) ) );
	}

	/// <summary>
	/// Keep the thumbnail renderer's work list in step with the preview flags, and render what the user
	/// is looking at first.
	/// <para>
	/// Polled from the frame hook rather than driven from <c>Graph.Changed</c> because the interesting
	/// events — a flag toggled, the viewport panned — are either coalesced by the undo stack or produce
	/// no event at all, and the check itself is one integer compare in the common case.
	/// </para>
	/// </summary>
	void SyncThumbnails()
	{
		if ( Panel( PrismWindowLayout.DockPreview ) is not Preview.PreviewPanel preview ) return;
		if ( !preview.Thumbnails.Enabled ) return;

		var view = Graph?.GraphView;

		if ( !view.IsValid() ) return;

		// Hashing the preview flags walks every node, so it runs on a cadence rather than every frame:
		// on a several-hundred-node graph a per-frame walk is the single most expensive idle thing the
		// window does, and a fifth of a second is invisible next to the GPU readback a thumbnail costs.
		if ( _sincePreviewScan >= 0.2f )
		{
			_sincePreviewScan = 0f;

			var flags = Session?.Graph is null ? 0 : PreviewFlagHash( Session.Graph );

			if ( flags != _lastPreviewFlags )
			{
				_lastPreviewFlags = flags;

				PrismLog.Guard( "Refresh the thumbnail work list", preview.RefreshThumbnails );
			}
		}

		if ( preview.Thumbnails.PendingCount <= 0 || _sincePrioritise < 0.25f ) return;

		_sincePrioritise = 0f;

		PrismLog.Guard( "Prioritise visible thumbnails",
			() => preview.PrioritiseThumbnails( view.VisibleNodes() ) );
	}

	static int PreviewFlagHash( PrismGraph graph )
	{
		var hash = 17;

		foreach ( var node in graph.Nodes )
		{
			if ( node is null || ( node.Flags & NodeFlags.Preview ) == 0 ) continue;

			hash = HashCode.Combine( hash, node.Id.Value );
		}

		return hash;
	}

	/// <summary>
	/// Feed the node library's Recently Used shelf from every route that can create a node.
	/// <para>
	/// Gated on the undo stack actually capturing, which is what separates a user edit from a
	/// deserialize: every mutation runs inside an <c>UndoScope</c>, and loading a document does not.
	/// Without that gate, opening a file would flood the shelf with every node type in it.
	/// </para>
	/// </summary>
	void OnGraphChangedForLibrary( GraphChange change )
	{
		if ( change.Kind != GraphChangeKind.NodeAdded ) return;
		if ( Session?.Undo is not { IsCapturing: true, IsRestoring: false } ) return;
		if ( Panel( PrismWindowLayout.DockNodeLibrary ) is not NodeLibraryPanel library ) return;

		PrismLog.Guard( "Note a node type as recently used", () =>
		{
			var id = Session.Graph?.FindNode( change.Node )?.Descriptor?.Id;

			if ( !string.IsNullOrEmpty( id ) ) library.NoteUsed( id );
		} );
	}

	/// <summary>The open document, its undo stack and its compiler.</summary>
	public PrismSession Session { get; }

	/// <summary>The canvas dock, when it was built.</summary>
	public GraphPanel Graph => Panel( PrismWindowLayout.DockGraph ) as GraphPanel;

	/// <summary>The widget hosting a dock by name, or null when it could not be built.</summary>
	public Widget Panel( string dockName ) =>
		dockName is not null && _panels.TryGetValue( dockName, out var widget ) && widget.IsValid()
			? widget
			: null;

	/// <summary>Record the widget a dock was built with. Called by the layout while it creates them.</summary>
	public void RegisterDock( string dockName, Widget widget )
	{
		if ( string.IsNullOrEmpty( dockName ) || widget is null ) return;

		_panels[dockName] = widget;
	}

	/// <summary>Bring a dock to the front of its tab group.</summary>
	public void ShowDock( string dockName )
	{
		if ( !DockManager.IsValid() ) return;

		PrismLog.Guard( $"Raise the {dockName} dock", () =>
		{
			DockManager.SetDockState( dockName, true );
			DockManager.RaiseDock( dockName );
		} );
	}

	// ---------------------------------------------------------------- entry points ----

	/// <summary>Focus an open window for a document, or open one. The addon's single entry point.</summary>
	public static PrismWindow OpenOrFocus( string absolutePath = null )
	{
		s_windows.RemoveAll( x => !x.IsValid() );

		if ( !string.IsNullOrWhiteSpace( absolutePath ) )
		{
			foreach ( var window in s_windows )
			{
				if ( !SamePath( window.Session?.FilePath, absolutePath ) ) continue;

				window.Focus();

				return window;
			}
		}

		var target = s_windows.FirstOrDefault( x =>
			x.Session is { IsDirty: false } session && string.IsNullOrEmpty( session.FilePath ) );

		if ( absolutePath is null )
		{
			target ??= s_windows.FirstOrDefault();
		}

		target ??= PrismLog.Guard<PrismWindow>( "Open a Prism window", () => new PrismWindow() );

		if ( target is null ) return null;

		PrismLog.Guard( "Focus the Prism window", () =>
		{
			target.Show();
			target.Focus();
		} );

		if ( !string.IsNullOrWhiteSpace( absolutePath ) ) target.Open( absolutePath );

		return target;
	}

	/// <summary>
	/// The window a brand-new document should go in: an untouched, untitled one if there is such a
	/// window already, otherwise a new one.
	/// <para>
	/// Deliberately different from <see cref="OpenOrFocus"/>, which falls back to <em>any</em> window
	/// because "open Prism" means "show me Prism". New Graph does not: reusing an occupied window there
	/// prompted about unsaved changes and then replaced the document the user was working on, which made
	/// a menu item that reads like "give me another one" into one that closes what you have. Prism's
	/// model is one document per window — the session, the undo stack, the compile service and the
	/// autosave tracker are all per window — so this follows it.
	/// </para>
	/// </summary>
	public static PrismWindow OpenForNewDocument()
	{
		s_windows.RemoveAll( x => !x.IsValid() );

		var target = s_windows.FirstOrDefault( x =>
			x.Session is { IsDirty: false } session && string.IsNullOrEmpty( session.FilePath ) );

		target ??= PrismLog.Guard<PrismWindow>( "Open a Prism window", () => new PrismWindow() );

		if ( target is null ) return null;

		PrismLog.Guard( "Focus the Prism window", () =>
		{
			target.Show();
			target.Focus();
		} );

		return target;
	}

	/// <summary>Open an asset. This is what <c>IAssetEditor</c> forwards to.</summary>
	public void AssetOpen( Asset asset )
	{
		if ( asset is null ) return;

		var path = PrismLog.Guard<string>( "Resolve the asset path", () => asset.AbsolutePath );

		if ( string.IsNullOrWhiteSpace( path ) ) return;

		Open( path );
	}

	/// <summary>Start a new document, asking about unsaved changes first.</summary>
	public void NewGraph( bool subgraph = false )
	{
		PrismDialogs.PromptUnsaved( Session,
			() => PrismDialogs.NewGraph( CreateDocument, subgraph ),
			() => Save() );
	}

	/// <summary>Open a document from disk, asking about unsaved changes first.</summary>
	public void Open( string absolutePath )
	{
		if ( string.IsNullOrWhiteSpace( absolutePath ) ) return;

		PrismDialogs.PromptUnsaved( Session, () => OpenFile( absolutePath ), () => Save() );
	}

	/// <summary>
	/// Write the document, its generated <c>.shader</c> and — when the graph targets it — its
	/// <c>.slang</c> module. Returns false when the user cancelled or the write failed.
	/// </summary>
	public bool Save( bool saveAs = false )
	{
		if ( Session?.Graph is null ) return false;

		var path = Session.FilePath;

		if ( saveAs || string.IsNullOrWhiteSpace( path ) )
		{
			path = PrismDialogs.PickSavePath( Session.DisplayName, Session.IsSubgraph );
		}

		if ( string.IsNullOrWhiteSpace( path ) ) return false;

		var sink = new DiagnosticSink();

		if ( !PrismLog.Guard( "Write the document", () => PrismSerializer.Save( Session.Graph, path, sink ), false ) )
		{
			Report( "The document could not be saved", sink );

			return false;
		}

		Session.MarkSaved( path );

		WriteGeneratedArtifacts( path );

		PrismLog.Guard( "Register the document", () =>
		{
			var asset = AssetSystem.RegisterFile( path );

			if ( asset is null ) return;

			MainAssetBrowser.Instance?.Local?.UpdateAssetList();

			EditorEvent.Run( PrismConstants.EventGraphSaved, asset.RelativePath ?? path );
		} );

		AddRecentFile( path );
		UpdateTitle();

		// Writing the .shader only generates text. Running it past the engine compiler is what turns a
		// syntax mistake into a diagnostic the user can click, so it is on by default and a preference
		// only because it costs a second or two on a large graph.
		if ( PrismLog.Guard( "Read the compile-on-save preference", () => PrismCookies.CompileOnSave, true ) )
		{
			PrismLog.Guard( "Compile on save", () =>
			{
				Session.Compiler?.Cancel();

				_ = Session.Compiler?.RequestNow( Session.Graph, CompileMode.Final );
			} );
		}

		return true;
	}

	/// <summary>
	/// Push the preferences that are this window's to honour onto its compile service. Called once at
	/// construction and again whenever the preferences change, so a toggle takes effect on the next
	/// compile rather than on the next restart.
	/// </summary>
	void ApplyPreferences()
	{
		// The theme is global rather than per-window, so it is applied before the early-out: a window
		// whose session has no compiler yet still has to end up in the right palette.
		PrismLog.Guard( "Apply the Prism theme", () =>
		{
			var wanted = PrismCookies.Theme;

			if ( string.Equals( wanted, PrismTheme.ActiveTheme, StringComparison.OrdinalIgnoreCase ) ) return;

			PrismTheme.LoadPreferred( wanted );
		} );

		if ( Session?.Compiler is null ) return;

		PrismLog.Guard( "Apply the Prism preferences", () =>
		{
			// Tier 2. It is a second process and it never gates rendering, so it stays off the preview
			// loop unless the user asks for it.
			Session.Compiler.ValidateSlang = PrismCookies.ValidateWithSlang;
		} );
	}

	/// <summary>
	/// Rebuild everything that cached a colour after a retheme.
	/// <para>
	/// Most painting reads <see cref="PrismTheme"/> inside <c>OnPaint</c> and needs nothing more than a
	/// repaint, but three things bake a colour and would otherwise stay in the old palette until the
	/// window was closed and reopened: the canvas grid pixmap, the node-graph framework's handle-config
	/// cache, and every laid-out wire.
	/// </para>
	/// </summary>
	void OnThemeChanged()
	{
		if ( !this.IsValid() ) return;

		PrismLog.Guard( "Repaint after a theme change", () =>
		{
			var view = Graph?.GraphView;

			// Not just a repaint: a theme file can move the grid size, the port pitch and the header
			// height, so every card has to be re-measured or the new palette lands on the old geometry.
			if ( view.IsValid() ) view.ApplyTheme();

			Update();

			foreach ( var panel in _panels.Values )
			{
				if ( panel.IsValid() ) panel.Update();
			}
		} );
	}

	// ---------------------------------------------------------------- document ----

	void CreateDocument( NewGraphChoice choice )
	{
		choice ??= new NewGraphChoice();

		var graph = new PrismGraph( choice.Subgraph )
		{
			DocumentId = Ids.NewShortId()
		};

		graph.Meta ??= new GraphMeta();
		graph.Settings ??= new GraphSettings();

		graph.Meta.Title = choice.Title;
		graph.Meta.Created = DateTimeOffset.UtcNow;
		graph.Settings.Domain = choice.Subgraph ? ShaderDomain.Subgraph : choice.Domain;
		graph.Settings.ShadingModel = choice.ShadingModel;
		graph.Settings.BlendMode = choice.BlendMode;
		graph.Settings.SetTarget( PrismConstants.BackendSlang, choice.EmitSlang );

		PrismLog.Guard( "Normalise the new document", () => graph.Settings.Normalize() );

		SeedOutputNode( graph, choice );

		Session.LoadFrom( graph, null );

		UpdateTitle();
		FocusCanvas();
	}

	/// <summary>
	/// Put the keyboard focus on the graph canvas. The canvas-scoped shortcuts gate on
	/// <c>ContainsFocus</c>, so a freshly opened document that nobody has clicked yet would otherwise
	/// answer none of them.
	/// </summary>
	public void FocusCanvas() =>
		PrismLog.Guard( "Focus the graph canvas", () => Graph?.GraphView?.Focus() );

	static void SeedOutputNode( PrismGraph graph, NewGraphChoice choice )
	{
		var typeId = choice.Subgraph
			? SubgraphOutputNode.TypeId
			: choice.Domain switch
			{
				ShaderDomain.PostProcess => "prism.output.postprocess",
				_ => choice.ShadingModel == ShadingModel.Unlit
					? "prism.output.unlit"
					: "prism.output.surface"
			};

		PrismLog.Guard( "Seed the output node", () =>
		{
			var node = NodeRegistry.Create( typeId );

			if ( node is null ) return;

			node.Position = Vector2.Zero;

			graph.AddNode( node );
		} );
	}

	void OpenFile( string absolutePath )
	{
		var sink = new DiagnosticSink();

		if ( LegacyShaderGraphImporter.CanImport( absolutePath ) )
		{
			ImportLegacy( absolutePath, sink );
			return;
		}

		var graph = PrismLog.Guard<PrismGraph>( "Read the document",
			() => PrismSerializer.ReadFile( absolutePath, sink ) );

		if ( graph is null )
		{
			Report( $"'{Path.GetFileName( absolutePath )}' could not be opened", sink );

			return;
		}

		Session.LoadFrom( graph, absolutePath );

		AddRecentFile( absolutePath );
		UpdateTitle();
		FocusCanvas();

		if ( sink.Count > 0 ) ShowDock( PrismWindowLayout.DockDiagnostics );
	}

	void ImportLegacy( string absolutePath, DiagnosticSink sink )
	{
		var graph = PrismLog.Guard<PrismGraph>( "Import a legacy shader graph",
			() => LegacyShaderGraphImporter.ImportFile( absolutePath, sink ) );

		if ( graph is null )
		{
			Report( $"'{Path.GetFileName( absolutePath )}' is not a shader graph Prism can read", sink );

			return;
		}

		// The import is a conversion, not an open: it must never write back over the original.
		Session.LoadFrom( graph, null );

		UpdateTitle();

		ShowDock( PrismWindowLayout.DockDiagnostics );

		Status( $"Imported {Path.GetFileName( absolutePath )} — save it as a .prism to keep it" );
	}

	/// <summary>
	/// Write the generated siblings beside the document, atomically. A crash mid-write must never leave
	/// a half-written <c>.shader</c> where the asset compiler can find it.
	/// </summary>
	void WriteGeneratedArtifacts( string documentPath )
	{
		var result = PrismLog.Guard<CompileResult>( "Generate the shader",
			() => GraphCompiler.Compile( Session.Graph, CompileMode.Final ) );

		if ( result is null ) return;

		Session.Publish( result );

		var directory = Path.GetDirectoryName( documentPath );
		var stem = Path.GetFileNameWithoutExtension( documentPath );

		if ( string.IsNullOrEmpty( directory ) || string.IsNullOrEmpty( stem ) ) return;

		var written = new List<string>();

		if ( !string.IsNullOrWhiteSpace( result.ShaderText ) )
		{
			var shader = Path.Combine( directory, $"{stem}.{PrismConstants.ShaderExtension}" );

			if ( WriteAtomic( shader, result.ShaderText ) ) written.Add( shader );
		}

		if ( Session.Graph.Settings.WantsTarget( PrismConstants.BackendSlang )
			&& !string.IsNullOrWhiteSpace( result.SlangText ) )
		{
			var slang = Path.Combine( directory, $"{stem}.{PrismConstants.SlangExtension}" );

			if ( WriteAtomic( slang, result.SlangText ) ) written.Add( slang );

			// The Slang backend also emits its runtime prelude; without it the module does not import.
			foreach ( var extra in result.Artifact( PrismConstants.BackendSlang )?.Extra
				?? Array.Empty<GeneratedArtifact>() )
			{
				if ( extra is null || string.IsNullOrWhiteSpace( extra.FileName ) ) continue;

				var path = Path.Combine( directory, extra.FileName );

				if ( WriteAtomic( path, extra.Text ) ) written.Add( path );
			}
		}

		foreach ( var path in written )
		{
			PrismLog.Guard( "Register a generated file", () => AssetSystem.RegisterFile( path ) );
		}

		if ( result.ErrorCount > 0 ) ShowDock( PrismWindowLayout.DockDiagnostics );
	}

	static bool WriteAtomic( string path, string text )
	{
		if ( string.IsNullOrWhiteSpace( path ) || text is null ) return false;

		return PrismLog.Guard( $"Write '{Path.GetFileName( path )}'", () =>
		{
			var directory = Path.GetDirectoryName( path );

			if ( !string.IsNullOrEmpty( directory ) ) Directory.CreateDirectory( directory );

			var temp = path + ".tmp";

			File.WriteAllText( temp, text, new UTF8Encoding( false ) );

			if ( File.Exists( path ) ) File.Replace( temp, path, null, true );
			else File.Move( temp, path );
		} );
	}

	// ---------------------------------------------------------------- toolbars ----

	void CreateToolBar()
	{
		var bar = new ToolBar( this, "PrismToolBar" );

		AddToolBar( bar, ToolbarPosition.Top );

		bar.SetIconSize( 16 );

		bar.AddOption( "New", "note_add", () => NewGraph() ).StatusTip = "New graph";
		bar.AddOption( "Open", PrismIcons.Open, OpenDialog ).StatusTip = "Open a graph";

		_saveToolbar = bar.AddOption( "Save", PrismIcons.Save, () => Save() );
		_saveToolbar.StatusTip = "Save the graph and its generated shader";

		bar.AddSeparator();

		_undoToolbar = bar.AddOption( "Undo", PrismIcons.Undo, Undo );
		_redoToolbar = bar.AddOption( "Redo", PrismIcons.Redo, Redo );

		bar.AddSeparator();

		bar.AddOption( "Compile", "refresh", CompileNow ).StatusTip = "Compile now";
		bar.AddOption( "Auto-Layout", PrismIcons.AutoLayout, () => Graph?.AutoLayout() ).StatusTip =
			"Lay the graph out by data flow";
		bar.AddOption( "Fit", PrismIcons.Fit, () => Graph?.FrameAll() ).StatusTip = "Fit the graph in view";

		bar.AddSeparator();

		bar.AddOption( "Generated Code", PrismIcons.Code, ShowGenerated ).StatusTip =
			"Show the generated shader";

		_undoToolbar.Enabled = false;
		_redoToolbar.Enabled = false;
	}

	void CreateStatusBar()
	{
		PrismLog.Guard( "Build the status bar", () =>
		{
			_status = new StatusStrip( Session )
			{
				DiagnosticsRequested = () => ShowDock( PrismWindowLayout.DockDiagnostics ),
				SlangRequested = PrismDialogs.SlangToolchainDialog
			};

			StatusBar.AddWidgetLeft( _status, 1 );
		} );
	}

	// ---------------------------------------------------------------- menus ----

	void BuildMenuBar()
	{
		BuildFileMenu();
		BuildEditMenu();
		BuildGraphMenu();

		var view = MenuBar.AddMenu( "View" );

		view.AboutToShow += () => BuildViewMenu( view );

		BuildCompileMenu();
		BuildHelpMenu();
	}

	void BuildFileMenu()
	{
		var file = MenuBar.AddMenu( "File" );

		file.AddOption( "New Graph", "note_add", () => NewGraph(), "editor.new" ).StatusTip = "New shader graph";
		file.AddOption( "New Shader Function", PrismIcons.Subgraph, () => NewGraph( true ), "prism.new-subgraph" );

		file.AddSeparator();

		file.AddOption( "Open…", PrismIcons.Open, OpenDialog, "editor.open" );

		_recentMenu = file.AddMenu( "Recent Files", "history" );

		file.AddSeparator();

		file.AddOption( "Save", PrismIcons.Save, () => Save(), "editor.save" );
		file.AddOption( "Save As…", "save_as", () => Save( true ), "editor.save-as" );

		file.AddSeparator();

		var export = file.AddMenu( "Export", "ios_share" );

		export.AddOption( "Export HLSL…", PrismIcons.Code, () => Export( false ) );
		export.AddOption( "Export Slang…", PrismIcons.Code, () => Export( true ) );

		file.AddSeparator();

		file.AddOption( "Close", "close", Close, "prism.close" );

		RefreshRecentMenu();
	}

	void BuildEditMenu()
	{
		var edit = MenuBar.AddMenu( "Edit" );

		_undoMenu = edit.AddOption( "Undo", PrismIcons.Undo, Undo, "editor.undo" );
		_redoMenu = edit.AddOption( "Redo", PrismIcons.Redo, Redo, "editor.redo" );

		edit.AddSeparator();

		edit.AddOption( "Cut", PrismIcons.Cut, Cut, "editor.cut" );
		edit.AddOption( "Copy", PrismIcons.Copy, Copy, "editor.copy" );
		edit.AddOption( "Paste", PrismIcons.Paste, Paste, "editor.paste" );
		edit.AddOption( "Duplicate", PrismIcons.Duplicate, Duplicate, "editor.duplicate" );
		edit.AddOption( "Delete", PrismIcons.Delete, DeleteSelection );

		edit.AddSeparator();

		edit.AddOption( "Select All", "select_all", SelectAll, "editor.select-all" );
		edit.AddOption( "Clear Selection", "deselect", ClearSelection, "editor.clear-selection" );

		edit.AddSeparator();

		edit.AddOption( "Find Node…", PrismIcons.Search, FindNode, "prism.find-node" );

		_undoMenu.Enabled = false;
		_redoMenu.Enabled = false;
	}

	void BuildGraphMenu()
	{
		var graph = MenuBar.AddMenu( "Graph" );

		graph.AddOption( "Add Node…", PrismIcons.Add, FindNode, "prism.add-node" );
		graph.AddOption( "Add Group", PrismIcons.Group, AddGroup, "prism.add-group" );
		graph.AddOption( "Add Note", PrismIcons.Note, AddNote );
		graph.AddOption( "Add Reroute", PrismIcons.Reroute, AddReroute );

		graph.AddSeparator();

		graph.AddOption( "Collapse To Shader Function…", PrismIcons.Collapse, CollapseToSubgraph,
			"prism.collapse" );
		graph.AddOption( "Expand Shader Function", PrismIcons.Expand, ExpandSubgraph );

		graph.AddSeparator();

		var align = graph.AddMenu( "Align", PrismIcons.Align );

		align.AddOption( "Left", "align_horizontal_left", () => Graph?.Align( AlignEdge.Left ) );
		align.AddOption( "Centre", "align_horizontal_center", () => Graph?.Align( AlignEdge.CenterX ) );
		align.AddOption( "Right", "align_horizontal_right", () => Graph?.Align( AlignEdge.Right ) );
		align.AddSeparator();
		align.AddOption( "Top", "align_vertical_top", () => Graph?.Align( AlignEdge.Top ) );
		align.AddOption( "Middle", "align_vertical_center", () => Graph?.Align( AlignEdge.Middle ) );
		align.AddOption( "Bottom", "align_vertical_bottom", () => Graph?.Align( AlignEdge.Bottom ) );

		var distribute = graph.AddMenu( "Distribute", PrismIcons.Distribute );

		distribute.AddOption( "Horizontally", "horizontal_distribute", () => Graph?.Distribute( true ) );
		distribute.AddOption( "Vertically", "vertical_distribute", () => Graph?.Distribute( false ) );

		graph.AddOption( "Auto-Layout", PrismIcons.AutoLayout, () => Graph?.AutoLayout(), "prism.auto-layout" );
		graph.AddOption( "Snap To Grid", PrismIcons.Snap, () => Graph?.SnapSelectionToGrid() );

		graph.AddSeparator();

		graph.AddOption( "Clean Up Unused Nodes", "cleaning_services", CleanUpUnused );
		graph.AddOption( "Import .shdrgrph…", "input", ImportLegacyDialog );
	}

	void BuildViewMenu( Menu view )
	{
		view.Clear();

		view.AddOption( "Reset Layout", "restart_alt", ResetLayout );
		view.AddSeparator();

		foreach ( var dock in DockManager.DockTypes.OrderBy( x => x.Title ) )
		{
			var option = view.AddOption( dock.Title, dock.Icon );

			option.Checkable = true;
			option.Checked = DockManager.IsDockOpen( dock.Title );
			option.Toggled += open => PrismLog.Guard( "Toggle a dock",
				() => DockManager.SetDockState( dock.Title, open ) );
		}

		view.AddSeparator();

		var wires = view.AddMenu( "Wire Style", PrismIcons.WireStyle );
		var style = Graph?.GraphView?.WireStyle ?? PrismWireStyle.Bezier;

		var bezier = wires.AddOption( "Curved", "gesture", () => SetWireStyle( PrismWireStyle.Bezier ) );

		bezier.Checkable = true;
		bezier.Checked = style == PrismWireStyle.Bezier;

		var ortho = wires.AddOption( "Angular", "turn_sharp_right",
			() => SetWireStyle( PrismWireStyle.Orthogonal ) );

		ortho.Checkable = true;
		ortho.Checked = style == PrismWireStyle.Orthogonal;

		var snap = view.AddOption( "Snap To Grid", PrismIcons.Snap );

		snap.Checkable = true;
		snap.Checked = Graph is { SnapToGrid: true };
		snap.Toggled += value => { if ( Graph is not null ) Graph.SnapToGrid = value; };

		var minimap = view.AddOption( "Minimap", "map" );

		minimap.Checkable = true;
		minimap.Checked = Graph is { ShowMinimap: true };
		minimap.Toggled += value => { if ( Graph is not null ) Graph.ShowMinimap = value; };

		var previews = view.AddOption( "Node Previews", PrismIcons.Preview );

		previews.Checkable = true;
		previews.Checked = NodePreviews;
		previews.Toggled += value => NodePreviews = value;

		var symbols = view.AddOption( "Debug Symbols", "bug_report" );

		symbols.Checkable = true;
		symbols.Checked = Session?.Graph?.Settings is { DebugSymbols: true };
		symbols.Toggled += value => Session?.Mutations?.UpdateSettings( s => s.DebugSymbols = value,
			"Toggle Debug Symbols" );

		view.AddSeparator();

		BuildThemeMenu( view.AddMenu( "Theme", "palette" ) );
	}

	/// <summary>
	/// The theme menu.
	/// <para>
	/// The preferences page has a theme <em>name</em> field, which is only useful if there is a way to
	/// get a file to name. Export writes the palette currently in force to
	/// <c>&lt;project&gt;/.sbox/prism/theme.json</c>, which is exactly the file the loader reads back,
	/// so "edit a colour and reload" is a two-click loop instead of a documentation exercise.
	/// </para>
	/// </summary>
	void BuildThemeMenu( Menu theme )
	{
		var builtIn = theme.AddOption( "Prism (built-in)", "dark_mode", () =>
		{
			PrismCookies.Theme = PrismTheme.BuiltInName;
			PrismTheme.Reset();
		} );

		builtIn.Checkable = true;
		builtIn.Checked = PrismTheme.ActiveTheme == PrismTheme.BuiltInName;

		var path = PrismTheme.DefaultThemePath;
		var exists = !string.IsNullOrEmpty( path )
			&& PrismLog.Guard( "Look for a theme file", () => File.Exists( path ), false );

		var custom = theme.AddOption( exists ? "Project theme.json" : "Project theme.json (not present)",
			"palette", () =>
			{
				if ( !PrismTheme.Load( path ) ) return;

				PrismCookies.Theme = PrismTheme.ActiveTheme;
			} );

		custom.Enabled = exists;
		custom.Checkable = true;
		custom.Checked = exists && PrismTheme.ActiveTheme != PrismTheme.BuiltInName;

		theme.AddSeparator();

		theme.AddOption( "Export Theme File…", "file_download", () =>
		{
			if ( string.IsNullOrEmpty( path ) )
			{
				Report( "There is no open project to write a theme file into", null );
				return;
			}

			if ( !PrismTheme.Export( path ) )
			{
				Report( $"The theme file could not be written to {path}", null );
				return;
			}

			PrismLog.Info( $"Prism wrote a theme file to {path}" );
		} );

		theme.AddOption( "Reload Theme", "refresh", () => PrismTheme.LoadPreferred( PrismCookies.Theme ) );
	}

	void BuildCompileMenu()
	{
		var compile = MenuBar.AddMenu( "Compile" );

		compile.AddOption( "Compile Now", "refresh", CompileNow, "prism.compile" );
		compile.AddOption( "Force Recompile", "restart_alt", ForceRecompile, "prism.force-compile" );

		compile.AddSeparator();

		compile.AddOption( "Validate Slang", "verified", ValidateSlang );
		compile.AddOption( "Round-trip Check", "sync_alt", RoundTripCheck );

		compile.AddSeparator();

		compile.AddOption( "Show Generated", PrismIcons.Code, ShowGenerated, "prism.show-generated" );
		compile.AddOption( "Open Generated In Editor", "open_in_new", OpenGeneratedInEditor );
	}

	void BuildHelpMenu()
	{
		var help = MenuBar.AddMenu( "Help" );

		help.AddOption( "Keyboard & Mouse", "keyboard", ShowShortcuts, "prism.shortcuts" );
		help.AddOption( "Node Reference", "widgets", () => ShowDock( PrismWindowLayout.DockNodeLibrary ) );
		help.AddOption( "Slang Toolchain…", "verified", PrismDialogs.SlangToolchainDialog );

		help.AddSeparator();

		help.AddOption( "About Prism", PrismIcons.Info, About );
	}

	// ---------------------------------------------------------------- shortcuts ----

	/// <summary>Undo one step.</summary>
	[Shortcut( "editor.undo", "CTRL+Z", ShortcutType.Window )]
	public void Undo()
	{
		if ( Session?.Undo is null ) return;

		PrismLog.Guard( "Undo", () => Session.Undo.Undo() );
	}

	/// <summary>Redo one step. Bound to CTRL+Y.</summary>
	[Shortcut( "editor.redo", "CTRL+Y", ShortcutType.Window )]
	public void Redo()
	{
		if ( Session?.Undo is null ) return;

		PrismLog.Guard( "Redo", () => Session.Undo.Redo() );
	}

	/// <summary>
	/// Redo one step. Bound to CTRL+SHIFT+Z as well, because the built-in editor binds only CTRL+Y and
	/// that surprises everyone who has ever used another graph editor.
	/// </summary>
	[Shortcut( "prism.redo", "CTRL+SHIFT+Z", ShortcutType.Window )]
	public void RedoAlternate() => Redo();

	// ---- canvas shortcuts -------------------------------------------------
	//
	// Everything from here to FindNode is scoped to the graph canvas rather than to the window, and the
	// `typeof( PrismGraphView )` override is what does it. Shortcuts are dispatched from a global key
	// filter *before* the focused widget ever sees the key, and ShortcutType.Window is gated only on
	// IsActiveWindow — true for every child of the active top-level window. So these used to fire from
	// anywhere inside Prism: F over the 3D preview jumped the graph instead of framing the subject
	// (PreviewViewport's own handler was unreachable dead code), CTRL+A in the read-only Code dock
	// selected every node, CTRL+C copied the graph rather than the generated shader, CTRL+F opened the
	// node palette, and ESC cleared the selection whatever the focused widget was doing. The engine's
	// only exemption is for LineEdit and TextEdit, and neither the code editor nor the viewport is one.
	//
	// ShortcutType.Widget gates on Visible && Enabled && ContainsFocus instead, and EditorShortcuts
	// resolves the declaring instance by walking up from the gate — so the method stays here on the
	// window while the *canvas* decides whether the key was meant for it. The document-level shortcuts
	// below (save, open, undo, redo, new) stay window-scoped on purpose: those should work wherever the
	// focus is.

	/// <summary>Cut the selection to the clipboard. Canvas-scoped; see the note above.</summary>
	[Shortcut( "editor.cut", "CTRL+X", typeof( PrismGraphView ), ShortcutType.Widget )]
	public void Cut() => PrismLog.Guard( "Cut", () => Graph?.GraphView?.CutSelection() );

	/// <summary>Copy the selection to the clipboard.</summary>
	[Shortcut( "editor.copy", "CTRL+C", typeof( PrismGraphView ), ShortcutType.Widget )]
	public void Copy() => PrismLog.Guard( "Copy", () => Graph?.GraphView?.CopySelection() );

	/// <summary>Paste the clipboard.</summary>
	[Shortcut( "editor.paste", "CTRL+V", typeof( PrismGraphView ), ShortcutType.Widget )]
	public void Paste() => PrismLog.Guard( "Paste", () => Graph?.GraphView?.PasteSelection() );

	/// <summary>Duplicate the selection in place.</summary>
	[Shortcut( "editor.duplicate", "CTRL+D", typeof( PrismGraphView ), ShortcutType.Widget )]
	public void Duplicate() => PrismLog.Guard( "Duplicate", () => Graph?.GraphView?.DuplicateSelection() );

	/// <summary>Delete the selection.</summary>
	public void DeleteSelection() =>
		PrismLog.Guard( "Delete the selection", () => Graph?.GraphView?.DeleteSelection() );

	/// <summary>Select every node.</summary>
	[Shortcut( "editor.select-all", "CTRL+A", typeof( PrismGraphView ), ShortcutType.Widget )]
	public void SelectAll() => PrismLog.Guard( "Select all", () => Graph?.GraphView?.SelectAll() );

	/// <summary>Select nothing.</summary>
	[Shortcut( "editor.clear-selection", "ESC", typeof( PrismGraphView ), ShortcutType.Widget )]
	public void ClearSelection() =>
		PrismLog.Guard( "Clear the selection", () => Graph?.GraphView?.ClearSelection() );

	/// <summary>Centre the view on the selection.</summary>
	[Shortcut( "gameObject.frame", "F", typeof( PrismGraphView ), ShortcutType.Widget )]
	public void FrameSelection() => Graph?.FrameSelection();

	/// <summary>Fit the whole graph in view.</summary>
	[Shortcut( "prism.frame-all", "SHIFT+F", typeof( PrismGraphView ), ShortcutType.Widget )]
	public void FrameAll() => Graph?.FrameAll();

	/// <summary>Open the node palette.</summary>
	[Shortcut( "prism.find-node", "CTRL+F", typeof( PrismGraphView ), ShortcutType.Widget )]
	public void FindNode() => Graph?.OpenNodeSearch();

	/// <summary>Open a document with a file dialog.</summary>
	[Shortcut( "editor.open", "CTRL+O", ShortcutType.Window )]
	public void OpenDialog()
	{
		var path = PrismDialogs.PickOpenPath( Session is { IsSubgraph: true } );

		if ( string.IsNullOrWhiteSpace( path ) ) return;

		Open( path );
	}

	/// <summary>Save the document.</summary>
	[Shortcut( "editor.save", "CTRL+S", ShortcutType.Window )]
	public void SaveShortcut() => Save();

	/// <summary>Save the document under a new name.</summary>
	[Shortcut( "editor.save-as", "CTRL+SHIFT+S", ShortcutType.Window )]
	public void SaveAsShortcut() => Save( true );

	/// <summary>Start a new document.</summary>
	[Shortcut( "editor.new", "CTRL+N", ShortcutType.Window )]
	public void NewShortcut() => NewGraph();

	/// <summary>Start a new shader function.</summary>
	[Shortcut( "prism.new-subgraph", "CTRL+SHIFT+N", ShortcutType.Window )]
	public void NewSubgraphShortcut() => NewGraph( true );

	/// <summary>Compile the graph now, skipping the debounce.</summary>
	[Shortcut( "prism.compile", "F7", ShortcutType.Window )]
	public void CompileNow()
	{
		if ( Session?.Graph is null || Session.Compiler is null ) return;

		PrismLog.Guard( "Compile now", () => _ = Session.Compiler.RequestNow( Session.Graph ) );
	}

	/// <summary>
	/// Show the keyboard and mouse reference. Bound to F1 because the gestures Prism adds on top of the
	/// framework — Alt+drag to detach, Ctrl+drag to duplicate a wire, the digits — have nowhere else to
	/// announce themselves.
	/// </summary>
	[Shortcut( "prism.shortcuts", "F1", ShortcutType.Window )]
	public void ShowShortcuts() => PrismDialogs.ShortcutsDialog();

	/// <summary>Group the selection.</summary>
	[Shortcut( "prism.add-group", "CTRL+G", ShortcutType.Window )]
	public void AddGroup()
	{
		var view = Graph?.GraphView;

		if ( view is null || !view.IsValid() ) return;

		PrismLog.Guard( "Add a group", () =>
		{
			var bounds = SelectionBounds( view );

			view.CreateNewComment( "Untitled", CommentColor.Blue, bounds.Position, bounds.Size );
		} );
	}

	static Rect SelectionBounds( PrismGraphView view )
	{
		var bounds = new Rect();
		var any = false;

		foreach ( var item in view.SelectedItems )
		{
			if ( item is not NodeUI card ) continue;

			if ( !any ) bounds = card.SceneRect;
			else bounds.Add( card.SceneRect );

			any = true;
		}

		if ( !any )
		{
			var centre = view.ToScene( view.LocalRect.Center );

			return new Rect( centre - new Vector2( 180f, 110f ), new Vector2( 360f, 220f ) );
		}

		return new Rect( bounds.Position - new Vector2( 32f, 72f ),
			bounds.Size + new Vector2( 64f, 104f ) );
	}

	/// <summary>Add a sticky note at the centre of the view.</summary>
	public void AddNote()
	{
		if ( Session?.Mutations is null || Graph?.GraphView is null ) return;

		PrismLog.Guard( "Add a note", () =>
		{
			var centre = Graph.GraphView.ToScene( Graph.GraphView.LocalRect.Center );

			Session.Mutations.AddNote( new StickyNote( "Note", centre ) );
		} );
	}

	/// <summary>Add a free-standing reroute at the centre of the view.</summary>
	public void AddReroute()
	{
		var view = Graph?.GraphView;

		if ( view is null || !view.IsValid() ) return;

		PrismLog.Guard( "Add a reroute", () => view.CreateNewReroute( view.ToScene( view.LocalRect.Center ) ) );
	}

	// ---------------------------------------------------------------- commands ----

	void SetWireStyle( PrismWireStyle style )
	{
		if ( Graph?.GraphView is null ) return;

		Graph.GraphView.WireStyle = style;

		Graph.Overlay?.Refresh();
	}

	/// <summary>
	/// Whether node cards render their own preview thumbnails. Persisted per user, through
	/// <see cref="PrismCookies"/> so that the View menu and the preferences page are the same setting
	/// rather than two that happen to share a key.
	/// </summary>
	public bool NodePreviews
	{
		get => PrismLog.Guard( "Read the node preview cookie", () => PrismCookies.NodePreviews, true );
		set
		{
			PrismLog.Guard( "Save the node preview cookie", () => PrismCookies.NodePreviews = value );

			PrismLog.Guard( "Refresh the canvas", () => Graph?.GraphView?.SyncFromDocument() );
		}
	}

	void ForceRecompile()
	{
		if ( Session?.Graph is null || Session.Compiler is null ) return;

		PrismLog.Guard( "Force a recompile", () =>
		{
			Session.Compiler.Cancel();

			// Without this the byte-compare short circuit would skip straight past the engine compiler
			// and the menu item would do nothing observable — which is the whole reason it exists.
			Session.Compiler.ForceRegenerate = true;

			_ = Session.Compiler.RequestNow( Session.Graph, CompileMode.Final );
		} );

		ShowDock( PrismWindowLayout.DockDiagnostics );
	}

	void ValidateSlang()
	{
		if ( !PrismLog.Guard( "Read the Slang toolchain", () => SlangToolchain.IsAvailable, false ) )
		{
			PrismDialogs.SlangToolchainDialog();

			return;
		}

		if ( Session?.Graph is null || Session.Compiler is null ) return;

		PrismLog.Guard( "Validate the Slang module", () =>
		{
			Session.Compiler.ValidateSlang = true;
			Session.Compiler.SlangValidator = SlangToolchain.CreateValidator();

			_ = Session.Compiler.RequestNow( Session.Graph, CompileMode.Final );
		} );

		ShowDock( PrismWindowLayout.DockDiagnostics );

		Status( "Validating the generated Slang module…" );
	}

	/// <summary>
	/// Prove the document survives a save and that emission is deterministic — the two properties every
	/// short-circuit in the compile loop depends on.
	/// </summary>
	void RoundTripCheck()
	{
		if ( Session?.Graph is null ) return;

		var report = PrismLog.Guard<string>( "Run the round-trip check", () =>
		{
			var sink = new DiagnosticSink();
			var text = PrismSerializer.Write( Session.Graph );

			if ( !PrismSerializer.TryRead( text, out var reparsed, sink ) || reparsed is null )
			{
				return "The document did not survive a save/load round trip.";
			}

			var again = PrismSerializer.Write( reparsed );
			var stable = string.Equals( text, again, StringComparison.Ordinal );

			var first = GraphCompiler.Compile( Session.Graph, CompileMode.Final );
			var second = GraphCompiler.Compile( Session.Graph, CompileMode.Final );

			var deterministic = string.Equals( first?.ShaderText, second?.ShaderText, StringComparison.Ordinal );

			var lines = new List<string>
			{
				stable
					? $"Document round trip: OK ({reparsed.Nodes.Count} nodes, {text.Length} chars)"
					: "Document round trip: FAILED — re-serialising produced different text",
				deterministic
					? "Shader emission: deterministic"
					: "Shader emission: FAILED — two identical compiles produced different text"
			};

			if ( sink.Count > 0 )
			{
				lines.Add( string.Empty );
				lines.AddRange( sink.All.Select( x => x.ToString() ) );
			}

			return string.Join( Environment.NewLine, lines );
		} );

		if ( string.IsNullOrEmpty( report ) ) return;

		PrismLog.Info( report );

		PrismLog.Guard( "Show the round-trip report",
			() => new PopupWindow( "Round-trip Check", report, "Close" ).Show() );
	}

	void ShowGenerated()
	{
		ShowDock( PrismWindowLayout.DockCode );

		if ( Session?.LastCompile is null ) CompileNow();
	}

	void OpenGeneratedInEditor()
	{
		var result = Session?.LastCompile;
		var text = result?.ShaderText;

		if ( string.IsNullOrWhiteSpace( text ) )
		{
			Status( "There is no generated shader yet — compile first" );

			return;
		}

		PrismLog.Guard( "Open the generated shader in the code editor", () =>
			CodeWindow.Open()?.OpenText( $"{Session.DisplayName}.{PrismConstants.ShaderExtension}",
				text, "vfx" ) );
	}

	void Export( bool slang )
	{
		var result = Session?.LastCompile
			?? PrismLog.Guard<CompileResult>( "Generate for export",
				() => GraphCompiler.Compile( Session?.Graph, CompileMode.Final ) );

		var text = slang ? result?.SlangText : result?.ShaderText;

		if ( string.IsNullOrWhiteSpace( text ) )
		{
			Status( slang
				? "This graph does not target Slang — enable it in the graph settings"
				: "There is nothing to export yet" );

			return;
		}

		var extension = slang ? PrismConstants.SlangExtension : PrismConstants.ShaderExtension;
		var path = PrismDialogs.PickPath( slang ? "Export Slang" : "Export HLSL", extension,
			slang ? "Slang module" : "s&box shader", true, Session?.DisplayName );

		if ( string.IsNullOrWhiteSpace( path ) ) return;

		if ( !WriteAtomic( path, text ) )
		{
			Status( "The export could not be written" );

			return;
		}

		PrismLog.Guard( "Register the exported file", () => AssetSystem.RegisterFile( path ) );

		Status( $"Exported {Path.GetFileName( path )}" );
	}

	void CleanUpUnused()
	{
		var unused = AlignTools.UnusedNodes( Session?.Graph );

		if ( unused.Count == 0 )
		{
			Status( "Every node feeds an output — nothing to clean up" );

			return;
		}

		var message = unused.Count == 1
			? "One node does not contribute to any output. Delete it?"
			: $"{unused.Count} nodes do not contribute to any output. Delete them?";

		PrismDialogs.Confirm( message, () =>
		{
			Session?.Mutations?.RemoveNodes( unused.Select( x => x.Id ), "Clean Up Unused" );

			Graph?.GraphView?.SyncFromDocument();
		}, "Clean Up Unused", "Delete" );
	}

	void ImportLegacyDialog()
	{
		var path = PrismDialogs.PickPath( "Import Shader Graph", "shdrgrph",
			"Legacy shader graph", false );

		if ( string.IsNullOrWhiteSpace( path ) ) return;

		PrismDialogs.PromptUnsaved( Session, () => ImportLegacy( path, new DiagnosticSink() ), () => Save() );
	}

	void About()
	{
		var text =
			$"{PrismConstants.ProductName} {PrismConstants.EditorVersion}\n\n" +
			$"{NodeRegistry.Count} node types · schema v{PrismConstants.DocumentSchemaVersion} · " +
			$"target SM {ShaderModel.Target} (Vulkan)\n" +
			$"Documents: .{PrismConstants.GraphExtension} and .{PrismConstants.SubgraphExtension}";

		PrismLog.Guard( "Show the about box",
			() => new PopupWindow( $"About {PrismConstants.ProductName}", text, "Close" ).Show() );
	}

	// ---------------------------------------------------------------- subgraphs ----

	/// <summary>
	/// Turn the selection into a reusable shader function.
	/// <para>
	/// The boundary is computed from the edge list: every wire entering the selection becomes a subgraph
	/// input, every wire leaving it becomes an output slot. The new document is written first and only
	/// then is the selection replaced, all inside a single undo scope — so a failure at any point costs
	/// one Ctrl+Z.
	/// </para>
	/// </summary>
	public void CollapseToSubgraph()
	{
		var graph = Session?.Graph;
		var mutations = Session?.Mutations;
		var selection = Graph?.SelectedNodes;

		if ( graph is null || mutations is null || selection is null || selection.Count == 0 )
		{
			Status( "Select the nodes to collapse first" );

			return;
		}

		if ( string.IsNullOrWhiteSpace( Session.FilePath ) && !Save() ) return;

		var suggested = $"{PrismDialogs.Sanitise( Session.DisplayName )}_function";
		var path = PrismDialogs.PickPath( "Collapse To Shader Function", PrismConstants.SubgraphExtension,
			"Prism Shader Function", true, suggested );

		if ( string.IsNullOrWhiteSpace( path ) ) return;

		PrismLog.Guard( "Collapse to a shader function", () => Collapse( graph, mutations, selection, path ) );
	}

	void Collapse( PrismGraph graph, GraphMutations mutations, IReadOnlyList<PrismNode> selection,
		string path )
	{
		var inside = new HashSet<NodeId>( selection.Select( x => x.Id ) );

		// Distinct external sources feeding the selection, and distinct internal sources feeding out.
		var inbound = graph.Edges
			.Where( x => inside.Contains( x.ToNode ) && !inside.Contains( x.FromNode ) )
			.ToArray();

		var outbound = graph.Edges
			.Where( x => inside.Contains( x.FromNode ) && !inside.Contains( x.ToNode ) )
			.ToArray();

		var inputSources = inbound.Select( x => x.From ).Distinct().ToArray();
		var outputSources = outbound.Select( x => x.From ).Distinct().ToArray();

		var subgraph = new PrismGraph( true ) { DocumentId = Ids.NewShortId() };

		subgraph.Meta.Title = Path.GetFileNameWithoutExtension( path );
		subgraph.Settings.Domain = ShaderDomain.Subgraph;

		var fragment = PrismSerializer.WriteNodes( graph, selection );

		if ( string.IsNullOrWhiteSpace( fragment ) )
		{
			Status( "The selection could not be serialized" );

			return;
		}

		PrismSerializer.ReadNodes( subgraph, fragment, false );

		var bounds = Bounds( selection );
		var inputNames = new Dictionary<PortRef, string>();

		for ( int i = 0; i < inputSources.Length; i++ )
		{
			var source = inputSources[i];
			var target = inbound.First( x => x.From == source ).To;
			var port = graph.FindNode( target.Node )?.FindInput( target.Port );
			var name = Unique( inputNames.Values, port?.DisplayName ?? $"Input{i + 1}" );

			inputNames[source] = name;

			if ( NodeRegistry.Create( SubgraphInputNode.TypeId ) is not SubgraphInputNode input ) continue;

			input.InputName = name;
			input.InputType = ( port?.EffectiveType ?? ShaderType.Float ).Hlsl;
			input.PortOrder = i;
			input.Position = new Vector2( bounds.Left - 320f, bounds.Top + i * 120f );

			subgraph.AddNode( input );

			foreach ( var edge in inbound.Where( x => x.From == source ) )
			{
				subgraph.Connect( new PortRef( input.Id, input.Outputs.First().Id ), edge.To );
			}
		}

		var outputNames = new Dictionary<PortRef, string>();

		if ( NodeRegistry.Create( SubgraphOutputNode.TypeId ) is SubgraphOutputNode terminal )
		{
			terminal.Position = new Vector2( bounds.Right + 220f, bounds.Top );

			subgraph.AddNode( terminal );

			for ( int i = 0; i < outputSources.Length; i++ )
			{
				var source = outputSources[i];
				var port = graph.FindNode( source.Node )?.FindOutput( source.Port );
				var slot = terminal.AddSlot( port?.DisplayName ?? $"Out{i + 1}",
					( port?.EffectiveType ?? ShaderType.Float ).Hlsl );

				outputNames[source] = slot.Name;

				var target = terminal.FindInput( new PortId( SubgraphLibrary.Identifier( slot.Name ) ) );

				if ( target is null ) continue;

				subgraph.Connect( source, new PortRef( terminal.Id, target.Id ) );
			}
		}

		if ( !PrismSerializer.Save( subgraph, path ) )
		{
			Status( "The shader function could not be written" );

			return;
		}

		var asset = PrismLog.Guard<Asset>( "Register the shader function",
			() => AssetSystem.RegisterFile( path ) );

		var relative = asset?.RelativePath ?? path;

		using ( mutations.Begin( "Collapse To Shader Function" ) )
		{
			var instance = NodeRegistry.Create( SubgraphInstanceNode.TypeId ) as SubgraphInstanceNode;

			if ( instance is null )
			{
				Status( "The subgraph node type is not registered" );

				return;
			}

			instance.SubgraphPath = relative;
			instance.Position = new Vector2( bounds.Center.x - 90f, bounds.Center.y );

			graph.AddNode( instance );

			foreach ( var pair in inputNames )
			{
				var port = instance.FindInput( new PortId( SubgraphLibrary.Identifier( pair.Value ) ) );

				if ( port is null ) continue;

				graph.Connect( pair.Key, new PortRef( instance.Id, port.Id ) );
			}

			foreach ( var pair in outputNames )
			{
				var port = instance.FindOutput( new PortId( SubgraphLibrary.Identifier( pair.Value ) ) );

				if ( port is null ) continue;

				foreach ( var edge in outbound.Where( x => x.From == pair.Key ) )
				{
					graph.Connect( new PortRef( instance.Id, port.Id ), edge.To );
				}
			}

			foreach ( var node in selection ) graph.RemoveNode( node.Id );
		}

		Graph?.GraphView?.SyncFromDocument();

		Status( $"Collapsed {selection.Count} nodes into {Path.GetFileName( path )}" );
	}

	/// <summary>Replace the selected subgraph instance with the nodes it references.</summary>
	public void ExpandSubgraph()
	{
		var graph = Session?.Graph;
		var mutations = Session?.Mutations;
		var instance = Graph?.SelectedNodes?.OfType<SubgraphInstanceNode>().FirstOrDefault();

		if ( graph is null || mutations is null || instance is null )
		{
			Status( "Select a shader function node to expand" );

			return;
		}

		var referenced = PrismLog.Guard<PrismGraph>( "Load the shader function",
			() => SubgraphLibrary.Load( instance.SubgraphPath, out _ ) );

		if ( referenced is null )
		{
			Status( "That shader function could not be read" );

			return;
		}

		// Everything except the boundary declarations, which have no meaning outside a subgraph.
		var body = referenced.Nodes
			.Where( x => x is not SubgraphInputNode && x is not SubgraphOutputNode )
			.ToArray();

		if ( body.Length == 0 )
		{
			Status( "That shader function is empty" );

			return;
		}

		var fragment = PrismSerializer.WriteNodes( referenced, body );

		using ( mutations.Begin( "Expand Shader Function" ) )
		{
			var added = PrismSerializer.ReadNodes( graph, fragment, true );
			var offset = instance.Position - Bounds( body ).Position + new Vector2( 0f, 220f );

			foreach ( var node in added ) node.Position += offset;

			graph.RemoveNode( instance.Id );
		}

		Graph?.GraphView?.SyncFromDocument();

		Status( "Expanded the shader function — reconnect its boundary by hand" );
	}

	static Rect Bounds( IReadOnlyList<PrismNode> nodes )
	{
		var min = new Vector2( float.MaxValue, float.MaxValue );
		var max = new Vector2( float.MinValue, float.MinValue );

		foreach ( var node in nodes )
		{
			var size = AlignTools.Measure( node );

			min = min.ComponentMin( node.Position );
			max = max.ComponentMax( node.Position + size );
		}

		if ( min.x > max.x ) return new Rect( 0f, 0f, 1f, 1f );

		return new Rect( min, max - min );
	}

	static string Unique( IEnumerable<string> taken, string name )
	{
		var used = new HashSet<string>( taken, StringComparer.OrdinalIgnoreCase );

		if ( !used.Contains( name ) ) return name;

		for ( int i = 2; i < 1000; i++ )
		{
			var candidate = $"{name} {i}";

			if ( !used.Contains( candidate ) ) return candidate;
		}

		return name + Ids.NewShortId( 4 );
	}

	// ---------------------------------------------------------------- recent files ----
	//
	// There is one recent list and it lives in PrismCookies, on the ProjectCookie. This window used to
	// keep a second one in FileSystem.Temporary, which meant the two never agreed: opening a document by
	// double-clicking its asset wrote the cookie list and never appeared in this menu, while opening one
	// from this menu wrote the temp file and never appeared anywhere else. FileSystem.Temporary was also
	// the wrong home for it — it is disposable by definition.

	void LoadRecentFiles()
	{
		PrismLog.Guard( "Read the recent-files list", () =>
		{
			_recent.Clear();
			_recent.AddRange( PrismCookies.RecentFiles
				.Where( x => !string.IsNullOrWhiteSpace( x ) && File.Exists( x ) ) );
		} );
	}

	void AddRecentFile( string path )
	{
		if ( string.IsNullOrWhiteSpace( path ) ) return;

		PrismCookies.PushRecent( path );

		LoadRecentFiles();
		RefreshRecentMenu();
	}

	void RefreshRecentMenu()
	{
		if ( _recentMenu is null ) return;

		PrismLog.Guard( "Rebuild the recent-files menu", () =>
		{
			_recentMenu.Clear();
			_recentMenu.Enabled = _recent.Count > 0;

			for ( int i = 0; i < _recent.Count; i++ )
			{
				var path = _recent[i];
				var option = _recentMenu.AddOption( $"{i + 1}.  {Path.GetFileName( path )}", null,
					() => Open( path ) );

				option.StatusTip = path;
				option.ToolTip = path;
			}

			if ( _recent.Count == 0 ) return;

			_recentMenu.AddSeparator();
			_recentMenu.AddOption( "Clear Recent Files", PrismIcons.Delete, () =>
			{
				PrismCookies.ClearRecent();

				_recent.Clear();
				RefreshRecentMenu();
			} );
		} );
	}

	// ---------------------------------------------------------------- frame ----

	/// <summary>Keep the undo affordances honest, and take an autosave when one is due.</summary>
	[EditorEvent.Frame]
	public void OnPrismWindowFrame()
	{
		if ( !this.IsValid() || Session?.Undo is null ) return;

		var level = Session.Undo.Level * 1000 + Session.Undo.Count;

		if ( level != _lastUndoLevel )
		{
			_lastUndoLevel = level;

			var canUndo = Session.Undo.CanUndo;
			var canRedo = Session.Undo.CanRedo;

			SetEnabled( _undoToolbar, canUndo );
			SetEnabled( _undoMenu, canUndo );
			SetEnabled( _redoToolbar, canRedo );
			SetEnabled( _redoMenu, canRedo );

			if ( _undoMenu is not null )
			{
				_undoMenu.Text = canUndo ? $"Undo {Session.Undo.UndoName}" : "Undo";
			}

			if ( _redoMenu is not null )
			{
				_redoMenu.Text = canRedo ? $"Redo {Session.Undo.RedoName}" : "Redo";
			}
		}

		SyncLibraryPlugFilter();
		SyncThumbnails();
	}

	/// <summary>
	/// Narrow the node library to types that can accept the wire being dragged, and widen it again when
	/// the drag ends.
	/// <para>
	/// Polled rather than event-driven because <c>GraphView</c> raises nothing when a wire is picked up:
	/// the in-flight connection is added straight to the scene. The common case costs one bool compare.
	/// </para>
	/// </summary>
	void SyncLibraryPlugFilter()
	{
		var view = Graph?.GraphView;
		var dragging = view.IsValid() && view.IsDraggingWire;

		if ( dragging == _wasDraggingWire ) return;

		_wasDraggingWire = dragging;

		if ( Panel( PrismWindowLayout.DockNodeLibrary ) is not NodeLibraryPanel library ) return;

		PrismLog.Guard( "Filter the node library to the dragged wire", () =>
		{
			if ( !dragging )
			{
				library.ClearPlugFilter();
				return;
			}

			if ( view.DraggingPlug is not { } plug || !plug.IsValid() || plug.Inner is not PrismPlug inner )
			{
				return;
			}

			library.SetPlugFilter( inner.EffectiveType,
				plug is PlugOut ? PortDirection.Output : PortDirection.Input );
		} );
	}

	static void SetEnabled( Option option, bool enabled )
	{
		if ( option is null ) return;

		option.Enabled = enabled;
	}

	/// <summary>
	/// Cancel every in-flight compile before the assembly swaps.
	/// <para>
	/// A compile in flight is a task holding node instances, an emit context and a continuation, all of
	/// types that are about to be replaced. Letting it land after the swap is how a hotload turns into a
	/// stream of cast exceptions from a stack the user cannot see. The sessions are owned by the windows,
	/// so this has to run here — nothing else has a list of them.
	/// </para>
	/// </summary>
	[EditorEvent.Hotload]
	public static void CancelCompilesOnHotload()
	{
		s_windows.RemoveAll( x => !x.IsValid() );

		foreach ( var window in s_windows )
		{
			PrismLog.Guard( "Cancel a compile for hotload", () => window.Session?.Compiler?.Cancel() );
		}
	}

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

	/// <inheritdoc/>
	protected override void BuildDefaultLayout() => PrismWindowLayout.BuildDefault( this );

	/// <inheritdoc/>
	protected override bool OnClose()
	{
		if ( _closing || Session is null || !Session.IsDirty ) return true;

		PrismDialogs.PromptUnsaved( Session, () =>
		{
			_closing = true;

			Close();
		}, () => Save() );

		return false;
	}

	/// <inheritdoc/>
	protected override void OnClosed()
	{
		s_windows.Remove( this );

		PrismLog.Guard( "Stop tracking the window for autosave", () => PrismAutosave.Untrack( this ) );

		PrismCookies.Changed -= ApplyPreferences;
		PrismTheme.Changed -= OnThemeChanged;
		AssetHooks.DocumentChangedOnDisk -= OnDocumentChangedOnDisk;

		if ( Panel( PrismWindowLayout.DockPreview ) is Preview.PreviewPanel preview )
		{
			preview.ThumbnailReady -= OnThumbnailReady;

			if ( Session is not null ) Session.SelectionChanged -= OnSelectionChangedForPreview;
		}

		if ( Session is not null )
		{
			Session.DirtyChanged -= UpdateTitle;
			Session.DocumentReplaced -= UpdateTitle;
			Session.FocusRequested -= OnFocusRequested;

			if ( Session.Graph is not null ) Session.Graph.Changed -= OnGraphChangedForLibrary;
		}

		PrismLog.Guard( "Dispose the session", () => Session?.Dispose() );

		base.OnClosed();
	}

	void OnFocusRequested( NodeId node, PortId port ) => Graph?.Focus( node, port );

	void UpdateTitle()
	{
		PrismLog.Guard( "Update the window title", () =>
		{
			var name = Session?.DisplayName ?? "untitled";
			var dirty = Session is { IsDirty: true } ? "*" : string.Empty;
			var kind = Session is { IsSubgraph: true } ? "Shader Function" : "Shader Graph";

			Title = $"{name}{dirty} — {PrismConstants.ProductName} {kind}";

			if ( _saveToolbar is not null )
			{
				_saveToolbar.StatusTip = Session is { IsDirty: true }
					? $"Save {name} — there are unsaved changes"
					: $"Save {name}";
			}

			if ( _status.IsValid() ) _status.Update();

			if ( Graph.IsValid() ) Graph.WindowTitle = $"{name}{dirty}";
		} );
	}

	void Status( string message )
	{
		if ( string.IsNullOrWhiteSpace( message ) ) return;

		PrismLog.Info( message );
		PrismLog.Guard( "Show a status message", () => StatusBar?.ShowMessage( message, 6f ) );
	}

	void Report( string message, DiagnosticSink sink )
	{
		var detail = sink is null || sink.Count == 0
			? string.Empty
			: Environment.NewLine + Environment.NewLine +
			  string.Join( Environment.NewLine, sink.All.Take( 8 ).Select( x => x.ToString() ) );

		PrismLog.Error( message + detail );

		PrismLog.Guard( "Show an error dialog",
			() => new PopupWindow( PrismConstants.ProductName, message + detail, "Close" ).Show() );
	}

	static bool SamePath( string a, string b )
	{
		if ( string.IsNullOrWhiteSpace( a ) || string.IsNullOrWhiteSpace( b ) ) return false;

		return string.Equals( Path.GetFullPath( a ), Path.GetFullPath( b ), StringComparison.OrdinalIgnoreCase );
	}
}