Editor/Prism/Ui/NodeSearchPopup.cs

A UI popup for a node palette used in the Prism graph editor. It builds a searchable, grouped list of node types with fuzzy/multi-term scoring, keyboard and mouse navigation, and a live preview of the highlighted node’s card; it can be opened to accept or connect a node at a scene position.

ReflectionNetworkingFile AccessNative InteropProcess ExecutionObfuscated CodeEncoded DataExternal DownloadCredential AccessSelf Modifying CodeHttp Calls
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Ui.Adapters;

using Sandbox.UI;

namespace Editor.Prism.Ui;

/// <summary>
/// The node palette.
/// <para>
/// The framework's own create-node menu is a <c>Menu</c> that pokes Qt's private visibility flag by
/// reflection to stop itself flickering, truncates at twenty results with "…and N more", and carries a
/// source comment saying the real fix is a custom widget. This is that widget: a search field with
/// multi-term fuzzy scoring, results grouped by category with an icon, path and one-line description,
/// full keyboard navigation, and a live mock-up of the highlighted node's card so you can see what you
/// are about to add before you add it.
/// </para>
/// <para>
/// When it is opened by dropping a wire into space it inherits that plug's type and direction, and only
/// offers node types with a port that can actually take the connection.
/// </para>
/// </summary>
public sealed class NodeSearchPopup : PopupWidget
{
	/// <summary>Popup width.</summary>
	public const float PopupWidth = 560f;

	/// <summary>Popup height.</summary>
	public const float PopupHeight = 420f;

	const float HeaderHeight = 46f;
	const float FooterHeight = 26f;
	const float PreviewWidth = 214f;
	const float Pad = 10f;

	/// <summary>Hard cap on rendered results. Beyond this nobody is reading, they are refining.</summary>
	const int MaxResults = 400;

	readonly PrismGraphView _view;
	readonly Vector2 _scenePosition;
	readonly Plug _targetPlug;

	readonly ShaderType _plugType;
	readonly PortDirection? _plugDirection;

	SearchField _search;
	ResultList _list;
	PreviewPane _preview;

	NodeSearchPopup( PrismGraphView view, Vector2 scenePosition, Plug targetPlug ) : base( view )
	{
		_view = view;
		_scenePosition = scenePosition;
		_targetPlug = targetPlug;

		if ( targetPlug.IsValid() && targetPlug.Inner is PrismPlug plug )
		{
			_plugType = plug.EffectiveType;
			_plugDirection = targetPlug is PlugOut ? PortDirection.Output : PortDirection.Input;
		}

		FixedWidth = PopupWidth;
		FixedHeight = PopupHeight;

		Build();
	}

	/// <summary>
	/// Show the palette over a canvas. <paramref name="onClose"/> is invoked exactly once, however the
	/// popup goes away — the framework uses it to tear down the in-flight preview wire.
	/// </summary>
	public static NodeSearchPopup Open( PrismGraphView view, Vector2 screenPosition, Vector2 scenePosition,
		Plug targetPlug = null, Action onClose = null )
	{
		if ( !view.IsValid() )
		{
			onClose?.Invoke();
			return null;
		}

		var popup = new NodeSearchPopup( view, scenePosition, targetPlug )
		{
			OnLostFocus = onClose
		};

		// Centred on the gesture rather than on the screen: when a wire has just been dropped, the
		// results have to be next to the wire, not somewhere the eye has to go looking for them.
		var position = screenPosition - new Vector2( PopupWidth * 0.5f, 24f );

		popup.OpenAt( position );
		popup.Refilter();
		popup.FocusSearch();

		return popup;
	}

	// ---------------------------------------------------------------- construction ----

