Editor/Prism/Ui/PrismDialogs.cs

Editor UI dialogs for the Prism editor. Defines NewGraphChoice record, and a static PrismDialogs class that shows modal prompts (unsaved changes, reload-on-disk, confirm, ask name), file open/save pickers, sanitisation, New Graph wizard dialog, Slang toolchain installer dialog (probe/locate/download via SlangToolchain API), and the ShortcutsWindow that lists keyboard/mouse shortcuts.

File AccessExternal DownloadNetworking
using Editor.Prism.Core;
using Editor.Prism.Toolchain;

namespace Editor.Prism.Ui;

/// <summary>What the New Graph wizard was asked to create.</summary>
public sealed record NewGraphChoice
{
	/// <summary>The document title. Never empty.</summary>
	public string Title { get; init; } = "Untitled";

	/// <summary>True for a shader function (<c>.prismfn</c>) rather than a shader (<c>.prism</c>).</summary>
	public bool Subgraph { get; init; }

	/// <summary>What the graph is for.</summary>
	public ShaderDomain Domain { get; init; } = ShaderDomain.Surface;

	/// <summary>How the surface is shaded.</summary>
	public ShadingModel ShadingModel { get; init; } = ShadingModel.Lit;

	/// <summary>How the surface blends.</summary>
	public SurfaceBlendMode BlendMode { get; init; } = SurfaceBlendMode.Opaque;

	/// <summary>Also emit a portable <c>.slang</c> module beside the <c>.shader</c>.</summary>
	public bool EmitSlang { get; init; }
}

/// <summary>
/// Every modal Prism puts in front of the user: the unsaved-changes gate, rename, the new-graph wizard,
/// the file pickers and the Slang toolchain installer.
/// <para>
/// They live together because they share one rule — a dialog never blocks the editor and never throws.
/// Everything is callback-shaped, so a user who dismisses a prompt with the window manager simply does
/// not get the callback, and nothing is left half done.
/// </para>
/// </summary>
public static class PrismDialogs
{
	// ---------------------------------------------------------------- unsaved ----

	/// <summary>
	/// The unsaved-changes gate. Runs <paramref name="onProceed"/> immediately when there is nothing to
	/// lose, and otherwise asks. Cancelling runs nothing at all, which is the point.
	/// </summary>
	public static void PromptUnsaved( PrismSession session, Action onProceed, Func<bool> save )
	{
		if ( session is null || !session.IsDirty )
		{
			PrismLog.Guard( "Continue past the unsaved-changes prompt", () => onProceed?.Invoke() );
			return;
		}

		var name = session.DisplayName;

		var confirm = new PopupWindow(
			"Unsaved Changes",
			$"\"{name}\" has changes you have not saved. Save before continuing?",
			"Cancel",
			new Dictionary<string, Action>
			{
				{ "Discard", () => PrismLog.Guard( "Discard changes", () => onProceed?.Invoke() ) },
				{
					"Save", () => PrismLog.Guard( "Save before continuing", () =>
					{
						if ( save is null || save() ) onProceed?.Invoke();
					} )
				}
			} );

		PrismLog.Guard( "Show the unsaved-changes prompt", confirm.Show );
	}

	/// <summary>
	/// Offer to take a version of the document that appeared on disk while this one had unsaved edits.
	/// <para>
	/// Defaults to keeping what is in the editor: the file on disk can be reloaded at any time, whereas
	/// the unsaved graph exists nowhere else. Cancelling is therefore the safe answer and is what
	/// dismissing the dialog does.
	/// </para>
	/// </summary>
	public static void PromptReloadChangedOnDisk( string fileName, Action onReload )
	{
		var confirm = new PopupWindow(
			"Changed On Disk",
			$"\"{fileName}\" was changed outside this window, and you have unsaved changes here. " +
			"Reloading will discard them.",
			"Keep Mine",
			new Dictionary<string, Action>
			{
				{ "Reload", () => PrismLog.Guard( "Reload the changed document", () => onReload?.Invoke() ) }
			} );

		PrismLog.Guard( "Show the changed-on-disk prompt", confirm.Show );
	}

