Editor/Tool/Subtools/ArchNumberEdit.cs
using System;
using Editor;
using Sandbox;

namespace Sunless.Architecture;

// Values step on the field's SNAP, not freely; typing still works. The drag is RATCHETED from where it was
// grabbed: a value already off the ladder is put back onto it by the first step, so 41 on a 5 degree ladder
// pulls up to 45 and down to 40 rather than losing the remainder to a rounding the instant the box is touched.
public sealed class ArchNumberEdit : LineEdit {
	const float Threshold = 3f;

	readonly float snap;
	readonly float perPixel;
	readonly string format;
	readonly Action<float> pulled;
	Vector2 pressed;
	float anchor;
	bool dragging;

	public ArchNumberEdit( string text, float snap, float perPixel, string format, Action<float> pulled ) : base( text ) {
		this.snap = MathF.Max( 0.001f, snap );
		// Free of the snap, so a field can ask for several pixels PER step: pinned to the step, one pixel was
		// one step and a 15 degree ladder flicked through a whole turn before the mouse had crossed the box.
		this.perPixel = MathF.Max( 0.001f, perPixel );
		this.format = format;
		this.pulled = pulled;

		ToolTip = $"Drag sideways to pull the value in steps of {this.snap:0.##}.";
	}

	protected override void OnMousePress( MouseEvent e ) {
		base.OnMousePress( e );

		pressed = e.LocalPosition;
		anchor = float.TryParse( Text, out var parsed ) ? parsed : 0f;
		dragging = false;
	}

	protected override void OnMouseMove( MouseEvent e ) {
		if ( (e.ButtonState & MouseButtons.Left) == 0 ) {
			base.OnMouseMove( e );
			return;
		}

		var travelled = e.LocalPosition.x - pressed.x;

		// Below the threshold it's still a click, so caret and selection stay with the base.
		if ( !dragging && MathF.Abs( travelled ) < Threshold ) {
			base.OnMouseMove( e );
			return;
		}

		dragging = true;
		Cursor = CursorShape.SizeAll;

		var value = Ratcheted( travelled * perPixel );

		Text = value.ToString( format );
		pulled?.Invoke( value );
	}

	// Whole steps off the anchor, with the first one landing on the next rung in the direction of travel. An
	// anchor already on the ladder has no remainder to give up, so it simply counts.
	float Ratcheted( float travelled ) {
		var steps = (int)(travelled / snap);

		if ( steps == 0 ) {
			return anchor;
		}

		if ( MathF.Abs( ArchGridService.Snap( anchor, snap ) - anchor ) < 0.001f ) {
			return anchor + steps * snap;
		}

		var rung = (steps > 0 ? MathF.Ceiling( anchor / snap ) : MathF.Floor( anchor / snap )) * snap;

		return rung + (steps - MathF.Sign( steps )) * snap;
	}

	protected override void OnMouseReleased( MouseEvent e ) {
		base.OnMouseReleased( e );

		dragging = false;
		Cursor = CursorShape.Arrow;
	}
}