UI/Components/Vector3Field/Vector3FieldCell.razor

A UI component Razor file for a Vector3 field axis cell. It renders a label and either a readout or an editable TextEntry, and implements mouse scrubbing (drag to change value), double-click to edit, fine-scrub when a modifier action is held, and commits edits via a setter callback.

Native Interop
@namespace Sunless.Libraries.UI
@using Sandbox
@using Sandbox.UI
@attribute [StyleSheet]
@inherits Panel

<root class="v3-cell axis-@AxisLower @(_scrubbing ? "is-scrubbing" : "") @(_editing ? "is-editing" : "")">
	<div class="v3-tag">
		@if ( _scrubbing )
		{
			<text>&lt;&gt;</text>
		}
		else
		{
			<text>@Axis</text>
		}
	</div>

	@if ( _editing )
	{
		<TextEntry @ref=_entry Numeric=@true NumberFormat="@Format" Text="@_editBuffer" OnTextEdited="@OnEditBufferChanged" />
	}
	else
	{
		<div class="v3-readout">@Value.ToString(Format)</div>
	}
</root>

@code
{
	public string Axis { get; set; } = "X";
	public float Value { get; set; }
	public string Format { get; set; } = "0.###";
	public float Min { get; set; } = float.MinValue;
	public float Max { get; set; } = float.MaxValue;
	public Action<float> Setter { get; set; }

	// Pixels per 1.0 of value change. Calibrated for integer position ranges;
	// pass higher for 0..1 normalized ranges.
	public float PixelsPerUnit { get; set; } = 4f;

	// Action name the project's input system has bound to "fine scrubbing"
	// (Shift / Walk in most s&box setups). Empty disables fine-scrub.
	public string FineScrubAction { get; set; } = "Run";

	TextEntry _entry;

	bool _armed;
	bool _scrubbing;
	bool _editing;
	string _editBuffer = "";
	bool _entryEverFocused;
	Vector2 _downPos;
	float _valueAtDown;

	static Vector3FieldCell _activeScrubber;
	public static bool IsAnyScrubbing => _activeScrubber is not null;

	string AxisLower => Axis?.ToLowerInvariant() ?? "x";

	const float DragThresholdPx = 4f;
	const float FineMultiplier = 8f;

	protected override void OnMouseDown( MousePanelEvent e )
	{
		base.OnMouseDown( e );
		if ( e.MouseButton != MouseButtons.Left ) return;
		if ( _editing ) return;

		_armed = true;
		_scrubbing = false;
		_downPos = Mouse.Position;
		_valueAtDown = Value;
		e.StopPropagation();
	}

	protected override void OnDoubleClick( MousePanelEvent e )
	{
		base.OnDoubleClick( e );
		if ( e.MouseButton != MouseButtons.Left ) return;
		if ( _editing ) return;
		EnterEditMode();
		e.StopPropagation();
	}

	protected override void OnMouseMove( MousePanelEvent e )
	{
		base.OnMouseMove( e );
		if ( !_armed ) return;
		if ( !HasActive ) return;

		var delta = Mouse.Position - _downPos;

		if ( !_scrubbing )
		{
			if ( delta.Length <= DragThresholdPx ) return;
			_scrubbing = true;
			_activeScrubber = this;
			StateHasChanged();
		}

		var fine = !string.IsNullOrEmpty( FineScrubAction ) && Input.Down( FineScrubAction );
		var perUnit = PixelsPerUnit * (fine ? FineMultiplier : 1f);
		var v = (_valueAtDown + delta.x / perUnit).Clamp( Min, Max );
		if ( v != Value )
		{
			Value = v;
			Setter?.Invoke( v );
		}
		e.StopPropagation();
	}

	protected override void OnMouseUp( MousePanelEvent e )
	{
		base.OnMouseUp( e );
		if ( !_armed ) return;

		var wasScrubbing = _scrubbing;
		EndScrub();

		if ( wasScrubbing ) e.StopPropagation();
	}

	public override void Tick()
	{
		base.Tick();
		if ( _armed && !HasActive )
		{
			EndScrub();
		}
		if ( _editing && _entry is not null )
		{
			if ( Sandbox.UI.InputFocus.Current == _entry )
			{
				_entryEverFocused = true;
			}
			else if ( _entryEverFocused )
			{
				CommitAndExit();
			}
		}
	}

	void EndScrub()
	{
		if ( !_armed && !_scrubbing ) return;
		_armed = false;
		if ( _scrubbing )
		{
			_scrubbing = false;
			if ( _activeScrubber == this ) _activeScrubber = null;
			StateHasChanged();
		}
	}

	void EnterEditMode()
	{
		_editBuffer = Value.ToString( Format );
		_editing = true;
		_entryEverFocused = false;
		StateHasChanged();
	}

	void ExitEditMode()
	{
		_editing = false;
		StateHasChanged();
	}

	void OnEditBufferChanged( string s )
	{
		_editBuffer = s;
	}

	void CommitAndExit()
	{
		if ( float.TryParse( _editBuffer, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var v ) )
		{
			v = v.Clamp( Min, Max );
			Value = v;
			Setter?.Invoke( v );
		}
		ExitEditMode();
	}

	public override void OnDeleted()
	{
		if ( _activeScrubber == this ) _activeScrubber = null;
		base.OnDeleted();
	}

	protected override int BuildHash() => HashCode.Combine( Axis, Value, Format, _scrubbing, _editing );
}