	/// <summary>Ask a yes/no question.</summary>
	public static void Confirm( string question, Action onYes, string title = "Prism", string okay = "Yes",
		string cancel = "Cancel" )
	{
		PrismLog.Guard( "Ask for confirmation",
			() => Dialog.AskConfirm( () => PrismLog.Guard( title, () => onYes?.Invoke() ),
				question, title, okay, cancel ) );
	}

	/// <summary>Ask for a single line of text — a rename, a new parameter name, a group title.</summary>
	public static void AskName( string question, string initial, Action<string> onAccept,
		string title = "Rename", string okay = "Rename" )
	{
		PrismLog.Guard( "Ask for a name", () => Dialog.AskString(
			value =>
			{
				if ( string.IsNullOrWhiteSpace( value ) ) return;

				PrismLog.Guard( title, () => onAccept?.Invoke( value.Trim() ) );
			},
			question, okay, "Cancel", initial ?? string.Empty, title, 1 ) );
	}

	// ---------------------------------------------------------------- files ----

	/// <summary>Pick a graph to open. Returns null when the user cancelled.</summary>
	public static string PickOpenPath( bool subgraph )
	{
		var extension = subgraph ? PrismConstants.SubgraphExtension : PrismConstants.GraphExtension;
		var label = subgraph ? "Prism Shader Function" : "Prism Shader Graph";

		return PrismLog.Guard<string>( "Show the open dialog", () =>
		{
			var dialog = new FileDialog( null )
			{
				Title = $"Open {label}",
				DefaultSuffix = $".{extension}"
			};

			dialog.SetNameFilter( $"{label} (*.{extension})" );
			dialog.SetFindExistingFile();
			dialog.SetModeOpen();

			return dialog.Execute() ? dialog.SelectedFile : null;
		} );
	}

	/// <summary>Pick where to save a graph. Returns null when the user cancelled.</summary>
	public static string PickSavePath( string suggestedName, bool subgraph )
	{
		var extension = subgraph ? PrismConstants.SubgraphExtension : PrismConstants.GraphExtension;
		var label = subgraph ? "Prism Shader Function" : "Prism Shader Graph";
		var name = string.IsNullOrWhiteSpace( suggestedName ) ? "untitled" : Sanitise( suggestedName );

		return PrismLog.Guard<string>( "Show the save dialog", () =>
		{
			var dialog = new FileDialog( null )
			{
				Title = $"Save {label}",
				DefaultSuffix = $".{extension}"
			};

			dialog.SelectFile( $"{name}.{extension}" );
			dialog.SetFindFile();
			dialog.SetModeSave();
			dialog.SetNameFilter( $"{label} (*.{extension})" );

			return dialog.Execute() ? dialog.SelectedFile : null;
		} );
	}

	/// <summary>Pick a file to import or export. Returns null when the user cancelled.</summary>
	public static string PickPath( string title, string extension, string description, bool save,
		string suggestedName = null )
	{
		return PrismLog.Guard<string>( "Show a file dialog", () =>
		{
			var dialog = new FileDialog( null )
			{
				Title = title,
				DefaultSuffix = $".{extension}"
			};

			dialog.SetNameFilter( $"{description} (*.{extension})" );

			if ( save )
			{
				dialog.SelectFile( $"{Sanitise( suggestedName ?? "untitled" )}.{extension}" );
				dialog.SetFindFile();
				dialog.SetModeSave();
			}
			else
			{
				dialog.SetFindExistingFile();
				dialog.SetModeOpen();
			}

			return dialog.Execute() ? dialog.SelectedFile : null;
		} );
	}

