UI classes for the Prism node editor. PrismNodeUi lays out and paints a node card (headers, ports, preview thumbnail, diagnostics, reroute dot) and handles interactions like preview toggling and detaching wires. PrismCommentUi repaints comment/group boxes using the Prism theme.
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Ui.Adapters;
namespace Editor.Prism.Ui;
/// <summary>
/// The node card.
/// <para>
/// <b>This class never calls <c>base.OnPaint</c>.</b> The built-in card is a rounded rectangle tinted
/// from a single primary colour with a hardcoded <c>"ERROR!!!"</c> badge stapled to the bottom; none of
/// it is drawn here. Neither is the built-in <c>Layout</c>: every socket is placed by hand, set
/// <c>Visible = false</c> so the framework draws nothing for it, and sized so that
/// <c>Plug.ConnectionPosition</c> lands exactly on the handle we painted ourselves. That combination —
/// documented in the contracts as the <c>Visible = false</c> port rule — is what buys total visual
/// control without forking the node-graph library and losing copy/paste, undo scopes, marquee select,
/// reroute insertion and drag-and-drop with it.
/// </para>
/// </summary>
public class PrismNodeUi : NodeUI
{
/// <summary>One laid-out line of the card: a socket, or a heading above a run of sockets.</summary>
sealed class Row
{
public PrismPlug Port;
public Plug Item;
public bool IsInput;
public bool IsHeader;
public string Text;
public Vector2 Handle;
public Rect Label;
}
/// <summary>
/// Whether the thumbnail renderer is running at all, mirrored here by the preview dock so a card
/// with its preview flag set can say <em>why</em> its box is still empty instead of just sitting
/// there. Read during paint, so it is a plain field rather than a cookie lookup.
/// </summary>
public static bool ThumbnailsEnabled { get; set; } = true;
List<Row> _rows;
Rect _bodyRect;
Rect _thumbRect;
Rect _diagnosticRect;
Rect _previewChipRect;
ulong _hoverMask;
bool _wasFlashing;
float _width = PrismTheme.NodeMinWidth;
float _height = PrismTheme.NodeHeaderHeight;
/// <summary>Build a card for a node.</summary>
public PrismNodeUi( GraphView graph, PrismNodeAdapter node ) : base( graph, node )
{
ZIndex = 1;
Movable = true;
Selectable = true;
HoverEvents = true;
Cursor = CursorShape.SizeAll;
SelectionOutline = PrismTheme.Accent;
PrimaryColor = node?.CategoryColor ?? PrismTheme.CategoryUtility;
if ( node is { IsReroute: true } )
{
// Deliberately not HandlePosition = 0.5 the way the built-in RerouteUI does it: that moves
// the item's origin but not its paint rect, so the dot and the wire endpoints end up half a
// dot apart. A reroute here is just a very small card whose sockets sit at its centre.
ZIndex = 0;
}
// The base constructor already ran Layout() once, before any of our fields existed.
Layout();
// Registering here is what lets the canvas do its per-frame work over a list of cards instead of
// over every graphics item in the scene.
( graph as PrismGraphView )?.RegisterCard( this );
}
/// <summary>The node adapter this card draws.</summary>
public PrismNodeAdapter Adapter => Node as PrismNodeAdapter;
/// <summary>The document node behind the card.</summary>
public PrismNode Model => Adapter?.PrismNode;
/// <summary>True when the card is drawn as a bare dot rather than a card.</summary>
public bool IsReroute => Adapter?.IsReroute ?? false;
/// <summary>True when the card is drawn as a header-only strip.</summary>
public bool IsCollapsed => Adapter?.IsCollapsed ?? false;
/// <summary>Re-measure and repaint the card. Use after anything that changes its height.</summary>
public void Relayout()
{
Layout();
Update();
}
/// <inheritdoc/>
protected override float TitleHeight => IsReroute ? 0f : PrismTheme.NodeHeaderHeight;
/// <inheritdoc/>
public override Rect BoundingRect => new Rect( -HandlePosition * Size, Size ).Grow( 22f );
// ---------------------------------------------------------------- layout ----
/// <inheritdoc/>
protected override void Layout()
{
_rows ??= new List<Row>();
_rows.Clear();
// The framework hardcodes its own plug types inside a private method, so the only place a socket
// can be swapped for one with a drag threshold is here — the framework calls Layout() at the end
// of every UpdatePlugs pass, which is the moment after a plug is created and before anything can
// connect to it.
PrismLog.Guard( "Upgrade the sockets of a card", () => { PrismPlugItems.Upgrade( this ); } );
var adapter = Adapter;
if ( adapter is null )
{
Size = new Vector2( PrismTheme.NodeMinWidth, PrismTheme.NodeHeaderHeight );
PrepareGeometryChange();
return;
}
if ( adapter.IsReroute )
{
LayoutReroute();
return;
}
if ( adapter.IsCollapsed )
{
LayoutCollapsed( adapter );
return;
}
LayoutCard( adapter );
}
void LayoutReroute()
{
const float size = 16f;
// Both plugs sit exactly on the dot's centre; whichever end the user is more likely to want is
// raised above the other, the same trick the built-in reroute uses.
var preferOutput = Inputs.Any( x => x.Connection is not null );
foreach ( var plug in Inputs )
{
plug.Visible = false;
plug.Size = new Vector2( 14f, 14f );
plug.Position = new Vector2( 1f, 1f );
plug.ZIndex = plug.DefaultZIndex = preferOutput ? 0f : 2f;
}
foreach ( var plug in Outputs )
{
plug.Visible = false;
plug.Size = new Vector2( 14f, 14f );
plug.Position = new Vector2( 1f, 1f );
plug.ZIndex = plug.DefaultZIndex = preferOutput ? 2f : 0f;
}
_width = size;
_height = size;
_bodyRect = new Rect( 0f, 0f, size, size );
Size = new Vector2( size, size );
PrepareGeometryChange();
}
void LayoutCollapsed( PrismNodeAdapter adapter )
{
var width = MathF.Max( PrismTheme.NodeMinWidth, MeasureHeader( adapter ) );
width = Snap( width );
var inputs = Inputs;
var outputs = Outputs;
PlaceEdgeDots( inputs, true, width );
PlaceEdgeDots( outputs, false, width );
_width = width;
_height = PrismTheme.NodeHeaderHeight;
_bodyRect = new Rect( 0f, PrismTheme.NodeHeaderHeight, width, 0f );
_thumbRect = default;
_diagnosticRect = default;
Size = new Vector2( _width, _height );
PrepareGeometryChange();
}
void PlaceEdgeDots( IEnumerable<Plug> plugs, bool input, float width )
{
var list = plugs.ToArray();
if ( list.Length == 0 ) return;
var spacing = PrismTheme.NodeHeaderHeight / ( list.Length + 1f );
for ( int i = 0; i < list.Length; i++ )
{
var plug = list[i];
var y = spacing * ( i + 1 );
plug.Visible = false;
plug.Size = new Vector2( 18f, 20f );
plug.Position = new Vector2( input ? -7f : width - 11f, y - 10f );
plug.ZIndex = plug.DefaultZIndex = 1f;
_rows.Add( new Row
{
Port = plug.Inner as PrismPlug,
Item = plug,
IsInput = input,
Handle = new Vector2( input ? 0f : width, y )
} );
}
}
void LayoutCard( PrismNodeAdapter adapter )
{
var inputs = Inputs;
var outputs = Outputs;
var inputWidth = MeasureColumn( inputs );
var outputWidth = MeasureColumn( outputs );
var headerWidth = MeasureHeader( adapter );
var gap = inputWidth > 0f && outputWidth > 0f ? 24f : 12f;
var content = inputWidth + outputWidth + gap;
var width = MathF.Max( PrismTheme.NodeMinWidth, MathF.Max( headerWidth, content + 24f ) );
width = Snap( MathF.Min( width, PrismTheme.NodeMaxWidth ) );
// The card is capped at a maximum width, so two very long label runs would otherwise overlap in
// the middle. Share the shortfall between the columns rather than letting one win.
var available = width - 24f - gap;
if ( inputWidth + outputWidth > available && inputWidth + outputWidth > 0f )
{
var scale = available / ( inputWidth + outputWidth );
inputWidth = MathF.Max( 8f, inputWidth * scale );
outputWidth = MathF.Max( 8f, outputWidth * scale );
}
var top = PrismTheme.NodeHeaderHeight + 6f;
var inputBottom = BuildRows( inputs, true, top, width, inputWidth );
var outputBottom = BuildRows( outputs, false, top, width, outputWidth );
var bottom = MathF.Max( MathF.Max( inputBottom, outputBottom ), top + 4f );
_bodyRect = new Rect( 0f, PrismTheme.NodeHeaderHeight, width, bottom - PrismTheme.NodeHeaderHeight );
// The box is reserved as soon as the user asks for a preview, not once an image has arrived.
// Otherwise toggling node preview does nothing visible until the next compile lands, and the
// toggle reads as broken — which is exactly what it did before.
if ( adapter.WantsPreview )
{
var side = MathF.Min( PrismTheme.ThumbnailSize, width - 24f );
_thumbRect = new Rect( ( width - side ) * 0.5f, bottom + 2f, side, side );
bottom = _thumbRect.Bottom + 4f;
}
else
{
_thumbRect = default;
}
if ( adapter.PrimaryDiagnostic is not null )
{
_diagnosticRect = new Rect( 8f, bottom + 2f, width - 16f, 20f );
bottom = _diagnosticRect.Bottom + 4f;
}
else
{
_diagnosticRect = default;
}
_width = width;
_height = Snap( bottom + 6f );
Size = new Vector2( _width, _height );
PrepareGeometryChange();
}
float BuildRows( IEnumerable<Plug> plugs, bool input, float top, float width, float columnWidth )
{
var y = top;
string group = null;
foreach ( var plug in plugs )
{
if ( plug.Inner is not PrismPlug port ) continue;
var portGroup = port.GroupName;
if ( !string.Equals( portGroup, group, StringComparison.Ordinal ) )
{
group = portGroup;
if ( !string.IsNullOrEmpty( group ) )
{
_rows.Add( new Row
{
IsInput = input,
IsHeader = true,
// Upper-cased here, once, rather than on every repaint: the painter draws the
// caption verbatim and the column measurement below needs the same text.
Text = group.ToUpperInvariant(),
Label = input
? new Rect( 12f, y, MathF.Max( 8f, columnWidth ), 16f )
: new Rect( width - 12f - MathF.Max( 8f, columnWidth ), y, MathF.Max( 8f, columnWidth ), 16f )
} );
y += 16f;
}
}
var center = y + PrismTheme.PortPitch * 0.5f;
plug.Visible = false;
plug.Size = new Vector2( 18f, 20f );
plug.Position = new Vector2( input ? -7f : width - 11f, center - 10f );
plug.ZIndex = plug.DefaultZIndex = 1f;
_rows.Add( new Row
{
Port = port,
Item = plug,
IsInput = input,
Handle = new Vector2( input ? 0f : width, center ),
Label = input
? new Rect( 12f, y, MathF.Max( 8f, columnWidth ), PrismTheme.PortPitch )
: new Rect( width - 12f - MathF.Max( 8f, columnWidth ), y, MathF.Max( 8f, columnWidth ),
PrismTheme.PortPitch )
} );
y += PrismTheme.PortPitch;
}
return y;
}
static float MeasureColumn( IEnumerable<Plug> plugs )
{
var width = 0f;
foreach ( var plug in plugs )
{
if ( plug.Inner is not PrismPlug port ) continue;
// The group is drawn as its own heading row rather than as a prefix, so it is measured as a
// separate line rather than added to the label's width.
width = MathF.Max( width, PrismPortPainter.MeasureLabel( port.Label, null ) );
if ( string.IsNullOrEmpty( port.GroupName ) ) continue;
width = MathF.Max( width, PrismPaint.MeasureText( port.GroupName.ToUpperInvariant(),
PrismTheme.GroupHeaderSize, PrismTheme.GroupHeaderWeight ) + 8f );
}
return width;
}
float MeasureHeader( PrismNodeAdapter adapter )
{
var title = adapter.DisplayInfo.Name ?? string.Empty;
var width = PrismPaint.MeasureText( title, PrismTheme.NodeTitleSize, PrismTheme.NodeTitleWeight );
// icon + accent bar + padding + room for the status chips
return width + 34f + 16f + ChipBudget( adapter );
}
static float ChipBudget( PrismNodeAdapter adapter )
{
// The preview chip is always present, so its width is always reserved — otherwise toggling it
// would re-measure the title and the card would breathe every time the user pressed 1.
var budget = 20f;
if ( adapter.Descriptor is { IsRegistered: false } ) budget += 62f;
if ( adapter.IsPinned ) budget += 20f;
if ( adapter.IsDisabled ) budget += 20f;
if ( adapter.ErrorCount > 0 || adapter.WarningCount > 0 ) budget += 34f;
return budget;
}
static float Snap( float value ) => MathF.Ceiling( value / PrismTheme.GridSize ) * PrismTheme.GridSize;
// ---------------------------------------------------------------- painting ----
/// <inheritdoc/>
protected override void OnPaint()
{
var adapter = Adapter;
if ( adapter is null ) return;
Paint.Antialiasing = true;
Paint.TextAntialiasing = true;
if ( adapter.IsReroute )
{
PaintReroute( adapter );
return;
}
var rect = new Rect( 0f, Size );
var alpha = adapter.IsReachable ? 1f : 0.55f;
if ( adapter.IsDisabled ) alpha *= 0.7f;
var selected = Paint.HasSelected;
var hovered = Paint.HasMouseOver;
var hasError = adapter.ErrorCount > 0;
PrismPaint.DropShadow( rect, PrismTheme.RadiusNode, PrismTheme.Shadow.WithAlpha( 0.4f * alpha ) );
// body
Paint.ClearPen();
Paint.SetBrush( PrismTheme.NodeBody.WithAlpha( alpha ) );
Paint.DrawRect( rect, PrismTheme.RadiusNode );
PaintHeader( adapter, rect, alpha );
if ( !adapter.IsCollapsed )
{
Paint.ClearBrush();
Paint.SetPen( PrismTheme.BorderSubtle.WithAlpha( 0.9f * alpha ), 1f );
Paint.DrawLine( new Vector2( 1f, PrismTheme.NodeHeaderHeight ),
new Vector2( rect.Width - 1f, PrismTheme.NodeHeaderHeight ) );
PaintBody( adapter, alpha );
}
PaintPorts( adapter, alpha );
PaintOutline( adapter, rect, selected, hovered, hasError, alpha );
}
void PaintHeader( PrismNodeAdapter adapter, Rect rect, float alpha )
{
var header = new Rect( 0f, 0f, rect.Width, PrismTheme.NodeHeaderHeight );
var accent = adapter.CategoryColor;
// The category bar is the whole header painted in the category colour and then covered by the
// header colour, shifted right by the bar's width and using the *same* corner radius. Two
// concentric rounded rectangles leave a crescent exactly the bar's width that follows the card's
// curve, and — because the right-hand arcs coincide exactly — the top-right corner stays clean.
// Painting the bar as its own small rounded rectangle instead, which is the obvious thing to do,
// gives it a 3 px corner where the card has an 8 px one: the bar juts out past the card's outline
// at the top and leaves a body-coloured divot beside it.
// A collapsed card is nothing but its header, so its bottom corners are the card's own and have to
// stay round; an expanded one is seamed to the body below and has to stay square there.
var collapsed = adapter.IsCollapsed;
void Fill( Rect area )
{
if ( collapsed ) Paint.DrawRect( area, PrismTheme.RadiusNode );
else PrismPaint.RoundedTop( area, PrismTheme.RadiusNode );
}
Paint.ClearPen();
Paint.SetBrush( accent.WithAlpha( alpha ) );
Fill( header );
Paint.SetBrush( PrismTheme.NodeHeader.WithAlpha( alpha ) );
Fill( new Rect( PrismTheme.AccentBarWidth, 0f,
MathF.Max( 0f, header.Width - PrismTheme.AccentBarWidth ), header.Height ) );
var content = new Rect( PrismTheme.AccentBarWidth + 7f, 0f,
header.Width - PrismTheme.AccentBarWidth - 14f, header.Height );
var icon = adapter.DisplayInfo.Icon;
if ( !string.IsNullOrEmpty( icon ) )
{
Paint.SetPen( accent.Lighten( 0.35f ).WithAlpha( 0.7f * alpha ) );
Paint.DrawIcon( new Rect( content.Left, content.Top, 16f, content.Height ), icon, 16f,
TextFlag.LeftCenter );
content.Left += 22f;
}
var chipsWidth = PaintChips( adapter, header, alpha );
content.Width = MathF.Max( 8f, content.Width - chipsWidth - ( chipsWidth > 0f ? 6f : 0f ) );
PrismPaint.Text( content, adapter.DisplayInfo.Name,
PrismTheme.TextPrimary.WithAlpha( alpha ), PrismTheme.NodeTitleSize, PrismTheme.NodeTitleWeight );
}
/// <summary>
/// The status chips on the right of the header, laid out right to left.
/// <para>
/// The preview chip is drawn <em>always</em> and drawn <em>first</em>, dimmed when the flag is off
/// and brightened on hover. That costs a permanent 20 px of header, and buys the thing the design
/// language asks for and the card did not have: a per-node preview toggle you can see and click.
/// Drawing it conditionally would move every other chip whenever the flag changed, and an eye that
/// only appears once you have already turned it on is not an affordance.
/// </para>
/// </summary>
float PaintChips( PrismNodeAdapter adapter, Rect header, float alpha )
{
var x = header.Right - 7f;
var y = header.Top + ( header.Height - 17f ) * 0.5f;
var used = 0f;
var hovered = Paint.HasMouseOver;
Rect Chip( string text, string icon, Color fg, Color bg )
{
var chip = PrismPaint.Chip( new Vector2( x, y ), text, fg.WithAlpha( fg.a * alpha ),
bg.WithAlpha( bg.a * alpha ), icon );
x -= chip.Width + 4f;
used += chip.Width + 4f;
return chip;
}
var previewOn = adapter.WantsPreview;
var previewAlpha = previewOn ? 1f : hovered ? 0.7f : 0.25f;
_previewChipRect = Chip( null,
previewOn ? PrismIcons.Preview : PrismIcons.PreviewOff,
( previewOn ? PrismTheme.Accent : PrismTheme.TextMuted ).WithAlpha( previewAlpha ),
PrismTheme.Canvas.WithAlpha( previewOn ? 0.5f : hovered ? 0.35f : 0f ) );
if ( adapter.IsPinned )
Chip( null, PrismIcons.Pinned, PrismTheme.Accent2, PrismTheme.Canvas.WithAlpha( 0.5f ) );
if ( adapter.IsDisabled )
Chip( null, PrismIcons.Disabled, PrismTheme.TextMuted, PrismTheme.Canvas.WithAlpha( 0.5f ) );
if ( adapter.ErrorCount > 0 )
{
Chip( adapter.ErrorCount.ToString(), PrismIcons.Error, PrismTheme.Error,
PrismTheme.Error.WithAlpha( 0.16f ) );
}
else if ( adapter.WarningCount > 0 )
{
Chip( adapter.WarningCount.ToString(), PrismIcons.Warning, PrismTheme.Warning,
PrismTheme.Warning.WithAlpha( 0.16f ) );
}
if ( adapter.Descriptor is { IsRegistered: false } )
{
Chip( "missing", PrismIcons.Missing, PrismTheme.TextMuted, PrismTheme.Canvas.WithAlpha( 0.6f ) );
}
return used;
}
void PaintBody( PrismNodeAdapter adapter, float alpha )
{
if ( _thumbRect.Width > 1f )
{
Paint.ClearPen();
Paint.SetBrush( PrismTheme.Canvas.WithAlpha( alpha ) );
Paint.DrawRect( _thumbRect.Grow( 1f ), PrismTheme.RadiusChip );
var thumbnail = adapter.Thumbnail;
if ( thumbnail is not null )
{
Paint.Draw( _thumbRect, thumbnail, alpha, PrismTheme.RadiusChip );
}
else
{
PaintThumbnailPlaceholder( alpha );
}
Paint.ClearBrush();
Paint.SetPen( PrismTheme.BorderSubtle.WithAlpha( 0.9f * alpha ), 1f );
Paint.DrawRect( _thumbRect.Grow( 1f ), PrismTheme.RadiusChip );
}
if ( _diagnosticRect.Width > 1f && adapter.PrimaryDiagnostic is { } diagnostic )
{
var color = PrismTheme.ForSeverity( diagnostic.Severity );
PrismPaint.Pill( _diagnosticRect, color.WithAlpha( 0.12f * alpha ), PrismTheme.RadiusChip );
Paint.SetPen( color.WithAlpha( alpha ) );
Paint.DrawIcon( _diagnosticRect.Shrink( 4f, 0f, 0f, 0f ),
diagnostic.Severity == DiagnosticSeverity.Error ? PrismIcons.Error : PrismIcons.Warning,
12f, TextFlag.LeftCenter );
PrismPaint.Text( _diagnosticRect.Shrink( 20f, 0f, 5f, 0f ), diagnostic.Message,
color.WithAlpha( alpha ), 10, 500 );
}
var free = FreeBodyRect();
if ( free.Width > 4f && free.Height > 4f )
{
var state = new NodePaintState( Paint.HasSelected, Paint.HasMouseOver, adapter.IsDisabled,
adapter.ErrorCount > 0, Graph?.Scale.x ?? 1f );
adapter.PaintBody( free, state );
}
}
/// <summary>
/// What a reserved preview box shows before an image exists for it: a transparency checkerboard and
/// one honest line of text. Silence here is what made the preview toggle feel broken — the user asks
/// for a preview, nothing changes, and there is nothing to tell them a compile is what produces one.
/// </summary>
void PaintThumbnailPlaceholder( float alpha )
{
PrismPaint.Checkerboard( _thumbRect, PrismTheme.Panel.WithAlpha( alpha ),
PrismTheme.PanelAlt.WithAlpha( alpha ), 8f );
var caption = ThumbnailsEnabled ? "rendering…" : "thumbnails off";
PrismPaint.Text( _thumbRect, caption, PrismTheme.TextMuted.WithAlpha( alpha ), 10, 500,
TextFlag.Center );
}
Rect FreeBodyRect()
{
if ( _bodyRect.Width <= 0f ) return default;
var left = 0f;
var right = _bodyRect.Width;
foreach ( var row in _rows )
{
if ( row.IsHeader || row.Port is null ) continue;
if ( row.IsInput ) left = MathF.Max( left, row.Label.Right + 6f );
else right = MathF.Min( right, row.Label.Left - 6f );
}
return new Rect( left, _bodyRect.Top + 4f, MathF.Max( 0f, right - left ), _bodyRect.Height - 8f );
}
void PaintPorts( PrismNodeAdapter adapter, float alpha )
{
var collapsed = adapter.IsCollapsed;
foreach ( var row in _rows )
{
if ( row.IsHeader )
{
PrismPortPainter.DrawGroupHeader( row.Label, row.Text, !row.IsInput, alpha );
continue;
}
var port = row.Port;
if ( port is null ) continue;
var connected = row.Item?.IsConnected ?? false;
if ( collapsed )
{
PrismPortPainter.DrawCollapsedDot( row.Handle, port.TypeColor, connected, alpha );
continue;
}
var visual = new PortVisual
{
Color = port.TypeColor,
Connected = connected,
Required = port.IsRequired && row.IsInput,
Generic = port.IsGeneric,
Hovered = row.Item?.Hovered ?? false,
Error = !string.IsNullOrEmpty( port.ErrorMessage ),
Alpha = alpha,
Flash = port.FlashAmount
};
PrismPortPainter.DrawHandle( row.Handle, visual );
PrismPortPainter.DrawLabel( row.Label, port.Label, null,
visual.Hovered ? PrismTheme.TextPrimary : PrismTheme.TextSecondary, !row.IsInput, alpha );
}
}
void PaintOutline( PrismNodeAdapter adapter, Rect rect, bool selected, bool hovered, bool hasError, float alpha )
{
// An unrecognised node type round-trips losslessly through the document, so it must also read as
// "preserved but not understood" rather than as a broken node.
if ( adapter.Descriptor is { IsRegistered: false } && !selected )
{
Paint.ClearBrush();
Paint.SetPen( PrismTheme.TextMuted.WithAlpha( 0.8f * alpha ), 1.25f, PenStyle.Dash );
Paint.DrawRect( rect, PrismTheme.RadiusNode );
return;
}
if ( selected )
{
PrismPaint.Glow( rect, PrismTheme.Accent, PrismTheme.RadiusNode );
Paint.ClearBrush();
Paint.SetPen( PrismTheme.Accent.WithAlpha( alpha ), PrismTheme.SelectionWidth );
Paint.DrawRect( rect, PrismTheme.RadiusNode );
}
else if ( hasError )
{
Paint.ClearBrush();
Paint.SetPen( PrismTheme.Error.WithAlpha( alpha ), PrismTheme.SelectionWidth );
Paint.DrawRect( rect, PrismTheme.RadiusNode );
}
else if ( hovered )
{
Paint.ClearBrush();
Paint.SetPen( PrismTheme.BorderStrong.Lighten( 0.35f ).WithAlpha( alpha ), 1f );
Paint.DrawRect( rect, PrismTheme.RadiusNode );
}
else
{
Paint.ClearBrush();
Paint.SetPen( PrismTheme.BorderSubtle.WithAlpha( alpha ), 1f );
Paint.DrawRect( rect, PrismTheme.RadiusNode );
}
}
void PaintReroute( PrismNodeAdapter adapter )
{
var alpha = adapter.IsReachable ? 1f : 0.55f;
// A reroute takes the colour of whatever is flowing through it, which is the only thing it says.
// Read with two plain loops rather than an iterator method: this is a paint path, and a compiler
// generated enumerator is one heap allocation per reroute per repaint for a two-element search.
var color = ReroutePortColor();
var center = Size * 0.5f;
var radius = Paint.HasMouseOver ? 7f : 6f;
if ( Paint.HasSelected )
{
PrismPaint.Dot( center, radius + 4f, PrismTheme.Accent.WithAlpha( 0.35f * alpha ) );
}
PrismPaint.Dot( center, radius + 2f, PrismTheme.Canvas.WithAlpha( alpha ) );
PrismPaint.Dot( center, radius, ( Paint.HasSelected ? PrismTheme.Accent : color ).WithAlpha( alpha ) );
}
/// <summary>
/// The colour flowing through a reroute: its output's resolved type, falling back to its input's and
/// then to the unresolved grey.
/// </summary>
Color ReroutePortColor()
{
for ( int i = 0; i < Outputs.Count; i++ )
{
if ( Outputs[i].Inner is PrismPlug port ) return port.TypeColor;
}
for ( int i = 0; i < Inputs.Count; i++ )
{
if ( Inputs[i].Inner is PrismPlug port ) return port.TypeColor;
}
return PrismTheme.TypeGeneric;
}
// ---------------------------------------------------------------- interaction ----
/// <summary>
/// Repaint when a socket's hover state changed. Plugs are invisible children that repaint
/// themselves and draw nothing, so without this the grow-on-hover handle would never appear.
/// Called once per frame by the view rather than per plug, so it costs one integer compare per node.
/// </summary>
public void SyncHoverState()
{
if ( _rows is null ) return;
ulong mask = 0;
var bit = 0;
var flashing = false;
foreach ( var row in _rows )
{
if ( row.Item is null ) continue;
if ( row.Item.Hovered && bit < 64 ) mask |= 1UL << bit;
if ( row.Port is { IsFlashing: true } ) flashing = true;
bit++;
}
// The trailing "was flashing" compare guarantees one final repaint after a rejection flash ends,
// so the handle does not keep the last tinted frame until something else invalidates the card.
if ( mask == _hoverMask && !flashing && !_wasFlashing ) return;
_hoverMask = mask;
_wasFlashing = flashing;
Update();
}
/// <inheritdoc/>
protected override void OnHoverEnter( GraphicsHoverEvent e )
{
var adapter = Adapter;
if ( adapter is not null )
{
var display = adapter.DisplayInfo;
ToolTip = FormatToolTip( display.Name, Describe( adapter ), null, adapter.ErrorMessage );
}
base.OnHoverEnter( e );
}
static string Describe( PrismNodeAdapter adapter )
{
var description = adapter.DisplayInfo.Description;
var id = adapter.Descriptor?.Id;
if ( string.IsNullOrEmpty( id ) ) return description;
var suffix = $"<br/><span style=\"font-size: 10px; color: {PrismTheme.TextMuted.Hex};\">{id}</span>";
return string.IsNullOrEmpty( description ) ? suffix : description + suffix;
}
/// <inheritdoc/>
protected override void OnMousePressed( GraphicsMouseEvent e )
{
if ( e.LeftMouseButton && !e.HasAlt && HitPreviewChip( e.LocalPosition ) )
{
TogglePreview();
// Accepted and not forwarded: the base would otherwise open a move bracket and the card
// would drift by however far the pointer travelled while the button was down.
e.Accepted = true;
return;
}
// Alt+drag detaches the card from everything wired to it and then drags it, which is how every
// other node editor lets you pull a node out of a chain without visiting six sockets. Done on
// press rather than on move because the framework starts its move bracket in the base call and
// there is no "drag began" hook to hang it off.
if ( e.LeftMouseButton && e.HasAlt ) DetachFromWires();
// Never skip: the base is what opens the framework's move-undo bracket.
base.OnMousePressed( e );
}
bool HitPreviewChip( Vector2 local ) =>
!IsReroute && _previewChipRect.Width > 1f && _previewChipRect.Grow( 2f ).IsInside( local );
/// <summary>Flip this node's preview flag, as one undo step, and re-measure the card around it.</summary>
public void TogglePreview()
{
var adapter = Adapter;
var model = adapter?.PrismNode;
var mutations = adapter?.Adapter?.Mutations;
if ( model is null || mutations is null ) return;
var wanted = !adapter.WantsPreview;
mutations.SetFlag( model.Id, NodeFlags.Preview, wanted, wanted ? "Show Preview" : "Hide Preview" );
Relayout();
}
/// <summary>
/// Remove every connection touching this node, as one undo step. Returns how many wires were cut.
/// </summary>
public int DetachFromWires()
{
var adapter = Adapter;
var model = adapter?.PrismNode;
var mutations = adapter?.Adapter?.Mutations;
var document = adapter?.Adapter?.Document;
if ( model is null || mutations is null || document is null ) return 0;
var edges = new List<EdgeId>();
foreach ( var edge in document.Edges )
{
if ( edge.Touches( model.Id ) ) edges.Add( edge.Id );
}
if ( edges.Count == 0 ) return 0;
using ( mutations.Begin( "Detach Node" ) )
{
foreach ( var id in edges ) mutations.Disconnect( id, "Detach Node" );
}
adapter.Invalidate();
return edges.Count;
}
/// <inheritdoc/>
protected override void OnMouseReleased( GraphicsMouseEvent e )
{
base.OnMouseReleased( e );
}
/// <inheritdoc/>
protected override void OnPositionChanged()
{
// Also load-bearing: the base snaps to grid, writes the position back to the model and re-lays
// out every connection attached to this node.
base.OnPositionChanged();
}
/// <inheritdoc/>
protected override void OnRebuild()
{
base.OnRebuild();
PrimaryColor = Adapter?.CategoryColor ?? PrimaryColor;
Layout();
Update();
}
}
/// <summary>
/// A comment / group box, re-skinned.
/// <para>
/// Everything except the paint is inherited: eight-direction resizing with grid snapping and its own
/// undo bracket, nested-box layering, and click-to-select-everything-inside. Only <c>OnPaint</c> is
/// replaced, because the base draws from a fixed palette of seven hardcoded hex colours that have
/// nothing to do with the Prism theme.
/// </para>
/// </summary>
public class PrismCommentUi : CommentUI
{
/// <summary>Build a comment box for a comment node.</summary>
public PrismCommentUi( GraphView graph, ICommentNode node ) : base( graph, node )
{
SelectionOutline = PrismTheme.Accent;
}
/// <summary>The comment node behind the box.</summary>
public ICommentNode Comment => Node as ICommentNode;
/// <summary>The theme colour a comment colour maps onto.</summary>
public static Color ColorFor( CommentColor color ) => color switch
{
CommentColor.Red => PrismTheme.Error,
CommentColor.Green => PrismTheme.Success,
CommentColor.Blue => PrismTheme.Accent,
CommentColor.Yellow => PrismTheme.Warning,
CommentColor.Purple => PrismTheme.Accent2,
CommentColor.Orange => PrismTheme.TypeColor,
_ => PrismTheme.TextSecondary
};
/// <inheritdoc/>
protected override void OnPaint()
{
var comment = Comment;
if ( comment is null ) return;
Paint.Antialiasing = true;
Paint.TextAntialiasing = true;
var rect = new Rect( 0f, Size );
var color = ColorFor( comment.Color );
var selected = Paint.HasSelected;
var hovered = Paint.HasMouseOver;
PrimaryColor = color;
Paint.SetBrush( color.WithAlpha( selected ? 0.10f : hovered ? 0.08f : 0.05f ) );
Paint.SetPen( color.WithAlpha( selected ? 0.85f : 0.45f ), selected ? 1.5f : 1f );
Paint.DrawRect( rect, PrismTheme.RadiusNode );
var header = new Rect( 0f, 0f, rect.Width, 40f ).Shrink( 3f );
Paint.ClearPen();
Paint.SetBrush( color.WithAlpha( 0.18f ) );
Paint.DrawRect( header, PrismTheme.RadiusChip );
var content = header.Shrink( 8f, 0f, 8f, 0f );
Paint.SetPen( color );
Paint.DrawIcon( new Rect( content.Left, content.Top, 18f, content.Height ), PrismIcons.Comment, 16f,
TextFlag.LeftCenter );
content.Left += 24f;
PrismPaint.Text( content, comment.Title, PrismTheme.TextPrimary, 13, 600 );
if ( string.IsNullOrWhiteSpace( comment.Description ) ) return;
var body = new Rect( 10f, 46f, rect.Width - 20f, MathF.Min( 96f, rect.Height - 52f ) );
if ( body.Height <= 4f ) return;
Paint.SetFont( PrismTheme.FontFamily, 11, 400, false, true );
Paint.SetPen( PrismTheme.TextSecondary.WithAlpha( 0.8f ) );
Paint.DrawText( body, comment.Description, TextFlag.LeftTop | TextFlag.WordWrap | TextFlag.DontClip );
}
}