Static helper that centralises Prism editor and per-project persistent settings (cookies). It defines typed properties for theme, UI and editor preferences, recent/open documents and session state, caches reads, writes through cookie containers, and raises a Changed event when settings change.
using Editor.Prism.Core;
using Editor.Prism.Ui;
namespace Editor.Prism.Integration;
/// <summary>
/// Every persisted Prism setting, in one place and strongly typed.
/// <para>
/// Two stores are in play and the split is deliberate. <c>EditorCookie</c> is machine-global
/// (<c><sbox>/config/tools.json</c>) and holds preferences that describe the *person* — theme,
/// wire style, whether Prism claims shader files. <c>ProjectCookie</c> is per project
/// (<c><project>/.sbox/project.json</c>) and holds facts about the *work* — recent documents,
/// the last folder a dialog landed in, the unclean-shutdown marker.
/// </para>
/// <para>
/// Everything here degrades to its default rather than throwing: a cookie container is always
/// present (it falls back to an in-memory one), but the value inside may be anything a previous
/// version wrote, so every read is guarded.
/// </para>
/// </summary>
public static class PrismCookies
{
// ---- keys ------------------------------------------------------------
const string KeyTheme = "prism.theme";
const string KeyAutosaveEnabled = "prism.autosave.enabled";
const string KeyAutosaveInterval = "prism.autosave.intervalSeconds";
const string KeyAutosaveRetained = "prism.autosave.retained";
const string KeyCompileOnSave = "prism.compileOnSave";
const string KeyValidateSlang = "prism.validateSlang";
const string KeyRouteCodeFiles = "prism.routeCodeFiles";
const string KeyFallbackCodeEditor = "prism.fallbackCodeEditor";
const string KeyWelcomeShown = "prism.welcomeShown";
const string KeyVerboseLogging = "prism.verboseLogging";
const string KeyRecentFiles = "prism.recentFiles";
const string KeyLastDirectory = "prism.lastDirectory";
const string KeyOpenDocuments = "prism.session.openDocuments";
const string KeySessionMarker = "prism.session.dirty";
/// <summary>How many documents the recent list keeps.</summary>
public const int RecentLimit = 12;
/// <summary>
/// Raised after any setting changes, so open windows and the preferences page stay in step.
/// Handlers must be cheap and must never throw.
/// </summary>
public static event Action Changed;
// ---- appearance ------------------------------------------------------
/// <summary>
/// Colour palette used by the graph, panels and code editor. <c>"Prism"</c> is the built-in dark
/// palette; anything else is resolved against <c>.sbox/prism/theme.json</c> and falls back to the
/// built-in when missing.
/// </summary>
[Title( "Theme" )]
public static string Theme
{
get => GetString( KeyTheme, "Prism" );
set => SetString( KeyTheme, string.IsNullOrWhiteSpace( value ) ? "Prism" : value );
}
/// <summary>
/// How connections are routed across the canvas. Curved wires read faster; orthogonal wires make
/// dense graphs easier to follow.
/// </summary>
[Title( "Wire Style" )]
public static PrismWireStyle WireStyle
{
get
{
var raw = GetString( PrismConstants.CookieWireStyle, null );
return Enum.TryParse<PrismWireStyle>( raw, true, out var style ) ? style : PrismWireStyle.Bezier;
}
set => SetString( PrismConstants.CookieWireStyle, value.ToString() );
}
/// <summary>
/// Render a live thumbnail on node cards. Costs one small render per visible node.
/// </summary>
[Title( "Node Previews" )]
public static bool NodePreviews
{
get => Get( PrismConstants.CookieNodePreviews, true );
set => Set( PrismConstants.CookieNodePreviews, value );
}
// ---- editing ---------------------------------------------------------
/// <summary>
/// Periodically snapshot unsaved documents so a crash cannot lose work.
/// </summary>
[Title( "Autosave" )]
public static bool AutosaveEnabled
{
get => Get( KeyAutosaveEnabled, true );
set => Set( KeyAutosaveEnabled, value );
}
/// <summary>
/// Seconds between snapshots while a document has unsaved changes. Clamped to 15..1800.
/// </summary>
[Title( "Autosave Interval" )]
[Range( 15, 600 )]
public static int AutosaveIntervalSeconds
{
get => Math.Clamp( Get( KeyAutosaveInterval, PrismConstants.AutosaveIntervalSeconds ), 15, 1800 );
set => Set( KeyAutosaveInterval, Math.Clamp( value, 15, 1800 ) );
}
/// <summary>
/// Snapshot generations kept per document before the oldest is discarded. Clamped to 1..50.
/// </summary>
[Title( "Autosave History" )]
[Range( 1, 30 )]
public static int AutosaveRetained
{
get => Math.Clamp( Get( KeyAutosaveRetained, PrismConstants.AutosaveRetained ), 1, 50 );
set => Set( KeyAutosaveRetained, Math.Clamp( value, 1, 50 ) );
}
/// <summary>
/// Run the engine shader compiler after every save.
/// </summary>
[Title( "Compile On Save" )]
public static bool CompileOnSave
{
get => Get( KeyCompileOnSave, true );
set => Set( KeyCompileOnSave, value );
}
/// <summary>
/// Cross-check generated code with slangc. Informational only — it never blocks rendering.
/// </summary>
[Title( "Validate With Slang" )]
public static bool ValidateWithSlang
{
get => Get( KeyValidateSlang, false );
set => Set( KeyValidateSlang, value );
}
// ---- integration -----------------------------------------------------
/// <summary>
/// Double-clicking a <c>.shader</c> opens it in Prism's code editor. The engine also notifies any
/// external editor — that cannot be cancelled. On by default: answering <c>open.shader</c> is the
/// only way to make double-click land in Prism, because that path never reaches <c>IAssetEditor</c>.
/// </summary>
[Title( "Open Shader Files In Prism" )]
public static bool ClaimShaderFiles
{
get => Get( PrismConstants.CookieClaimShaderFiles, true );
set => Set( PrismConstants.CookieClaimShaderFiles, value );
}
/// <summary>
/// Route shader sources to Prism editor-wide. C#, Razor and SCSS are handed straight to the
/// previously selected editor, so this never takes over your IDE.
/// </summary>
[Title( "Use Prism As The Code Editor" )]
public static bool RouteCodeFiles
{
get => Get( KeyRouteCodeFiles, false );
set => Set( KeyRouteCodeFiles, value );
}
/// <summary>
/// Type name of the editor Prism delegates non-shader files to. Empty means "pick the best
/// installed one".
/// </summary>
public static string FallbackCodeEditor
{
get => GetString( KeyFallbackCodeEditor, null );
set => SetString( KeyFallbackCodeEditor, value );
}
/// <summary>Mirrors <c>PrismLog.Verbose</c> across sessions.</summary>
[Title( "Verbose Logging" ), Description( "Log every compile stage and toolchain probe to the console." )]
public static bool VerboseLogging
{
get => Get( KeyVerboseLogging, false );
set
{
Set( KeyVerboseLogging, value );
PrismLog.Verbose = value;
}
}
/// <summary>Whether the "What is Prism" panel has already been shown once.</summary>
public static bool WelcomeShown
{
get => Get( KeyWelcomeShown, false );
set => Set( KeyWelcomeShown, value );
}
// ---- per-project state -----------------------------------------------
/// <summary>Most recently opened documents, newest first. Absolute paths.</summary>
public static IReadOnlyList<string> RecentFiles
{
get => PrismLog.Guard( "Reading the recent document list",
() => (IReadOnlyList<string>)(ProjectCookie?.Get( KeyRecentFiles, Array.Empty<string>() ) ?? Array.Empty<string>()),
Array.Empty<string>() );
}
/// <summary>Move a document to the front of the recent list, trimming to <see cref="RecentLimit"/>.</summary>
public static void PushRecent( string absolutePath )
{
if ( string.IsNullOrWhiteSpace( absolutePath ) ) return;
PrismLog.Guard( "Updating the recent document list", () =>
{
var list = new List<string>();
list.Add( absolutePath );
foreach ( var existing in RecentFiles )
{
if ( string.IsNullOrWhiteSpace( existing ) ) continue;
if ( string.Equals( existing, absolutePath, StringComparison.OrdinalIgnoreCase ) ) continue;
if ( list.Count >= RecentLimit ) break;
list.Add( existing );
}
ProjectCookie?.Set( KeyRecentFiles, list.ToArray() );
} );
Notify();
}
/// <summary>Drop a document from the recent list — used when a file turns out to be gone.</summary>
public static void ForgetRecent( string absolutePath )
{
if ( string.IsNullOrWhiteSpace( absolutePath ) ) return;
PrismLog.Guard( "Pruning the recent document list", () =>
{
var list = RecentFiles
.Where( x => !string.IsNullOrWhiteSpace( x ) )
.Where( x => !string.Equals( x, absolutePath, StringComparison.OrdinalIgnoreCase ) )
.ToArray();
ProjectCookie?.Set( KeyRecentFiles, list );
} );
Notify();
}
/// <summary>Empty the recent list.</summary>
public static void ClearRecent()
{
PrismLog.Guard( "Clearing the recent document list",
() => ProjectCookie?.Set( KeyRecentFiles, Array.Empty<string>() ) );
Notify();
}
/// <summary>Directory the last open/save dialog finished in, so the next one starts there.</summary>
public static string LastDirectory
{
get => PrismLog.Guard( "Reading the last Prism directory",
() => ProjectCookie?.GetString( KeyLastDirectory, null ), null );
set => PrismLog.Guard( "Writing the last Prism directory",
() => ProjectCookie?.SetString( KeyLastDirectory, value ?? string.Empty ) );
}
/// <summary>
/// Absolute paths of the documents that were open when the editor was last running. Written by
/// the autosave service and read by crash recovery.
/// </summary>
public static IReadOnlyList<string> OpenDocuments
{
get => PrismLog.Guard( "Reading the open document list",
() => (IReadOnlyList<string>)(ProjectCookie?.Get( KeyOpenDocuments, Array.Empty<string>() ) ?? Array.Empty<string>()),
Array.Empty<string>() );
set => PrismLog.Guard( "Writing the open document list",
() => ProjectCookie?.Set( KeyOpenDocuments, value?.ToArray() ?? Array.Empty<string>() ) );
}
/// <summary>
/// True between "a document became dirty" and "the editor exited cleanly". If it is still true on
/// startup the previous session did not shut down, so recovery offers the autosaves.
/// </summary>
public static bool SessionMarker
{
get => PrismLog.Guard( "Reading the Prism session marker",
() => ProjectCookie?.Get( KeySessionMarker, false ) ?? false, false );
set => PrismLog.Guard( "Writing the Prism session marker",
() => ProjectCookie?.Set( KeySessionMarker, value ) );
}
// ---- plumbing --------------------------------------------------------
/// <summary>Raise <see cref="Changed"/> without letting a handler take the caller down.</summary>
public static void Notify()
{
s_cache.Clear();
PrismLog.Guard( "Raising PrismCookies.Changed", () => Changed?.Invoke() );
}
/// <summary>
/// Drop the memoised values. Only needed when something outside this class writes one of our keys —
/// <c>SlangToolchain</c> owns its own, and a hotload resets the field anyway.
/// </summary>
public static void FlushCache()
{
s_cache.Clear();
}
/// <summary>
/// Drop every <see cref="Changed"/> handler. A static event outlives the assembly that subscribed
/// to it, so after a hotload the invocation list is full of methods on types that no longer exist.
/// </summary>
public static void ClearSubscribers()
{
Changed = null;
}
/// <summary>Restore every Prism setting to its shipped default. Per-project state is left alone.</summary>
public static void ResetToDefaults()
{
PrismLog.Guard( "Resetting Prism preferences", () =>
{
foreach ( var key in new[]
{
KeyTheme, PrismConstants.CookieWireStyle, PrismConstants.CookieNodePreviews,
KeyAutosaveEnabled, KeyAutosaveInterval, KeyAutosaveRetained,
KeyCompileOnSave, KeyValidateSlang,
PrismConstants.CookieClaimShaderFiles, KeyRouteCodeFiles, KeyFallbackCodeEditor,
KeyVerboseLogging
} )
{
EditorCookie?.Remove( key );
}
} );
PrismLog.Verbose = false;
Notify();
}
/// <summary>
/// Memoised reads. <c>CookieContainer.Get<T></c> runs a JSON deserialize on every call, and
/// several of these are read from the per-frame tick, so the values are cached until something
/// writes one. Every write goes through <see cref="Notify"/>, which drops the cache.
/// </summary>
static readonly Dictionary<string, object> s_cache = new();
static T Get<T>( string key, T fallback )
{
if ( s_cache.TryGetValue( key, out var cached ) && cached is T typed ) return typed;
var value = PrismLog.Guard( $"Reading the cookie '{key}'",
() => EditorCookie is null ? fallback : EditorCookie.Get( key, fallback ), fallback );
s_cache[key] = value;
return value;
}
static void Set<T>( string key, T value )
{
PrismLog.Guard( $"Writing the cookie '{key}'", () => EditorCookie?.Set( key, value ) );
Notify();
}
static string GetString( string key, string fallback )
{
if ( s_cache.TryGetValue( key, out var cached ) ) return cached as string;
var value = PrismLog.Guard( $"Reading the cookie '{key}'",
() =>
{
if ( EditorCookie is null ) return fallback;
var stored = EditorCookie.GetString( key, fallback );
return string.IsNullOrEmpty( stored ) ? fallback : stored;
}, fallback );
s_cache[key] = value;
return value;
}
static void SetString( string key, string value )
{
PrismLog.Guard( $"Writing the cookie '{key}'", () => EditorCookie?.SetString( key, value ?? string.Empty ) );
Notify();
}
}