	void Build()
	{
		Layout = Layout.Column();
		Layout.Margin = new Margin( 1f, 1f, 1f, 1f );

		var header = Layout.AddRow();

		_search = new SearchField( this )
		{
			PlaceholderText = _plugDirection is null
				? "Search nodes…"
				: $"Nodes that accept {_plugType}…",
			FixedHeight = HeaderHeight - 8f
		};

		_search.SetStyles(
			"background-color: transparent; border: none; padding: 0px;" +
			$"color: {PrismTheme.TextPrimary.Hex}; font-size: 15px;" +
			$"selection-background-color: {PrismTheme.Accent.Hex};" );

		_search.TextEdited += _ => Refilter();
		_search.Navigate = OnNavigate;

		header.Add( _search, 1 );
		header.Margin = new Margin( Pad + 24f, 4f, Pad, 4f );

		var body = Layout.AddRow( 1 );

		_list = new ResultList( this );
		_preview = new PreviewPane( this ) { FixedWidth = PreviewWidth };

		body.Add( _list, 1 );
		body.Add( _preview );

		Layout.AddSpacingCell( FooterHeight );
	}

	void FocusSearch() => PrismLog.Guard( "Focus the palette search", () => _search?.Focus() );

	// ---------------------------------------------------------------- filtering ----

	void Refilter()
	{
		var text = _search?.Text ?? string.Empty;
		var rows = Score( text );

		_list?.SetRows( rows );
		_preview?.SetType( _list?.Highlighted );

		Update();
	}

	List<Row> Score( string text )
	{
		var results = new List<Result>();
		var parts = PrismLog.Guard<IReadOnlyList<FilterPart>>( "Parse the palette query",
			() => FilterPart.Parse( text ), Array.Empty<FilterPart>() ) ?? Array.Empty<FilterPart>();

		var positive = parts.Where( x => x.Modifier == FilterModifier.None ).Select( x => x.Value ).ToArray();
		var negative = parts.Where( x => x.Modifier == FilterModifier.Not ).Select( x => x.Value ).ToArray();

		var types = PrismLog.Guard<IReadOnlyCollection<PrismNodeTypeAdapter>>( "Read the node registry",
			() => _view?.NodeTypes, Array.Empty<PrismNodeTypeAdapter>() ) ?? Array.Empty<PrismNodeTypeAdapter>();

		foreach ( var adapter in types )
		{
			var type = adapter?.Type;

			if ( type is null ) continue;

			int total;

			if ( positive.Length == 0 )
			{
				// An empty query still has to run through Score: it is what filters out deprecated and
				// non-common types, and what applies the dragged plug's type compatibility.
				if ( type.Score( Query( null ) ) is not { } baseScore ) continue;

				total = baseScore;
			}
			else
			{
				total = 0;

				var matched = true;

				foreach ( var term in positive )
				{
					if ( type.Score( Query( term ) ) is not { } score )
					{
						matched = false;
						break;
					}

					total += score;
				}

				if ( !matched ) continue;
			}

			var excluded = false;

			foreach ( var term in negative )
			{
				if ( !type.Matches( Query( term ) ) ) continue;

				excluded = true;
				break;
			}

			if ( excluded ) continue;

			results.Add( new Result( adapter, total ) );
		}

		return Group( results );
	}

	NodeSearchQuery Query( string term ) => _plugDirection is { } direction
		? NodeSearchQuery.ForPlug( term, _plugType, direction )
		: NodeSearchQuery.ForText( term );

	static List<Row> Group( List<Result> results )
	{
		var rows = new List<Row>();

		if ( results.Count == 0 ) return rows;

		var groups = results
			.GroupBy( x => Head( x.Adapter.Type.Category ) )
			.Select( g => new
			{
				Name = g.Key,
				Best = g.Max( x => x.Score ),
				Items = g.OrderByDescending( x => x.Score )
					.ThenBy( x => x.Adapter.Type.Title, StringComparer.OrdinalIgnoreCase )
					.ToArray()
			} )
			.OrderByDescending( x => x.Best )
			.ThenBy( x => x.Name, StringComparer.OrdinalIgnoreCase );

		var emitted = 0;

		foreach ( var group in groups )
		{
			if ( emitted >= MaxResults ) break;

			rows.Add( Row.Header( group.Name ) );

			foreach ( var item in group.Items )
			{
				if ( emitted >= MaxResults ) break;

				rows.Add( Row.Item( item.Adapter ) );
				emitted++;
			}
		}

		var total = results.Count;

		if ( total > emitted ) rows.Add( Row.Header( $"…and {total - emitted} more — refine the search" ) );

		return rows;
	}

