Editor/Prism/Ui/BlackboardPanel.cs

Editor UI for the Prism blackboard dock. Defines shared chrome drawing (row backgrounds, section headers, search field), an empty-state widget, list entry model, list view with drag/drop, and the BlackboardPanel that shows parameters and keywords, supports search, add/rename/delete/duplicate, reorder, and editing parameter/keyword details and defaults.

File Access
using Editor.AssetPickers;
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Serialization;
using Editor.Prism.Undo;
using Margin = Sandbox.UI.Margin;
using System.Globalization;

namespace Editor.Prism.Ui;

/// <summary>
/// The chrome every Prism dock panel shares: row metrics, section headers, alternating row fills and
/// the standard search field.
/// <para>
/// Panels are the part of the editor a user stares at all day, so the rhythm has to be identical in
/// all of them. Centralising the four numbers and the three paint calls that establish it is cheaper
/// than hoping six files stay in agreement.
/// </para>
/// </summary>
internal static class PrismPanelChrome
{
	/// <summary>Height of one list row.</summary>
	public const float RowHeight = 24f;

	/// <summary>Horizontal padding inside a panel.</summary>
	public const float Pad = 8f;

	/// <summary>Fill the background of a list row, alternating and reacting to hover and selection.</summary>
	public static void PaintRow( Rect rect, int index, bool hovered, bool selected )
	{
		Paint.ClearPen();

		if ( selected )
		{
			Paint.SetBrush( PrismTheme.AccentSoft );
			Paint.DrawRect( rect, 3f );

			Paint.ClearPen();
			Paint.SetBrush( PrismTheme.Accent );
			Paint.DrawRect( new Rect( rect.Left, rect.Top + 2f, 2f, rect.Height - 4f ), 1f );
			return;
		}

		if ( hovered )
		{
			Paint.SetBrush( PrismTheme.Elevated );
			Paint.DrawRect( rect, 3f );
			return;
		}

		if ( ( index & 1 ) == 0 ) return;

		Paint.SetBrush( PrismTheme.PanelAlt );
		Paint.DrawRect( rect, 3f );
	}

	/// <summary>Draw an uppercase section header, 11 px / 700, in muted text with a trailing rule.</summary>
	public static void PaintSectionHeader( Rect rect, string text, string trailing = null )
	{
		var inner = rect.Shrink( Pad, 0f, Pad, 0f );

		Paint.SetFont( PrismTheme.FontFamily, PrismTheme.PanelHeaderSize, PrismTheme.PanelHeaderWeight, false, true );
		Paint.SetPen( PrismTheme.TextMuted );

		var label = ( text ?? string.Empty ).ToUpperInvariant();
		var used = Paint.DrawText( inner, label, TextFlag.LeftCenter | TextFlag.SingleLine );

		if ( !string.IsNullOrEmpty( trailing ) )
		{
			Paint.SetPen( PrismTheme.TextDisabled );
			Paint.DrawText( inner, trailing, TextFlag.RightCenter | TextFlag.SingleLine );
		}

		var lineLeft = used.Right + 8f;
		var lineRight = inner.Right - ( string.IsNullOrEmpty( trailing ) ? 0f : 40f );

		if ( lineRight <= lineLeft ) return;

		Paint.SetPen( PrismTheme.BorderSubtle, 1f );
		Paint.DrawLine( new Vector2( lineLeft, inner.Center.y ), new Vector2( lineRight, inner.Center.y ) );
	}

	/// <summary>The standard filter field: placeholder, clear button, muted chrome.</summary>
	public static LineEdit CreateSearchField( Widget parent, string placeholder )
	{
		var edit = new LineEdit( parent )
		{
			PlaceholderText = placeholder,
			ClearButtonEnabled = true,
			FixedHeight = 24f
		};

		edit.ToolTip = $"{placeholder} — Ctrl+F";

		return edit;
	}

	/// <summary>A label styled as a small caption.</summary>
	public static Label CreateCaption( string text ) => new( text ) { Color = PrismTheme.TextMuted };
}

/// <summary>
/// The empty state a panel shows when it has nothing to display: an icon, a sentence and, when there
/// is something useful to do about it, a call to action.
/// <para>
/// Painted rather than composed from widgets so the call-to-action pill sits exactly where the layout
/// wants it, and so a panel that is 120 px wide degrades to the icon and the title instead of
/// clipping a button.
/// </para>
/// </summary>
internal sealed class PrismEmptyState : Widget
{
	Rect _actionRect;
	bool _actionHovered;

	/// <summary>Build an empty state.</summary>
	public PrismEmptyState( Widget parent, string icon, string title, string message,
		string actionText = null, Action action = null ) : base( parent )
	{
		Icon = icon;
		Title = title;
		Message = message;
		ActionText = actionText;
		Action = action;

		MouseTracking = true;
		MinimumHeight = 96f;
	}

	/// <summary>Material icon drawn above the title.</summary>
	public string Icon { get; set; }

	/// <summary>One short line explaining what the panel is for.</summary>
	public string Title { get; set; }

	/// <summary>One sentence explaining why it is empty and what to do.</summary>
	public string Message { get; set; }

	/// <summary>Label of the call-to-action pill. Null hides it.</summary>
	public string ActionText { get; set; }

	/// <summary>Invoked when the call to action is clicked.</summary>
	public Action Action { get; set; }

	/// <summary>
	/// A second, dimmer line under <see cref="Message"/> — the place for the keyboard shortcut that does
	/// the same thing as the button. Null hides it.
	/// </summary>
	public string Detail { get; set; }

	/// <summary>
	/// Paint an elevated card behind the text. Off inside a dock, where the panel background is already
	/// the right surface; on when the state floats over the graph canvas and needs to separate from it.
	/// </summary>
	public bool Card { get; set; }

	/// <summary>Replace the text without rebuilding the widget.</summary>
	public void Set( string title, string message )
	{
		Title = title;
		Message = message;
		Update();
	}

