Editor/Prism/Ui/InlineEditors/VectorInlineEditor.cs

An inline UI editor component for vector-like ports in the editor. It draws a pill with 2–4 scrubbable numeric fields (X,Y,Z,W), handles mouse drag to change component values, formats and reads/writes values via a ValueCodec and commits changes to the bound plug/port.

Native Interop
using Editor.Prism.Core;
using Editor.Prism.Serialization;
using Editor.Prism.Ui.Adapters;

namespace Editor.Prism.Ui.InlineEditors;

/// <summary>
/// Two to four scalars in one pill, each field individually scrubbable.
/// <para>
/// The component letters are drawn in the type colour rather than as plain text, because a
/// <c>float3</c> pill next to a <c>float2</c> pill has to be distinguishable without counting fields.
/// </para>
/// </summary>
public sealed class VectorInlineEditor : PrismInlineEditor
{
	static readonly string[] s_labels = { "X", "Y", "Z", "W" };

	int _dragging = -1;
	float _lastX;

	/// <summary>Bind a vector editor to a port.</summary>
	public VectorInlineEditor( Plug plug, PrismPlugIn port ) : base( plug, port )
	{
		Cursor = CursorShape.SizeH;
	}

	/// <summary>How many components the port's resolved type has, clamped to what we can draw.</summary>
	public int Components => Math.Clamp( Port?.EffectiveType.Components ?? 2, 2, 4 );

	/// <summary>True when the components are whole numbers.</summary>
	public bool IsInteger => Port?.EffectiveType.IsIntegral ?? false;

	/// <summary>Width of one component field.</summary>
	float FieldWidth => IsInteger ? 30f : 38f;

	/// <inheritdoc/>
	protected override float MeasureWidth() => Components * FieldWidth + 4f;

	/// <inheritdoc/>
	protected override void OnPaintPill( Rect pill )
	{
		var hovered = Paint.HasMouseOver || _dragging >= 0;
		var background = hovered ? PrismTheme.Elevated.Lighten( 0.12f ) : PrismTheme.Elevated;

		PrismPaint.Pill( pill, background, PrismTheme.BorderSubtle.WithAlpha( hovered ? 0.9f : 0.55f ),
			PrismTheme.RadiusChip );

		Paint.ClearPen();
		Paint.SetBrush( TypeColor.WithAlpha( hovered ? 1f : 0.8f ) );
		Paint.DrawRect( new Rect( pill.Left, pill.Top, 2f, pill.Height ), 1f );

		var components = Read();
		var count = Components;

		for ( int i = 0; i < count; i++ )
		{
			var field = FieldRect( pill, i );

			if ( i > 0 )
			{
				Paint.ClearBrush();
				Paint.SetPen( PrismTheme.BorderSubtle.WithAlpha( 0.7f ), 1f );
				Paint.DrawLine( new Vector2( field.Left, pill.Top + 3f ), new Vector2( field.Left, pill.Bottom - 3f ) );
			}

			var active = _dragging == i;
			var label = field.Shrink( 3f, 0f, 0f, 0f );

			PrismPaint.Text( label, s_labels[i], TypeColor.WithAlpha( active ? 1f : 0.7f ),
				PrismTheme.InlineValueSize - 1, 600, TextFlag.LeftCenter );

			PrismPaint.Text( field.Shrink( 10f, 0f, 4f, 0f ), Format( components, i ),
				active || hovered ? PrismTheme.TextPrimary : PrismTheme.TextSecondary,
				PrismTheme.InlineValueSize, PrismTheme.InlineValueWeight, TextFlag.RightCenter );
		}
	}

	/// <inheritdoc/>
	protected override void OnMousePressed( GraphicsMouseEvent e )
	{
		if ( !ShouldDraw || !e.LeftMouseButton ) return;

		var pill = PillRect;

		if ( !pill.Grow( 3f ).IsInside( e.LocalPosition ) ) return;

		_dragging = IndexAt( pill, e.LocalPosition );
		_lastX = e.ScreenPosition.x;

		e.Accepted = true;
	}

	/// <inheritdoc/>
	protected override void OnMouseMove( GraphicsMouseEvent e )
	{
		if ( _dragging < 0 ) return;

		var delta = e.ScreenPosition.x - _lastX;

		_lastX = e.ScreenPosition.x;

		var scale = e.HasCtrl ? 0.001f : e.HasShift ? 0.1f : 0.01f;
		var components = Read();

		if ( _dragging >= components.Length ) return;

		components[_dragging] += IsInteger ? MathF.Round( delta * 0.25f ) : delta * scale;

		if ( IsInteger ) components[_dragging] = MathF.Round( components[_dragging] );

		Write( components );

		e.Accepted = true;
	}

	/// <inheritdoc/>
	protected override void OnMouseReleased( GraphicsMouseEvent e )
	{
		if ( _dragging < 0 ) return;

		_dragging = -1;
		e.Accepted = true;

		Update();
	}

	Rect FieldRect( Rect pill, int index )
	{
		var width = ( pill.Width - 3f ) / Components;

		return new Rect( pill.Left + 3f + width * index, pill.Top, width, pill.Height );
	}

	int IndexAt( Rect pill, Vector2 local )
	{
		var width = ( pill.Width - 3f ) / Components;
		var index = (int)MathF.Floor( ( local.x - pill.Left - 3f ) / MathF.Max( 1f, width ) );

		return Math.Clamp( index, 0, Components - 1 );
	}

	string Format( float[] components, int index )
	{
		if ( components is null || index >= components.Length ) return "0";

		var value = components[index];

		if ( IsInteger ) return ( (int)MathF.Round( value ) ).ToString();

		return MathF.Abs( value ) >= 100f ? value.ToString( "0.#" ) : value.ToString( "0.##" );
	}

	float[] Read()
	{
		var count = Components;
		var source = ValueCodec.ToFloats( Value ) ?? Array.Empty<float>();
		var result = new float[count];

		for ( int i = 0; i < count; i++ )
		{
			result[i] = i < source.Length ? source[i] : 0f;
		}

		return result;
	}

	void Write( float[] components ) =>
		Commit( Shape( ValueCodec.ToComponents( components, Components ) ) );
}