Editor UI integration for Prism preferences. It adds a Prism page to the Editor Preferences, exposes a standalone Prism Preferences window, and builds the settings page UI including appearance, editing, integration, Slang toolchain install/locate controls, and maintenance actions.
using Editor.Prism.Core;
using Editor.Prism.Toolchain;
using Editor.Prism.Ui;
using System.IO;
namespace Editor.Prism.Integration;
/// <summary>
/// Prism's page in <i>Editor Settings</i>, plus the standalone window the <c>Prism</c> menu opens.
/// <para>
/// <c>EditorPreferencesWindow</c> raises <c>editor.preferences</c> with its <c>NavigationView</c>
/// after building its own pages, which is the only supported way for a third-party project to add
/// one. Project settings are not extensible at all — the category list there is hard-coded — so every
/// Prism setting lives in a cookie rather than in <c>ProjectSettings/</c>.
/// </para>
/// </summary>
public static class PrismPreferences
{
static PrismPreferencesWindow s_window;
/// <summary>Add the Prism page to the editor preferences window.</summary>
[Event( "editor.preferences" )]
public static void OnEditorPreferences( NavigationView container )
{
if ( container is null ) return;
PrismLog.Guard( "Adding the Prism preferences page",
() => container.AddPage( "Prism", "gradient", new PrismPreferencesPage( container ) ) );
}
/// <summary>Open Prism's settings on their own, without going through the editor preferences window.</summary>
public static void OpenPreferencesPage()
{
PrismLog.Guard( "Opening the Prism preferences window", () =>
{
if ( s_window is null || !s_window.IsValid )
{
s_window = new PrismPreferencesWindow();
}
s_window.Show();
s_window.Focus();
} );
}
/// <summary>Drop the cached window outright.</summary>
public static void Reset()
{
s_window = null;
}
/// <summary>
/// What hotload calls. The window instance is migrated rather than destroyed, so it is kept if it
/// is still on screen — dropping it would mint a second settings window next time.
/// </summary>
public static void Revalidate()
{
if ( s_window is not null && !s_window.IsValid ) s_window = null;
}
}
/// <summary>A standalone host for <see cref="PrismPreferencesPage"/>.</summary>
public sealed class PrismPreferencesWindow : BaseWindow
{
/// <summary>Build the window.</summary>
public PrismPreferencesWindow()
{
WindowTitle = "Prism Settings";
SetWindowIcon( "gradient" );
Size = new Vector2( 720f, 720f );
MinimumSize = new Vector2( 560f, 420f );
Layout = Layout.Column();
Layout.Add( new PrismPreferencesPage( this ), 1 );
}
}
/// <summary>
/// The settings themselves. Every control writes straight through to <see cref="PrismCookies"/>, so
/// there is no apply step and nothing to lose by closing the window.
/// </summary>
public sealed class PrismPreferencesPage : Widget
{
Label _slangStatus;
Label _slangDetail;
Button _slangAction;
Button _slangForget;
Label _codeEditorNote;
CancellationTokenSource _install;
/// <summary>Build the page.</summary>
public PrismPreferencesPage( Widget parent ) : base( parent )
{
Layout = Layout.Column();
var scroll = new ScrollArea( this );
scroll.Canvas = new Widget( scroll );
scroll.Canvas.Layout = Layout.Column();
scroll.Canvas.Layout.Margin = 28f;
scroll.Canvas.Layout.Spacing = 6f;
Build( scroll.Canvas.Layout );
Layout.Add( scroll, 1 );
PrismCookies.Changed += OnCookiesChanged;
SlangToolchain.Changed += OnToolchainChanged;
RefreshToolchain();
RefreshCodeEditorNote();
}
/// <summary>Unsubscribe. A preferences page outlives nothing, but the events are static.</summary>
public override void OnDestroyed()
{
base.OnDestroyed();
PrismCookies.Changed -= OnCookiesChanged;
SlangToolchain.Changed -= OnToolchainChanged;
_install?.Cancel();
_install?.Dispose();
_install = null;
}
void Build( Layout layout )
{
// Reads here are memoised, and some of these keys are also written from outside this class —
// the View menu's Node Previews toggle, and the preview panel's own settings. Dropping the memo
// as the page opens is what stops it showing a value the user changed somewhere else.
PrismCookies.FlushCache();
var title = layout.Add( new Label.Title( "Prism" ) );
title.Color = PrismTheme.TextPrimary;
var version = layout.Add( new Label.Small( $"Shader editor · version {PrismConstants.EditorVersion}" ) );
version.Color = PrismTheme.TextMuted;
BuildAppearance( layout );
BuildEditing( layout );
BuildIntegration( layout );
BuildToolchain( layout );
BuildMaintenance( layout );
layout.AddStretchCell();
}
// ---- appearance --------------------------------------------------------
void BuildAppearance( Layout layout )
{
Section( layout, "Appearance" );
var sheet = new ControlSheet();
sheet.AddProperty( () => PrismCookies.Theme );
sheet.AddProperty( () => PrismCookies.WireStyle );
sheet.AddProperty( () => PrismCookies.NodePreviews );
layout.Add( sheet );
// The theme picker is a plain text field on purpose: a theme is a file, not an enum, and there
// is exactly one conventional path for it. "Prism" is the built-in palette; anything else loads
// <project>/.sbox/prism/theme.json. Export writes the current palette out as a starting point,
// which is the only practical way to author one — there are 73 tokens.
var row = layout.AddRow();
row.Add( new Button( "Export Theme…" )
{
Clicked = ExportTheme,
ToolTip = "Write the palette in force to a theme.json you can edit."
} );
row.Add( new Button( "Reload Theme" )
{
Clicked = () => PrismLog.Guard( "Reloading the Prism theme",
() => PrismTheme.LoadPreferred( PrismCookies.Theme ) ),
ToolTip = "Re-read the theme file after editing it."
} );
row.AddStretchCell();
var themeNote = layout.Add( new Label.Small(
$"\"Prism\" is the built-in dark palette. Any other name loads {PrismConstants.ThemeFile} "
+ "from the project root and falls back to the built-in when that file is missing." ) );
themeNote.Color = PrismTheme.TextDisabled;
themeNote.WordWrap = true;
}
/// <summary>Write the palette in force to a file the user picks, so a theme can be edited from it.</summary>
static void ExportTheme()
{
PrismLog.Guard( "Exporting the Prism theme", () =>
{
var dialog = new FileDialog( null )
{
Title = "Export Prism Theme",
Directory = System.IO.Path.GetDirectoryName( PrismTheme.DefaultThemePath ?? string.Empty ),
DefaultSuffix = ".json"
};
dialog.SetNameFilter( "Prism Theme (*.json)" );
dialog.SetModeSave();
if ( !dialog.Execute() ) return;
if ( PrismTheme.Export( dialog.SelectedFile ) )
{
PrismLog.Info( $"Prism: theme written to {dialog.SelectedFile}" );
return;
}
PrismLog.Warn( $"Prism: the theme could not be written to {dialog.SelectedFile}" );
} );
}
// ---- editing -----------------------------------------------------------
void BuildEditing( Layout layout )
{
Section( layout, "Editing" );
var sheet = new ControlSheet();
sheet.AddProperty( () => PrismCookies.AutosaveEnabled );
sheet.AddProperty( () => PrismCookies.AutosaveIntervalSeconds );
sheet.AddProperty( () => PrismCookies.AutosaveRetained );
sheet.AddProperty( () => PrismCookies.CompileOnSave );
layout.Add( sheet );
var note = layout.Add( new Label.Small(
"Snapshots are written to .sbox/prism/autosave and are only offered back when the editor "
+ "went down with unsaved changes." ) );
note.Color = PrismTheme.TextDisabled;
note.WordWrap = true;
}
// ---- integration -------------------------------------------------------
void BuildIntegration( Layout layout )
{
Section( layout, "Editor Integration" );
var sheet = new ControlSheet();
sheet.AddProperty( () => PrismCookies.ClaimShaderFiles );
sheet.AddProperty( () => PrismCookies.RouteCodeFiles );
layout.Add( sheet );
_codeEditorNote = layout.Add( new Label.Small( "" ) );
_codeEditorNote.Color = PrismTheme.TextDisabled;
_codeEditorNote.WordWrap = true;
var warning = layout.Add( new Label.Small(
"Opening a .shader is broadcast to every listener and cannot be cancelled, so an installed "
+ "external editor may still open it alongside Prism. Right-click ▸ Edit in Prism is always exact." ) );
warning.Color = PrismTheme.TextDisabled;
warning.WordWrap = true;
}
void RefreshCodeEditorNote()
{
if ( _codeEditorNote is null || !_codeEditorNote.IsValid ) return;
_codeEditorNote.Text = PrismCookies.RouteCodeFiles
? $"Shader sources open in Prism. Everything else goes to {CodeFileEditor.FallbackTitle}."
: $"Code files open in {CodeFileEditor.FallbackTitle}. Prism is still available in the code editor list.";
}
// ---- toolchain ---------------------------------------------------------
void BuildToolchain( Layout layout )
{
Section( layout, "Slang Toolchain" );
var blurb = layout.Add( new Label.Body(
"Optional. Prism can cross-check the Slang it generates with slangc. This never gates rendering "
+ "and never blocks a save — it only adds informational diagnostics." ) );
blurb.Color = PrismTheme.TextSecondary;
blurb.WordWrap = true;
layout.AddSpacingCell( 6f );
_slangStatus = layout.Add( new Label( "", this ) );
_slangDetail = layout.Add( new Label.Small( "" ) );
_slangDetail.Color = PrismTheme.TextMuted;
_slangDetail.WordWrap = true;
layout.AddSpacingCell( 6f );
var buttons = layout.AddRow();
buttons.Spacing = 8f;
_slangAction = buttons.Add( new Button( "Download", "download", this ) );
_slangAction.Clicked = OnSlangAction;
var locate = buttons.Add( new Button( "Locate...", "folder_open", this ) );
locate.Clicked = OnLocateSlang;
locate.StatusTip = "Point Prism at an existing slangc executable";
var probe = buttons.Add( new Button( "Re-check", "refresh", this ) );
probe.Clicked = () =>
{
PrismLog.Guard( "Re-probing the Slang toolchain", () => SlangToolchain.Probe( true ) );
RefreshToolchain();
};
_slangForget = buttons.Add( new Button.Clear( "Forget", "close", this ) );
_slangForget.Clicked = () =>
{
PrismLog.Guard( "Forgetting the Slang toolchain", SlangToolchain.Forget );
RefreshToolchain();
};
buttons.AddStretchCell();
var sheet = new ControlSheet();
sheet.AddProperty( () => PrismCookies.ValidateWithSlang );
layout.Add( sheet );
}
void RefreshToolchain()
{
if ( _slangStatus is null || !_slangStatus.IsValid ) return;
var info = PrismLog.Guard( "Reading the Slang toolchain", () => SlangToolchain.Current, null );
var available = info is not null && info.IsAvailable;
_slangStatus.Text = available ? $"Ready — slangc {info.Version}" : "Not installed";
_slangStatus.Color = available ? PrismTheme.Success : PrismTheme.TextMuted;
_slangDetail.Text = available
? info.ExecutablePath
: $"Prism will download the official release into {PrismConstants.SlangToolchainDir}.";
if ( _slangAction is not null && _slangAction.IsValid )
{
_slangAction.Text = available ? "Reinstall" : "Download";
_slangAction.Enabled = _install is null;
}
if ( _slangForget is not null && _slangForget.IsValid )
{
_slangForget.Enabled = available;
}
}
void OnToolchainChanged()
{
PrismLog.Guard( "Refreshing the Slang toolchain status", () => MainThread.Queue( RefreshToolchain ) );
}
void OnCookiesChanged()
{
PrismLog.Guard( "Refreshing the Prism preferences page", RefreshCodeEditorNote );
}
async void OnSlangAction()
{
if ( _install is not null ) return;
_install = new CancellationTokenSource();
if ( _slangAction is not null && _slangAction.IsValid ) _slangAction.Enabled = false;
var progress = new Progress<SlangInstallProgress>( report => MainThread.Queue( () =>
{
if ( _slangDetail is null || !_slangDetail.IsValid ) return;
_slangDetail.Text = report.Describe();
} ) );
SlangInstallResult result = null;
try
{
result = await SlangToolchain.Install( progress, _install.Token );
}
catch ( Exception e )
{
PrismLog.Error( e, "The Slang install failed" );
}
finally
{
_install?.Dispose();
_install = null;
}
// Everything from here down runs whatever happened, and it is guarded because this is the
// codebase's one async void — a throw in the tail has no caller to catch it. The failure branch
// used to `return` early, which skipped RefreshToolchain, which is the ONLY place the Download
// button is re-enabled: losing the network once left the button dead for the rest of the
// session, with no way to retry short of restarting the editor.
PrismLog.Guard( "Finishing the Slang install", () =>
{
var failed = result is not null && !result.Ok && !result.Cancelled;
if ( failed ) PrismLog.Warn( $"Slang install failed: {result.Message}" );
if ( _slangDetail is not null && _slangDetail.IsValid )
{
if ( failed )
{
_slangDetail.Text = result.Message;
_slangDetail.Color = PrismTheme.Warning;
}
else
{
_slangDetail.Color = PrismTheme.TextMuted;
}
}
// Re-enables the action and repaints the chip. On the failure path this also overwrites the
// message above with the toolchain's own status, so the detail label is set again after.
var message = failed ? result.Message : null;
RefreshToolchain();
if ( message is null || _slangDetail is null || !_slangDetail.IsValid ) return;
_slangDetail.Text = message;
_slangDetail.Color = PrismTheme.Warning;
} );
}
void OnLocateSlang()
{
PrismLog.Guard( "Locating slangc", () =>
{
var dialog = new FileDialog( null )
{
Title = "Locate slangc",
Directory = SlangToolchain.InstallDirectory
};
dialog.SetNameFilter( $"slangc ({SlangToolchain.ExecutableName})" );
dialog.SetFindExistingFile();
dialog.SetModeOpen();
if ( !dialog.Execute() ) return;
SlangToolchain.Override = dialog.SelectedFile;
RefreshToolchain();
} );
}
// ---- maintenance -------------------------------------------------------
void BuildMaintenance( Layout layout )
{
Section( layout, "Maintenance" );
var sheet = new ControlSheet();
sheet.AddProperty( () => PrismCookies.VerboseLogging );
layout.Add( sheet );
var row = layout.AddRow();
row.Spacing = 8f;
var autosaves = row.Add( new Button( "Autosaves...", "history", this ) );
autosaves.Clicked = () => PrismAutosave.ShowRecovery( true );
var recent = row.Add( new Button( "Clear Recent", "playlist_remove", this ) );
recent.Clicked = PrismCookies.ClearRecent;
var scratch = row.Add( new Button( "Clear Scratch Files", "cleaning_services", this ) );
scratch.Clicked = () => PrismLog.Guard( "Collecting Prism scratch files", () =>
{
var removed = TempWorkspace.CollectGarbage( 0 );
PrismLog.Info( $"Removed {removed} Prism scratch session(s)" );
} );
scratch.StatusTip = "Delete generated shaders left in .source2/temp/prism";
row.AddStretchCell();
var reset = row.Add( new Button.Clear( "Restore Defaults", "settings_backup_restore", this ) );
reset.Clicked = () =>
{
PrismCookies.ResetToDefaults();
RefreshToolchain();
RefreshCodeEditorNote();
};
}
// ---- shared ------------------------------------------------------------
void Section( Layout layout, string heading )
{
layout.AddSpacingCell( 18f );
var label = layout.Add( new Label.Subtitle( heading ) );
label.Color = PrismTheme.TextPrimary;
}
}