	static string Head( string category )
	{
		if ( string.IsNullOrWhiteSpace( category ) ) return "Uncategorised";

		var slash = category.IndexOf( '/' );

		return slash <= 0 ? category.Trim() : category[..slash].Trim();
	}

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

	void OnNavigate( KeyCode key )
	{
		switch ( key )
		{
			case KeyCode.Up:
				_list?.Move( -1 );
				break;

			case KeyCode.Down:
				_list?.Move( 1 );
				break;

			case KeyCode.PageUp:
				_list?.Move( -8 );
				break;

			case KeyCode.PageDown:
				_list?.Move( 8 );
				break;

			case KeyCode.Home:
				_list?.MoveTo( 0 );
				break;

			case KeyCode.End:
				_list?.MoveTo( int.MaxValue );
				break;

			case KeyCode.Return:
			case KeyCode.Enter:
				Accept( _list?.Highlighted );
				return;

			case KeyCode.Escape:
				Close();
				return;
		}

		_preview?.SetType( _list?.Highlighted );
	}

	/// <summary>Create the highlighted node and close.</summary>
	internal void Accept( PrismNodeTypeAdapter adapter )
	{
		if ( adapter is null ) return;

		if ( _view.IsValid() )
		{
			PrismLog.Guard( $"Create node '{adapter.Id}'",
				() => _view.CreateNewNode( adapter, _scenePosition, _targetPlug ) );
		}

		Close();
	}

	/// <summary>Show a type in the preview pane. Called by the result list as the highlight moves.</summary>
	internal void SetPreview( PrismNodeTypeAdapter adapter ) => _preview?.SetType( adapter );

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

	/// <inheritdoc/>
	protected override void OnPaint()
	{
		var rect = LocalRect;

		Paint.Antialiasing = true;

		Paint.SetBrushAndPen( PrismTheme.Elevated, PrismTheme.BorderStrong, 1f );
		Paint.DrawRect( rect.Shrink( 0.5f ), PrismTheme.RadiusPopup );

		var header = new Rect( rect.Left, rect.Top, rect.Width, HeaderHeight );

		Paint.SetPen( PrismTheme.TextMuted );
		Paint.DrawIcon( new Rect( header.Left + Pad, header.Top, 20f, header.Height ), PrismIcons.Search, 16f );

		Paint.SetPen( PrismTheme.BorderSubtle, 1f );
		Paint.DrawLine( new Vector2( header.Left + 1f, header.Bottom ), new Vector2( header.Right - 1f, header.Bottom ) );

		var footer = new Rect( rect.Left, rect.Bottom - FooterHeight, rect.Width, FooterHeight );

		Paint.SetPen( PrismTheme.BorderSubtle, 1f );
		Paint.DrawLine( new Vector2( footer.Left + 1f, footer.Top ), new Vector2( footer.Right - 1f, footer.Top ) );

		var hint = _plugDirection is null
			? "↑↓ navigate   ↵ add   esc cancel"
			: $"filtered to {_plugType} · ↑↓ navigate   ↵ connect   esc cancel";

		PrismPaint.Text( footer.Shrink( Pad, 0f ), hint, PrismTheme.TextMuted,
			PrismTheme.PortLabelSize, PrismTheme.PortLabelWeight );

		var count = _list?.ItemCount ?? 0;

		PrismPaint.Text( footer.Shrink( Pad, 0f ), count == 1 ? "1 node" : $"{count} nodes",
			PrismTheme.TextDisabled, PrismTheme.PortLabelSize, PrismTheme.PortLabelWeight, TextFlag.RightCenter );
	}

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

	readonly record struct Result( PrismNodeTypeAdapter Adapter, int Score );

	sealed class Row
	{
		public string HeaderText;
		public PrismNodeTypeAdapter Adapter;

		public bool IsHeader => Adapter is null;
		public float Height => IsHeader ? 24f : 40f;

		public static Row Header( string text ) => new() { HeaderText = text };
		public static Row Item( PrismNodeTypeAdapter adapter ) => new() { Adapter = adapter };
	}

	// ---------------------------------------------------------------- search field ----