	/// <inheritdoc/>
	protected override void OnPaint()
	{
		Paint.Antialiasing = true;

		var rect = LocalRect;
		var hasAction = !string.IsNullOrEmpty( ActionText ) && Action is not null;
		var hasDetail = !string.IsNullOrEmpty( Detail );
		var block = ( hasAction ? 132f : 96f ) + ( hasDetail ? 20f : 0f );
		var top = rect.Top + MathF.Max( 12f, ( rect.Height - block ) * 0.45f );

		if ( Card )
		{
			PrismPaint.DropShadow( rect, PrismTheme.RadiusPopup, PrismTheme.Shadow );
			PrismPaint.Pill( rect, PrismTheme.Elevated.WithAlpha( 0.96f ),
				PrismTheme.BorderStrong, PrismTheme.RadiusPopup );
		}

		if ( !string.IsNullOrEmpty( Icon ) && rect.Height > 74f )
		{
			Paint.SetPen( PrismTheme.TextDisabled.WithAlpha( 0.75f ) );
			Paint.DrawIcon( new Rect( rect.Left, top, rect.Width, 34f ), Icon, 30f, TextFlag.Center );
			top += 42f;
		}

		Paint.SetFont( PrismTheme.FontFamily, 12, 600, false, true );
		Paint.SetPen( PrismTheme.TextSecondary );
		Paint.DrawText( new Rect( rect.Left + 12f, top, rect.Width - 24f, 18f ), Title ?? string.Empty,
			TextFlag.Center | TextFlag.SingleLine );

		top += 20f;

		if ( !string.IsNullOrEmpty( Message ) && rect.Height > 52f )
		{
			Paint.SetFont( PrismTheme.FontFamily, 11, 400, false, true );
			Paint.SetPen( PrismTheme.TextMuted );

			var text = Paint.GetElidedText( Message, rect.Width - 24f, ElideMode.Right, TextFlag.Center );

			Paint.DrawText( new Rect( rect.Left + 12f, top, rect.Width - 24f, 16f ), text,
				TextFlag.Center | TextFlag.SingleLine );

			top += 24f;
		}

		if ( hasDetail && rect.Height > 72f )
		{
			Paint.SetFont( PrismTheme.FontFamily, 11, 400, false, true );
			Paint.SetPen( PrismTheme.TextDisabled );

			var text = Paint.GetElidedText( Detail, rect.Width - 24f, ElideMode.Right, TextFlag.Center );

			Paint.DrawText( new Rect( rect.Left + 12f, top, rect.Width - 24f, 16f ), text,
				TextFlag.Center | TextFlag.SingleLine );

			top += 22f;
		}

		_actionRect = default;

		if ( !hasAction ) return;

		Paint.SetFont( PrismTheme.FontFamily, 11, 500, false, true );

		var width = MathF.Min( rect.Width - 32f, Paint.MeasureText( ActionText ).x + 26f );

		if ( width < 40f ) return;

		_actionRect = new Rect( rect.Center.x - width * 0.5f, top, width, 24f );

		PrismPaint.Pill( _actionRect,
			_actionHovered ? PrismTheme.Accent.WithAlpha( 0.22f ) : PrismTheme.AccentSoft,
			PrismTheme.Accent.WithAlpha( _actionHovered ? 0.8f : 0.45f ), PrismTheme.RadiusChip );

		Paint.SetFont( PrismTheme.FontFamily, 11, 500, false, true );
		Paint.SetPen( PrismTheme.Accent );
		Paint.DrawText( _actionRect, ActionText, TextFlag.Center | TextFlag.SingleLine );
	}

	/// <inheritdoc/>
	protected override void OnMouseMove( MouseEvent e )
	{
		base.OnMouseMove( e );

		var hovered = _actionRect.Width > 0f && _actionRect.IsInside( e.LocalPosition );

		if ( hovered == _actionHovered ) return;

		_actionHovered = hovered;
		Cursor = hovered ? CursorShape.Finger : CursorShape.Arrow;
		Update();
	}

	/// <inheritdoc/>
	protected override void OnMouseLeave()
	{
		base.OnMouseLeave();

		_actionHovered = false;
		Cursor = CursorShape.Arrow;
		Update();
	}

	/// <inheritdoc/>
	protected override void OnMouseClick( MouseEvent e )
	{
		base.OnMouseClick( e );

		if ( !e.LeftMouseButton ) return;
		if ( _actionRect.Width <= 0f || !_actionRect.IsInside( e.LocalPosition ) ) return;

		PrismLog.Guard( "Empty-state action", () => Action?.Invoke() );
	}
}

/// <summary>One row of the blackboard: a section header, a parameter or a keyword.</summary>
internal sealed class BlackboardEntry
{
	/// <summary>Header text when this row is a section header.</summary>
	public string Header { get; init; }

	/// <summary>The parameter this row presents, when it is a parameter row.</summary>
	public Parameter Parameter { get; init; }

	/// <summary>The keyword this row presents, when it is a keyword row.</summary>
	public Keyword Keyword { get; init; }

	/// <summary>How many nodes reference this entry.</summary>
	public int Uses { get; set; }

	/// <summary>Index within its own kind, used to compute the reorder target.</summary>
	public int Index { get; init; }

	/// <summary>True when the row is a section header and cannot be selected.</summary>
	public bool IsHeader => Parameter is null && Keyword is null;

	/// <summary>The stable id of the entry, or none for a header.</summary>
	public ParamId Id => Parameter?.Id ?? Keyword?.Id ?? default;

	/// <inheritdoc/>
	public override string ToString() => Header ?? Parameter?.Name ?? Keyword?.Name ?? "(row)";
}

/// <summary>
/// A list view that can start a drag from a row and accept a row dropped back onto it, which is how
/// blackboard entries get onto the canvas and how they get reordered.
/// </summary>
internal sealed class BlackboardListView : ListView
{
	/// <summary>Build the list.</summary>
	public BlackboardListView( Widget parent ) : base( parent )
	{
		MultiSelect = false;
		AcceptDrops = true;
	}

	/// <summary>Called to start a drag for a row. Return true when a drag was started.</summary>
	public Func<object, bool> StartDrag { get; set; }

	/// <summary>Called when a row is dropped onto another row, with the drop target and the edge.</summary>
	public Func<BaseItemWidget.ItemDragEvent, DropAction> DropOnRow { get; set; }

	/// <inheritdoc/>
	protected override bool OnDragItem( VirtualWidget item )
	{
		if ( item?.Object is null || StartDrag is null ) return false;

		return StartDrag( item.Object );
	}

	/// <inheritdoc/>
	protected override DropAction OnItemDrag( BaseItemWidget.ItemDragEvent e )
	{
		return DropOnRow is null ? DropAction.Ignore : DropOnRow( e );
	}
}

