Editor/Prism/Ui/InlineEditors/InlineEditorFactory.cs

Editor UI code for Prism inline value editors. It defines InlineRange helper, an abstract PrismInlineEditor that draws and commits small value "pills" for plugs, and InlineEditorFactory which chooses and creates the appropriate inline editor (enum, asset, float, vector, color, bool) based on port metadata and bound CLR properties.

Reflection
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Serialization;
using Editor.Prism.Ui.Adapters;
using System.Reflection;

namespace Editor.Prism.Ui.InlineEditors;

/// <summary>Everything an inline editor needs to know about the range a value is allowed to take.</summary>
public readonly record struct InlineRange( bool Defined, float Min, float Max, float Step )
{
	/// <summary>No declared range: the editor drags freely instead of showing a slider.</summary>
	public static readonly InlineRange None = new( false, 0f, 1f, 0f );

	/// <summary>Where a value sits in the range, from zero to one.</summary>
	public float Fraction( float value ) =>
		Max - Min <= float.Epsilon ? 0f : Math.Clamp( ( value - Min ) / ( Max - Min ), 0f, 1f );

	/// <summary>The value at a fraction of the range, snapped to the step.</summary>
	public float Value( float fraction )
	{
		var value = Min + ( Max - Min ) * Math.Clamp( fraction, 0f, 1f );

		return Snap( value );
	}

	/// <summary>Clamp to the range, when there is one, and snap to the step, when there is one.</summary>
	public float Snap( float value )
	{
		if ( Step > 0f ) value = MathF.Round( value / Step ) * Step;
		if ( Defined ) value = Math.Clamp( value, Min, Max );

		return value;
	}
}

/// <summary>
/// The base every Prism inline value editor is built on.
/// <para>
/// The framework parents a <see cref="ValueEditor"/> to its plug and gives it a sliver of space inside
/// the card. Prism draws its value pills <em>outside</em> the card instead, to the left of the handle,
/// so the card stays a clean rectangle and long values do not force every node wider. That is done by
/// overriding <see cref="BoundingRect"/> — a graphics item may paint anywhere inside its bounds, and
/// the bounds are ours to declare — and calling <c>PrepareGeometryChange()</c> whenever the pill
/// resizes, without which the scene keeps the stale bounds and the pill smears.
/// </para>
/// </summary>
public abstract class PrismInlineEditor : ValueEditor
{
	Rect _bounds;
	float _width = 40f;

	/// <summary>Bind an editor to a plug.</summary>
	protected PrismInlineEditor( Plug plug, PrismPlugIn port ) : base( plug )
	{
		Plug = plug;
		Port = port;

		HoverEvents = true;
		Cursor = CursorShape.Finger;
		ZIndex = 1f;

		_bounds = new Rect( -60f, -6f, 70f, 32f );
	}

	/// <summary>The framework plug this editor is attached to.</summary>
	public Plug Plug { get; }

	/// <summary>The Prism input port this editor edits.</summary>
	public PrismPlugIn Port { get; }

	/// <summary>Height of the value pill.</summary>
	protected virtual float PillHeight => 16f;

	/// <summary>Gap between the pill and the port handle, filled by the connecting stub.</summary>
	protected virtual float StubLength => 8f;

	/// <inheritdoc/>
	public override Rect BoundingRect => _bounds;

	/// <inheritdoc/>
	public override bool HideLabel => true;

	/// <summary>The colour of this port's type, which tints the pill's edge and its stub.</summary>
	protected Color TypeColor => Port?.TypeColor ?? PrismTheme.TypeGeneric;

	/// <summary>The current literal on the port.</summary>
	protected object Value => Port?.InlineValue;

	/// <summary>The declared range of the bound property, when it has one.</summary>
	protected InlineRange Range => InlineEditorFactory.RangeOf( Port );

	/// <summary>True when the pill should be drawn at all.</summary>
	protected bool ShouldDraw => Enabled && Port is not null && !Port.IsConnectedInDocument;

