adui_dioxus/components/
slider_base.rs

1//! Shared slider math and keyboard/pointer helpers.
2//!
3//! This stays UI-agnostic so multiple slider-like components (Slider, Rate, ColorPicker) can
4//! share the same value math, orientation handling and accessibility behaviors.
5
6use crate::components::number_utils::{clamp, round_with_precision};
7use dioxus::prelude::Key;
8
9/// Orientation of the slider track.
10#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
11pub enum SliderOrientation {
12    #[default]
13    Horizontal,
14    Vertical,
15}
16
17impl SliderOrientation {
18    pub fn is_vertical(self) -> bool {
19        matches!(self, SliderOrientation::Vertical)
20    }
21}
22
23/// Core numeric configuration for slider-like controls.
24#[derive(Clone, Copy, Debug, PartialEq)]
25pub struct SliderMath {
26    pub min: f64,
27    pub max: f64,
28    /// Step granularity; when `None`, treat as continuous.
29    pub step: Option<f64>,
30    /// Optional decimal precision enforced on output.
31    pub precision: Option<u32>,
32    /// When true, visual direction is reversed (RTL / vertical top-down).
33    pub reverse: bool,
34    pub orientation: SliderOrientation,
35}
36
37impl Default for SliderMath {
38    fn default() -> Self {
39        Self {
40            min: 0.0,
41            max: 100.0,
42            step: Some(1.0),
43            precision: None,
44            reverse: false,
45            orientation: SliderOrientation::Horizontal,
46        }
47    }
48}
49
50impl SliderMath {
51    /// Ensure min/max ordering is valid by swapping if needed.
52    pub fn normalized(self) -> Self {
53        if self.max >= self.min {
54            self
55        } else {
56            Self {
57                min: self.max,
58                max: self.min,
59                ..self
60            }
61        }
62    }
63
64    pub fn range(&self) -> f64 {
65        (self.max - self.min).abs().max(f64::EPSILON)
66    }
67}
68
69/// Clamp and snap a value to step/precision within min/max.
70pub fn snap_value(value: f64, math: &SliderMath) -> f64 {
71    let math = math.normalized();
72    let clamped = clamp(
73        value,
74        &crate::components::number_utils::NumberRules {
75            min: Some(math.min),
76            max: Some(math.max),
77            step: None,
78            precision: None,
79        },
80    );
81
82    // Snap to step relative to min when step is present.
83    let snapped = if let Some(step) = math.step {
84        let positive_step = step.abs();
85        if positive_step <= f64::EPSILON {
86            clamped
87        } else {
88            let steps = ((clamped - math.min) / positive_step).round();
89            let candidate = math.min + steps * positive_step;
90            // Ensure the snapped value doesn't exceed max
91            candidate.min(math.max)
92        }
93    } else {
94        clamped
95    };
96
97    round_with_precision(snapped, math.precision)
98}
99
100/// Convert an absolute value into a [0.0, 1.0] ratio along the track.
101pub fn value_to_ratio(value: f64, math: &SliderMath) -> f64 {
102    let math = math.normalized();
103    let snapped = snap_value(value, &math);
104    let ratio = (snapped - math.min) / math.range();
105    if math.reverse { 1.0 - ratio } else { ratio }.clamp(0.0, 1.0)
106}
107
108/// Convert a [0.0, 1.0] ratio into an absolute value.
109pub fn ratio_to_value(ratio: f64, math: &SliderMath) -> f64 {
110    let math = math.normalized();
111    let mut normalized = ratio.clamp(0.0, 1.0);
112    if math.reverse {
113        normalized = 1.0 - normalized;
114    }
115    let raw = math.min + normalized * math.range();
116    snap_value(raw, &math)
117}
118
119/// Keyboard intents supported by slider-like controls.
120#[derive(Clone, Copy, Debug, PartialEq, Eq)]
121pub enum KeyboardAction {
122    /// Relative step (can be >1 for PageUp/PageDown).
123    Step(i32),
124    /// Jump to minimum value.
125    ToMin,
126    /// Jump to maximum value.
127    ToMax,
128}
129
130/// Map a key into a keyboard action, respecting reverse direction.
131pub fn keyboard_action_for_key(key: &Key, reverse: bool) -> Option<KeyboardAction> {
132    let direction = |positive: KeyboardAction, negative: KeyboardAction| {
133        if reverse { negative } else { positive }
134    };
135    match key {
136        Key::ArrowRight | Key::ArrowUp => {
137            Some(direction(KeyboardAction::Step(1), KeyboardAction::Step(-1)))
138        }
139        Key::ArrowLeft | Key::ArrowDown => {
140            Some(direction(KeyboardAction::Step(-1), KeyboardAction::Step(1)))
141        }
142        Key::PageUp => Some(direction(
143            KeyboardAction::Step(10),
144            KeyboardAction::Step(-10),
145        )),
146        Key::PageDown => Some(direction(
147            KeyboardAction::Step(-10),
148            KeyboardAction::Step(10),
149        )),
150        Key::Home => Some(KeyboardAction::ToMin),
151        Key::End => Some(KeyboardAction::ToMax),
152        _ => None,
153    }
154}
155
156/// Apply a keyboard action to the current value.
157pub fn apply_keyboard_action(current: f64, action: KeyboardAction, math: &SliderMath) -> f64 {
158    match action {
159        KeyboardAction::Step(delta) => {
160            let step = math.step.unwrap_or(1.0).abs();
161            let next = current + (delta as f64) * step;
162            snap_value(next, math)
163        }
164        KeyboardAction::ToMin => snap_value(math.normalized().min, math),
165        KeyboardAction::ToMax => snap_value(math.normalized().max, math),
166    }
167}
168
169/// Convert pointer position to ratio using track bounding box.
170#[cfg(target_arch = "wasm32")]
171pub fn ratio_from_pointer_event(
172    event: &web_sys::PointerEvent,
173    rect: &web_sys::DomRect,
174    math: &SliderMath,
175) -> Option<f64> {
176    let axis_size = if math.orientation.is_vertical() {
177        rect.height()
178    } else {
179        rect.width()
180    };
181    if axis_size <= 0.0 {
182        return None;
183    }
184
185    let origin = if math.orientation.is_vertical() {
186        rect.y()
187    } else {
188        rect.x()
189    };
190    let position = if math.orientation.is_vertical() {
191        event.client_y() as f64
192    } else {
193        event.client_x() as f64
194    };
195
196    let mut ratio = (position - origin) / axis_size;
197    ratio = ratio.clamp(0.0, 1.0);
198    if math.reverse {
199        ratio = 1.0 - ratio;
200    }
201    Some(ratio)
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn snap_respects_step_and_precision() {
210        let math = SliderMath {
211            min: 0.0,
212            max: 5.0,
213            step: Some(0.3),
214            precision: Some(2),
215            reverse: false,
216            orientation: SliderOrientation::Horizontal,
217        };
218        assert_eq!(snap_value(1.11, &math), 1.2);
219        assert_eq!(snap_value(5.5, &math), 5.0);
220    }
221
222    #[test]
223    fn ratio_conversion_respects_reverse() {
224        let math = SliderMath {
225            min: 0.0,
226            max: 100.0,
227            step: Some(1.0),
228            precision: None,
229            reverse: true,
230            orientation: SliderOrientation::Horizontal,
231        };
232        // Midpoint stays midpoint
233        assert!((value_to_ratio(50.0, &math) - 0.5).abs() < 1e-6);
234        // Lower value yields higher ratio when reversed
235        assert!(value_to_ratio(10.0, &math) > value_to_ratio(90.0, &math));
236    }
237
238    #[test]
239    fn ratio_roundtrip() {
240        let math = SliderMath {
241            min: -10.0,
242            max: 10.0,
243            step: Some(0.5),
244            precision: Some(1),
245            reverse: false,
246            orientation: SliderOrientation::Horizontal,
247        };
248        let ratio = value_to_ratio(2.5, &math);
249        let back = ratio_to_value(ratio, &math);
250        assert!((back - 2.5).abs() < 1e-6);
251    }
252
253    #[test]
254    fn keyboard_actions_apply_steps() {
255        let math = SliderMath {
256            min: 0.0,
257            max: 10.0,
258            step: Some(1.0),
259            precision: None,
260            reverse: false,
261            orientation: SliderOrientation::Horizontal,
262        };
263        let next = apply_keyboard_action(5.0, KeyboardAction::Step(1), &math);
264        assert_eq!(next, 6.0);
265        let prev = apply_keyboard_action(0.2, KeyboardAction::Step(-1), &math);
266        assert_eq!(prev, 0.0);
267        let maxed = apply_keyboard_action(3.0, KeyboardAction::ToMax, &math);
268        assert_eq!(maxed, 10.0);
269    }
270
271    #[test]
272    fn keyboard_action_for_key_respects_reverse() {
273        let forward = keyboard_action_for_key(&Key::ArrowRight, false).unwrap();
274        assert_eq!(forward, KeyboardAction::Step(1));
275        let reversed = keyboard_action_for_key(&Key::ArrowRight, true).unwrap();
276        assert_eq!(reversed, KeyboardAction::Step(-1));
277    }
278}