/// <summary>
/// The Blackboard dock: the parameters and keywords a graph exposes.
/// <para>
/// Parameters are first-class document entities here rather than a side effect of a named-constant
/// node, so this panel is where they are created, typed, defaulted, grouped and given their material
/// UI. Dragging a row onto the canvas drops a reference node wired to that parameter; the payload is
/// the string <c>param:&lt;id&gt;</c>, which <see cref="PrismGraphView"/> already understands.
/// </para>
/// <para>
/// Every edit goes through <see cref="GraphMutations"/>, so every edit is one undo step and the
/// History panel reads like a sentence.
/// </para>
/// </summary>
public sealed class BlackboardPanel : Widget
{
	/// <summary>The dock name this panel registers under. Frozen.</summary>
	public const string DockName = "Blackboard";

	readonly List<BlackboardEntry> _entries = new();

	PrismSession _session;
	LineEdit _search;
	BlackboardListView _list;
	Widget _details;
	PrismEmptyState _empty;
	Widget _body;

	ParamId _selected;
	bool _isKeyword;
	bool _suppress;
	bool _rebuildQueued;

	/// <summary>Build the panel. A null session is legal and shows the empty state.</summary>
	public BlackboardPanel( PrismSession session ) : base( null )
	{
		Name = "PrismBlackboard";
		WindowTitle = DockName;

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

		BuildToolbar();

		_body = new Widget( this );
		_body.Layout = Layout.Column();
		_body.Layout.Margin = 0;
		_body.Layout.Spacing = 0;
		Layout.Add( _body, 1 );

		_list = new BlackboardListView( _body )
		{
			ItemSize = new Vector2( -1, PrismPanelChrome.RowHeight ),
			ItemPaint = PaintRow,
			ItemClicked = OnRowClicked,
			ItemActivated = OnRowActivated,
			ItemContextMenu = OnRowContextMenu,
			StartDrag = OnStartDrag,
			DropOnRow = OnDropOnRow,
			BodyContextMenu = OpenAddMenu
		};

		_body.Layout.Add( _list, 1 );

		_empty = new PrismEmptyState( _body, PrismIcons.Parameter, "No parameters yet",
			"Parameters are the values your material exposes.", "Add a parameter", () => AddParameter( ShaderType.Float ) );

		_body.Layout.Add( _empty, 1 );

		_details = new Widget( this );
		_details.Layout = Layout.Column();
		_details.Layout.Margin = new Margin( 8, 6, 8, 8 );
		_details.Layout.Spacing = 4;
		_details.Visible = false;

		Layout.Add( _details );

		Bind( session );
	}

	/// <summary>Material icon shown on the dock tab.</summary>
	public string DockIcon => "dashboard_customize";

	/// <summary>The session this panel is bound to. Null is legal.</summary>
	public PrismSession Session => _session;

	// ---------------------------------------------------------------- binding ----

	void Bind( PrismSession session )
	{
		Unbind();

		_session = session;

		if ( _session is not null )
		{
			_session.GraphStructureChanged += QueueRebuild;
			_session.GraphValuesChanged += QueueRebuild;
			_session.DocumentReplaced += OnDocumentReplaced;
		}

		Rebuild();
	}

	void Unbind()
	{
		if ( _session is null ) return;

		_session.GraphStructureChanged -= QueueRebuild;
		_session.GraphValuesChanged -= QueueRebuild;
		_session.DocumentReplaced -= OnDocumentReplaced;
		_session = null;
	}

	/// <inheritdoc/>
	public override void OnDestroyed()
	{
		Unbind();
		base.OnDestroyed();
	}

	void OnDocumentReplaced()
	{
		_selected = default;
		_isKeyword = false;
		QueueRebuild();
	}

	void QueueRebuild()
	{
		if ( _rebuildQueued ) return;

		_rebuildQueued = true;

		// Coalesce the burst of events one mutation produces into a single rebuild on the next frame.
		MainThread.Queue( () =>
		{
			_rebuildQueued = false;

			if ( !this.IsValid() ) return;

			Rebuild();
		} );
	}

	// ---------------------------------------------------------------- toolbar ----

	void BuildToolbar()
	{
		var bar = new Widget( this );
		bar.Layout = Layout.Row();
		bar.Layout.Margin = new Margin( 6, 6, 6, 4 );
		bar.Layout.Spacing = 4;

		_search = PrismPanelChrome.CreateSearchField( bar, "Filter parameters" );
		_search.TextEdited += _ => Rebuild();
		bar.Layout.Add( _search, 1 );

		var add = new IconButton( PrismIcons.Add, OpenAddMenu, bar )
		{
			ToolTip = "Add a parameter or a keyword",
			FixedWidth = 24f,
			FixedHeight = 24f
		};

		bar.Layout.Add( add );

		Layout.Add( bar );
	}

	void OpenAddMenu()
	{
		var menu = new Menu( this );

		foreach ( var choice in s_types )
		{
			var type = choice.Type;
			menu.AddOption( $"Add Parameter/{choice.Label}", choice.Icon, () => AddParameter( type ) );
		}

		menu.AddSeparator();

		menu.AddOption( "Add Keyword/Feature", PrismIcons.Keyword, () => AddKeyword( ComboKind.Feature ) );
		menu.AddOption( "Add Keyword/Static Combo", PrismIcons.Keyword, () => AddKeyword( ComboKind.Static ) );
		menu.AddOption( "Add Keyword/Dynamic Combo", PrismIcons.Keyword, () => AddKeyword( ComboKind.Dynamic ) );

		if ( _entries.Any( x => !x.IsHeader && x.Uses == 0 ) )
		{
			menu.AddSeparator();
			menu.AddOption( "Delete Unused", PrismIcons.Delete, DeleteUnused );
		}

		menu.OpenAtCursor( false );
	}

	// ---------------------------------------------------------------- model ----

	GraphMutations Mutations =>
		_session?.Graph is null ? null : GraphMutations.For( _session.Graph, _session.Undo );