	/// <summary>Strip anything a file name cannot contain.</summary>
	public static string Sanitise( string name )
	{
		if ( string.IsNullOrWhiteSpace( name ) ) return "untitled";

		var invalid = System.IO.Path.GetInvalidFileNameChars();
		var builder = new System.Text.StringBuilder( name.Length );

		foreach ( var c in name.Trim() )
		{
			builder.Append( Array.IndexOf( invalid, c ) >= 0 ? '_' : c );
		}

		var result = builder.ToString().Trim();

		return string.IsNullOrEmpty( result ) ? "untitled" : result;
	}

	// ---------------------------------------------------------------- new graph ----

	/// <summary>Open the New Graph wizard.</summary>
	public static void NewGraph( Action<NewGraphChoice> onCreate, bool subgraph = false )
	{
		PrismLog.Guard( "Show the new-graph wizard", () => new NewGraphDialog( onCreate, subgraph ).Show() );
	}

	/// <summary>Open the Slang toolchain dialog.</summary>
	public static void SlangToolchainDialog()
	{
		PrismLog.Guard( "Show the Slang toolchain dialog", () => new SlangDialog().Show() );
	}

	/// <summary>Open the keyboard and mouse reference.</summary>
	public static void ShortcutsDialog()
	{
		PrismLog.Guard( "Show the shortcut reference", () => new ShortcutsWindow().Show() );
	}

	// ---------------------------------------------------------------- wizard ----

	sealed class NewGraphDialog : Dialog
	{
		readonly Action<NewGraphChoice> _onCreate;

		LineEdit _title;
		ComboBox _domain;
		ComboBox _shading;
		ComboBox _blend;
		Checkbox _subgraph;
		Checkbox _slang;

		public NewGraphDialog( Action<NewGraphChoice> onCreate, bool subgraph ) : base( null )
		{
			_onCreate = onCreate;

			Window.WindowTitle = "New Prism Graph";
			Window.SetWindowIcon( PrismIcons.Graph );
			Window.MinimumWidth = 460;
			Window.SetModal( true, true );

			Layout = Layout.Column();
			Layout.Margin = 16f;
			Layout.Spacing = 10f;

			Layout.Add( new Label.Subtitle( "Create a new shader graph" ) );

			_title = Row( "Title", new LineEdit( this ) { Text = "Untitled", PlaceholderText = "Untitled" } );

			_domain = Row( "Domain", Combo( ShaderDomain.Surface.ToString(), ShaderDomain.PostProcess.ToString(),
				ShaderDomain.Compute.ToString() ) );

			_shading = Row( "Shading", Combo( ShadingModel.Lit.ToString(), ShadingModel.Unlit.ToString(),
				ShadingModel.Custom.ToString() ) );

			_blend = Row( "Blend", Combo( SurfaceBlendMode.Opaque.ToString(), SurfaceBlendMode.Masked.ToString(),
				SurfaceBlendMode.Translucent.ToString(), SurfaceBlendMode.Additive.ToString(),
				SurfaceBlendMode.Multiply.ToString() ) );

			_subgraph = new Checkbox( this )
			{
				Text = "Shader function — a reusable subgraph rather than a material",
				Value = subgraph
			};

			_slang = new Checkbox( this )
			{
				Text = "Also emit a portable .slang module on save",
				Value = false
			};

			Layout.Add( _subgraph );
			Layout.Add( _slang );
			Layout.AddStretchCell();

			var buttons = Layout.AddRow();

			buttons.Spacing = 8f;
			buttons.AddStretchCell();

			var cancel = new Button( "Cancel", this );

			cancel.Clicked = Close;

			var create = new Button.Primary( "Create", this );

			create.Clicked = Accept;

			buttons.Add( cancel );
			buttons.Add( create );
		}

		T Row<T>( string label, T widget ) where T : Widget
		{
			var row = Layout.AddRow();

			row.Spacing = 8f;

			var text = new Label( label, this ) { FixedWidth = 80f };

			row.Add( text );
			row.Add( widget, 1 );

			return widget;
		}

		ComboBox Combo( params string[] items )
		{
			var combo = new ComboBox( this );

			foreach ( var item in items ) combo.AddItem( item );

			return combo;
		}

