Editor/Prism/Integration/PrismPreferences.cs

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.

File AccessExternal DownloadNetworking
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;
	}
}