	void Rebuild()
	{
		_entries.Clear();

		var graph = _session?.Graph;

		if ( graph is null )
		{
			_empty.Set( "No document", "Open or create a graph to author its parameters." );
			_empty.ActionText = null;
			_empty.Visible = true;
			_list.Visible = false;
			_list.SetItems( _entries );

			_selected = default;
			BuildDetails( null );
			return;
		}

		var uses = CountUses( graph );
		var filter = _search?.Text?.Trim() ?? string.Empty;

		var parameters = graph.Parameters
			.Select( ( p, i ) => new BlackboardEntry { Parameter = p, Index = i, Uses = Lookup( uses, p.Id ) } )
			.Where( x => Matches( x, filter ) )
			.ToList();

		var keywords = graph.Keywords
			.Select( ( k, i ) => new BlackboardEntry { Keyword = k, Index = i, Uses = Lookup( uses, k.Id ) } )
			.Where( x => Matches( x, filter ) )
			.ToList();

		foreach ( var group in parameters
			.GroupBy( x => string.IsNullOrWhiteSpace( x.Parameter.Group ) ? "Ungrouped" : x.Parameter.Group.Trim() )
			.OrderBy( x => x.Key == "Ungrouped" ? 1 : 0 )
			.ThenBy( x => x.Key, StringComparer.OrdinalIgnoreCase ) )
		{
			_entries.Add( new BlackboardEntry { Header = group.Key } );
			_entries.AddRange( group.OrderBy( x => x.Parameter.Order ).ThenBy( x => x.Index ) );
		}

		if ( keywords.Count > 0 )
		{
			_entries.Add( new BlackboardEntry { Header = "Keywords" } );
			_entries.AddRange( keywords );
		}

		var anything = graph.Parameters.Count > 0 || graph.Keywords.Count > 0;

		if ( !anything )
		{
			_empty.Set( "No parameters yet", "Parameters are the values your material exposes." );
			_empty.ActionText = "Add a parameter";
			_empty.Action = () => AddParameter( ShaderType.Float );
		}
		else if ( _entries.Count == 0 )
		{
			_empty.Set( "Nothing matches", $"No parameter or keyword matches “{filter}”." );
			_empty.ActionText = "Clear the filter";
			_empty.Action = () => { _search.Text = string.Empty; Rebuild(); };
		}

		_empty.Visible = _entries.Count == 0;
		_list.Visible = _entries.Count > 0;

		_list.SetItems( _entries );

		var current = _entries.FirstOrDefault( x => !x.IsHeader && x.Id == _selected );

		if ( current is null )
		{
			_selected = default;
			BuildDetails( null );
		}
		else
		{
			_list.SelectItem( current, false, true );

			// Rebuilding the details pane while the user is typing in it would destroy the field under
			// their caret. A value edit that came from this pane is exactly the case that fires this.
			if ( !DetailsHasFocus() ) BuildDetails( current );
		}
	}

	bool DetailsHasFocus()
	{
		if ( _details is null || !_details.Visible ) return false;

		return _details.GetDescendants<Widget>().Any( x => x.IsValid() && x.IsFocused );
	}

	static int Lookup( Dictionary<ParamId, int> uses, ParamId id ) =>
		uses.TryGetValue( id, out var count ) ? count : 0;

	static bool Matches( BlackboardEntry entry, string filter )
	{
		if ( string.IsNullOrEmpty( filter ) ) return true;

		var name = entry.Parameter?.Name ?? entry.Keyword?.Name ?? string.Empty;
		var group = entry.Parameter?.Group ?? entry.Keyword?.Group ?? string.Empty;

		return name.Contains( filter, StringComparison.OrdinalIgnoreCase )
			|| group.Contains( filter, StringComparison.OrdinalIgnoreCase );
	}

	/// <summary>
	/// How many nodes reference each parameter or keyword. Found generically, by looking for any
	/// serialized property of type <see cref="ParamId"/>, so a third-party node that references a
	/// parameter is counted without this panel knowing the node type exists.
	/// </summary>
	static Dictionary<ParamId, int> CountUses( PrismGraph graph )
	{
		var counts = new Dictionary<ParamId, int>();

		foreach ( var node in graph.Nodes )
		{
			foreach ( var property in NodeProperties.Structural( node.GetType() ) )
			{
				if ( property.PropertyType != typeof( ParamId ) ) continue;

				if ( NodeProperties.Get( node, property.Name ) is not ParamId id || !id.IsValid ) continue;

				counts.TryGetValue( id, out var current );
				counts[id] = current + 1;
			}
		}

		return counts;
	}

	// ---------------------------------------------------------------- painting ----

	void PaintRow( VirtualWidget item )
	{
		if ( item.Object is not BlackboardEntry entry ) return;

		var rect = item.Rect;

		if ( entry.IsHeader )
		{
			PrismPanelChrome.PaintSectionHeader( rect, entry.Header );
			return;
		}

		var index = _entries.IndexOf( entry );
		var selected = entry.Id == _selected;

		PrismPanelChrome.PaintRow( rect, index, item.Hovered, selected );

		var unused = entry.Uses == 0;
		var alpha = unused ? 0.5f : 1f;
		var inner = rect.Shrink( PrismPanelChrome.Pad, 0f, PrismPanelChrome.Pad, 0f );

		if ( entry.Parameter is not null )
		{
			PaintParameterRow( inner, entry, selected, alpha );
			return;
		}

		PaintKeywordRow( inner, entry, selected, alpha );
	}

