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