	/// <summary>
	/// The search box. <c>LineEdit.ForwardNavigationEvents</c> only relays Up, Down and Enter, and the
	/// palette needs Escape, Home, End and the page keys as well — so it forwards them itself.
	/// </summary>
	sealed class SearchField : LineEdit
	{
		public SearchField( Widget parent ) : base( parent ) { }

		public Action<KeyCode> Navigate { get; set; }

		protected override void OnKeyPress( KeyEvent e )
		{
			// KeyEvent is a ref struct and cannot be captured, so the key is copied out first.
			var key = e.Key;

			switch ( key )
			{
				case KeyCode.Up:
				case KeyCode.Down:
				case KeyCode.PageUp:
				case KeyCode.PageDown:
				case KeyCode.Home:
				case KeyCode.End:
				case KeyCode.Return:
				case KeyCode.Enter:
				case KeyCode.Escape:
					// Home and End move the caret in any other text field, but in a palette the list is
					// what the user is looking at, and there is never enough text here to care.
					PrismLog.Guard( "Palette navigation", () => Navigate?.Invoke( key ) );
					e.Accepted = true;
					return;
			}

			base.OnKeyPress( e );
		}
	}

	// ---------------------------------------------------------------- results ----

	/// <summary>The grouped, scrollable result list. Painted by hand so it can carry a real row layout.</summary>
	sealed class ResultList : Widget
	{
		readonly NodeSearchPopup _popup;
		readonly List<Row> _rows = new();

		float _scroll;
		int _highlight = -1;
		int _hovered = -1;

		public ResultList( NodeSearchPopup popup ) : base( popup )
		{
			_popup = popup;

			MouseTracking = true;
			Cursor = CursorShape.Finger;
		}

		public int ItemCount => _rows.Count( x => !x.IsHeader );

		public PrismNodeTypeAdapter Highlighted =>
			_highlight >= 0 && _highlight < _rows.Count ? _rows[_highlight].Adapter : null;

		public void SetRows( List<Row> rows )
		{
			_rows.Clear();
			_rows.AddRange( rows );

			_scroll = 0f;
			_hovered = -1;
			_highlight = FirstItem( 0, 1 );

			Update();
		}

		public void Move( int delta )
		{
			if ( _rows.Count == 0 ) return;

			var step = Math.Sign( delta );
			var remaining = Math.Abs( delta );
			var index = _highlight;

			while ( remaining-- > 0 )
			{
				var next = FirstItem( index + step, step );

				if ( next < 0 ) break;

				index = next;
			}

			MoveTo( index );
		}

		public void MoveTo( int index )
		{
			if ( _rows.Count == 0 ) return;

			if ( index == int.MaxValue ) index = FirstItem( _rows.Count - 1, -1 );
			else if ( index < 0 ) index = FirstItem( 0, 1 );

			if ( index < 0 || index >= _rows.Count || _rows[index].IsHeader ) return;

			_highlight = index;

			ScrollIntoView( index );

			_popup?.SetPreview( Highlighted );

			Update();
		}

		int FirstItem( int from, int step )
		{
			for ( int i = from; i >= 0 && i < _rows.Count; i += step )
			{
				if ( !_rows[i].IsHeader ) return i;
			}

			return -1;
		}

		float TopOf( int index )
		{
			var y = 0f;

			for ( int i = 0; i < index && i < _rows.Count; i++ ) y += _rows[i].Height;

			return y;
		}

		float TotalHeight()
		{
			var y = 0f;

			foreach ( var row in _rows ) y += row.Height;

			return y;
		}

		void ScrollIntoView( int index )
		{
			var top = TopOf( index );
			var bottom = top + _rows[index].Height;

			if ( top < _scroll ) _scroll = top;
			else if ( bottom > _scroll + Height ) _scroll = bottom - Height;

			Clamp();
		}

		void Clamp() => _scroll = Math.Clamp( _scroll, 0f, MathF.Max( 0f, TotalHeight() - Height ) );

