Editor/Prism/Ui/NodeLibraryPanel.cs

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.

File AccessExternal Download
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( "&", "&amp;" ).Replace( "<", "&lt;" ).Replace( ">", "&gt;" );

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