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.
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:<id></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 )
};
}