	void PaintParameterRow( Rect inner, BlackboardEntry entry, bool selected, float alpha )
	{
		var parameter = entry.Parameter;
		var color = PrismTheme.ForType( parameter.Type ).WithAlpha( alpha );

		PrismPaint.Dot( new Vector2( inner.Left + 4f, inner.Center.y ), 4f, color );

		var right = inner.Right;

		if ( entry.Uses > 0 && inner.Width > 150f )
		{
			Paint.SetFont( PrismTheme.FontFamily, 10, 500, false, true );
			Paint.SetPen( PrismTheme.TextDisabled );

			var text = entry.Uses == 1 ? "1 use" : $"{entry.Uses} uses";
			var width = Paint.MeasureText( text ).x + 4f;

			Paint.DrawText( new Rect( right - width, inner.Top, width, inner.Height ), text,
				TextFlag.RightCenter | TextFlag.SingleLine );

			right -= width + 8f;
		}

		if ( inner.Width > 190f )
		{
			var value = ValueCodec.Describe( parameter.Default ?? ValueCodec.Default( parameter.Type ) );
			var box = new Rect( right - 78f, inner.Top, 78f, inner.Height );

			Paint.SetFont( PrismTheme.FontFamily, 10, 400, false, true );
			Paint.SetPen( PrismTheme.TextMuted.WithAlpha( alpha ) );
			Paint.DrawText( box, Paint.GetElidedText( value ?? string.Empty, box.Width, ElideMode.Right,
				TextFlag.RightCenter ), TextFlag.RightCenter | TextFlag.SingleLine );

			right -= 86f;
		}

		var typeText = parameter.Type.Hlsl ?? "?";

		Paint.SetFont( PrismTheme.FontFamily, 10, 500, false, true );
		var typeWidth = Paint.MeasureText( typeText ).x + 6f;

		if ( inner.Width > 120f )
		{
			Paint.SetPen( color.WithAlpha( alpha * 0.85f ) );
			Paint.DrawText( new Rect( right - typeWidth, inner.Top, typeWidth, inner.Height ), typeText,
				TextFlag.RightCenter | TextFlag.SingleLine );

			right -= typeWidth + 8f;
		}

		var nameRect = new Rect( inner.Left + 14f, inner.Top, MathF.Max( 20f, right - inner.Left - 14f ), inner.Height );

		PrismPaint.Text( nameRect, parameter.Name,
			( selected ? PrismTheme.TextPrimary : PrismTheme.TextSecondary ).WithAlpha( alpha ),
			PrismTheme.BodySize, selected ? 500 : 400 );
	}

	void PaintKeywordRow( Rect inner, BlackboardEntry entry, bool selected, float alpha )
	{
		var keyword = entry.Keyword;

		Paint.SetPen( PrismTheme.CategoryParameter.WithAlpha( alpha ) );
		Paint.DrawIcon( new Rect( inner.Left, inner.Top, 16f, inner.Height ), PrismIcons.Keyword, 13f, TextFlag.Center );

		var right = inner.Right;
		var kind = keyword.Kind.ToString();

		Paint.SetFont( PrismTheme.FontFamily, 10, 500, false, true );
		var kindWidth = Paint.MeasureText( kind ).x + 6f;

		if ( inner.Width > 130f )
		{
			Paint.SetPen( PrismTheme.TextMuted.WithAlpha( alpha ) );
			Paint.DrawText( new Rect( right - kindWidth, inner.Top, kindWidth, inner.Height ), kind,
				TextFlag.RightCenter | TextFlag.SingleLine );

			right -= kindWidth + 8f;
		}

		var nameRect = new Rect( inner.Left + 20f, inner.Top, MathF.Max( 20f, right - inner.Left - 20f ), inner.Height );

		PrismPaint.Text( nameRect, keyword.NormalizedName,
			( selected ? PrismTheme.TextPrimary : PrismTheme.TextSecondary ).WithAlpha( alpha ),
			PrismTheme.BodySize, selected ? 500 : 400 );
	}

	/// <inheritdoc/>
	protected override void OnPaint()
	{
		Paint.ClearPen();
		Paint.SetBrush( PrismTheme.Panel );
		Paint.DrawRect( LocalRect );
	}

	// ---------------------------------------------------------------- interaction ----

	void OnRowClicked( object item )
	{
		if ( item is not BlackboardEntry entry || entry.IsHeader )
		{
			return;
		}

		_selected = entry.Id;
		_isKeyword = entry.Keyword is not null;

		BuildDetails( entry );
		_list.Update();
	}

	void OnRowActivated( object item )
	{
		OnRowClicked( item );
		BeginRename();
	}

	void OnRowContextMenu( object item )
	{
		if ( item is not BlackboardEntry entry || entry.IsHeader )
		{
			OpenAddMenu();
			return;
		}

		OnRowClicked( item );

		var menu = new Menu( this );

		// The last argument is a shortcut *identifier*, not a key string — the menu renders the bound
		// keys itself, so a rebind stays visible here without anyone remembering to update it.
		menu.AddOption( "Rename", "edit", BeginRename, "prism.blackboard.rename" );
		menu.AddOption( "Duplicate", PrismIcons.Duplicate, Duplicate );

		if ( entry.Parameter is not null && entry.Uses > 0 )
		{
			menu.AddOption( $"Select {entry.Uses} Node(s)", PrismIcons.Frame, () => SelectUsers( entry.Id ) );
		}

		menu.AddSeparator();
		menu.AddOption( "Delete", PrismIcons.Delete, DeleteSelected, "prism.blackboard.delete" );

		menu.OpenAtCursor( false );
	}

	bool OnStartDrag( object item )
	{
		if ( item is not BlackboardEntry entry || entry.IsHeader || entry.Parameter is null ) return false;

		var drag = new Drag( this );

		// The graph view accepts a plain string payload; the object payload is what the list uses to
		// recognise its own row coming back for a reorder.
		drag.Data.Text = $"param:{entry.Parameter.Id.Value}";
		drag.Data.Object = entry;
		drag.Execute();

		return true;
	}

	DropAction OnDropOnRow( BaseItemWidget.ItemDragEvent e )
	{
		if ( e.Data?.Object is not BlackboardEntry source || source.Parameter is null ) return DropAction.Ignore;
		if ( e.Item?.Object is not BlackboardEntry target || target.Parameter is null ) return DropAction.Ignore;
		if ( ReferenceEquals( source, target ) ) return DropAction.Ignore;

		if ( !e.IsDrop ) return DropAction.Move;

		// Standard reorder arithmetic: dropping below the midpoint lands after the target, and removing
		// the source first shifts every index above it down by one.
		var index = target.Index;

		if ( e.Item is not null && e.LocalPosition.y > e.Item.Rect.Center.y ) index++;
		if ( source.Index < index ) index--;

		Mutations?.ReorderParameter( source.Parameter.Id, Math.Max( 0, index ) );

		var group = string.IsNullOrWhiteSpace( target.Parameter.Group ) ? null : target.Parameter.Group;

		if ( !string.Equals( source.Parameter.Group, group, StringComparison.Ordinal ) )
		{
			Mutations?.EditParameter( source.Parameter.Id, p => p.Group = group, "Move Parameter" );
		}

		_session?.Touch();

		return DropAction.Move;
	}

	// ---------------------------------------------------------------- commands ----

