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 );
}