	/// <summary>The pill rectangle in this item's local space, left of the handle.</summary>
	protected Rect PillRect => new( -StubLength - _width, ( 20f - PillHeight ) * 0.5f, _width, PillHeight );

	/// <summary>Width the pill wants. Recomputed every paint so a value change resizes it immediately.</summary>
	protected abstract float MeasureWidth();

	/// <summary>Draw the pill. The base has already handled visibility, geometry and the stub.</summary>
	protected abstract void OnPaintPill( Rect pill );

	/// <inheritdoc/>
	protected override void OnPaint()
	{
		if ( !ShouldDraw ) return;

		var width = MathF.Max( 18f, MeasureWidth() );

		if ( MathF.Abs( width - _width ) > 0.5f )
		{
			_width = width;
			UpdateBounds();
		}

		var pill = PillRect;

		PrismPaint.Stub( new Vector2( pill.Right, pill.Center.y ), new Vector2( 1f, pill.Center.y ),
			TypeColor.WithAlpha( 0.85f ), 2f );

		OnPaintPill( pill );
	}

	/// <summary>Write a new value to the port through the mutation API, as one undoable step.</summary>
	protected void Commit( object value )
	{
		Port?.SetInlineValue( value );

		PrismLog.Guard( "Inline value changed", () =>
		{
			Plug?.Node?.Graph?.ChildValuesChanged( null );
			Plug?.Node?.Update();
		} );

		Update();
	}

	/// <summary>Recompute the bounding rect from the current pill width.</summary>
	protected void UpdateBounds()
	{
		PrepareGeometryChange();

		var pill = PillRect;

		// Stop short of the handle so the pill never steals a click meant for the socket.
		_bounds = new Rect( pill.Left - 4f, pill.Top - 6f, ( 1f - pill.Left ) + 4f, pill.Height + 12f );

		Update();
	}

	/// <summary>
	/// Reshape a value into the canonical form for the port's type before storing it — but leave it
	/// alone when the type is still unresolved, because coercing to <c>void</c> flattens a vector to a
	/// single float and silently loses three components.
	/// </summary>
	protected object Shape( object value )
	{
		var type = Port?.EffectiveType ?? ShaderType.Void;

		return type.IsVoid ? value : ValueCodec.Coerce( value, type );
	}

	/// <summary>Convenience: the value as a float, or zero.</summary>
	protected float AsFloat()
	{
		var components = ValueCodec.ToFloats( Value );

		return components is { Length: > 0 } ? components[0] : 0f;
	}
}

/// <summary>
/// Picks the right inline editor for a port.
/// <para>
/// The choice is made from the port's <em>resolved</em> type where possible, so a generic port that the
/// solver has decided is a <c>float3</c> gets three fields rather than one, and from the bound CLR
/// property where the shader type cannot say enough — an <c>enum</c> is an <c>int</c> to a shader and a
/// dropdown to a human.
/// </para>
/// </summary>
public static class InlineEditorFactory
{
	/// <summary>Create the inline editor a port deserves, or null when it should not have one.</summary>
	public static ValueEditor Create( NodeUI node, Plug plug, PrismPlugIn port )
	{
		if ( node is null || plug is null || port is null ) return null;

		return PrismLog.Guard<ValueEditor>( "Create inline editor", () => Build( plug, port ), null );
	}

	static ValueEditor Build( Plug plug, PrismPlugIn port )
	{
		var def = port.Def;

		if ( def is null ) return null;
		if ( ( def.Flags & PortFlags.NoInlineEditor ) != 0 ) return null;
		if ( ( def.Flags & PortFlags.Variadic ) != 0 ) return null;

		var property = BoundProperty( port );

		if ( property is not null && property.PropertyType.IsEnum )
		{
			return new EnumInlineEditor( plug, port, property.PropertyType );
		}

		var type = port.EffectiveType;

		if ( type.IsObject )
		{
			// Textures, samplers and buffers are picked, not typed.
			return type.IsSampler ? null : new AssetInlineEditor( plug, port );
		}

		if ( type.IsVoid ) return null;
		if ( type.IsMatrix ) return null;
		if ( type.IsStruct ) return null;

		if ( type.IsBoolean && type.Components <= 1 ) return new BoolInlineEditor( plug, port );

		if ( port.IsColor || Value( port ) is Color ) return new ColorInlineEditor( plug, port );

		if ( type.Components <= 1 ) return new FloatInlineEditor( plug, port );

		return new VectorInlineEditor( plug, port );
	}