		protected override void OnPaint()
		{
			var rect = LocalRect;

			Paint.Antialiasing = true;
			Paint.SetBrushAndPen( PrismTheme.Panel.WithAlpha( 0.5f ) );
			Paint.DrawRect( rect );

			if ( _rows.Count == 0 )
			{
				PrismPaint.Text( rect, "No node matches that search", PrismTheme.TextDisabled,
					PrismTheme.BodySize, PrismTheme.PortLabelWeight, TextFlag.Center );
				return;
			}

			var y = -_scroll;

			for ( int i = 0; i < _rows.Count; i++ )
			{
				var row = _rows[i];
				var slot = new Rect( rect.Left, rect.Top + y, rect.Width, row.Height );

				y += row.Height;

				if ( slot.Bottom < rect.Top ) continue;
				if ( slot.Top > rect.Bottom ) break;

				if ( row.IsHeader ) PaintHeader( slot, row.HeaderText );
				else PaintItem( slot, row.Adapter, i );
			}

			PaintScrollbar( rect );
		}

		static void PaintHeader( Rect slot, string text )
		{
			PrismPaint.Text( slot.Shrink( Pad, 0f, 0f, 0f ), text?.ToUpperInvariant(), PrismTheme.TextMuted,
				PrismTheme.PanelHeaderSize, PrismTheme.PanelHeaderWeight );

			Paint.SetPen( PrismTheme.BorderSubtle, 1f );
			Paint.DrawLine( new Vector2( slot.Left + Pad, slot.Bottom - 1f ),
				new Vector2( slot.Right - Pad, slot.Bottom - 1f ) );
		}

		void PaintItem( Rect slot, PrismNodeTypeAdapter adapter, int index )
		{
			var type = adapter?.Type;

			if ( type is null ) return;

			var selected = index == _highlight;
			var hovered = index == _hovered;
			var color = PrismTheme.ForCategory( type.Category );

			if ( selected || hovered )
			{
				Paint.SetBrushAndPen( selected ? PrismTheme.AccentSoft : PrismTheme.BorderSubtle.WithAlpha( 0.6f ) );
				Paint.DrawRect( slot.Shrink( 4f, 2f ), PrismTheme.RadiusChip );
			}

			if ( selected )
			{
				Paint.SetBrushAndPen( PrismTheme.Accent );
				Paint.DrawRect( new Rect( slot.Left + 4f, slot.Top + 6f, 2f, slot.Height - 12f ), 1f );
			}

			var iconRect = new Rect( slot.Left + 12f, slot.Top + 8f, 24f, 24f );

			Paint.SetBrushAndPen( color.WithAlpha( 0.16f ) );
			Paint.DrawRect( iconRect, PrismTheme.RadiusChip );

			Paint.SetPen( color );
			Paint.DrawIcon( iconRect, string.IsNullOrEmpty( type.Icon ) ? PrismIcons.Add : type.Icon, 14f );

			var textLeft = iconRect.Right + 10f;
			var textWidth = slot.Right - textLeft - Pad;

			PrismPaint.Text( new Rect( textLeft, slot.Top + 5f, textWidth, 16f ), type.Title,
				selected ? PrismTheme.TextPrimary : PrismTheme.TextPrimary.WithAlpha( 0.92f ),
				PrismTheme.BodySize, PrismTheme.InlineValueWeight );

			var detail = string.IsNullOrWhiteSpace( type.Description )
				? type.Category
				: $"{type.Category}  ·  {type.Description}";

			PrismPaint.Text( new Rect( textLeft, slot.Top + 21f, textWidth, 14f ), detail,
				PrismTheme.TextMuted, PrismTheme.PortLabelSize, PrismTheme.PortLabelWeight );
		}

		void PaintScrollbar( Rect rect )
		{
			var total = TotalHeight();

			if ( total <= rect.Height ) return;

			var fraction = rect.Height / total;
			var height = MathF.Max( 24f, rect.Height * fraction );
			var travel = rect.Height - height;
			var offset = total - rect.Height <= 0f ? 0f : _scroll / ( total - rect.Height ) * travel;

			Paint.SetBrushAndPen( PrismTheme.BorderStrong.WithAlpha( 0.8f ) );
			Paint.DrawRect( new Rect( rect.Right - 5f, rect.Top + offset, 3f, height ), 1.5f );
		}

		protected override void OnMouseWheel( WheelEvent e )
		{
			base.OnMouseWheel( e );

			_scroll -= e.Delta * 0.6f;

			Clamp();
			Update();

			e.Accept();
		}

