UI helper for the Prism editor. Defines a PortVisual value type carrying visual state for a socket, and provides static paint routines to draw port handles, labels, group headers, collapsed dots, and measure label width using PrismPaint and PrismTheme.
namespace Editor.Prism.Ui;
/// <summary>
/// Everything the card painter needs to know to draw one socket. Assembled by
/// <c>PrismNodeUi</c> from the model and the framework's hover state, so the painter itself stays a
/// pure function of its inputs and can be unit-eyeballed against a swatch sheet.
/// </summary>
public readonly record struct PortVisual
{
/// <summary>The port's resolved type colour.</summary>
public Color Color { get; init; }
/// <summary>True when something is wired to this socket.</summary>
public bool Connected { get; init; }
/// <summary>True when leaving the socket unconnected is an error.</summary>
public bool Required { get; init; }
/// <summary>True when the type is still a variable the solver has not pinned down.</summary>
public bool Generic { get; init; }
/// <summary>True while the pointer is over the socket.</summary>
public bool Hovered { get; init; }
/// <summary>True when a diagnostic names this port.</summary>
public bool Error { get; init; }
/// <summary>Global opacity, used to dim unreachable nodes.</summary>
public float Alpha { get; init; }
/// <summary>How far through a rejection flash the handle is, from one down to zero.</summary>
public float Flash { get; init; }
}
/// <summary>
/// Draws port handles and labels.
/// <para>
/// The built-in <c>Plug</c> is set <c>Visible = false</c> and its <c>ShowLabel</c> returns false, so it
/// draws nothing at all while remaining hit-testable and while still feeding correct endpoints to
/// connections. Every pixel of every socket comes from here instead, which is what makes a bespoke card
/// possible without forking the node-graph library.
/// </para>
/// </summary>
public static class PrismPortPainter
{
/// <summary>The radius a handle is drawn at, given its state.</summary>
public static float RadiusFor( in PortVisual visual ) =>
visual.Hovered ? PrismTheme.HandleHoverDiameter * 0.5f : PrismTheme.HandleRadius;
/// <summary>
/// One socket handle: a filled circle when connected, a hollow ring when not, a dotted ring when the
/// type is generic, and an error-coloured ring when a required input is empty.
/// </summary>
public static void DrawHandle( Vector2 center, in PortVisual visual )
{
var alpha = Math.Clamp( visual.Alpha, 0f, 1f );
var radius = RadiusFor( visual );
var color = visual.Color;
if ( visual.Error || ( visual.Required && !visual.Connected ) ) color = PrismTheme.Error;
if ( visual.Flash > 0f ) color = Color.Lerp( color, PrismTheme.Error, visual.Flash );
color = color.WithAlpha( color.a * alpha );
if ( visual.Hovered )
{
// A soft halo, so the grow-on-hover reads even against a busy background.
PrismPaint.Dot( center, radius + 3.5f, color.WithAlpha( 0.25f * alpha ) );
}
// The ring in the canvas colour is what keeps a handle legible when a wire passes behind it.
PrismPaint.Dot( center, radius + PrismTheme.HandleRing, PrismTheme.Canvas.WithAlpha( alpha ) );
if ( visual.Generic && !visual.Connected )
{
PrismPaint.Ring( center, radius - 0.75f, color, 2f, PenStyle.Dot );
return;
}
if ( visual.Connected )
{
PrismPaint.Dot( center, radius, color );
if ( visual.Flash > 0f ) PrismPaint.Ring( center, radius + 2f, color, 1.5f );
return;
}
PrismPaint.Ring( center, radius - 1f, color, 2f );
}
/// <summary>
/// A socket label. An optional group prefix is drawn in muted italics ahead of the name, matching
/// the built-in editor's grammar so a user coming from ShaderGraph is not surprised.
/// </summary>
public static void DrawLabel( Rect rect, string text, string group, Color color, bool rightAligned,
float alpha = 1f )
{
if ( rect.Width <= 2f ) return;
var flags = rightAligned ? TextFlag.RightCenter : TextFlag.LeftCenter;
color = color.WithAlpha( color.a * Math.Clamp( alpha, 0f, 1f ) );
if ( !string.IsNullOrEmpty( group ) )
{
var prefix = $"{group} › ";
var width = PrismPaint.MeasureText( prefix, PrismTheme.PortLabelSize, PrismTheme.PortLabelWeight, true );
if ( width < rect.Width * 0.6f )
{
var prefixRect = rightAligned
? new Rect( rect.Left, rect.Top, rect.Width, rect.Height )
: rect;
PrismPaint.Text( prefixRect, prefix, PrismTheme.TextMuted.WithAlpha( alpha ),
PrismTheme.PortLabelSize, PrismTheme.PortLabelWeight,
rightAligned ? TextFlag.RightCenter : TextFlag.LeftCenter, true );
if ( rightAligned ) rect = rect.Shrink( 0f, 0f, width, 0f );
else rect = rect.Shrink( width, 0f, 0f, 0f );
}
}
PrismPaint.Text( rect, text, color, PrismTheme.PortLabelSize, PrismTheme.PortLabelWeight, flags );
}
/// <summary>
/// A group heading drawn between two runs of sockets.
/// <para>
/// <paramref name="caption"/> is drawn verbatim — the caller supplies it already upper-cased, because
/// this runs on the paint path and <c>ToUpperInvariant</c> allocates a string every time, while the
/// card's layout has to produce the same text anyway in order to measure the column.
/// </para>
/// </summary>
public static void DrawGroupHeader( Rect rect, string caption, bool rightAligned, float alpha = 1f )
{
if ( string.IsNullOrEmpty( caption ) || rect.Width <= 2f ) return;
var flags = rightAligned ? TextFlag.RightCenter : TextFlag.LeftCenter;
PrismPaint.Text( rect, caption, PrismTheme.TextMuted.WithAlpha( alpha ),
PrismTheme.GroupHeaderSize, PrismTheme.GroupHeaderWeight, flags );
// A rule filling whatever the caption did not use, on the side the sockets are not on.
var captionWidth = PrismPaint.MeasureText( caption, PrismTheme.GroupHeaderSize,
PrismTheme.GroupHeaderWeight ) + 6f;
var line = rightAligned
? new Rect( rect.Left, rect.Center.y, MathF.Max( 0f, rect.Width - captionWidth ), 1f )
: new Rect( rect.Left + captionWidth, rect.Center.y, MathF.Max( 0f, rect.Width - captionWidth ), 1f );
if ( line.Width <= 2f ) return;
PrismPaint.Divider( line, PrismTheme.BorderSubtle.WithAlpha( 0.7f * alpha ) );
}
/// <summary>
/// The compact dot a collapsed card shows instead of a socket row. Connected ports are filled, the
/// rest are hollow, so a collapsed node still says how much is wired into it.
/// </summary>
public static void DrawCollapsedDot( Vector2 center, Color color, bool connected, float alpha = 1f )
{
color = color.WithAlpha( color.a * Math.Clamp( alpha, 0f, 1f ) );
PrismPaint.Dot( center, 3.5f, PrismTheme.Canvas.WithAlpha( alpha ) );
if ( connected ) PrismPaint.Dot( center, 2.5f, color );
else PrismPaint.Ring( center, 2f, color, 1.25f );
}
/// <summary>How wide a socket label wants to be, including its group prefix.</summary>
public static float MeasureLabel( string text, string group )
{
var width = PrismPaint.MeasureText( text, PrismTheme.PortLabelSize, PrismTheme.PortLabelWeight );
if ( !string.IsNullOrEmpty( group ) )
{
width += PrismPaint.MeasureText( $"{group} › ", PrismTheme.PortLabelSize,
PrismTheme.PortLabelWeight, true );
}
return width;
}
}