Editor/EffigyEditor/EffigyNumericField.cs
using Editor;
using Effigy;
using Sandbox;
using System;
namespace Marionette.EditorTools;
/// <summary>
/// Onshape's numeric field: a box you type a number OR an expression into.
///
/// This replaces the FloatSlider that every dimension used to be, and the replacement is the
/// single biggest workflow fix in the tool. A parametric modeller whose dimensions can only be
/// dragged is not a parametric modeller - you cannot type 4, you cannot type 1/8, and you cannot
/// say "the same as the last one". Worse, most of Effigy's FloatParams declare min 0.0001 and no
/// maximum at all (see BasicFeatures.cs), so the old slider invented a 0..9999 range and gave you
/// a hundred thousand steps of travel to hit a value with.
///
/// Sliders are not gone, they are demoted: EffigyFeatureDialog pairs one alongside this field only
/// when the parameter declares finite bounds narrow enough for dragging to mean something. An
/// unbounded length gets the field alone, which is what Onshape shows.
///
/// WHERE THIS DIVERGES FROM ONSHAPE, DELIBERATELY. Onshape swaps the text between the expression
/// and its result as the field gains and loses focus. That needs focus events on LineEdit which
/// are not proven against this editor's API, so instead the evaluated result is shown in a label
/// beside the field the moment it differs from what you typed: `1/8` sits there reading `= 0.125`.
/// Same information, continuously, and it costs no API surface that RigControlEditor has not
/// already used in anger.
/// </summary>
internal sealed class EffigyNumericField : Widget
{
private readonly LineEdit _edit;
private readonly Editor.Label _readout;
/// <summary>The parameter's unit, passed through to the evaluator - "deg" makes `rad` and `°`
/// suffixes legal, anything else makes the field a plain number.</summary>
public string Unit { get; init; }
public float Min { get; init; } = float.MinValue;
public float Max { get; init; } = float.MaxValue;
/// <summary>Round to whole numbers on the way out, for IntParam.</summary>
public bool Integer { get; init; }
/// <summary>Fires only when the text parsed. A field mid-keystroke ("1/") holds the last good
/// value rather than pushing a NaN into the feature and blanking the viewport.</summary>
public Action<float> ValueEdited { get; set; }
private float _value;
public float Value => _value;
/// <summary>
/// Whether the box is the one being typed in.
///
/// For controls that re-read their value after every rebuild. The rebuild a keystroke causes
/// arrives while you are still typing, so a refresh that pushed the value back would replace
/// "4" with "4" — and setting the text moves the caret, which is how typing 48 into a field
/// becomes typing 4, then 8 somewhere else. Asking first is the difference.
/// </summary>
public bool IsEditing => _edit.IsValid() && _edit.IsFocused;
public EffigyNumericField( Widget parent, float value, string unit = null ) : base( parent )
{
Unit = unit;
_value = value;
Layout = Layout.Row();
Layout.Spacing = 6;
_edit = new LineEdit( Expression.Format( value ), this );
_edit.TextEdited += OnTextEdited;
Layout.Add( _edit, 1 );
_readout = new Editor.Label( "" ) { Color = Theme.TextControl.WithAlpha( 0.55f ) };
Layout.Add( _readout );
}
/// <summary>
/// Push a value in from outside without firing ValueEdited.
///
/// Used when a paired slider moves, and when the dialog reopens on the same feature. It must
/// not echo back out as an edit or the two controls drive each other in a loop.
/// </summary>
public void SetValue( float value )
{
_value = Clamp( value );
if ( _edit.IsValid() )
_edit.Text = Expression.Format( _value );
ShowReadout( null );
}
private void OnTextEdited( string text )
{
if ( !Expression.TryEvaluate( text, Unit, out var parsed ) )
{
// Half-typed is the common case, not an error worth shouting about - the field just
// stops agreeing with the model until it makes sense again.
ShowReadout( "?", invalid: true );
return;
}
var clamped = Clamp( parsed );
// Show the evaluated number whenever it is not literally what was typed. That covers both
// `1/8` becoming 0.125 and a value being clamped against the parameter's own limits, which
// is the case that otherwise looks like the field ignoring you.
ShowReadout( Expression.Format( clamped ) == text.Trim()
? null
: $"= {Expression.Format( clamped )}" );
if ( clamped == _value )
return;
_value = clamped;
ValueEdited?.Invoke( _value );
}
private float Clamp( float v )
{
if ( Integer )
v = MathF.Round( v );
return Math.Clamp( v, Min, Max );
}
private void ShowReadout( string text, bool invalid = false )
{
if ( !_readout.IsValid() )
return;
_readout.Text = text ?? "";
_readout.Color = invalid ? Theme.Red : Theme.TextControl.WithAlpha( 0.55f );
}
}
/// <summary>
/// Drag-to-scrub, shared by the coloured axis letters and the plain row labels.
///
/// The value is read at the START of the drag and every later position is an offset from it, never
/// an accumulation of per-frame deltas. Accumulating drifts: each step gets clamped and rounded on
/// its way through the parameter, and those roundings add up until the number no longer tracks the
/// cursor. Anchoring to where the drag began means letting go and starting again always lands on
/// the same value for the same travel.
///
/// A subclass supplies only what it paints. Min/Max default to unbounded, so a handle that does
/// not set them scrubs freely — which is exactly what an extrude Distance wants.
/// </summary>
internal abstract class EffigyScrub : Widget
{
/// <summary>Units per pixel of travel for the off-grid drag under Shift. A shade under a
/// hundredth, so a whole unit is about the width of the dialog and the common case — easing a
/// dimension a few tenths — is a short, controllable drag.</summary>
public float Sensitivity { get; init; } = 0.008f;
/// <summary>The grid a plain drag lands on, in the parameter's own units — whole numbers by
/// default, tens under Ctrl, nothing at all under Shift.
///
/// A dimension is nearly always meant to be a round number, and a continuous scrub can only
/// ever arrive at one by accident: Distance would read 1.088 when 1 was what the drag was
/// reaching for, and the only way to get the round number was to stop dragging and type it.
/// Snapping makes the plain drag say the thing you meant and leaves the exact value to Shift,
/// which is the arrangement every modeller uses (Blender's Ctrl, Fusion's snap increments).</summary>
public float Snap { get; init; } = 1f;
/// <summary>How far a plain drag may be asked to travel for one <see cref="Snap"/> step.
///
/// A snapped drag must not be slower than its own grid. At the declared sensitivity a whole
/// unit of an unbounded length is most of the dialog's width, which is a fine rate to slide
/// through decimals at and a hopeless one to count notches at — ten units would be a drag
/// across two monitors. So the snapped rate is whichever of the two is faster.</summary>
private const float PixelsPerStep = 16f;
/// <summary>The range the scrub is held to. Unbounded by default, so a length with no declared
/// maximum drags to any distance; a min-bounded parameter (a positive-only thickness) stops at
/// its floor instead of pushing a value the feature will reject.</summary>
public float Min { get; init; } = float.MinValue;
public float Max { get; init; } = float.MaxValue;
/// <summary>Reads the value as it is now, so a drag starts from wherever typing left it.</summary>
public Func<float> Value { get; set; }
/// <summary>Called on every move with the new value.</summary>
public Action<float> Dragged { get; set; }
protected bool Dragging;
private float _startValue;
private float _startX;
protected EffigyScrub( Widget parent ) : base( parent )
{
// SizeH says "this scrubs" before the first drag, which is the only hint the control can
// give that it does anything at all.
Cursor = CursorShape.SizeH;
MouseTracking = true;
TranslucentBackground = true;
NoSystemBackground = true;
}
protected override void OnMousePress( MouseEvent e )
{
if ( !e.LeftMouseButton || Value is null )
return;
Dragging = true;
_startValue = Value();
_startX = e.LocalPosition.x;
Update();
e.Accepted = true;
}
protected override void OnMouseMove( MouseEvent e )
{
if ( !Dragging )
return;
// The button coming up somewhere this widget never heard about — over the viewport, off the
// window — still ends the drag. Without this the field keeps scrubbing on a dead button.
if ( !e.ButtonState.HasFlag( MouseButtons.Left ) )
{
EndDrag();
return;
}
var travel = e.LocalPosition.x - _startX;
// Ctrl widens the grid rather than only speeding the cursor up, so coarse lands on tens the
// same way a plain drag lands on ones. Shift takes the grid away entirely.
var snap = e.HasShift ? 0f : e.HasCtrl ? Snap * 10f : Snap;
var v = snap > 0f
? MathF.Round( (_startValue + travel * MathF.Max( Sensitivity, snap / PixelsPerStep )) / snap ) * snap
: _startValue + travel * Sensitivity;
Dragged?.Invoke( Math.Clamp( v, Min, Max ) );
e.Accepted = true;
}
protected override void OnMouseReleased( MouseEvent e )
{
if ( Dragging )
EndDrag();
}
private void EndDrag()
{
Dragging = false;
Update();
}
}
/// <summary>
/// The coloured axis letter beside a Vec3 field, dragged sideways to scrub the number.
///
/// THE LETTER RATHER THAN THE FIELD. Dragging on the number itself is what Blender does, but the
/// number here is a real LineEdit you type expressions into, and a horizontal drag there already
/// means selecting text. Unity puts the scrub on the label for the same reason. The letter was
/// sitting there as decoration anyway, and it is already colour-coded to the axis it drives.
/// </summary>
internal sealed class EffigyAxisHandle : EffigyScrub
{
private readonly string _label;
private readonly Color _colour;
public EffigyAxisHandle( Widget parent, string label, Color colour ) : base( parent )
{
_label = label;
_colour = colour;
ToolTip = $"Drag to change {label} — hold Shift for decimals, Ctrl for coarse";
FixedWidth = 16f;
}
protected override void OnPaint()
{
// Brighter while it is being used or pointed at, so a scrub in progress is visible even
// though the cursor has usually left the letter by then.
var strength = Dragging ? 1f : IsUnderMouse ? 0.85f : 0.65f;
Paint.SetDefaultFont( 8f, 600 );
Paint.SetPen( _colour.WithAlpha( strength ) );
Paint.DrawText( LocalRect, _label, TextFlag.Center );
}
}
/// <summary>
/// A parameter row's label that also scrubs its number.
///
/// The scalar rows carried a dead label while a Vec3's coloured letters could be dragged, so a
/// part's position could be nudged by hand but an extrude's Distance — the number you most want to
/// feel your way to — could only be typed, or dragged on a slider that exists only when the
/// parameter declares finite bounds, which Distance does not. This puts the scrub every Vec3 axis
/// already had onto the label of any scalar row, which is where Onshape and Fusion both put "drag
/// to any distance". Left-aligned and full width because it is still the row's label; the SizeH
/// cursor and the tint under the mouse are the tell that it does more than name the row.
/// </summary>
internal sealed class EffigyScrubLabel : EffigyScrub
{
private readonly string _label;
public EffigyScrubLabel( Widget parent, string label ) : base( parent )
{
_label = label;
ToolTip = $"Drag to change {label} — hold Shift for decimals, Ctrl for coarse";
FixedWidth = 110f;
}
protected override void OnPaint()
{
var active = Dragging || IsUnderMouse;
Paint.SetDefaultFont( 8f );
Paint.SetPen( active ? Theme.Blue : Theme.TextControl.WithAlpha( 0.9f ) );
Paint.DrawText( LocalRect, _label, TextFlag.LeftCenter );
}
}