	static object Value( PrismPlugIn port ) => port.InlineValue;

	/// <summary>The CLR property a port's inline literal is bound to, when it is bound to one.</summary>
	public static PropertyInfo BoundProperty( PrismPlugIn port )
	{
		var node = port?.Owner?.PrismNode;
		var def = port?.Def;

		if ( node is null || def is null ) return null;

		var name = !string.IsNullOrEmpty( def.InlineValueProperty ) ? def.InlineValueProperty : def.PropertyName;

		if ( string.IsNullOrEmpty( name ) ) return null;

		return NodeProperties.Find( node.GetType(), name );
	}

	/// <summary>
	/// The string property holding the asset an object port references, when the node keeps one.
	/// <remarks>
	/// A texture node stores the image it samples in <c>DefaultTexture</c> and only reads its
	/// <c>Texture</c> port when something is wired into it, so the port's inline slot is empty by design
	/// and the card's asset chip had nothing to show — over a node that was sampling the texture
	/// perfectly well, and previewing it correctly. The same convention already drives dropping an asset
	/// onto a node, so the same name list drives both.
	/// <para>
	/// Not expressed as <c>[InlineValue]</c>: that enrols a property in the inline machinery, and
	/// <c>ResetInline</c> puts every inline property back to its prototype on load and on undo restore,
	/// which silently wiped the path out of every imported document.
	/// </para>
	/// </remarks>
	/// </summary>
	public static PropertyInfo AssetProperty( PrismPlugIn port )
	{
		var node = port?.Owner?.PrismNode;

		if ( node is null || port.Def is null ) return null;
		if ( !port.EffectiveType.IsObject || port.EffectiveType.IsSampler ) return null;

		// Already bound the ordinary way: that binding wins and this must not shadow it.
		if ( !string.IsNullOrEmpty( port.Def.InlineValueProperty ) ) return null;

		foreach ( var name in s_assetProperties )
		{
			if ( NodeProperties.Find( node.GetType(), name ) is not { } property ) continue;
			if ( property.PropertyType != typeof( string ) || !property.CanWrite ) continue;

			return property;
		}

		return null;
	}

	/// <summary>
	/// Property names an object port's asset is looked for under, most specific first. Kept in step with
	/// the drop handler in <c>PrismGraphView</c>, which resolves the same thing for the same reason.
	/// </summary>
	static readonly string[] s_assetProperties =
		["DefaultTexture", "SubgraphPath", "Image", "Asset", "Source"];

	/// <summary>
	/// The range declared on a port's bound property with the engine's <c>[Range]</c> attribute, which
	/// is what turns a bare number field into a slider.
	/// </summary>
	public static InlineRange RangeOf( PrismPlugIn port )
	{
		var property = BoundProperty( port );

		if ( property is null ) return InlineRange.None;

		var range = PrismLog.Guard<RangeAttribute>( "Read [Range]",
			() => property.GetCustomAttribute<RangeAttribute>(), null );

		if ( range is null )
		{
			return property.PropertyType == typeof( int ) || property.PropertyType == typeof( uint )
				? new InlineRange( false, 0f, 1f, 1f )
				: InlineRange.None;
		}

		var step = property.PropertyType == typeof( int ) || property.PropertyType == typeof( uint ) ? 1f : 0f;

		return new InlineRange( range.Slider && range.Max > range.Min, range.Min, range.Max, step );
	}
}