Skip to main content

agg_gui/widgets/
slider_math.rs

1//! Pure value-mapping math for [`crate::widgets::slider::Slider`].
2//!
3//! This module holds the numeric core of the slider — everything that turns a
4//! value into a normalized `[0, 1]` position and back — kept separate from the
5//! widget's paint/event code so the mapping can be unit-tested in isolation and
6//! so `slider.rs` stays under the project's 800-line limit.
7//!
8//! The logarithmic mapping (including ranges that span zero and infinity) and
9//! the "smart aim" round-number finder are ported from egui's
10//! `crates/egui/src/widgets/slider.rs` and `crates/emath/src/smart_aim.rs`
11//! respectively (see `egui-reference/`).  Semantics are preserved so the
12//! widget behaves like egui's; only the API shape (scalar `min`/`max` instead
13//! of `RangeInclusive`) differs to avoid dragging range types through the
14//! widget.
15
16use std::cmp::Ordering;
17
18/// Configuration for how a slider maps values to positions.
19///
20/// Mirrors egui's private `SliderSpec`.  For linear sliders only
21/// `logarithmic == false` matters; the other two fields shape how logarithmic
22/// sliders treat the special endpoints `0` and `∞`.
23#[derive(Clone, Copy, Debug)]
24pub struct SliderSpec {
25    pub logarithmic: bool,
26    /// For logarithmic sliders, the smallest positive value we care about.
27    /// `1` for integer sliders, `1e-6` for reals by default.
28    pub smallest_positive: f64,
29    /// For logarithmic sliders, the largest positive value before the slider
30    /// switches to `INFINITY` (when infinity is the high end). Default: `∞`.
31    pub largest_finite: f64,
32}
33
34impl Default for SliderSpec {
35    fn default() -> Self {
36        Self {
37            logarithmic: false,
38            smallest_positive: 1e-6,
39            largest_finite: f64::INFINITY,
40        }
41    }
42}
43
44const INFINITY: f64 = f64::INFINITY;
45
46/// For an infinitely large logarithmic range (e.g. from zero), span this many
47/// orders of magnitude.
48const INF_RANGE_MAGNITUDE: f64 = 10.0;
49
50#[inline]
51fn lerp(a: f64, b: f64, t: f64) -> f64 {
52    a + (b - a) * t
53}
54
55#[inline]
56fn remap(x: f64, from_lo: f64, from_hi: f64, to_lo: f64, to_hi: f64) -> f64 {
57    let t = (x - from_lo) / (from_hi - from_lo);
58    lerp(to_lo, to_hi, t)
59}
60
61#[inline]
62fn remap_clamp(x: f64, from_lo: f64, from_hi: f64, to_lo: f64, to_hi: f64) -> f64 {
63    if x <= from_lo.min(from_hi) {
64        if from_lo <= from_hi {
65            to_lo
66        } else {
67            to_hi
68        }
69    } else if x >= from_lo.max(from_hi) {
70        if from_lo <= from_hi {
71            to_hi
72        } else {
73            to_lo
74        }
75    } else {
76        remap(x, from_lo, from_hi, to_lo, to_hi)
77    }
78}
79
80/// Clamp `x` into `[min, max]`, tolerating a reversed (high-to-low) range and
81/// NaN endpoints the same way egui's `clamp_value_to_range` does.
82pub fn clamp_value_to_range(x: f64, min: f64, max: f64) -> f64 {
83    let (mut lo, mut hi) = (min, max);
84    if lo.total_cmp(&hi) == Ordering::Greater {
85        std::mem::swap(&mut lo, &mut hi);
86    }
87    match x.total_cmp(&lo) {
88        Ordering::Less | Ordering::Equal => lo,
89        Ordering::Greater => match x.total_cmp(&hi) {
90            Ordering::Greater | Ordering::Equal => hi,
91            Ordering::Less => x,
92        },
93    }
94}
95
96/// Map a value to its normalized `[0, 1]` slider position.
97pub fn normalized_from_value(value: f64, min: f64, max: f64, spec: &SliderSpec) -> f64 {
98    if min.is_nan() || max.is_nan() {
99        f64::NAN
100    } else if min == max {
101        0.5 // empty range, show center of slider
102    } else if min > max {
103        1.0 - normalized_from_value(value, max, min, spec)
104    } else if value <= min {
105        0.0
106    } else if value >= max {
107        1.0
108    } else if spec.logarithmic {
109        if max <= 0.0 {
110            // non-positive range
111            normalized_from_value(-value, -min, -max, spec)
112        } else if 0.0 <= min {
113            let (min_log, max_log) = range_log10(min, max, spec);
114            let value_log = value.log10();
115            remap_clamp(value_log, min_log, max_log, 0.0, 1.0)
116        } else {
117            let zero_cutoff = logarithmic_zero_cutoff(min, max);
118            if value < 0.0 {
119                remap(
120                    normalized_from_value(value, min, 0.0, spec),
121                    0.0,
122                    1.0,
123                    0.0,
124                    zero_cutoff,
125                )
126            } else {
127                remap(
128                    normalized_from_value(value, 0.0, max, spec),
129                    0.0,
130                    1.0,
131                    zero_cutoff,
132                    1.0,
133                )
134            }
135        }
136    } else {
137        remap_clamp(value, min, max, 0.0, 1.0)
138    }
139}
140
141/// Inverse of [`normalized_from_value`]: map a normalized `[0, 1]` position
142/// back to a value.
143pub fn value_from_normalized(normalized: f64, min: f64, max: f64, spec: &SliderSpec) -> f64 {
144    if min.is_nan() || max.is_nan() {
145        f64::NAN
146    } else if min == max {
147        min
148    } else if min > max {
149        value_from_normalized(1.0 - normalized, max, min, spec)
150    } else if normalized <= 0.0 {
151        min
152    } else if normalized >= 1.0 {
153        max
154    } else if spec.logarithmic {
155        if max <= 0.0 {
156            // non-positive range
157            -value_from_normalized(normalized, -min, -max, spec)
158        } else if 0.0 <= min {
159            let (min_log, max_log) = range_log10(min, max, spec);
160            let log = lerp(min_log, max_log, normalized);
161            10.0_f64.powf(log)
162        } else {
163            let zero_cutoff = logarithmic_zero_cutoff(min, max);
164            if normalized < zero_cutoff {
165                // negative
166                value_from_normalized(remap(normalized, 0.0, zero_cutoff, 0.0, 1.0), min, 0.0, spec)
167            } else {
168                // positive
169                value_from_normalized(remap(normalized, zero_cutoff, 1.0, 0.0, 1.0), 0.0, max, spec)
170            }
171        }
172    } else {
173        lerp(min, max, normalized.clamp(0.0, 1.0))
174    }
175}
176
177fn range_log10(min: f64, max: f64, spec: &SliderSpec) -> (f64, f64) {
178    debug_assert!(spec.logarithmic, "spec must be logarithmic");
179    debug_assert!(min <= max, "min must be <= max, got min={min} max={max}");
180
181    if min == 0.0 && max == INFINITY {
182        (spec.smallest_positive.log10(), INF_RANGE_MAGNITUDE)
183    } else if min == 0.0 {
184        if spec.smallest_positive < max {
185            (spec.smallest_positive.log10(), max.log10())
186        } else {
187            (max.log10() - INF_RANGE_MAGNITUDE, max.log10())
188        }
189    } else if max == INFINITY {
190        if min < spec.largest_finite {
191            (min.log10(), spec.largest_finite.log10())
192        } else {
193            (min.log10(), min.log10() + INF_RANGE_MAGNITUDE)
194        }
195    } else {
196        (min.log10(), max.log10())
197    }
198}
199
200/// Where to place the zero crossing for a logarithmic slider whose range spans
201/// zero (negative min, positive max).
202fn logarithmic_zero_cutoff(min: f64, max: f64) -> f64 {
203    debug_assert!(
204        min < 0.0 && 0.0 < max,
205        "min must be negative and max positive, got min={min} max={max}"
206    );
207
208    let min_magnitude = if min == -INFINITY {
209        INF_RANGE_MAGNITUDE
210    } else {
211        min.abs().log10().abs()
212    };
213    let max_magnitude = if max == INFINITY {
214        INF_RANGE_MAGNITUDE
215    } else {
216        max.log10().abs()
217    };
218
219    let cutoff = min_magnitude / (min_magnitude + max_magnitude);
220    debug_assert!(
221        (0.0..=1.0).contains(&cutoff),
222        "Bad cutoff {cutoff:?} for min {min:?} and max {max:?}"
223    );
224    cutoff
225}
226
227// ---------------------------------------------------------------------------
228// Smart aim — find the "roundest" number in a range so dragging snaps to nice
229// values.  Ported from egui's `emath::smart_aim`.
230// ---------------------------------------------------------------------------
231
232const NUM_DECIMALS: usize = 16;
233
234#[inline]
235fn fast_midpoint(a: f64, b: f64) -> f64 {
236    (a + b) / 2.0
237}
238
239#[inline]
240fn u8_midpoint(a: u8, b: u8) -> u8 {
241    ((a as u16 + b as u16) / 2) as u8
242}
243
244/// Find the "simplest" number in the closed range `[min, max]` — the one with
245/// the fewest decimal digits.  E.g. `[0.83, 1.354] -> 1.0`, `[0.37, 0.48] -> 0.4`.
246pub fn best_in_range_f64(min: f64, max: f64) -> f64 {
247    // Avoid NaN if we can:
248    if min.is_nan() {
249        return max;
250    }
251    if max.is_nan() {
252        return min;
253    }
254
255    if max < min {
256        return best_in_range_f64(max, min);
257    }
258    if min == max {
259        return min;
260    }
261    if min <= 0.0 && 0.0 <= max {
262        return 0.0; // always prefer zero
263    }
264    if min < 0.0 {
265        return -best_in_range_f64(-max, -min);
266    }
267
268    debug_assert!(0.0 < min && min < max, "Logic bug");
269
270    // Prefer finite numbers:
271    if !max.is_finite() {
272        return min;
273    }
274
275    let min_exponent = min.log10();
276    let max_exponent = max.log10();
277
278    if min_exponent.floor() != max_exponent.floor() {
279        // Different orders of magnitude — pick the geometric center of the two:
280        let exponent = fast_midpoint(min_exponent, max_exponent);
281        return 10.0_f64.powi(exponent.round() as i32);
282    }
283
284    if is_integer(min_exponent) {
285        return 10.0_f64.powf(min_exponent);
286    }
287    if is_integer(max_exponent) {
288        return 10.0_f64.powf(max_exponent);
289    }
290
291    // Find the proper scale, then convert to integers:
292    let scale = NUM_DECIMALS as i32 - max_exponent.floor() as i32 - 1;
293    let scale_factor = 10.0_f64.powi(scale);
294
295    let min_str = to_decimal_string((min * scale_factor).round() as u64);
296    let max_str = to_decimal_string((max * scale_factor).round() as u64);
297
298    // Two positive integers of the same length. Find the first non-matching
299    // ("deciding") digit; everything before it matches, everything after is
300    // zero, and the deciding digit is a "smart average".
301    let mut ret_str = [0u8; NUM_DECIMALS];
302
303    for i in 0..NUM_DECIMALS {
304        if min_str[i] == max_str[i] {
305            ret_str[i] = min_str[i];
306        } else {
307            let mut deciding_digit_min = min_str[i];
308            let deciding_digit_max = max_str[i];
309
310            debug_assert!(deciding_digit_min < deciding_digit_max, "Bug in smart aim");
311
312            let rest_of_min_is_zeroes = min_str[i + 1..].iter().all(|&c| c == 0);
313
314            if !rest_of_min_is_zeroes {
315                // There are more digits after `deciding_digit_min`, so we can't
316                // pick it — the true selectable min is one greater:
317                deciding_digit_min += 1;
318            }
319
320            let deciding_digit = if deciding_digit_min == 0 {
321                0
322            } else if deciding_digit_min <= 5 && 5 <= deciding_digit_max {
323                5 // 5 is the roundest number in the range
324            } else {
325                u8_midpoint(deciding_digit_min, deciding_digit_max)
326            };
327
328            ret_str[i] = deciding_digit;
329
330            return from_decimal_string(ret_str) as f64 / scale_factor;
331        }
332    }
333
334    min // All digits the same (already handled earlier, but be safe).
335}
336
337fn is_integer(f: f64) -> bool {
338    f.round() == f
339}
340
341fn to_decimal_string(v: u64) -> [u8; NUM_DECIMALS] {
342    let mut ret = [0u8; NUM_DECIMALS];
343    let mut value = v;
344    for i in (0..NUM_DECIMALS).rev() {
345        ret[i] = (value % 10) as u8;
346        value /= 10;
347    }
348    ret
349}
350
351fn from_decimal_string(s: [u8; NUM_DECIMALS]) -> u64 {
352    let mut value = 0u64;
353    for &c in &s {
354        debug_assert!(c <= 9, "Bad number");
355        value = value * 10 + c as u64;
356    }
357    value
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    fn log_spec() -> SliderSpec {
365        SliderSpec {
366            logarithmic: true,
367            smallest_positive: 1e-6,
368            largest_finite: f64::INFINITY,
369        }
370    }
371
372    #[test]
373    fn linear_mapping_roundtrips() {
374        let spec = SliderSpec::default();
375        for &(v, n) in &[(0.0, 0.0), (5.0, 0.5), (10.0, 1.0), (2.5, 0.25)] {
376            assert!((normalized_from_value(v, 0.0, 10.0, &spec) - n).abs() < 1e-9);
377            assert!((value_from_normalized(n, 0.0, 10.0, &spec) - v).abs() < 1e-9);
378        }
379    }
380
381    #[test]
382    fn linear_clamps_outside_range() {
383        let spec = SliderSpec::default();
384        assert_eq!(normalized_from_value(-5.0, 0.0, 10.0, &spec), 0.0);
385        assert_eq!(normalized_from_value(50.0, 0.0, 10.0, &spec), 1.0);
386    }
387
388    #[test]
389    fn logarithmic_midpoint_is_geometric_mean() {
390        let spec = log_spec();
391        // For 1..=100 log slider, the halfway position should be ~10.
392        let mid = value_from_normalized(0.5, 1.0, 100.0, &spec);
393        assert!((mid - 10.0).abs() < 1e-6, "got {mid}");
394        // And the inverse holds.
395        let n = normalized_from_value(10.0, 1.0, 100.0, &spec);
396        assert!((n - 0.5).abs() < 1e-9, "got {n}");
397    }
398
399    #[test]
400    fn logarithmic_reversed_range() {
401        let spec = log_spec();
402        // High-to-low range: normalized 0 -> max value.
403        let v = value_from_normalized(0.0, 100.0, 1.0, &spec);
404        assert!((v - 100.0).abs() < 1e-9, "got {v}");
405    }
406
407    #[test]
408    fn logarithmic_spanning_zero_puts_zero_at_cutoff() {
409        let spec = log_spec();
410        // Symmetric range around zero -> cutoff at 0.5, value there is ~0.
411        let cutoff = logarithmic_zero_cutoff(-1000.0, 1000.0);
412        assert!((cutoff - 0.5).abs() < 1e-9, "got {cutoff}");
413        let v = value_from_normalized(0.5, -1000.0, 1000.0, &spec);
414        assert!(v.abs() < 1.0, "expected near zero, got {v}");
415    }
416
417    #[test]
418    fn logarithmic_spanning_infinity() {
419        let spec = log_spec();
420        // 0..=∞ maps normalized 1.0 to ∞ and 0.0 to 0.0.
421        assert_eq!(value_from_normalized(1.0, 0.0, f64::INFINITY, &spec), f64::INFINITY);
422        assert_eq!(value_from_normalized(0.0, 0.0, f64::INFINITY, &spec), 0.0);
423        // A midpoint stays finite and positive.
424        let mid = value_from_normalized(0.5, 0.0, f64::INFINITY, &spec);
425        assert!(mid.is_finite() && mid > 0.0, "got {mid}");
426    }
427
428    /// Regression: the Sliders demo's range sliders span `-∞..=∞` (logarithmic)
429    /// and the demo slider itself may be rebuilt with an infinite bound. The
430    /// initial displayed value/position must be finite — never `NaN` — for the
431    /// exact values the demo starts with. (egui's Sliders demo shows finite
432    /// values, never NaN.)
433    #[test]
434    fn infinite_range_initial_mapping_is_finite() {
435        let spec = log_spec();
436        // Demo slider default: value 10 in 0..=10000 (log).
437        let n = normalized_from_value(10.0, 0.0, 10000.0, &spec);
438        assert!(n.is_finite() && (0.0..=1.0).contains(&n), "demo n={n}");
439
440        // Range sliders: both bounds infinite. The current min (0) and max
441        // (10000) must map to a finite, in-range position.
442        for &v in &[0.0, 10000.0] {
443            let n = normalized_from_value(v, -f64::INFINITY, f64::INFINITY, &spec);
444            assert!(n.is_finite() && (0.0..=1.0).contains(&n), "v={v} n={n}");
445        }
446        // A finite normalized position maps back to a finite value.
447        let mid = value_from_normalized(0.5, -f64::INFINITY, f64::INFINITY, &spec);
448        assert!(mid.is_finite(), "mid={mid}");
449    }
450
451    /// One infinite bound (e.g. `min..=∞` after the user drags a range slider):
452    /// a finite value in the range still maps to a finite, in-range position,
453    /// and round-trips.
454    #[test]
455    fn one_infinite_bound_roundtrips_finite() {
456        let spec = log_spec();
457        let n = normalized_from_value(10.0, -f64::INFINITY, 10000.0, &spec);
458        assert!(n.is_finite() && (0.0..=1.0).contains(&n), "n={n}");
459        let v = value_from_normalized(n, -f64::INFINITY, 10000.0, &spec);
460        assert!(v.is_finite(), "v={v}");
461
462        let n = normalized_from_value(10.0, 0.0, f64::INFINITY, &spec);
463        assert!(n.is_finite() && (0.0..=1.0).contains(&n), "n={n}");
464    }
465
466    #[test]
467    fn clamp_handles_reversed_range() {
468        assert_eq!(clamp_value_to_range(5.0, 10.0, 0.0), 5.0);
469        assert_eq!(clamp_value_to_range(-1.0, 10.0, 0.0), 0.0);
470        assert_eq!(clamp_value_to_range(11.0, 10.0, 0.0), 10.0);
471    }
472
473    // Smart-aim cases ported from egui's `smart_aim::test_aim`.
474    #[test]
475    fn smart_aim_round_numbers() {
476        assert_eq!(best_in_range_f64(-0.2, 0.0), 0.0);
477        assert_eq!(best_in_range_f64(-10_004.23, 3.14), 0.0);
478        assert_eq!(best_in_range_f64(7.8, 17.8), 10.0);
479        assert_eq!(best_in_range_f64(99.0, 300.0), 100.0);
480        assert_eq!(best_in_range_f64(-99.0, -300.0), -100.0);
481        assert_eq!(best_in_range_f64(0.4, 0.9), 0.5);
482        assert_eq!(best_in_range_f64(14.1, 19.99), 15.0);
483        assert_eq!(best_in_range_f64(12.3, 65.9), 50.0);
484        assert_eq!(best_in_range_f64(493.0, 879.0), 500.0);
485        assert_eq!(best_in_range_f64(0.37, 0.48), 0.40);
486        assert_eq!(best_in_range_f64(7.5, 16.3), 10.0);
487        assert_eq!(best_in_range_f64(7.5, 763.3), 100.0);
488        assert_eq!(best_in_range_f64(7.5, 123_456.0), 1000.0);
489        assert_eq!(best_in_range_f64(9.9999, 99.999), 10.0);
490        assert_eq!(best_in_range_f64(10.001, 99.999), 50.0);
491    }
492
493    #[test]
494    fn smart_aim_integers() {
495        assert_eq!(best_in_range_f64(99.0, 300.0), 100.0);
496        assert_eq!(best_in_range_f64(4.0, 9.0), 5.0);
497        assert_eq!(best_in_range_f64(14.0, 19.0), 15.0);
498        assert_eq!(best_in_range_f64(12.0, 65.0), 50.0);
499        assert_eq!(best_in_range_f64(37.0, 48.0), 40.0);
500        assert_eq!(best_in_range_f64(12345.0, 12780.0), 12500.0);
501    }
502
503    #[test]
504    fn smart_aim_nan_and_infinity() {
505        assert!(best_in_range_f64(f64::NAN, f64::NAN).is_nan());
506        assert_eq!(best_in_range_f64(f64::NAN, 1.2), 1.2);
507        assert_eq!(best_in_range_f64(1.2, f64::INFINITY), 1.2);
508        assert_eq!(best_in_range_f64(f64::NEG_INFINITY, 1.2), 0.0);
509        assert_eq!(best_in_range_f64(f64::NEG_INFINITY, -2.7), -2.7);
510        assert_eq!(best_in_range_f64(f64::NEG_INFINITY, f64::INFINITY), 0.0);
511    }
512}