		void Accept()
		{
			var choice = new NewGraphChoice
			{
				Title = string.IsNullOrWhiteSpace( _title?.Text ) ? "Untitled" : _title.Text.Trim(),
				Subgraph = _subgraph is { Value: true },
				EmitSlang = _slang is { Value: true },
				Domain = Parse( _domain?.CurrentText, ShaderDomain.Surface ),
				ShadingModel = Parse( _shading?.CurrentText, ShadingModel.Lit ),
				BlendMode = Parse( _blend?.CurrentText, SurfaceBlendMode.Opaque )
			};

			Close();

			PrismLog.Guard( "Create a new graph", () => _onCreate?.Invoke( choice ) );
		}

		static T Parse<T>( string text, T fallback ) where T : struct =>
			Enum.TryParse<T>( text, true, out var value ) ? value : fallback;
	}

	// ---------------------------------------------------------------- toolchain ----

	sealed class SlangDialog : Dialog
	{
		Label _status;
		Label _path;
		Button _install;
		CancellationTokenSource _cancellation;

		public SlangDialog() : base( null )
		{
			Window.WindowTitle = "Slang Toolchain";
			Window.SetWindowIcon( "verified" );
			Window.MinimumWidth = 560;

			Layout = Layout.Column();
			Layout.Margin = 16f;
			Layout.Spacing = 10f;

			Layout.Add( new Label.Subtitle( "Slang validation" ) );

			Layout.Add( new Label(
				"Prism always writes .slang modules — that is pure text generation and needs nothing " +
				"installed. A Slang toolchain adds an independent second opinion on the generated module, " +
				"which catches bugs in Prism's own emitter. It never gates rendering, and nothing about " +
				"graph editing, .shader generation or the preview changes without it.", this )
			{
				WordWrap = true
			} );

			_status = Layout.Add( new Label( string.Empty, this ) );
			_path = Layout.Add( new Label( string.Empty, this ) { WordWrap = true } );

			Layout.AddStretchCell();

			var buttons = Layout.AddRow();

			buttons.Spacing = 8f;

			var locate = new Button( "Locate slangc…", this );

			locate.Clicked = Locate;

			var forget = new Button( "Forget", this );

			forget.Clicked = () =>
			{
				PrismLog.Guard( "Forget the Slang toolchain", SlangToolchain.Forget );
				Refresh();
			};

			buttons.Add( locate );
			buttons.Add( forget );
			buttons.AddStretchCell();

			_install = new Button.Primary( "Download Slang", this );
			_install.Clicked = Install;

			var close = new Button( "Close", this );

			close.Clicked = Close;

			buttons.Add( _install );
			buttons.Add( close );

			Refresh();
		}

		void Refresh()
		{
			var info = PrismLog.Guard<SlangToolchainInfo>( "Probe the Slang toolchain",
				() => SlangToolchain.Probe( true ), SlangToolchainInfo.None ) ?? SlangToolchainInfo.None;

			if ( _status.IsValid() )
			{
				_status.Text = info.IsAvailable
					? $"Installed — {info.Describe()}"
					: "Not installed. Generated .slang modules are written but not validated.";

				_status.Color = info.IsAvailable ? PrismTheme.Success : PrismTheme.TextMuted;
			}

			if ( _path.IsValid() )
			{
				_path.Text = info.IsAvailable
					? info.ExecutablePath
					: $"Prism will install into {SlangToolchain.InstallDirectory}";
			}

			if ( _install.IsValid() ) _install.Text = info.IsAvailable ? "Reinstall" : "Download Slang";
		}

		void Locate()
		{
			var picked = PrismLog.Guard<string>( "Locate slangc", () =>
			{
				var dialog = new FileDialog( null ) { Title = "Locate slangc" };

				dialog.SetNameFilter( $"slangc ({SlangToolchain.ExecutableName})" );
				dialog.SetFindExistingFile();
				dialog.SetModeOpen();

				return dialog.Execute() ? dialog.SelectedFile : null;
			} );

			if ( string.IsNullOrWhiteSpace( picked ) ) return;

			PrismLog.Guard( "Set the Slang override", () => SlangToolchain.Override = picked );

			Refresh();
		}

