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(
167                    remap(normalized, 0.0, zero_cutoff, 0.0, 1.0),
168                    min,
169                    0.0,
170                    spec,
171                )
172            } else {
173                // positive
174                value_from_normalized(
175                    remap(normalized, zero_cutoff, 1.0, 0.0, 1.0),
176                    0.0,
177                    max,
178                    spec,
179                )
180            }
181        }
182    } else {
183        lerp(min, max, normalized.clamp(0.0, 1.0))
184    }
185}
186
187fn range_log10(min: f64, max: f64, spec: &SliderSpec) -> (f64, f64) {
188    debug_assert!(spec.logarithmic, "spec must be logarithmic");
189    debug_assert!(min <= max, "min must be <= max, got min={min} max={max}");
190
191    if min == 0.0 && max == INFINITY {
192        (spec.smallest_positive.log10(), INF_RANGE_MAGNITUDE)
193    } else if min == 0.0 {
194        if spec.smallest_positive < max {
195            (spec.smallest_positive.log10(), max.log10())
196        } else {
197            (max.log10() - INF_RANGE_MAGNITUDE, max.log10())
198        }
199    } else if max == INFINITY {
200        if min < spec.largest_finite {
201            (min.log10(), spec.largest_finite.log10())
202        } else {
203            (min.log10(), min.log10() + INF_RANGE_MAGNITUDE)
204        }
205    } else {
206        (min.log10(), max.log10())
207    }
208}
209
210/// Where to place the zero crossing for a logarithmic slider whose range spans
211/// zero (negative min, positive max).
212fn logarithmic_zero_cutoff(min: f64, max: f64) -> f64 {
213    debug_assert!(
214        min < 0.0 && 0.0 < max,
215        "min must be negative and max positive, got min={min} max={max}"
216    );
217
218    let min_magnitude = if min == -INFINITY {
219        INF_RANGE_MAGNITUDE
220    } else {
221        min.abs().log10().abs()
222    };
223    let max_magnitude = if max == INFINITY {
224        INF_RANGE_MAGNITUDE
225    } else {
226        max.log10().abs()
227    };
228
229    let cutoff = min_magnitude / (min_magnitude + max_magnitude);
230    debug_assert!(
231        (0.0..=1.0).contains(&cutoff),
232        "Bad cutoff {cutoff:?} for min {min:?} and max {max:?}"
233    );
234    cutoff
235}
236
237// ---------------------------------------------------------------------------
238// Smart aim — find the "roundest" number in a range so dragging snaps to nice
239// values.  Ported from egui's `emath::smart_aim`.
240// ---------------------------------------------------------------------------
241
242const NUM_DECIMALS: usize = 16;
243
244#[inline]
245fn fast_midpoint(a: f64, b: f64) -> f64 {
246    (a + b) / 2.0
247}
248
249#[inline]
250fn u8_midpoint(a: u8, b: u8) -> u8 {
251    ((a as u16 + b as u16) / 2) as u8
252}
253
254/// Find the "simplest" number in the closed range `[min, max]` — the one with
255/// the fewest decimal digits.  E.g. `[0.83, 1.354] -> 1.0`, `[0.37, 0.48] -> 0.4`.
256pub fn best_in_range_f64(min: f64, max: f64) -> f64 {
257    // Avoid NaN if we can:
258    if min.is_nan() {
259        return max;
260    }
261    if max.is_nan() {
262        return min;
263    }
264
265    if max < min {
266        return best_in_range_f64(max, min);
267    }
268    if min == max {
269        return min;
270    }
271    if min <= 0.0 && 0.0 <= max {
272        return 0.0; // always prefer zero
273    }
274    if min < 0.0 {
275        return -best_in_range_f64(-max, -min);
276    }
277
278    debug_assert!(0.0 < min && min < max, "Logic bug");
279
280    // Prefer finite numbers:
281    if !max.is_finite() {
282        return min;
283    }
284
285    let min_exponent = min.log10();
286    let max_exponent = max.log10();
287
288    if min_exponent.floor() != max_exponent.floor() {
289        // Different orders of magnitude — pick the geometric center of the two:
290        let exponent = fast_midpoint(min_exponent, max_exponent);
291        return 10.0_f64.powi(exponent.round() as i32);
292    }
293
294    if is_integer(min_exponent) {
295        return 10.0_f64.powf(min_exponent);
296    }
297    if is_integer(max_exponent) {
298        return 10.0_f64.powf(max_exponent);
299    }
300
301    // Find the proper scale, then convert to integers:
302    let scale = NUM_DECIMALS as i32 - max_exponent.floor() as i32 - 1;
303    let scale_factor = 10.0_f64.powi(scale);
304
305    let min_str = to_decimal_string((min * scale_factor).round() as u64);
306    let max_str = to_decimal_string((max * scale_factor).round() as u64);
307
308    // Two positive integers of the same length. Find the first non-matching
309    // ("deciding") digit; everything before it matches, everything after is
310    // zero, and the deciding digit is a "smart average".
311    let mut ret_str = [0u8; NUM_DECIMALS];
312
313    for i in 0..NUM_DECIMALS {
314        if min_str[i] == max_str[i] {
315            ret_str[i] = min_str[i];
316        } else {
317            let mut deciding_digit_min = min_str[i];
318            let deciding_digit_max = max_str[i];
319
320            debug_assert!(deciding_digit_min < deciding_digit_max, "Bug in smart aim");
321
322            let rest_of_min_is_zeroes = min_str[i + 1..].iter().all(|&c| c == 0);
323
324            if !rest_of_min_is_zeroes {
325                // There are more digits after `deciding_digit_min`, so we can't
326                // pick it — the true selectable min is one greater:
327                deciding_digit_min += 1;
328            }
329
330            let deciding_digit = if deciding_digit_min == 0 {
331                0
332            } else if deciding_digit_min <= 5 && 5 <= deciding_digit_max {
333                5 // 5 is the roundest number in the range
334            } else {
335                u8_midpoint(deciding_digit_min, deciding_digit_max)
336            };
337
338            ret_str[i] = deciding_digit;
339
340            return from_decimal_string(ret_str) as f64 / scale_factor;
341        }
342    }
343
344    min // All digits the same (already handled earlier, but be safe).
345}
346
347fn is_integer(f: f64) -> bool {
348    f.round() == f
349}
350
351fn to_decimal_string(v: u64) -> [u8; NUM_DECIMALS] {
352    let mut ret = [0u8; NUM_DECIMALS];
353    let mut value = v;
354    for i in (0..NUM_DECIMALS).rev() {
355        ret[i] = (value % 10) as u8;
356        value /= 10;
357    }
358    ret
359}
360
361fn from_decimal_string(s: [u8; NUM_DECIMALS]) -> u64 {
362    let mut value = 0u64;
363    for &c in &s {
364        debug_assert!(c <= 9, "Bad number");
365        value = value * 10 + c as u64;
366    }
367    value
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    fn log_spec() -> SliderSpec {
375        SliderSpec {
376            logarithmic: true,
377            smallest_positive: 1e-6,
378            largest_finite: f64::INFINITY,
379        }
380    }
381
382    #[test]
383    fn linear_mapping_roundtrips() {
384        let spec = SliderSpec::default();
385        for &(v, n) in &[(0.0, 0.0), (5.0, 0.5), (10.0, 1.0), (2.5, 0.25)] {
386            assert!((normalized_from_value(v, 0.0, 10.0, &spec) - n).abs() < 1e-9);
387            assert!((value_from_normalized(n, 0.0, 10.0, &spec) - v).abs() < 1e-9);
388        }
389    }
390
391    #[test]
392    fn linear_clamps_outside_range() {
393        let spec = SliderSpec::default();
394        assert_eq!(normalized_from_value(-5.0, 0.0, 10.0, &spec), 0.0);
395        assert_eq!(normalized_from_value(50.0, 0.0, 10.0, &spec), 1.0);
396    }
397
398    #[test]
399    fn logarithmic_midpoint_is_geometric_mean() {
400        let spec = log_spec();
401        // For 1..=100 log slider, the halfway position should be ~10.
402        let mid = value_from_normalized(0.5, 1.0, 100.0, &spec);
403        assert!((mid - 10.0).abs() < 1e-6, "got {mid}");
404        // And the inverse holds.
405        let n = normalized_from_value(10.0, 1.0, 100.0, &spec);
406        assert!((n - 0.5).abs() < 1e-9, "got {n}");
407    }
408
409    #[test]
410    fn logarithmic_reversed_range() {
411        let spec = log_spec();
412        // High-to-low range: normalized 0 -> max value.
413        let v = value_from_normalized(0.0, 100.0, 1.0, &spec);
414        assert!((v - 100.0).abs() < 1e-9, "got {v}");
415    }
416
417    #[test]
418    fn logarithmic_spanning_zero_puts_zero_at_cutoff() {
419        let spec = log_spec();
420        // Symmetric range around zero -> cutoff at 0.5, value there is ~0.
421        let cutoff = logarithmic_zero_cutoff(-1000.0, 1000.0);
422        assert!((cutoff - 0.5).abs() < 1e-9, "got {cutoff}");
423        let v = value_from_normalized(0.5, -1000.0, 1000.0, &spec);
424        assert!(v.abs() < 1.0, "expected near zero, got {v}");
425    }
426
427    #[test]
428    fn logarithmic_spanning_infinity() {
429        let spec = log_spec();
430        // 0..=∞ maps normalized 1.0 to ∞ and 0.0 to 0.0.
431        assert_eq!(
432            value_from_normalized(1.0, 0.0, f64::INFINITY, &spec),
433            f64::INFINITY
434        );
435        assert_eq!(value_from_normalized(0.0, 0.0, f64::INFINITY, &spec), 0.0);
436        // A midpoint stays finite and positive.
437        let mid = value_from_normalized(0.5, 0.0, f64::INFINITY, &spec);
438        assert!(mid.is_finite() && mid > 0.0, "got {mid}");
439    }
440
441    /// Regression: the Sliders demo's range sliders span `-∞..=∞` (logarithmic)
442    /// and the demo slider itself may be rebuilt with an infinite bound. The
443    /// initial displayed value/position must be finite — never `NaN` — for the
444    /// exact values the demo starts with. (egui's Sliders demo shows finite
445    /// values, never NaN.)
446    #[test]
447    fn infinite_range_initial_mapping_is_finite() {
448        let spec = log_spec();
449        // Demo slider default: value 10 in 0..=10000 (log).
450        let n = normalized_from_value(10.0, 0.0, 10000.0, &spec);
451        assert!(n.is_finite() && (0.0..=1.0).contains(&n), "demo n={n}");
452
453        // Range sliders: both bounds infinite. The current min (0) and max
454        // (10000) must map to a finite, in-range position.
455        for &v in &[0.0, 10000.0] {
456            let n = normalized_from_value(v, -f64::INFINITY, f64::INFINITY, &spec);
457            assert!(n.is_finite() && (0.0..=1.0).contains(&n), "v={v} n={n}");
458        }
459        // A finite normalized position maps back to a finite value.
460        let mid = value_from_normalized(0.5, -f64::INFINITY, f64::INFINITY, &spec);
461        assert!(mid.is_finite(), "mid={mid}");
462    }
463
464    /// One infinite bound (e.g. `min..=∞` after the user drags a range slider):
465    /// a finite value in the range still maps to a finite, in-range position,
466    /// and round-trips.
467    #[test]
468    fn one_infinite_bound_roundtrips_finite() {
469        let spec = log_spec();
470        let n = normalized_from_value(10.0, -f64::INFINITY, 10000.0, &spec);
471        assert!(n.is_finite() && (0.0..=1.0).contains(&n), "n={n}");
472        let v = value_from_normalized(n, -f64::INFINITY, 10000.0, &spec);
473        assert!(v.is_finite(), "v={v}");
474
475        let n = normalized_from_value(10.0, 0.0, f64::INFINITY, &spec);
476        assert!(n.is_finite() && (0.0..=1.0).contains(&n), "n={n}");
477    }
478
479    #[test]
480    fn clamp_handles_reversed_range() {
481        assert_eq!(clamp_value_to_range(5.0, 10.0, 0.0), 5.0);
482        assert_eq!(clamp_value_to_range(-1.0, 10.0, 0.0), 0.0);
483        assert_eq!(clamp_value_to_range(11.0, 10.0, 0.0), 10.0);
484    }
485
486    // Smart-aim cases ported from egui's `smart_aim::test_aim`.
487    #[test]
488    fn smart_aim_round_numbers() {
489        assert_eq!(best_in_range_f64(-0.2, 0.0), 0.0);
490        assert_eq!(best_in_range_f64(-10_004.23, 3.14), 0.0);
491        assert_eq!(best_in_range_f64(7.8, 17.8), 10.0);
492        assert_eq!(best_in_range_f64(99.0, 300.0), 100.0);
493        assert_eq!(best_in_range_f64(-99.0, -300.0), -100.0);
494        assert_eq!(best_in_range_f64(0.4, 0.9), 0.5);
495        assert_eq!(best_in_range_f64(14.1, 19.99), 15.0);
496        assert_eq!(best_in_range_f64(12.3, 65.9), 50.0);
497        assert_eq!(best_in_range_f64(493.0, 879.0), 500.0);
498        assert_eq!(best_in_range_f64(0.37, 0.48), 0.40);
499        assert_eq!(best_in_range_f64(7.5, 16.3), 10.0);
500        assert_eq!(best_in_range_f64(7.5, 763.3), 100.0);
501        assert_eq!(best_in_range_f64(7.5, 123_456.0), 1000.0);
502        assert_eq!(best_in_range_f64(9.9999, 99.999), 10.0);
503        assert_eq!(best_in_range_f64(10.001, 99.999), 50.0);
504    }
505
506    #[test]
507    fn smart_aim_integers() {
508        assert_eq!(best_in_range_f64(99.0, 300.0), 100.0);
509        assert_eq!(best_in_range_f64(4.0, 9.0), 5.0);
510        assert_eq!(best_in_range_f64(14.0, 19.0), 15.0);
511        assert_eq!(best_in_range_f64(12.0, 65.0), 50.0);
512        assert_eq!(best_in_range_f64(37.0, 48.0), 40.0);
513        assert_eq!(best_in_range_f64(12345.0, 12780.0), 12500.0);
514    }
515
516    #[test]
517    fn smart_aim_nan_and_infinity() {
518        assert!(best_in_range_f64(f64::NAN, f64::NAN).is_nan());
519        assert_eq!(best_in_range_f64(f64::NAN, 1.2), 1.2);
520        assert_eq!(best_in_range_f64(1.2, f64::INFINITY), 1.2);
521        assert_eq!(best_in_range_f64(f64::NEG_INFINITY, 1.2), 0.0);
522        assert_eq!(best_in_range_f64(f64::NEG_INFINITY, -2.7), -2.7);
523        assert_eq!(best_in_range_f64(f64::NEG_INFINITY, f64::INFINITY), 0.0);
524    }
525}