	/// <summary>Remove the selected blackboard entry.</summary>
	[Shortcut( "prism.blackboard.delete", "DEL" )]
	public void DeleteSelected()
	{
		if ( !_selected.IsValid ) return;

		var mutations = Mutations;

		if ( mutations is null ) return;

		if ( _isKeyword ) mutations.RemoveKeyword( _selected );
		else mutations.RemoveParameter( _selected );

		_selected = default;
		_session?.Touch();
	}

	/// <summary>Focus the name field so the selected entry can be renamed.</summary>
	[Shortcut( "prism.blackboard.rename", "F2" )]
	public void BeginRename()
	{
		if ( !_selected.IsValid || !_details.Visible ) return;

		var edit = _details.GetDescendants<LineEdit>().FirstOrDefault();

		if ( edit is null ) return;

		edit.Focus();
		edit.SelectAll();
	}

	/// <summary>Move keyboard focus to the filter field.</summary>
	[Shortcut( "prism.blackboard.search", "CTRL+F" )]
	public void FocusSearch()
	{
		_search?.Focus();
		_search?.SelectAll();
	}

	void AddParameter( ShaderType type )
	{
		var mutations = Mutations;

		if ( mutations is null ) return;

		var name = _session.Graph.UniqueParameterName( DefaultNameFor( type ) );
		var parameter = mutations.AddParameter( name, type );

		if ( parameter is null ) return;

		_selected = parameter.Id;
		_isKeyword = false;
		_session.Touch();

		MainThread.Queue( BeginRename );
	}

	void AddKeyword( ComboKind kind )
	{
		var mutations = Mutations;

		if ( mutations is null ) return;

		var keyword = mutations.AddKeyword( Keyword.PrefixFor( kind ) + "KEYWORD", kind );

		if ( keyword is null ) return;

		_selected = keyword.Id;
		_isKeyword = true;
		_session.Touch();

		MainThread.Queue( BeginRename );
	}

	void Duplicate()
	{
		var mutations = Mutations;

		if ( mutations is null || !_selected.IsValid ) return;

		if ( _isKeyword )
		{
			var keyword = _session.Graph.FindKeyword( _selected );

			if ( keyword is null ) return;

			var copy = keyword.CloneWithNewId();
			mutations.AddKeyword( copy, -1, "Duplicate Keyword" );
			_selected = copy.Id;
		}
		else
		{
			var parameter = _session.Graph.FindParameter( _selected );

			if ( parameter is null ) return;

			var copy = parameter.CloneWithNewId();
			copy.Name = _session.Graph.UniqueParameterName( parameter.Name );
			mutations.AddParameter( copy, -1, "Duplicate Parameter" );
			_selected = copy.Id;
		}

		_session.Touch();
	}

	void DeleteUnused()
	{
		var mutations = Mutations;

		if ( mutations is null ) return;

		var unused = _entries.Where( x => !x.IsHeader && x.Uses == 0 ).ToList();

		if ( unused.Count == 0 ) return;

		using ( mutations.Begin( "Delete Unused Parameters" ) )
		{
			foreach ( var entry in unused )
			{
				if ( entry.Keyword is not null ) mutations.RemoveKeyword( entry.Id );
				else mutations.RemoveParameter( entry.Id );
			}
		}

		_session.Touch();
	}

	void SelectUsers( ParamId id )
	{
		if ( _session?.Graph is null ) return;

		var nodes = new List<PrismNode>();

		foreach ( var node in _session.Graph.Nodes )
		{
			foreach ( var property in NodeProperties.Structural( node.GetType() ) )
			{
				if ( property.PropertyType != typeof( ParamId ) ) continue;
				if ( NodeProperties.Get( node, property.Name ) is not ParamId value || value != id ) continue;

				nodes.Add( node );
				break;
			}
		}

		if ( nodes.Count == 0 ) return;

		_session.Select( nodes );
		_session.RequestFocus( nodes[0].Id );
	}

	// ---------------------------------------------------------------- details ----

	void BuildDetails( BlackboardEntry entry )
	{
		_details.Layout.Clear( true );
		_details.DestroyChildren();

		if ( entry is null || entry.IsHeader )
		{
			_details.Visible = false;
			return;
		}

		_details.Visible = true;
		_suppress = true;

		var header = new Widget( _details );
		header.Layout = Layout.Row();
		header.Layout.Margin = 0;
		header.Layout.Spacing = 6;
		header.Layout.Add( new Label( entry.Keyword is null ? "PARAMETER" : "KEYWORD" )
		{
			Color = PrismTheme.TextMuted
		} );
		header.Layout.AddStretchCell();
		header.Layout.Add( PrismPanelChrome.CreateCaption(
			entry.Uses == 1 ? "used by 1 node" : $"used by {entry.Uses} nodes" ) );

		_details.Layout.Add( header );

		if ( entry.Parameter is not null ) BuildParameterDetails( entry.Parameter );
		else BuildKeywordDetails( entry.Keyword );

		_suppress = false;
	}

