ailloli_ui_core/
slider.rs

1//! Pure slider value mapping and range helpers.
2
3/// Numeric domain and optional snapping interval for a slider.
4///
5/// # Examples
6///
7/// ```
8/// use ailloli_ui_core::SliderSpec;
9///
10/// let spec = SliderSpec::new(0.0, 10.0).with_step(2.0);
11/// assert_eq!(spec.snap_value(3.0), 4.0);
12/// ```
13#[derive(Debug, Clone, Copy, PartialEq)]
14pub struct SliderSpec {
15    /// Inclusive minimum slider value.
16    pub min: f32,
17    /// Inclusive maximum slider value.
18    pub max: f32,
19    /// Positive snapping interval, or `None` for continuous values.
20    pub step: Option<f32>,
21}
22
23impl Default for SliderSpec {
24    fn default() -> Self {
25        Self {
26            min: 0.0,
27            max: 100.0,
28            step: None,
29        }
30    }
31}
32
33impl SliderSpec {
34    /// Creates a continuous, sanitized slider domain.
35    ///
36    /// Reversed bounds are swapped, equal bounds are expanded upward by adding
37    /// `1.0` to the maximum, and any non-finite input bound resets the domain
38    /// to the default `0.0..=100.0`. At magnitudes where that increment is not
39    /// representable, equal bounds can remain equal.
40    ///
41    /// # Examples
42    ///
43    /// ```
44    /// use ailloli_ui_core::SliderSpec;
45    /// assert_eq!(SliderSpec::new(10.0, 0.0).min, 0.0);
46    /// ```
47    pub fn new(min: f32, max: f32) -> Self {
48        Self {
49            min,
50            max,
51            step: None,
52        }
53        .sanitized()
54    }
55
56    /// Sets a snapping interval and sanitizes the complete specification.
57    ///
58    /// A non-positive or non-finite step is discarded and produces a
59    /// continuous slider.
60    ///
61    /// # Examples
62    ///
63    /// ```
64    /// use ailloli_ui_core::SliderSpec;
65    /// assert_eq!(SliderSpec::new(0.0, 10.0).with_step(2.0).step, Some(2.0));
66    /// ```
67    pub fn with_step(mut self, step: f32) -> Self {
68        self.step = Some(step);
69        self.sanitized()
70    }
71
72    /// Returns finite, ascending bounds and a valid optional step.
73    ///
74    /// Equal bounds are widened by adding `1.0` to `max`; `f32` rounding can
75    /// leave very large equal bounds unchanged.
76    ///
77    /// # Examples
78    ///
79    /// ```
80    /// use ailloli_ui_core::SliderSpec;
81    /// let spec = SliderSpec { min: 4.0, max: 4.0, step: Some(-1.0) }.sanitized();
82    /// assert_eq!((spec.max, spec.step), (5.0, None));
83    /// ```
84    pub fn sanitized(self) -> Self {
85        let fallback = Self::default();
86        let (mut min, mut max) = if self.min.is_finite() && self.max.is_finite() {
87            (self.min, self.max)
88        } else {
89            (fallback.min, fallback.max)
90        };
91        if min > max {
92            std::mem::swap(&mut min, &mut max);
93        }
94        if min == max {
95            max = min + 1.0;
96        }
97        let step = self.step.filter(|step| step.is_finite() && *step > 0.0);
98        Self { min, max, step }
99    }
100
101    /// Returns `max - min` for the sanitized domain.
102    ///
103    /// The result is normally positive and finite. It can be zero when two
104    /// large equal bounds cannot be widened by `1.0`, or infinity when the
105    /// difference between finite extreme bounds overflows.
106    ///
107    /// # Examples
108    ///
109    /// ```
110    /// use ailloli_ui_core::SliderSpec;
111    /// assert_eq!(SliderSpec::new(10.0, 30.0).span(), 20.0);
112    /// ```
113    pub fn span(self) -> f32 {
114        let spec = self.sanitized();
115        spec.max - spec.min
116    }
117
118    /// Clamps `value` to the inclusive domain; non-finite values select `min`.
119    ///
120    /// # Examples
121    ///
122    /// ```
123    /// use ailloli_ui_core::SliderSpec;
124    /// assert_eq!(SliderSpec::new(0.0, 10.0).clamp_value(12.0), 10.0);
125    /// ```
126    pub fn clamp_value(self, value: f32) -> f32 {
127        let spec = self.sanitized();
128        let value = if value.is_finite() { value } else { spec.min };
129        value.clamp(spec.min, spec.max)
130    }
131
132    /// Clamps a value and rounds it to the nearest step relative to `min`.
133    ///
134    /// Half-step ties follow [`f32::round`] and therefore round away from zero
135    /// in step coordinates. Continuous specifications only clamp the value.
136    ///
137    /// # Examples
138    ///
139    /// ```
140    /// use ailloli_ui_core::SliderSpec;
141    /// assert_eq!(SliderSpec::new(0.0, 10.0).with_step(2.0).snap_value(3.0), 4.0);
142    /// ```
143    pub fn snap_value(self, value: f32) -> f32 {
144        let spec = self.sanitized();
145        let value = spec.clamp_value(value);
146        let Some(step) = spec.step else {
147            return value;
148        };
149        let snapped = spec.min + ((value - spec.min) / step).round() * step;
150        spec.clamp_value(snapped)
151    }
152
153    /// Maps a value into the inclusive normalized interval `0.0..=1.0`.
154    ///
155    /// A zero or overflowing span caused by extreme finite bounds can make the
156    /// IEEE-754 division return `NaN`.
157    ///
158    /// # Examples
159    ///
160    /// ```
161    /// use ailloli_ui_core::SliderSpec;
162    /// assert_eq!(SliderSpec::new(10.0, 30.0).fraction_for_value(20.0), 0.5);
163    /// ```
164    pub fn fraction_for_value(self, value: f32) -> f32 {
165        let spec = self.sanitized();
166        (spec.clamp_value(value) - spec.min) / spec.span()
167    }
168
169    /// Maps a normalized fraction into the domain and applies step snapping.
170    ///
171    /// Fractions are clamped to `0.0..=1.0`; a non-finite fraction selects the
172    /// minimum. Intermediate arithmetic can overflow for extreme finite bounds;
173    /// the final snapping pass then applies its normal non-finite-value rules.
174    ///
175    /// # Examples
176    ///
177    /// ```
178    /// use ailloli_ui_core::SliderSpec;
179    /// assert_eq!(SliderSpec::new(10.0, 30.0).value_for_fraction(0.75), 25.0);
180    /// ```
181    pub fn value_for_fraction(self, fraction: f32) -> f32 {
182        let spec = self.sanitized();
183        let fraction = if fraction.is_finite() {
184            fraction.clamp(0.0, 1.0)
185        } else {
186            0.0
187        };
188        spec.snap_value(spec.min + fraction * spec.span())
189    }
190
191    /// Moves a value one small or large increment in `direction`.
192    ///
193    /// A small increment is the configured step or 1% of the domain. A large
194    /// increment is always 10% of the domain before optional step snapping.
195    /// Only the sign of `direction` matters; zero leaves the value at its
196    /// nearest valid step, and a non-finite direction ultimately selects `min`.
197    ///
198    /// # Examples
199    ///
200    /// ```
201    /// use ailloli_ui_core::SliderSpec;
202    /// let spec = SliderSpec::new(0.0, 100.0).with_step(5.0);
203    /// assert_eq!(spec.nudge_value(50.0, 1.0, false), 55.0);
204    /// ```
205    pub fn nudge_value(self, value: f32, direction: f32, large: bool) -> f32 {
206        let spec = self.sanitized();
207        let amount = spec.step.unwrap_or_else(|| spec.span() * 0.01);
208        let amount = if large { spec.span() * 0.10 } else { amount };
209        spec.snap_value(value + amount * direction.signum())
210    }
211
212    /// Clamps, snaps, and orders both thumbs of a range value.
213    ///
214    /// # Examples
215    ///
216    /// ```
217    /// use ailloli_ui_core::{SliderRangeValue, SliderSpec};
218    /// let spec = SliderSpec::new(0.0, 100.0).with_step(10.0);
219    /// assert_eq!(spec.clamp_range_value(SliderRangeValue::new(12.0, 83.0)), SliderRangeValue::new(10.0, 80.0));
220    /// ```
221    pub fn clamp_range_value(self, value: SliderRangeValue) -> SliderRangeValue {
222        let spec = self.sanitized();
223        let start = spec.snap_value(value.start);
224        let end = spec.snap_value(value.end);
225        SliderRangeValue::new(start.min(end), start.max(end))
226    }
227
228    /// Moves one range thumb without allowing it to cross the other.
229    ///
230    /// Both the existing range and `next` are clamped and snapped first. When a
231    /// thumb reaches the other, the result is a zero-width range.
232    ///
233    /// # Examples
234    ///
235    /// ```
236    /// use ailloli_ui_core::{SliderRangeValue, SliderSpec, SliderThumb};
237    /// let spec = SliderSpec::new(0.0, 100.0);
238    /// let range = spec.set_range_thumb(SliderRangeValue::new(20.0, 80.0), SliderThumb::Start, 90.0);
239    /// assert_eq!(range, SliderRangeValue::new(80.0, 80.0));
240    /// ```
241    pub fn set_range_thumb(
242        self,
243        value: SliderRangeValue,
244        thumb: SliderThumb,
245        next: f32,
246    ) -> SliderRangeValue {
247        let spec = self.sanitized();
248        let value = spec.clamp_range_value(value);
249        let next = spec.snap_value(next);
250        match thumb {
251            SliderThumb::Start => SliderRangeValue::new(next.min(value.end), value.end),
252            SliderThumb::End => SliderRangeValue::new(value.start, next.max(value.start)),
253        }
254    }
255
256    /// Returns the nearest thumb for a target value.
257    ///
258    /// The range and target are clamped before comparison. Exact ties prefer
259    /// [`SliderThumb::End`].
260    ///
261    /// # Examples
262    ///
263    /// ```
264    /// use ailloli_ui_core::{SliderRangeValue, SliderSpec, SliderThumb};
265    /// let thumb = SliderSpec::new(0.0, 100.0).nearest_thumb(SliderRangeValue::new(20.0, 80.0), 50.0);
266    /// assert_eq!(thumb, SliderThumb::End);
267    /// ```
268    pub fn nearest_thumb(self, value: SliderRangeValue, target: f32) -> SliderThumb {
269        let value = self.clamp_range_value(value);
270        let target = self.clamp_value(target);
271        let start_dist = (target - value.start).abs();
272        let end_dist = (target - value.end).abs();
273        if start_dist < end_dist {
274            SliderThumb::Start
275        } else {
276            SliderThumb::End
277        }
278    }
279}
280
281/// Ordered pair of values controlled by a range slider.
282///
283/// # Examples
284///
285/// ```
286/// use ailloli_ui_core::SliderRangeValue;
287/// assert_eq!(SliderRangeValue::new(80.0, 20.0), SliderRangeValue { start: 20.0, end: 80.0 });
288/// ```
289#[derive(Debug, Clone, Copy, PartialEq)]
290pub struct SliderRangeValue {
291    /// Lower thumb value for finite, ordered inputs.
292    pub start: f32,
293    /// Upper thumb value for finite, ordered inputs.
294    pub end: f32,
295}
296
297impl SliderRangeValue {
298    /// Creates a range and swaps finite endpoints when `start > end`.
299    ///
300    /// Non-finite inputs are not sanitized; use [`SliderSpec::clamp_range_value`]
301    /// before interaction or rendering.
302    ///
303    /// # Examples
304    ///
305    /// ```
306    /// use ailloli_ui_core::SliderRangeValue;
307    /// assert_eq!(SliderRangeValue::new(8.0, 2.0), SliderRangeValue { start: 2.0, end: 8.0 });
308    /// ```
309    pub const fn new(start: f32, end: f32) -> Self {
310        if start <= end {
311            Self { start, end }
312        } else {
313            Self {
314                start: end,
315                end: start,
316            }
317        }
318    }
319}
320
321/// Thumb selected for a [`SliderRangeValue`] operation.
322///
323/// Possible values are [`SliderThumb::Start`] and [`SliderThumb::End`].
324///
325/// # Examples
326///
327/// ```
328/// use ailloli_ui_core::SliderThumb;
329/// assert_ne!(SliderThumb::Start, SliderThumb::End);
330/// ```
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332pub enum SliderThumb {
333    /// The lower/start thumb.
334    Start,
335    /// The upper/end thumb.
336    End,
337}
338
339#[cfg(test)]
340mod tests {
341    //! Covers domain sanitization, snapping, nudging, and non-crossing range thumbs.
342
343    use super::*;
344
345    #[test]
346    fn spec_sanitizes_invalid_min_max_and_step() {
347        assert_eq!(SliderSpec::new(10.0, 0.0).min, 0.0);
348        assert_eq!(SliderSpec::new(10.0, 0.0).max, 10.0);
349
350        let equal = SliderSpec::new(4.0, 4.0);
351        assert_eq!(equal.min, 4.0);
352        assert_eq!(equal.max, 5.0);
353
354        let fallback = SliderSpec {
355            min: f32::NAN,
356            max: 10.0,
357            step: Some(-2.0),
358        }
359        .sanitized();
360        assert_eq!(fallback, SliderSpec::default());
361    }
362
363    #[test]
364    fn clamp_and_snap_values() {
365        let spec = SliderSpec::new(0.0, 10.0).with_step(2.5);
366        assert_eq!(spec.clamp_value(-2.0), 0.0);
367        assert_eq!(spec.clamp_value(12.0), 10.0);
368        assert_eq!(spec.snap_value(3.1), 2.5);
369        assert_eq!(spec.snap_value(3.8), 5.0);
370        assert_eq!(spec.snap_value(9.9), 10.0);
371    }
372
373    #[test]
374    fn fraction_and_value_mapping_are_stable() {
375        let spec = SliderSpec::new(10.0, 30.0);
376        assert_eq!(spec.fraction_for_value(20.0), 0.5);
377        assert_eq!(spec.value_for_fraction(0.75), 25.0);
378        assert_eq!(spec.value_for_fraction(-1.0), 10.0);
379        assert_eq!(spec.value_for_fraction(2.0), 30.0);
380    }
381
382    #[test]
383    fn nudge_uses_step_or_span_fraction() {
384        let stepped = SliderSpec::new(0.0, 100.0).with_step(5.0);
385        assert_eq!(stepped.nudge_value(50.0, 1.0, false), 55.0);
386        assert_eq!(stepped.nudge_value(50.0, -1.0, true), 40.0);
387
388        let continuous = SliderSpec::new(0.0, 100.0);
389        assert_eq!(continuous.nudge_value(50.0, 1.0, false), 51.0);
390    }
391
392    #[test]
393    fn range_value_clamps_without_crossing() {
394        let spec = SliderSpec::new(0.0, 100.0).with_step(10.0);
395        assert_eq!(
396            spec.clamp_range_value(SliderRangeValue::new(83.0, 12.0)),
397            SliderRangeValue::new(10.0, 80.0)
398        );
399        assert_eq!(
400            spec.set_range_thumb(SliderRangeValue::new(20.0, 80.0), SliderThumb::Start, 95.0),
401            SliderRangeValue::new(80.0, 80.0)
402        );
403        assert_eq!(
404            spec.set_range_thumb(SliderRangeValue::new(20.0, 80.0), SliderThumb::End, 5.0),
405            SliderRangeValue::new(20.0, 20.0)
406        );
407    }
408
409    #[test]
410    fn nearest_thumb_prefers_end_on_tie() {
411        let spec = SliderSpec::new(0.0, 100.0);
412        assert_eq!(
413            spec.nearest_thumb(SliderRangeValue::new(20.0, 80.0), 30.0),
414            SliderThumb::Start
415        );
416        assert_eq!(
417            spec.nearest_thumb(SliderRangeValue::new(20.0, 80.0), 50.0),
418            SliderThumb::End
419        );
420    }
421}