swb_editor/components/Slider.cs
using Sandbox.UI;
using Sandbox.UI.Construct;
using System;

namespace SWB.Editor;

/// <summary>
/// A horizontal slider. Can be float or whole number.
/// </summary>
public class Slider : Panel
{
	public Panel Track { get; protected set; }
	public Panel TrackInner { get; protected set; }
	public Panel Thumb { get; protected set; }
	public Label Name { get; protected set; }
	public float Sensitivity { get; protected set; } = 1;

	/// <summary>
	/// The right side of the slider
	/// </summary>
	public float MaxValue { get; set; } = 100;

	/// <summary>
	/// The left side of the slider
	/// </summary>
	public float MinValue { get; set; } = 0;

	/// <summary>
	/// If set to 1, value will be rounded to 1's
	/// If set to 10, value will be rounded to 10's
	/// If set to 0.1, value will be rounded to 0.1's
	/// </summary>
	public float Step { get; set; } = 1.0f;

	public Slider()
	{
		StyleSheet.Load( "/swb_editor/components/Slider.cs.scss" );

		Name = Add.Label( null, "name" );

		var sliderContainer = Add.Panel( "sliderContainer" );

		Track = sliderContainer.Add.Panel( "track" );
		TrackInner = Track.Add.Panel( "inner" );

		Thumb = sliderContainer.Add.Panel( "thumb" );
	}

	protected float _value = float.MaxValue;

	/// <summary>
	/// The actual value. Setting the value will snap and clamp it.
	/// </summary>
	public float Value
	{
		get => _value.Clamp( MinValue, MaxValue );
		set
		{
			var snapped = Step > 0 ? value.SnapToGrid( Step ) : value;
			snapped = snapped.Clamp( MinValue, MaxValue );

			if ( _value == snapped ) return;

			_value = snapped;

			CreateEvent( "onchange" );
			CreateValueEvent( "value", _value );
			UpdateSliderPositions();
		}
	}

	public override void SetProperty( string name, string value )
	{
		if ( name == "min" && float.TryParse( value, out var floatValue ) )
		{
			MinValue = floatValue;
			UpdateSliderPositions();
			return;
		}

		if ( name == "step" && float.TryParse( value, out floatValue ) )
		{
			Step = floatValue;
			UpdateSliderPositions();
			return;
		}

		if ( name == "max" && float.TryParse( value, out floatValue ) )
		{
			MaxValue = floatValue;
			UpdateSliderPositions();
			return;
		}

		if ( name == "value" && float.TryParse( value, out floatValue ) )
		{
			Value = floatValue;
			return;
		}

		if ( name == "name" )
		{
			Name.Text = value;
			Name.Style.Display = DisplayMode.Flex;
			return;
		}

		if ( name == "sensitivity" && float.TryParse( value, out floatValue ) )
		{
			Sensitivity = floatValue;
		}

		base.SetProperty( name, value );
	}

	/// <summary>
	/// Convert a screen position to a value. The value is clamped, but not snapped.
	/// </summary>
	public virtual float ScreenPosToValue( Vector2 pos )
	{
		var localPos = ScreenPositionToPanelPosition( pos );
		var thumbSize = Thumb.Box.Rect.Width * 0.5f;
		var normalized = MathX.LerpInverse( localPos.x, thumbSize, (Box.Rect.Width - thumbSize), true );
		var scaled = MathX.LerpTo( MinValue, MaxValue, normalized, true );
		return Step > 0 ? scaled.SnapToGrid( Step ) : scaled;
	}

	/// <summary>
	/// If we move the mouse while we're being pressed then set the position,
	/// but skip transitions.
	/// </summary>
	protected override void OnMouseMove( MousePanelEvent e )
	{
		base.OnMouseMove( e );

		if ( !HasActive ) return;

		Value = ScreenPosToValue( Mouse.Position );

		UpdateSliderPositions();
		SkipTransitions();
		e.StopPropagation();
	}

	/// <summary>
	/// On mouse press jump to that position
	/// </summary>
	protected override void OnMouseDown( MousePanelEvent e )
	{
		base.OnMouseDown( e );
		Value = ScreenPosToValue( Mouse.Position );
		UpdateSliderPositions();
		e.StopPropagation();
	}

	int positionHash;

	/// <summary>
	/// Updates the styles for TrackInner and Thumb to position us based on the current value.
	/// Note this purposely uses percentages instead of pixels when setting up, this way we don't
	/// have to worry about parent size, screen scale etc.
	/// </summary>
	void UpdateSliderPositions()
	{
		var hash = HashCode.Combine( Value, MinValue, MaxValue );
		if ( hash == positionHash ) return;

		positionHash = hash;

		var pos = MathX.LerpInverse( Value, MinValue, MaxValue, true );

		TrackInner.Style.Width = Length.Fraction( pos );
		Thumb.Style.Left = Length.Fraction( pos );

		TrackInner.Style.Dirty();
		Thumb.Style.Dirty();
	}

}

public static class SliderConstructor
{
	public static Slider Slider( this PanelCreator self, float min, float max, float step, string name = "" )
	{
		var control = self.panel.AddChild<Slider>();
		control.MinValue = min;
		control.MaxValue = max;
		control.Step = step;
		control.Name.Text = name;

		return control;
	}
}