UI/PixelSlider.razor
@using System
@using Sandbox
@using Sandbox.UI
@inherits Panel
@namespace BlockParty
@attribute [StyleSheet( "PixelSlider.razor.scss" )]

@* A minimal, fully self-styled horizontal slider. s&box stylesheets do NOT cascade into a child
   Razor component's internals, so the engine SliderControl can't be reskinned from a parent
   stylesheet. This component owns its own DOM + stylesheet, guaranteeing the pixel/cube look.
   Drag/click handling mirrors the engine SliderControl (ScreenPosToValue on press + drag). *@
<root class="pixel-slider @(Compact ? "compact" : "") @(Glow ? "glow" : "")">
	<div @ref="TrackPanel" class="track">
		<div class="fill" style="width: @(PositionCss)%;"></div>
		<div class="thumb" style="left: @(PositionCss)%;"></div>
	</div>
</root>

@code
{
	[Parameter] public float Value { get; set; }
	[Parameter] public float Min { get; set; } = 0f;
	[Parameter] public float Max { get; set; } = 100f;
	[Parameter] public float Step { get; set; } = 1f;
	[Parameter] public Action<float> OnValueChanged { get; set; }

	// Compact removes the slider's generous vertical click-padding (for tight rows like the editor
	// grid controls). Applied as a class on our own root so it's reliable — a bare `class=` attribute
	// on the component element is NOT forwarded onto this root by s&box, so the modifier is explicit.
	[Parameter] public bool Compact { get; set; }

	// Glow adds a hover highlight on the track + thumb (matching the ReplayScrubBar timeline). Opt-in
	// so only sliders that want it (e.g. the replay speed slider) glow — passed as its own class for
	// the same reason as Compact (a bare parent `class=` is NOT forwarded onto this component's root).
	[Parameter] public bool Glow { get; set; }

	Panel TrackPanel { get; set; }

	float SliderPosition => MathX.LerpInverse( Value, Min, Max, true ) * 100f;

	// Render as a plain decimal: very small positions (e.g. 3e-08 near the left end) otherwise
	// stringify in scientific notation, which the stylesheet parser rejects ("... is not valid with
	// width"). Invariant culture so a comma-decimal locale can't emit "0,5" either.
	string PositionCss => SliderPosition.ToString( "0.###", System.Globalization.CultureInfo.InvariantCulture );

	// Screen pixel x -> clamped, step-snapped value. Same math as the engine SliderControl.
	float ScreenPosToValue( Vector2 pos )
	{
		var normalized = MathX.LerpInverse( pos.x, TrackPanel.Box.Left, TrackPanel.Box.Right, true );
		var scaled = MathX.LerpTo( Min, Max, normalized, true );
		return Step > 0 ? scaled.SnapToGrid( Step ) : scaled;
	}

	// Which button started the press is latched HERE: move events always arrive buttonless (the
	// engine creates them with button "none") and a right/middle press also makes the panel Active,
	// so without the latch a right-drag would adjust the value.
	private bool _dragging;

	protected override void OnMouseDown( MousePanelEvent e )
	{
		base.OnMouseDown( e );
		if ( e.MouseButton != MouseButtons.Left ) return; // left-only, like CustomPatternGrid
		_dragging = true;
		SetValueFromMouse();
		e.StopPropagation();
	}

	// Drag: only while the LEFT press that armed _dragging still holds this panel active.
	protected override void OnMouseMove( MousePanelEvent e )
	{
		base.OnMouseMove( e );
		if ( !HasActive ) { _dragging = false; return; } // release fallback for a missed mouse-up
		if ( !_dragging ) return;
		SetValueFromMouse();
		e.StopPropagation();
	}

	protected override void OnMouseUp( MousePanelEvent e )
	{
		base.OnMouseUp( e );
		if ( e.MouseButton == MouseButtons.Left ) _dragging = false;
	}

	void SetValueFromMouse()
	{
		if ( TrackPanel is null ) return;
		Value = ScreenPosToValue( Mouse.Position );
		OnValueChanged?.Invoke( Value );
	}

	protected override int BuildHash() => System.HashCode.Combine( Value, Min, Max );
}