		void Install()
		{
			if ( _cancellation is not null ) return;

			_cancellation = new CancellationTokenSource();

			if ( _install.IsValid() )
			{
				_install.Text = "Downloading…";
				_install.Enabled = false;
			}

			var progress = new Progress<SlangInstallProgress>( report =>
			{
				if ( !_status.IsValid() ) return;

				_status.Text = report.Describe();
				_status.Color = PrismTheme.TextSecondary;
			} );

			_ = Run( progress );
		}

		async Task Run( IProgress<SlangInstallProgress> progress )
		{
			SlangInstallResult result = null;

			try
			{
				result = await SlangToolchain.Install( progress, _cancellation.Token );
			}
			catch ( Exception e )
			{
				PrismLog.Error( e, "The Slang install failed" );
			}

			_cancellation?.Dispose();
			_cancellation = null;

			if ( !this.IsValid() ) return;

			if ( _install.IsValid() ) _install.Enabled = true;

			if ( result is { Ok: false } && _status.IsValid() )
			{
				_status.Text = result.Message;
				_status.Color = PrismTheme.Error;

				if ( _install.IsValid() ) _install.Text = "Download Slang";

				return;
			}

			Refresh();
		}

		protected override void OnClosed()
		{
			PrismLog.Guard( "Cancel the Slang install", () => _cancellation?.Cancel() );

			base.OnClosed();
		}
	}
}

/// <summary>
/// The keyboard and mouse reference.
/// <para>
/// A graph editor's gestures are its interface, and Prism's are not all discoverable by poking at it:
/// nothing on screen says that Alt+drag detaches a node, that Ctrl+drag on a wire duplicates it, or
/// that <c>1</c>..<c>9</c> toggle node previews. This is where they are written down — painted as one
/// widget rather than assembled from labels so the key column stays aligned and the section rules read
/// as rules rather than as separators between rows of controls.
/// </para>
/// </summary>
internal sealed class ShortcutsWindow : Dialog
{
	/// <summary>One row: a section heading, or a gesture and what it does.</summary>
	readonly record struct Line( string Section, string Keys, string Description );

	static readonly Line[] s_lines =
	{
		new( "Canvas — when the graph has focus", null, null ),
		new( null, "Space / Tab", "Open the node palette at the cursor" ),
		new( null, "Ctrl+F", "Open the node palette, searching" ),
		new( null, "/", "Focus the Node Library search field" ),
		new( null, "Middle-drag", "Pan" ),
		new( null, "Wheel", "Zoom" ),
		new( null, "F", "Frame the selection" ),
		new( null, "Shift+F", "Frame the whole graph" ),
		new( null, "Drag empty space", "Marquee select" ),
		new( null, "Esc", "Clear the selection" ),
		new( null, "Ctrl+A", "Select every node" ),

		new( "Nodes", null, null ),
		new( null, "Delete", "Delete the selection" ),
		new( null, "Ctrl+D", "Duplicate the selection in place" ),
		new( null, "Ctrl+G", "Wrap the selection in a group" ),
		new( null, "Ctrl+Shift+G", "Collapse the selection into a shader function" ),
		new( null, "Alt+drag a node", "Detach it from every wire, then move it" ),
		new( null, "Double-click a node", "Collapse or expand it" ),
		new( null, "1 … 9", "Toggle the preview thumbnail on the selection" ),
		new( null, "Click the eye chip", "Toggle that one node's preview" ),

		new( "Wires", null, null ),
		new( null, "Drag a handle", "Start a wire — after a few pixels, so a click is still a click" ),
		new( null, "Hold R while dropping", "Drop a reroute instead of opening the palette" ),
		new( null, "Shift+click a wire", "Insert a reroute where you clicked" ),
		new( null, "Double-click a wire", "Insert a reroute where you clicked" ),
		new( null, "Ctrl+drag a wire", "Pull a second wire out of the same output" ),
		new( null, "Drag a wire", "Move its input end somewhere else" ),
		new( null, "Right-click a handle", "Disconnect, reset the value, choose pad fill" ),

		new( "Document — anywhere in the window", null, null ),
		new( null, "Ctrl+N / Ctrl+Shift+N", "New shader graph / new shader function" ),
		new( null, "Ctrl+O", "Open" ),
		new( null, "Ctrl+S / Ctrl+Shift+S", "Save / Save as" ),
		new( null, "Ctrl+Z / Ctrl+Y", "Undo / redo (Ctrl+Shift+Z also redoes)" ),
		new( null, "Ctrl+X / Ctrl+C / Ctrl+V", "Cut, copy, paste — on the canvas" ),
		new( null, "F7", "Compile now, skipping the debounce" ),
		new( null, "F1", "This window" ),

		new( "A note on scope", null, null ),
		new( null, "Canvas bindings", "Only fire while the graph canvas has focus" ),
		new( null, "In the Code dock", "Ctrl+A and Ctrl+C act on the code, not the graph" ),
		new( null, "Over the 3D preview", "F frames the subject, not the graph" )
	};

