Editor/Prism/Ui/PrismNodeUi.cs

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.

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