		protected override void OnMouseMove( MouseEvent e )
		{
			base.OnMouseMove( e );

			var index = IndexAt( e.LocalPosition );

			if ( index == _hovered ) return;

			_hovered = index;

			Update();
		}

		protected override void OnMouseLeave()
		{
			base.OnMouseLeave();

			_hovered = -1;

			Update();
		}

		protected override void OnMousePress( MouseEvent e )
		{
			base.OnMousePress( e );

			if ( !e.LeftMouseButton ) return;

			var index = IndexAt( e.LocalPosition );

			if ( index < 0 ) return;

			MoveTo( index );

			e.Accepted = true;
		}

		protected override void OnMouseClick( MouseEvent e )
		{
			base.OnMouseClick( e );

			var index = IndexAt( e.LocalPosition );

			if ( index < 0 ) return;

			_popup?.Accept( _rows[index].Adapter );
		}

		int IndexAt( Vector2 local )
		{
			var y = -_scroll;

			for ( int i = 0; i < _rows.Count; i++ )
			{
				var height = _rows[i].Height;

				if ( !_rows[i].IsHeader && local.y >= y && local.y < y + height ) return i;

				y += height;
			}

			return -1;
		}
	}

	// ---------------------------------------------------------------- preview ----

	/// <summary>
	/// A mock-up of the highlighted node's card, drawn from its descriptor. Seeing the ports and their
	/// type colours before committing is the difference between browsing a list and choosing a node.
	/// </summary>
	sealed class PreviewPane : Widget
	{
		PrismNodeTypeAdapter _adapter;

		public PreviewPane( Widget parent ) : base( parent ) { }

		public void SetType( PrismNodeTypeAdapter adapter )
		{
			if ( ReferenceEquals( _adapter, adapter ) ) return;

			_adapter = adapter;

			Update();
		}

		protected override void OnPaint()
		{
			var rect = LocalRect;

			Paint.Antialiasing = true;
			Paint.SetBrushAndPen( PrismTheme.Panel.WithAlpha( 0.35f ) );
			Paint.DrawRect( rect );

			Paint.SetPen( PrismTheme.BorderSubtle, 1f );
			Paint.DrawLine( new Vector2( rect.Left, rect.Top ), new Vector2( rect.Left, rect.Bottom ) );

			var type = _adapter?.Type;

			if ( type is null )
			{
				PrismPaint.Text( rect, "Nothing selected", PrismTheme.TextDisabled,
					PrismTheme.PortLabelSize, PrismTheme.PortLabelWeight, TextFlag.Center );
				return;
			}

			var card = new Rect( rect.Left + Pad + 6f, rect.Top + Pad + 4f, rect.Width - Pad * 2f - 12f, 0f );

			card.Height = CardHeight( type );

			PaintCard( card, type );

			var y = card.Bottom + 14f;

			y = PaintParagraph( new Rect( rect.Left + Pad, y, rect.Width - Pad * 2f, rect.Bottom - y ),
				string.IsNullOrWhiteSpace( type.Description )
					? "No description."
					: type.Description );

			PrismPaint.Text( new Rect( rect.Left + Pad, y + 6f, rect.Width - Pad * 2f, 14f ),
				type.Category, PrismTheme.TextDisabled, PrismTheme.PortLabelSize, PrismTheme.PortLabelWeight );

			PrismPaint.Text( new Rect( rect.Left + Pad, y + 22f, rect.Width - Pad * 2f, 14f ),
				type.Id, PrismTheme.TextDisabled.WithAlpha( 0.75f ), PrismTheme.PortLabelSize,
				PrismTheme.PortLabelWeight );
		}

		static float CardHeight( PrismNodeType type )
		{
			var rows = Math.Max( VisiblePorts( type.Inputs ).Count, VisiblePorts( type.Outputs ).Count );

			return PrismTheme.NodeHeaderHeight + Math.Max( 1, rows ) * 20f + 8f;
		}

		static List<PortDef> VisiblePorts( IReadOnlyList<PortDef> ports ) =>
			ports is null ? new List<PortDef>() : ports.Where( x => x is not null && !x.Hidden ).ToList();