	sealed class Sheet : Widget
	{
		const float RowHeight = 22f;
		const float SectionHeight = 30f;
		const float KeyColumn = 190f;

		public Sheet( Widget parent ) : base( parent )
		{
			var height = 16f;

			foreach ( var line in s_lines ) height += line.Section is null ? RowHeight : SectionHeight;

			MinimumHeight = height;
			MinimumWidth = 560f;
		}

		protected override void OnPaint()
		{
			Paint.Antialiasing = true;
			Paint.TextAntialiasing = true;

			Paint.SetBrushAndPen( PrismTheme.Panel );
			Paint.DrawRect( LocalRect );

			var y = 8f;

			foreach ( var line in s_lines )
			{
				if ( line.Section is not null )
				{
					var header = new Rect( 14f, y + 8f, Width - 28f, SectionHeight - 8f );

					PrismPaint.Text( header, line.Section.ToUpperInvariant(), PrismTheme.TextMuted,
						PrismTheme.PanelHeaderSize, PrismTheme.PanelHeaderWeight );

					y += SectionHeight;

					continue;
				}

				var keys = new Rect( 14f, y, KeyColumn, RowHeight );

				PrismPaint.Pill( keys.Shrink( 0f, 2f, 0f, 2f ), PrismTheme.Elevated, PrismTheme.BorderSubtle,
					PrismTheme.RadiusChip );

				PrismPaint.Text( keys.Shrink( 8f, 0f, 8f, 0f ), line.Keys, PrismTheme.TextPrimary,
					PrismTheme.InlineValueSize, PrismTheme.InlineValueWeight );

				var text = new Rect( 14f + KeyColumn + 12f, y, Width - KeyColumn - 40f, RowHeight );

				PrismPaint.Text( text, line.Description, PrismTheme.TextSecondary,
					PrismTheme.BodySize, PrismTheme.PortLabelWeight );

				y += RowHeight;
			}
		}
	}

	public ShortcutsWindow() : base( null )
	{
		Window.WindowTitle = $"{PrismConstants.ProductName} — Keyboard & Mouse";
		Window.SetWindowIcon( "keyboard" );
		Window.MinimumWidth = 600;
		Window.MinimumHeight = 560;

		Layout = Layout.Column();
		Layout.Margin = 0f;
		Layout.Spacing = 0f;

		var scroll = new ScrollArea( this );
		var sheet = new Sheet( scroll );

		scroll.Canvas = sheet;

		Layout.Add( scroll, 1 );

		var buttons = Layout.AddRow();

		buttons.Margin = 12f;
		buttons.AddStretchCell();

		var close = new Button.Primary( "Close", this );

		close.Clicked = Close;

		buttons.Add( close );
	}
}