	void BuildParameterDetails( Parameter parameter )
	{
		var id = parameter.Id;
		var ui = parameter.Ui ?? new ParameterUi();

		var name = new LineEdit( _details ) { Text = parameter.Name };
		name.EditingFinished += () =>
		{
			if ( _suppress ) return;

			Mutations?.RenameParameter( id, name.Text );
			_session?.Touch();
		};

		AddField( "Name", name );

		var type = new ComboBox( _details );

		foreach ( var choice in s_types )
		{
			var value = choice.Type;
			type.AddItem( choice.Label, choice.Icon,
				() => { if ( !_suppress ) SetParameterType( id, value ); }, null, value == parameter.Type );
		}

		AddField( "Type", type );

		var editor = BuildDefaultEditor( parameter );

		if ( editor is not null ) AddField( "Default", editor );

		var attribute = new LineEdit( _details )
		{
			Text = parameter.AttributeName ?? string.Empty,
			PlaceholderText = parameter.EffectiveAttributeName
		};

		attribute.ToolTip = "Render attribute name. When set, the value can be driven at runtime without a recompile.";
		attribute.EditingFinished += () =>
		{
			if ( _suppress ) return;

			Mutations?.EditParameter( id, p => p.AttributeName =
				string.IsNullOrWhiteSpace( attribute.Text ) ? null : attribute.Text.Trim(), "Set Attribute" );

			_session?.Touch();
		};

		AddField( "Attribute", attribute );

		_details.Layout.AddSpacingCell( 4f );
		_details.Layout.Add( new Label( "MATERIAL UI" ) { Color = PrismTheme.TextMuted } );

		var control = new ComboBox( _details );

		foreach ( var value in Enum.GetValues<UiControl>() )
		{
			var captured = value;
			control.AddItem( value.ToString(), null,
				() => { if ( !_suppress ) EditUi( id, u => u.Control = captured, "Set Control" ); },
				null, value == ui.Control );
		}

		AddField( "Control", control );

		var range = new Widget( _details );
		range.Layout = Layout.Row();
		range.Layout.Margin = 0;
		range.Layout.Spacing = 4;

		range.Layout.Add( NumberField( ui.Min ?? 0f, v => EditUi( id, u => u.Min = v, "Set Range" ), "min" ), 1 );
		range.Layout.Add( NumberField( ui.Max ?? 1f, v => EditUi( id, u => u.Max = v, "Set Range" ), "max" ), 1 );
		range.Layout.Add( NumberField( ui.Step ?? 0f, v => EditUi( id, u => u.Step = v, "Set Step" ), "step" ), 1 );

		AddField( "Range", range );

		var group = new LineEdit( _details ) { Text = ui.Group ?? string.Empty, PlaceholderText = "Ungrouped" };
		group.EditingFinished += () =>
		{
			if ( _suppress ) return;

			EditUi( id, u => u.Group = string.IsNullOrWhiteSpace( group.Text ) ? null : group.Text.Trim(), "Set Group" );
		};

		var order = new LineEdit( _details ) { Text = ui.Order.ToString( CultureInfo.InvariantCulture ) };
		order.FixedWidth = 56f;
		order.EditingFinished += () =>
		{
			if ( _suppress ) return;
			if ( !int.TryParse( order.Text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value ) ) return;

			EditUi( id, u => u.Order = value, "Set Order" );
		};

		var grouping = new Widget( _details );
		grouping.Layout = Layout.Row();
		grouping.Layout.Margin = 0;
		grouping.Layout.Spacing = 4;
		grouping.Layout.Add( group, 1 );
		grouping.Layout.Add( order );

		AddField( "Group", grouping );

		var tooltip = new LineEdit( _details ) { Text = ui.Tooltip ?? string.Empty, PlaceholderText = "None" };
		tooltip.EditingFinished += () =>
		{
			if ( _suppress ) return;

			EditUi( id, u => u.Tooltip = string.IsNullOrWhiteSpace( tooltip.Text ) ? null : tooltip.Text, "Set Tooltip" );
		};

		AddField( "Tooltip", tooltip );
	}

