Editor UI panel for the Prism node library. Builds a searchable, categorised list of node types, supports favourites, recent items, drag-and-drop of node type ids, tooltips, context menu actions, and a plug-type filter for showing compatible nodes.
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Undo;
using Margin = Sandbox.UI.Margin;
using System.Text;
namespace Editor.Prism.Ui;
/// <summary>One row of the node library: a section header, a category folder or a node type.</summary>
internal sealed class NodeLibraryEntry
{
/// <summary>Header text when this row is a section header.</summary>
public string Header { get; init; }
/// <summary>Full category path when this row is a folder, e.g. <c>Math/Basic</c>.</summary>
public string Category { get; init; }
/// <summary>The node type this row offers, when it is a node row.</summary>
public PrismNodeType Type { get; init; }
/// <summary>Indent depth in the tree.</summary>
public int Depth { get; init; }
/// <summary>True when a folder row is currently expanded.</summary>
public bool Expanded { get; set; }
/// <summary>Number of node types under a folder.</summary>
public int Count { get; set; }
/// <summary>True when the node type is marked as a favourite.</summary>
public bool Favourite { get; set; }
/// <summary>True when the row is a section header.</summary>
public bool IsHeader => Header is not null;
/// <summary>True when the row is an expandable category folder.</summary>
public bool IsFolder => Header is null && Type is null && Category is not null;
/// <inheritdoc/>
public override string ToString() => Header ?? Type?.Title ?? Category ?? "(row)";
}
/// <summary>A list view that can start a node-type drag and produce a rich per-node tooltip.</summary>
internal sealed class NodeLibraryListView : ListView
{
/// <summary>Build the list.</summary>
public NodeLibraryListView( Widget parent ) : base( parent )
{
MultiSelect = false;
}
/// <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>Produces the tooltip for a row.</summary>
public Func<object, string> TooltipFor { get; set; }
/// <summary>
/// Called before a row is selected, with the press position and the row rectangle, so the panel can
/// claim the press for the favourite star without the list also changing the selection.
/// </summary>
public Func<object, Vector2, Rect, bool> ItemPressed { get; set; }
/// <inheritdoc/>
protected override bool OnItemPressed( VirtualWidget pressedItem, MouseEvent e )
{
if ( pressedItem?.Object is not null && ItemPressed is not null && e.LeftMouseButton &&
ItemPressed( pressedItem.Object, e.LocalPosition, pressedItem.Rect ) )
{
return true;
}
return base.OnItemPressed( pressedItem, e );
}
/// <inheritdoc/>
protected override bool OnDragItem( VirtualWidget item )
{
if ( item?.Object is null || StartDrag is null ) return false;
return StartDrag( item.Object );
}
/// <inheritdoc/>
protected override string GetTooltip( object obj ) =>
TooltipFor is null ? base.GetTooltip( obj ) : TooltipFor( obj );
}
/// <summary>
/// The Node Library dock: everything the editor can create, searchable, with favourites and a
/// most-recently-used shelf.
/// <para>
/// Rows are a drag source. The payload is the stable node type id as plain text, which is exactly what
/// <see cref="PrismGraphView"/> resolves on drop, so the library never has to know how a node gets
/// built. Double-clicking adds the node next to the selection instead, for people who would rather not
/// drag.
/// </para>
/// <para>
/// When a wire is being dragged the window calls <see cref="SetPlugFilter"/> and the list collapses to
/// the node types that can actually connect to it — the same ranking the in-canvas search palette uses,
/// so the two never disagree about what is compatible.
/// </para>
/// </summary>
public sealed class NodeLibraryPanel : Widget
{
/// <summary>The dock name this panel registers under. Frozen.</summary>
public const string DockName = "Node Library";
/// <summary>Cookie holding the favourite node type ids.</summary>
public const string FavouritesCookie = "prism.library.favourites";
/// <summary>Cookie holding the most-recently-used node type ids.</summary>
public const string RecentCookie = "prism.library.recent";
const int RecentLimit = 8;
readonly List<NodeLibraryEntry> _entries = new();
readonly HashSet<string> _favourites = new( StringComparer.Ordinal );
readonly List<string> _recent = new();
readonly HashSet<string> _collapsed = new( StringComparer.OrdinalIgnoreCase );
PrismSession _session;
LineEdit _search;
NodeLibraryListView _list;
PrismEmptyState _empty;
Label _filterChip;
ShaderType _plugType;
PortDirection? _plugDirection;
bool _rebuildQueued;
/// <summary>Build the panel. A null session is legal — the catalogue is a static thing.</summary>
public NodeLibraryPanel( PrismSession session ) : base( null )
{
Name = "PrismNodeLibrary";
WindowTitle = DockName;
Layout = Layout.Column();
Layout.Margin = 0;
Layout.Spacing = 0;
LoadCookies();
BuildToolbar();
_list = new NodeLibraryListView( this )
{
ItemSize = new Vector2( -1, PrismPanelChrome.RowHeight ),
ItemPaint = PaintRow,
ItemClicked = OnRowClicked,
ItemActivated = OnRowActivated,
ItemContextMenu = OnRowContextMenu,
StartDrag = OnStartDrag,
TooltipFor = TooltipFor,
ItemPressed = OnRowPressed
};
Layout.Add( _list, 1 );
_empty = new PrismEmptyState( this, PrismIcons.Search, "No matching nodes",
"Try a different search, or clear the filter.", "Clear", ClearSearch );
Layout.Add( _empty, 1 );
NodeRegistry.Refreshed += QueueRebuild;
Bind( session );
}
/// <summary>Material icon shown on the dock tab.</summary>
public string DockIcon => "widgets";
/// <summary>The session this panel is bound to. Null is legal.</summary>
public PrismSession Session => _session;
// ---------------------------------------------------------------- binding ----
void Bind( PrismSession session )
{
_session = session;
Rebuild();
}
/// <inheritdoc/>
public override void OnDestroyed()
{
NodeRegistry.Refreshed -= QueueRebuild;
_session = null;
base.OnDestroyed();
}
void QueueRebuild()
{
if ( _rebuildQueued ) return;
_rebuildQueued = true;
MainThread.Queue( () =>
{
_rebuildQueued = false;
if ( !this.IsValid() ) return;
Rebuild();
} );
}
// ---------------------------------------------------------------- public API ----
/// <summary>
/// Restrict the library to node types that could connect to a plug of this type and direction.
/// <c>Output</c> means the user dragged from an output and needs a node with a matching input.
/// </summary>
public void SetPlugFilter( ShaderType type, PortDirection direction )
{
_plugType = type;
_plugDirection = direction;
Rebuild();
}
/// <summary>Drop the plug filter and show the whole catalogue again.</summary>
public void ClearPlugFilter()
{
if ( _plugDirection is null ) return;
_plugType = default;
_plugDirection = null;
Rebuild();
}
/// <summary>Record that a node type was used, so it rises to the top of the Recent shelf.</summary>
public void NoteUsed( string typeId )
{
if ( string.IsNullOrEmpty( typeId ) ) return;
_recent.Remove( typeId );
_recent.Insert( 0, typeId );
while ( _recent.Count > RecentLimit ) _recent.RemoveAt( _recent.Count - 1 );
SaveCookies();
QueueRebuild();
}
/// <summary>Move keyboard focus to the search field.</summary>
[Shortcut( "prism.library.search", "CTRL+F" )]
public void FocusSearch()
{
_search?.Focus();
_search?.SelectAll();
}
// ---------------------------------------------------------------- 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, "Search nodes" );
_search.TextEdited += _ => Rebuild();
bar.Layout.Add( _search, 1 );
var collapse = new IconButton( "unfold_less", CollapseAll, bar )
{
ToolTip = "Collapse all categories",
FixedWidth = 24f,
FixedHeight = 24f
};
bar.Layout.Add( collapse );
Layout.Add( bar );
_filterChip = new Label( string.Empty ) { Color = PrismTheme.Accent };
_filterChip.ContentMargins = new Margin( 8, 0, 8, 4 );
_filterChip.Visible = false;
Layout.Add( _filterChip );
}
void ClearSearch()
{
if ( _search is not null ) _search.Text = string.Empty;
ClearPlugFilter();
Rebuild();
}
void CollapseAll()
{
foreach ( var category in NodeRegistry.Categories )
{
foreach ( var path in Prefixes( category ) ) _collapsed.Add( path );
}
Rebuild();
}
static IEnumerable<string> Prefixes( string category )
{
if ( string.IsNullOrEmpty( category ) ) yield break;
var parts = category.Split( '/', StringSplitOptions.RemoveEmptyEntries );
var path = string.Empty;
foreach ( var part in parts )
{
path = path.Length == 0 ? part : path + "/" + part;
yield return path;
}
}
// ---------------------------------------------------------------- model ----
void Rebuild()
{
_entries.Clear();
NodeRegistry.EnsureBuilt();
var text = _search?.Text?.Trim() ?? string.Empty;
var filtering = _plugDirection is not null;
_filterChip.Visible = filtering;
if ( filtering )
{
_filterChip.Text = _plugDirection == PortDirection.Output
? $"Showing nodes that accept {Describe( _plugType )}"
: $"Showing nodes that produce {Describe( _plugType )}";
}
if ( text.Length > 0 || filtering ) BuildSearchResults( text );
else BuildTree();
_empty.Visible = _entries.Count == 0;
_list.Visible = _entries.Count > 0;
if ( _entries.Count == 0 )
{
_empty.Set( NodeRegistry.Count == 0 ? "No node types registered" : "No matching nodes",
NodeRegistry.Count == 0
? "The node catalogue is empty — something failed to load."
: "Try a different search, or clear the filter." );
}
_list.SetItems( _entries );
}
static string Describe( ShaderType type ) => type.IsVoid ? "any type" : type.Hlsl;
void BuildSearchResults( string text )
{
var query = _plugDirection is null
? NodeSearchQuery.ForText( text )
: NodeSearchQuery.ForPlug( text, _plugType, _plugDirection.Value );
var results = NodeRegistry.Search( query, 240 ).ToList();
if ( results.Count == 0 ) return;
var grouped = results
.GroupBy( x => string.IsNullOrEmpty( x.Category ) ? "Uncategorised" : x.Category )
.OrderBy( x => x.Key, StringComparer.OrdinalIgnoreCase );
foreach ( var group in grouped )
{
_entries.Add( new NodeLibraryEntry { Header = group.Key, Count = group.Count() } );
foreach ( var type in group )
{
_entries.Add( new NodeLibraryEntry
{
Type = type,
Depth = 1,
Favourite = _favourites.Contains( type.Id )
} );
}
}
}
void BuildTree()
{
var types = NodeRegistry.Types.Where( x => !x.IsHidden ).ToList();
var favourites = types.Where( x => _favourites.Contains( x.Id ) )
.OrderBy( x => x.Title, StringComparer.OrdinalIgnoreCase ).ToList();
if ( favourites.Count > 0 )
{
_entries.Add( new NodeLibraryEntry { Header = "Favourites", Count = favourites.Count } );
foreach ( var type in favourites )
{
_entries.Add( new NodeLibraryEntry { Type = type, Depth = 1, Favourite = true } );
}
}
var recent = _recent
.Select( id => types.FirstOrDefault( x => x.Id == id ) )
.Where( x => x is not null )
.ToList();
if ( recent.Count > 0 )
{
_entries.Add( new NodeLibraryEntry { Header = "Recently Used", Count = recent.Count } );
foreach ( var type in recent )
{
_entries.Add( new NodeLibraryEntry
{
Type = type,
Depth = 1,
Favourite = _favourites.Contains( type.Id )
} );
}
}
_entries.Add( new NodeLibraryEntry { Header = "All Nodes", Count = types.Count } );
var roots = types
.GroupBy( x => Segment( x.Category, 0 ) )
.OrderBy( x => x.Key, StringComparer.OrdinalIgnoreCase );
foreach ( var root in roots )
{
AddCategory( root.Key, root.ToList(), 1 );
}
}
void AddCategory( string path, List<PrismNodeType> types, int depth )
{
var expanded = !_collapsed.Contains( path );
_entries.Add( new NodeLibraryEntry
{
Category = path,
Depth = depth,
Expanded = expanded,
Count = types.Count
} );
if ( !expanded ) return;
var level = path.Split( '/', StringSplitOptions.RemoveEmptyEntries ).Length;
var children = types
.Where( x => Depth( x.Category ) > level )
.GroupBy( x => path + "/" + Segment( x.Category, level ) )
.OrderBy( x => x.Key, StringComparer.OrdinalIgnoreCase );
foreach ( var child in children )
{
AddCategory( child.Key, child.ToList(), depth + 1 );
}
var leaves = types
.Where( x => Depth( x.Category ) <= level )
.OrderBy( x => x.Title, StringComparer.OrdinalIgnoreCase );
foreach ( var type in leaves )
{
_entries.Add( new NodeLibraryEntry
{
Type = type,
Depth = depth + 1,
Favourite = _favourites.Contains( type.Id )
} );
}
}
static int Depth( string category ) =>
string.IsNullOrEmpty( category ) ? 1 : category.Split( '/', StringSplitOptions.RemoveEmptyEntries ).Length;
static string Segment( string category, int index )
{
if ( string.IsNullOrEmpty( category ) ) return "Uncategorised";
var parts = category.Split( '/', StringSplitOptions.RemoveEmptyEntries );
return index < parts.Length ? parts[index].Trim() : parts[^1].Trim();
}
// ---------------------------------------------------------------- painting ----
void PaintRow( VirtualWidget item )
{
if ( item.Object is not NodeLibraryEntry entry ) return;
var rect = item.Rect;
if ( entry.IsHeader )
{
PrismPanelChrome.PaintSectionHeader( rect, entry.Header, entry.Count > 0 ? entry.Count.ToString() : null );
return;
}
var index = _entries.IndexOf( entry );
PrismPanelChrome.PaintRow( rect, index, item.Hovered, item.Selected );
var indent = PrismPanelChrome.Pad + entry.Depth * 11f;
var inner = new Rect( rect.Left + indent, rect.Top, MathF.Max( 20f, rect.Width - indent - 8f ), rect.Height );
if ( entry.IsFolder )
{
Paint.SetPen( PrismTheme.TextMuted );
Paint.DrawIcon( new Rect( inner.Left, inner.Top, 14f, inner.Height ),
entry.Expanded ? "expand_more" : "chevron_right", 14f, TextFlag.Center );
var name = Segment( entry.Category, Depth( entry.Category ) - 1 );
PrismPaint.Text( new Rect( inner.Left + 16f, inner.Top, inner.Width - 46f, inner.Height ),
name, PrismTheme.TextSecondary, PrismTheme.BodySize, 500 );
Paint.SetFont( PrismTheme.FontFamily, 10, 400, false, true );
Paint.SetPen( PrismTheme.TextDisabled );
Paint.DrawText( inner, entry.Count.ToString(), TextFlag.RightCenter | TextFlag.SingleLine );
return;
}
var type = entry.Type;
var accent = PrismTheme.ForCategory( type.Category );
Paint.SetPen( accent.WithAlpha( 0.85f ) );
Paint.DrawIcon( new Rect( inner.Left, inner.Top, 16f, inner.Height ),
string.IsNullOrEmpty( type.Icon ) ? "circle" : type.Icon, 13f, TextFlag.Center );
var right = inner.Right;
if ( entry.Favourite || item.Hovered )
{
Paint.SetPen( entry.Favourite ? PrismTheme.Warning : PrismTheme.TextDisabled );
Paint.DrawIcon( new Rect( right - 16f, inner.Top, 16f, inner.Height ),
entry.Favourite ? "star" : "star_outline", 13f, TextFlag.Center );
right -= 20f;
}
if ( type.Tier != NodeTier.Common && inner.Width > 160f )
{
Paint.SetFont( PrismTheme.FontFamily, 9, 500, false, true );
Paint.SetPen( PrismTheme.TextDisabled );
var tier = type.Tier.ToString().ToUpperInvariant();
var width = Paint.MeasureText( tier ).x + 4f;
Paint.DrawText( new Rect( right - width, inner.Top, width, inner.Height ), tier,
TextFlag.RightCenter | TextFlag.SingleLine );
right -= width + 6f;
}
var titleRect = new Rect( inner.Left + 20f, inner.Top,
MathF.Max( 20f, right - inner.Left - 20f ), inner.Height );
PrismPaint.Text( titleRect, type.Title,
item.Selected ? PrismTheme.TextPrimary : PrismTheme.TextSecondary, PrismTheme.BodySize, 400 );
}
/// <inheritdoc/>
protected override void OnPaint()
{
Paint.ClearPen();
Paint.SetBrush( PrismTheme.Panel );
Paint.DrawRect( LocalRect );
}
// ---------------------------------------------------------------- tooltips ----
string TooltipFor( object item )
{
if ( item is not NodeLibraryEntry entry || entry.Type is null ) return null;
var type = entry.Type;
var sb = new StringBuilder();
sb.Append( "<h3>" ).Append( Escape( type.Title ) ).Append( "</h3>" );
if ( !string.IsNullOrWhiteSpace( type.Description ) )
{
sb.Append( "<p>" ).Append( Escape( type.Description ) ).Append( "</p>" );
}
AppendPorts( sb, "In", type.Inputs );
AppendPorts( sb, "Out", type.Outputs );
sb.Append( "<p><i>" ).Append( Escape( type.Category ?? "Uncategorised" ) ).Append( " · " )
.Append( Escape( type.Id ) ).Append( "</i></p>" );
sb.Append( "<p><i>Drag onto the canvas, or double-click to add it beside the selection.</i></p>" );
return sb.ToString();
}
static void AppendPorts( StringBuilder sb, string label, IReadOnlyList<PortDef> ports )
{
if ( ports is null || ports.Count == 0 ) return;
sb.Append( "<p><b>" ).Append( label ).Append( "</b>: " );
for ( var i = 0; i < ports.Count; i++ )
{
if ( i > 0 ) sb.Append( ", " );
sb.Append( Escape( ports[i].DisplayName ) ).Append( " (" )
.Append( Escape( ports[i].DeclaredType ) ).Append( ')' );
}
sb.Append( "</p>" );
}
static string Escape( string text ) =>
string.IsNullOrEmpty( text ) ? string.Empty
: text.Replace( "&", "&" ).Replace( "<", "<" ).Replace( ">", ">" );
// ---------------------------------------------------------------- interaction ----
void OnRowClicked( object item )
{
if ( item is not NodeLibraryEntry entry ) return;
if ( !entry.IsFolder ) return;
if ( !_collapsed.Remove( entry.Category ) ) _collapsed.Add( entry.Category );
Rebuild();
}
/// <summary>
/// The favourite star lives in the last 22 px of a node row. Claiming the press there keeps
/// starring a node from also selecting it, which is what makes the star feel like a control rather
/// than a decoration.
/// </summary>
bool OnRowPressed( object item, Vector2 position, Rect rect )
{
if ( item is not NodeLibraryEntry entry || entry.Type is null ) return false;
if ( position.x < rect.Right - 22f ) return false;
ToggleFavourite( entry.Type.Id );
return true;
}
void OnRowActivated( object item )
{
if ( item is not NodeLibraryEntry entry ) return;
if ( entry.IsFolder )
{
OnRowClicked( item );
return;
}
if ( entry.Type is not null ) AddNode( entry.Type );
}
void OnRowContextMenu( object item )
{
if ( item is not NodeLibraryEntry entry || entry.Type is null ) return;
var menu = new Menu( this );
var id = entry.Type.Id;
menu.AddOption( "Add To Graph", PrismIcons.Add, () => AddNode( entry.Type ) );
menu.AddOption( _favourites.Contains( id ) ? "Remove From Favourites" : "Add To Favourites",
_favourites.Contains( id ) ? "star_outline" : "star", () => ToggleFavourite( id ) );
menu.AddSeparator();
menu.AddOption( "Copy Type Id", PrismIcons.Copy, () => EditorUtility.Clipboard.Copy( id ) );
menu.OpenAtCursor( false );
}
bool OnStartDrag( object item )
{
if ( item is not NodeLibraryEntry entry || entry.Type is null ) return false;
var drag = new Drag( this );
drag.Data.Text = entry.Type.Id;
drag.Data.Object = entry.Type;
drag.Execute();
return true;
}
void ToggleFavourite( string id )
{
if ( string.IsNullOrEmpty( id ) ) return;
if ( !_favourites.Remove( id ) ) _favourites.Add( id );
SaveCookies();
Rebuild();
}
/// <summary>
/// Add a node without a drag. It lands to the right of the selection, or at the origin when there
/// is none, and is then selected and focused so the user is looking at what they just made.
/// </summary>
void AddNode( PrismNodeType type )
{
if ( _session?.Graph is null || type is null ) return;
var mutations = GraphMutations.For( _session.Graph, _session.Undo );
var position = new Vector2( 0f, 0f );
var anchor = _session.Selection?.LastOrDefault();
if ( anchor is not null ) position = anchor.Position + new Vector2( 260f, 0f );
else if ( _session.Graph.Nodes.Count > 0 )
{
position = new Vector2(
_session.Graph.Nodes.Min( x => x.Position.x ) - 260f,
_session.Graph.Nodes.Average( x => x.Position.y ) );
}
var node = mutations.AddNode( type.Id, position, $"Add {type.Title}" );
if ( node is null ) return;
NoteUsed( type.Id );
_session.SelectNode( node.Id );
_session.RequestFocus( node.Id );
_session.Touch();
}
// ---------------------------------------------------------------- cookies ----
void LoadCookies()
{
PrismLog.Guard( "Load node library cookies", () =>
{
foreach ( var id in Split( EditorCookie.GetString( FavouritesCookie, string.Empty ) ) )
{
_favourites.Add( id );
}
_recent.AddRange( Split( EditorCookie.GetString( RecentCookie, string.Empty ) ) );
} );
}
void SaveCookies()
{
PrismLog.Guard( "Save node library cookies", () =>
{
EditorCookie.SetString( FavouritesCookie, string.Join( ",", _favourites ) );
EditorCookie.SetString( RecentCookie, string.Join( ",", _recent ) );
} );
}
static IEnumerable<string> Split( string value ) =>
string.IsNullOrWhiteSpace( value )
? Array.Empty<string>()
: value.Split( ',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries );
}