Editor/Prism/Ui/PrismPortPainter.cs

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.

Native Interop
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;
	}
}