		static void PaintCard( Rect card, PrismNodeType type )
		{
			var accent = PrismTheme.ForCategory( type.Category );
			var header = new Rect( card.Left, card.Top, card.Width, PrismTheme.NodeHeaderHeight );

			PrismPaint.DropShadow( card, PrismTheme.RadiusNode, PrismTheme.Shadow, 2f, 2 );

			Paint.SetBrushAndPen( PrismTheme.NodeBody, PrismTheme.BorderStrong );
			Paint.DrawRect( card, PrismTheme.RadiusNode );

			Paint.SetBrushAndPen( PrismTheme.NodeHeader );
			PrismPaint.RoundedTop( header, PrismTheme.RadiusNode );

			Paint.SetBrushAndPen( accent );
			Paint.DrawRect( new Rect( header.Left + 1f, header.Top + 4f, PrismTheme.AccentBarWidth,
				header.Height - 8f ), 1f );

			Paint.SetPen( accent.WithAlpha( 0.75f ) );
			Paint.DrawIcon( new Rect( header.Left + 10f, header.Top, 18f, header.Height ),
				string.IsNullOrEmpty( type.Icon ) ? PrismIcons.Add : type.Icon, 14f );

			PrismPaint.Text( new Rect( header.Left + 30f, header.Top, header.Width - 38f, header.Height ),
				type.Title, PrismTheme.TextPrimary, PrismTheme.NodeTitleSize, PrismTheme.NodeTitleWeight );

			var inputs = VisiblePorts( type.Inputs );
			var outputs = VisiblePorts( type.Outputs );
			var rows = Math.Max( inputs.Count, outputs.Count );

			for ( int i = 0; i < rows; i++ )
			{
				var y = header.Bottom + 4f + i * 20f;

				if ( i < inputs.Count ) PaintPort( card, y, inputs[i], true );
				if ( i < outputs.Count ) PaintPort( card, y, outputs[i], false );
			}
		}

		static void PaintPort( Rect card, float y, PortDef def, bool input )
		{
			var type = def.IsGeneric ? ShaderType.Void : def.FixedType;
			var color = def.IsGeneric ? PrismTheme.TypeGeneric : PrismTheme.ForType( type );
			var centre = new Vector2( input ? card.Left : card.Right, y + 10f );

			if ( def.IsGeneric ) PrismPaint.Ring( centre, 4f, color, 1.5f );
			else PrismPaint.Dot( centre, 4f, color );

			var label = new Rect( input ? card.Left + 10f : card.Center.x, y,
				card.Width * 0.5f - 12f, 20f );

			PrismPaint.Text( label, def.DisplayName, PrismTheme.TextSecondary,
				PrismTheme.PortLabelSize, PrismTheme.PortLabelWeight,
				input ? TextFlag.LeftCenter : TextFlag.RightCenter );
		}

		/// <summary>Word-wrap a paragraph by hand. Returns the y the next block should start at.</summary>
		static float PaintParagraph( Rect rect, string text )
		{
			if ( string.IsNullOrWhiteSpace( text ) || rect.Height <= 0f ) return rect.Top;

			var words = text.Split( ' ', StringSplitOptions.RemoveEmptyEntries );
			var line = string.Empty;
			var y = rect.Top;

			foreach ( var word in words )
			{
				var candidate = line.Length == 0 ? word : line + " " + word;

				if ( PrismPaint.MeasureText( candidate, PrismTheme.PortLabelSize,
						PrismTheme.PortLabelWeight ) <= rect.Width )
				{
					line = candidate;
					continue;
				}

				if ( y + 14f > rect.Bottom ) return y;

				PrismPaint.Text( new Rect( rect.Left, y, rect.Width, 14f ), line,
					PrismTheme.TextSecondary, PrismTheme.PortLabelSize, PrismTheme.PortLabelWeight );

				y += 14f;
				line = word;
			}

			if ( line.Length > 0 && y + 14f <= rect.Bottom )
			{
				PrismPaint.Text( new Rect( rect.Left, y, rect.Width, 14f ), line,
					PrismTheme.TextSecondary, PrismTheme.PortLabelSize, PrismTheme.PortLabelWeight );

				y += 14f;
			}

			return y;
		}
	}
}