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.
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;
}
}
}