diff --git a/egui/src/input.rs b/egui/src/input.rs index 7311d652..630edaff 100644 --- a/egui/src/input.rs +++ b/egui/src/input.rs @@ -244,6 +244,18 @@ impl InputState { ) }) } + + /// Size of a physical pixel in logical gui coordinates (points). + pub fn physical_pixel_size(&self) -> f32 { + 1.0 / self.pixels_per_point + } + + /// How imprecise do we expect the mouse/touch input to be? + /// Returns imprecision in points. + pub fn aim_radius(&self) -> f32 { + // TODO: multiply by ~3 for touch inputs because fingers are fat + self.physical_pixel_size() + } } impl MouseInput { diff --git a/egui/src/math.rs b/egui/src/math.rs index 2b406944..4a48d681 100644 --- a/egui/src/math.rs +++ b/egui/src/math.rs @@ -7,6 +7,7 @@ use std::ops::{Add, Mul, RangeInclusive}; mod movement_tracker; mod pos2; mod rect; +pub mod smart_aim; mod vec2; pub use {movement_tracker::*, pos2::*, rect::*, vec2::*}; diff --git a/egui/src/math/smart_aim.rs b/egui/src/math/smart_aim.rs new file mode 100644 index 00000000..4f857cbb --- /dev/null +++ b/egui/src/math/smart_aim.rs @@ -0,0 +1,160 @@ +#![allow(clippy::float_cmp)] // I know what I'm doing + +const NUM_DECIMALS: usize = 15; + +pub fn best_in_range_f32(min: f32, max: f32) -> f32 { + best_in_range_f64(min as f64, max as f64) as f32 +} + +/// Find the "simplest" number in a closed range [min, max], i.e. the one with the fewest decimal digits. +/// +/// So in the range `[0.83, 1.354]` you will get `1.0`, and for `[0.37, 0.48]` you will get `0.4`. +/// This is used when dragging sliders etc to get the values that users are most likely to desire. +/// This assumes a decimal centric user. +pub fn best_in_range_f64(min: f64, max: f64) -> f64 { + // Avoid NaN if we can: + if min.is_nan() { + return max; + } + if max.is_nan() { + return min; + } + + if max < min { + return best_in_range_f64(max, min); + } + if min == max { + return min; + } + if min <= 0.0 && 0.0 <= max { + return 0.0; // always prefer zero + } + if min < 0.0 { + return -best_in_range_f64(-max, -min); + } + + // Prefer finite numbers: + if !max.is_finite() { + return min; + } + debug_assert!(min.is_finite() && max.is_finite()); + + let min_exponent = min.log10(); + let max_exponent = max.log10(); + + if min_exponent.floor() != max_exponent.floor() { + // pick the geometric center of the two: + let exponent = (min_exponent + max_exponent) / 2.0; + return 10.0_f64.powi(exponent.round() as i32); + } + + if is_integer(min_exponent) { + return 10.0_f64.powf(min_exponent); + } + if is_integer(max_exponent) { + return 10.0_f64.powf(max_exponent); + } + + let exp_factor = 10.0_f64.powi(max_exponent.floor() as i32); + + let min_str = to_decimal_string(min / exp_factor); + let max_str = to_decimal_string(max / exp_factor); + + // eprintln!("min_str: {:?}", min_str); + // eprintln!("max_str: {:?}", max_str); + + let mut ret_str = [0; NUM_DECIMALS]; + + // Select the common prefix: + let mut i = 0; + while i < NUM_DECIMALS && max_str[i] == min_str[i] { + ret_str[i] = max_str[i]; + i += 1; + } + + if i < NUM_DECIMALS { + // Pick the deciding digit. + // Note that "to_decimal_string" rounds down, so we that's why we add 1 here + ret_str[i] = simplest_digit_closed_range(min_str[i] + 1, max_str[i]); + } + + from_decimal_string(&ret_str) * exp_factor +} + +fn is_integer(f: f64) -> bool { + f.round() == f +} + +fn to_decimal_string(v: f64) -> [i32; NUM_DECIMALS] { + debug_assert!(v < 10.0, "{:?}", v); + let mut digits = [0; NUM_DECIMALS]; + let mut v = v.abs(); + for r in digits.iter_mut() { + let digit = v.floor(); + *r = digit as i32; + v -= digit; + v *= 10.0; + } + digits +} + +fn from_decimal_string(s: &[i32]) -> f64 { + let mut ret: f64 = 0.0; + for (i, &digit) in s.iter().enumerate() { + ret += (digit as f64) * 10.0_f64.powi(-(i as i32)); + } + ret +} + +/// Find the simplest integer in the range [min, max] +fn simplest_digit_closed_range(min: i32, max: i32) -> i32 { + debug_assert!(1 <= min && min <= max && max <= 9); + if min <= 5 && 5 <= max { + 5 + } else { + (min + max) / 2 + } +} + +#[test] +fn test_aim() { + assert_eq!(best_in_range_f64(-0.2, 0.0), 0.0, "Prefer zero"); + assert_eq!(best_in_range_f64(-10_004.23, 3.14), 0.0, "Prefer zero"); + assert_eq!(best_in_range_f64(-0.2, 100.0), 0.0, "Prefer zero"); + assert_eq!(best_in_range_f64(0.2, 0.0), 0.0, "Prefer zero"); + assert_eq!(best_in_range_f64(7.8, 17.8), 10.0); + assert_eq!(best_in_range_f64(99.0, 300.0), 100.0); + assert_eq!(best_in_range_f64(-99.0, -300.0), -100.0); + assert_eq!(best_in_range_f64(0.4, 0.9), 0.5, "Prefer ending on 5"); + assert_eq!(best_in_range_f64(14.1, 19.99), 15.0, "Prefer ending on 5"); + assert_eq!(best_in_range_f64(12.3, 65.9), 50.0, "Prefer leading 5"); + assert_eq!(best_in_range_f64(493.0, 879.0), 500.0, "Prefer leading 5"); + assert_eq!(best_in_range_f64(0.37, 0.48), 0.40); + // assert_eq!(best_in_range_f64(123.71, 123.76), 123.75); // TODO: we get 123.74999999999999 here + assert_eq!(best_in_range_f32(123.71, 123.76), 123.75); // TODO: we get 123.74999999999999 here + assert_eq!(best_in_range_f64(7.5, 16.3), 10.0); + assert_eq!(best_in_range_f64(7.5, 76.3), 10.0); + assert_eq!(best_in_range_f64(7.5, 763.3), 100.0); + assert_eq!(best_in_range_f64(7.5, 1_345.0), 100.0); + assert_eq!(best_in_range_f64(7.5, 123_456.0), 1000.0, "Geometric mean"); + assert_eq!(best_in_range_f64(9.9999, 99.999), 10.0); + assert_eq!(best_in_range_f64(10.000, 99.999), 10.0); + assert_eq!(best_in_range_f64(10.001, 99.999), 50.0); + assert_eq!(best_in_range_f64(10.001, 100.000), 100.0); + assert_eq!(best_in_range_f64(99.999, 100.000), 100.0); + assert_eq!(best_in_range_f64(10.001, 100.001), 100.0); + + use std::f64::{INFINITY, NAN, NEG_INFINITY}; + assert!(best_in_range_f64(NAN, NAN).is_nan()); + assert_eq!(best_in_range_f64(NAN, 1.2), 1.2); + assert_eq!(best_in_range_f64(NAN, INFINITY), INFINITY); + assert_eq!(best_in_range_f64(1.2, NAN), 1.2); + assert_eq!(best_in_range_f64(1.2, INFINITY), 1.2); + assert_eq!(best_in_range_f64(INFINITY, 1.2), 1.2); + assert_eq!(best_in_range_f64(NEG_INFINITY, 1.2), 0.0); + assert_eq!(best_in_range_f64(NEG_INFINITY, -2.7), -2.7); + assert_eq!(best_in_range_f64(INFINITY, INFINITY), INFINITY); + assert_eq!(best_in_range_f64(NEG_INFINITY, NEG_INFINITY), NEG_INFINITY); + assert_eq!(best_in_range_f64(NEG_INFINITY, INFINITY), 0.0); + assert_eq!(best_in_range_f64(INFINITY, NEG_INFINITY), 0.0); +} diff --git a/egui/src/widgets.rs b/egui/src/widgets.rs index cc9b3c16..6b9cec11 100644 --- a/egui/src/widgets.rs +++ b/egui/src/widgets.rs @@ -581,6 +581,8 @@ impl<'a> Widget for DragValue<'a> { if delta_value != 0.0 { *value += delta_value; *value = round_to_precision(*value, precision); + // TODO: To make use or `smart_aim` for `DragValue` we need to store some state somewhere, + // otherwise we will just keep rounding to the same value while moving the mouse. } } interact.into() diff --git a/egui/src/widgets/slider.rs b/egui/src/widgets/slider.rs index 94b41b5b..48eacd12 100644 --- a/egui/src/widgets/slider.rs +++ b/egui/src/widgets/slider.rs @@ -82,6 +82,9 @@ impl<'a> Slider<'a> { self } + /// Precision (number of decimals) used when displaying the value. + /// Values will also be rounded to this precision. + /// Regardless of precision the slider will use "smart aim" to help the user select nice, round values. pub fn precision(mut self, precision: usize) -> Self { self.precision = precision; self @@ -95,6 +98,11 @@ impl<'a> Slider<'a> { value = round_to_precision(value, self.precision); (self.get_set_value)(Some(value)); } + + /// For instance, `point` is the mouse position and `point_range` is the physical location of the slider on the screen. + fn value_from_point(&self, point: f32, point_range: RangeInclusive) -> f32 { + remap_clamp(point, point_range, self.range.clone()) + } } impl<'a> Widget for Slider<'a> { @@ -154,7 +162,12 @@ impl<'a> Widget for Slider<'a> { if let Some(mouse_pos) = ui.input().mouse.pos { if interact.active { - self.set_value_f32(remap_clamp(mouse_pos.x, left..=right, range.clone())); + let aim_radius = ui.input().aim_radius(); + let new_value = crate::math::smart_aim::best_in_range_f32( + self.value_from_point(mouse_pos.x - aim_radius, left..=right), + self.value_from_point(mouse_pos.x + aim_radius, left..=right), + ); + self.set_value_f32(new_value); } }