	void BuildKeywordDetails( Keyword keyword )
	{
		var id = keyword.Id;

		var name = new LineEdit( _details ) { Text = keyword.Name };
		name.ToolTip = $"Emitted as {keyword.NormalizedName}";
		name.EditingFinished += () =>
		{
			if ( _suppress ) return;

			Mutations?.EditKeyword( id, k => k.Name = name.Text, "Rename Keyword" );
			_session?.Touch();
		};

		AddField( "Name", name );

		var kind = new ComboBox( _details );

		foreach ( var value in Enum.GetValues<ComboKind>() )
		{
			var captured = value;
			kind.AddItem( value.ToString(), null,
				() => { if ( !_suppress ) EditKeyword( id, k => k.Kind = captured, "Set Combo Kind" ); },
				null, value == keyword.Kind );
		}

		AddField( "Kind", kind );

		var values = new LineEdit( _details )
		{
			Text = string.Join( ", ", keyword.Values ?? new List<string> { "Off", "On" } ),
			PlaceholderText = "Off, On"
		};

		values.ToolTip = "Comma-separated value labels. Index 0 is value 0.";
		values.EditingFinished += () =>
		{
			if ( _suppress ) return;

			var parts = ( values.Text ?? string.Empty )
				.Split( ',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries )
				.ToList();

			if ( parts.Count < 2 ) parts = new List<string> { "Off", "On" };

			EditKeyword( id, k => k.Values = parts, "Set Keyword Values" );
		};

		AddField( "Values", values );

		var def = new ComboBox( _details );
		var labels = keyword.Values ?? new List<string> { "Off", "On" };

		for ( var i = 0; i < labels.Count; i++ )
		{
			var index = i;
			def.AddItem( labels[i], null,
				() => { if ( !_suppress ) EditKeyword( id, k => k.Default = index, "Set Keyword Default" ); },
				null, i == keyword.SafeDefault );
		}

		AddField( "Default", def );

		var group = new LineEdit( _details ) { Text = keyword.Group ?? string.Empty, PlaceholderText = "Ungrouped" };
		group.EditingFinished += () =>
		{
			if ( _suppress ) return;

			EditKeyword( id, k => k.Group = string.IsNullOrWhiteSpace( group.Text ) ? null : group.Text.Trim(),
				"Set Group" );
		};

		AddField( "Group", group );

		foreach ( var problem in keyword.Validate() )
		{
			var label = new Label( problem.Message ) { Color = PrismTheme.ForSeverity( problem.Severity ) };
			label.ToolTip = problem.Detail;
			_details.Layout.Add( label );
		}
	}

	void AddField( string label, Widget field )
	{
		var row = new Widget( _details );
		row.Layout = Layout.Row();
		row.Layout.Margin = 0;
		row.Layout.Spacing = 6;

		var caption = new Label( label ) { Color = PrismTheme.TextMuted, FixedWidth = 70f };

		row.Layout.Add( caption );
		row.Layout.Add( field, 1 );

		_details.Layout.Add( row );
	}

	LineEdit NumberField( float value, Action<float> commit, string placeholder )
	{
		var edit = new LineEdit( _details )
		{
			Text = value.ToString( "0.###", CultureInfo.InvariantCulture ),
			PlaceholderText = placeholder
		};

		edit.EditingFinished += () =>
		{
			if ( _suppress ) return;
			if ( !float.TryParse( edit.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed ) ) return;

			commit( parsed );
		};

		return edit;
	}

	void EditUi( ParamId id, Action<ParameterUi> edit, string label )
	{
		Mutations?.EditParameter( id, p =>
		{
			p.Ui ??= new ParameterUi();
			edit( p.Ui );
		}, label );

		_session?.Touch();
	}

	void EditKeyword( ParamId id, Action<Keyword> edit, string label )
	{
		Mutations?.EditKeyword( id, edit, label );
		_session?.Touch();
	}

	void SetParameterType( ParamId id, ShaderType type )
	{
		Mutations?.SetParameterType( id, type );
		_session?.Touch();
	}

	// ---------------------------------------------------------------- default editors ----

	/// <summary>
	/// The editor for a parameter's default value, chosen from its type. Numbers and vectors become
	/// text fields, booleans a checkbox, colours a swatch that opens the picker, and resources an
	/// asset field — the same set of affordances the material editor will eventually show.
	/// </summary>
	Widget BuildDefaultEditor( Parameter parameter )
	{
		var id = parameter.Id;
		var type = parameter.Type;
		var current = parameter.Default ?? ValueCodec.Default( type );

		if ( type.IsObject )
		{
			var path = current is TextureValue texture ? texture.Path : current as string;
			return BuildAssetField( id, path );
		}

		if ( type.IsBoolean && type.Components <= 1 )
		{
			var check = new Checkbox( string.Empty, _details ) { Value = current is bool b && b };

			check.Toggled += () =>
			{
				if ( _suppress ) return;

				Mutations?.SetParameterDefault( id, check.Value );
				_session?.Touch();
			};

			return check;
		}

		var wantsColor = parameter.Ui is { Control: UiControl.Color } ||
			( type.Components == 4 && type.IsFloatingPoint && parameter.Ui is null );

		if ( wantsColor && type.Components == 4 )
		{
			return BuildColorField( id, current );
		}

		var components = Math.Clamp( type.Components, 1, 4 );
		var floats = ValueCodec.ToFloats( current );

		var host = new Widget( _details );
		host.Layout = Layout.Row();
		host.Layout.Margin = 0;
		host.Layout.Spacing = 4;

		var edits = new LineEdit[components];

		for ( var i = 0; i < components; i++ )
		{
			var value = floats is not null && i < floats.Length ? floats[i] : 0f;

			var edit = new LineEdit( host )
			{
				Text = value.ToString( "0.####", CultureInfo.InvariantCulture ),
				PlaceholderText = s_componentNames[i]
			};

			edits[i] = edit;
			host.Layout.Add( edit, 1 );
		}

		foreach ( var edit in edits )
		{
			edit.EditingFinished += () =>
			{
				if ( _suppress ) return;

				var parsed = new float[components];

				for ( var i = 0; i < components; i++ )
				{
					float.TryParse( edits[i].Text, NumberStyles.Float, CultureInfo.InvariantCulture, out parsed[i] );
				}

				Mutations?.SetParameterDefault( id, ValueCodec.ToComponents( parsed, components ) );
				_session?.Touch();
			};
		}

		return host;
	}

	Widget BuildColorField( ParamId id, object current )
	{
		var color = current is Color c ? c : Color.White;
		var button = new Button( string.Empty, _details ) { FixedHeight = 22f, Tint = color };

		button.ToolTip = "Pick a colour";
		button.Clicked = () =>
		{
			var picked = button.Tint;

			ColorPicker.OpenColorPopup( picked, value =>
			{
				// The popup outlives a rebuild of this pane, so the button it captured may already be
				// gone by the time a drag finishes.
				if ( button.IsValid() ) button.Tint = value;

				Mutations?.SetParameterDefault( id, value );
				_session?.Touch();
			}, button.ScreenPosition );
		};

		return button;
	}

	Widget BuildAssetField( ParamId id, string path )
	{
		var host = new Widget( _details );
		host.Layout = Layout.Row();
		host.Layout.Margin = 0;
		host.Layout.Spacing = 4;

		var field = new LineEdit( host )
		{
			Text = path ?? string.Empty,
			PlaceholderText = "No asset",
			ReadOnly = true
		};

		host.Layout.Add( field, 1 );

		var browse = new IconButton( "folder_open", null, host ) { FixedWidth = 22f, FixedHeight = 22f };

		browse.OnClick = () => PrismLog.Guard( "Pick parameter asset", () =>
		{
			var picker = new GenericPicker( this, new List<AssetType> { AssetType.ImageFile },
				new AssetPicker.PickerOptions() )
			{
				Title = "Select a default texture"
			};

			if ( !string.IsNullOrWhiteSpace( field.Text ) ) picker.SetSelection( field.Text );

			picker.OnAssetPicked = assets =>
			{
				var asset = assets?.FirstOrDefault();

				if ( asset is null ) return;

				var relative = asset.RelativePath ?? asset.Path;

				field.Text = relative;
				Mutations?.SetParameterDefault( id, new TextureValue { Path = relative } );
				_session?.Touch();
			};

			picker.Show();
		} );

		host.Layout.Add( browse );

		return host;
	}

	static string DefaultNameFor( ShaderType type )
	{
		if ( type.IsTexture ) return "Texture";
		if ( type.IsSampler ) return "Sampler";
		if ( type.IsBoolean ) return "Toggle";
		if ( type.IsMatrix ) return "Matrix";

		return type.Components switch
		{
			2 => "Vector 2",
			3 => "Vector 3",
			4 => "Vector 4",
			_ => "Value"
		};
	}

	static readonly string[] s_componentNames = { "x", "y", "z", "w" };

	static readonly (string Label, ShaderType Type, string Icon)[] s_types =
	{
		( "Float", ShaderType.Float, PrismIcons.Number ),
		( "Vector 2", ShaderType.Float2, PrismIcons.Vector ),
		( "Vector 3", ShaderType.Float3, PrismIcons.Vector ),
		( "Vector 4", ShaderType.Float4, PrismIcons.Vector ),
		( "Color", ShaderType.Float4, PrismIcons.Color ),
		( "Int", ShaderType.Int, PrismIcons.Number ),
		( "Bool", ShaderType.Bool, PrismIcons.Boolean ),
		( "Texture 2D", ShaderType.Texture2D, PrismIcons.Texture ),
		( "Texture Cube", ShaderType.TextureCube, PrismIcons.Texture ),
		( "Texture 3D", ShaderType.Texture3D, PrismIcons.Texture ),
		( "Sampler", ShaderType.Sampler, PrismIcons.Sampler ),
		( "Matrix 4x4", ShaderType.Float4x4, PrismIcons.Vector )
	};
}