Skip to content

Commit 9253acd

Browse files
committed
Fix edge cases in "smart aiming" in sliders (#7680)
When dragging slider, we try to pick nice, round values. There were a couple edge cases there that were handled wrong. This is now fixed.
1 parent 1a6f2ab commit 9253acd

File tree

1 file changed

+115
-43
lines changed

1 file changed

+115
-43
lines changed

crates/emath/src/smart_aim.rs

Lines changed: 115 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ pub fn best_in_range_f64(min: f64, max: f64) -> f64 {
3131
return -best_in_range_f64(-max, -min);
3232
}
3333

34+
debug_assert!(0.0 < min && min < max, "Logic bug");
35+
3436
// Prefer finite numbers:
3537
if !max.is_finite() {
3638
return min;
@@ -44,7 +46,8 @@ pub fn best_in_range_f64(min: f64, max: f64) -> f64 {
4446
let max_exponent = max.log10();
4547

4648
if min_exponent.floor() != max_exponent.floor() {
47-
// pick the geometric center of the two:
49+
// Different orders of magnitude.
50+
// Pick the geometric center of the two:
4851
let exponent = fast_midpoint(min_exponent, max_exponent);
4952
return 10.0_f64.powi(exponent.round() as i32);
5053
}
@@ -56,65 +59,85 @@ pub fn best_in_range_f64(min: f64, max: f64) -> f64 {
5659
return 10.0_f64.powf(max_exponent);
5760
}
5861

59-
let exp_factor = 10.0_f64.powi(max_exponent.floor() as i32);
62+
// Find the proper scale, and then convert to integers:
6063

61-
let min_str = to_decimal_string(min / exp_factor);
62-
let max_str = to_decimal_string(max / exp_factor);
64+
let scale = NUM_DECIMALS as i32 - max_exponent.floor() as i32 - 1;
65+
let scale_factor = 10.0_f64.powi(scale);
6366

64-
let mut ret_str = [0; NUM_DECIMALS];
67+
let min_str = to_decimal_string((min * scale_factor).round() as u64);
68+
let max_str = to_decimal_string((max * scale_factor).round() as u64);
6569

66-
// Select the common prefix:
67-
let mut i = 0;
68-
while i < NUM_DECIMALS && max_str[i] == min_str[i] {
69-
ret_str[i] = max_str[i];
70-
i += 1;
71-
}
70+
// We now have two positive integers of the same length.
71+
// We want to find the first non-matching digit,
72+
// which we will call the "deciding digit".
73+
// Everything before it will be the same,
74+
// everything after will be zero,
75+
// and the deciding digit itself will be picked as a "smart average"
76+
// min: 12345
77+
// max: 12780
78+
// output: 12500
7279

73-
if i < NUM_DECIMALS {
74-
// Pick the deciding digit.
75-
// Note that "to_decimal_string" rounds down, so we that's why we add 1 here
76-
ret_str[i] = simplest_digit_closed_range(min_str[i] + 1, max_str[i]);
80+
let mut ret_str = [0; NUM_DECIMALS];
81+
82+
for i in 0..NUM_DECIMALS {
83+
if min_str[i] == max_str[i] {
84+
ret_str[i] = min_str[i];
85+
} else {
86+
// Found the deciding digit at index `i`
87+
let mut deciding_digit_min = min_str[i];
88+
let deciding_digit_max = max_str[i];
89+
90+
debug_assert!(
91+
deciding_digit_min < deciding_digit_max,
92+
"Bug in smart aim code"
93+
);
94+
95+
let rest_of_min_is_zeroes = min_str[i + 1..].iter().all(|&c| c == 0);
96+
97+
if !rest_of_min_is_zeroes {
98+
// There are more digits coming after `deciding_digit_min`, so we cannot pick it.
99+
// So the true min of what we can pick is one greater:
100+
deciding_digit_min += 1;
101+
}
102+
103+
let deciding_digit = if deciding_digit_min == 0 {
104+
0
105+
} else if deciding_digit_min <= 5 && 5 <= deciding_digit_max {
106+
5 // 5 is the roundest number in the range
107+
} else {
108+
deciding_digit_min.midpoint(deciding_digit_max)
109+
};
110+
111+
ret_str[i] = deciding_digit;
112+
113+
return from_decimal_string(ret_str) as f64 / scale_factor;
114+
}
77115
}
78116

79-
from_decimal_string(&ret_str) * exp_factor
117+
min // All digits are the same. Already handled earlier, but better safe than sorry
80118
}
81119

82120
fn is_integer(f: f64) -> bool {
83121
f.round() == f
84122
}
85123

86-
fn to_decimal_string(v: f64) -> [i32; NUM_DECIMALS] {
87-
debug_assert!(v < 10.0, "{v:?}");
88-
let mut digits = [0; NUM_DECIMALS];
89-
let mut v = v.abs();
90-
for r in &mut digits {
91-
let digit = v.floor();
92-
*r = digit as i32;
93-
v -= digit;
94-
v *= 10.0;
95-
}
96-
digits
97-
}
98-
99-
fn from_decimal_string(s: &[i32]) -> f64 {
100-
let mut ret: f64 = 0.0;
101-
for (i, &digit) in s.iter().enumerate() {
102-
ret += (digit as f64) * 10.0_f64.powi(-(i as i32));
124+
fn to_decimal_string(v: u64) -> [u8; NUM_DECIMALS] {
125+
let mut ret = [0; NUM_DECIMALS];
126+
let mut value = v;
127+
for i in (0..NUM_DECIMALS).rev() {
128+
ret[i] = (value % 10) as u8;
129+
value /= 10;
103130
}
104131
ret
105132
}
106133

107-
/// Find the simplest integer in the range [min, max]
108-
fn simplest_digit_closed_range(min: i32, max: i32) -> i32 {
109-
debug_assert!(
110-
1 <= min && min <= max && max <= 9,
111-
"min should be in [1, 9], but was {min:?} and max should be in [min, 9], but was {max:?}"
112-
);
113-
if min <= 5 && 5 <= max {
114-
5
115-
} else {
116-
min.midpoint(max)
134+
fn from_decimal_string(s: [u8; NUM_DECIMALS]) -> u64 {
135+
let mut value = 0;
136+
for &c in &s {
137+
debug_assert!(c <= 9, "Bad number");
138+
value = value * 10 + c as u64;
117139
}
140+
value
118141
}
119142

120143
#[expect(clippy::approx_constant)]
@@ -161,4 +184,53 @@ fn test_aim() {
161184
assert_eq!(best_in_range_f64(NEG_INFINITY, NEG_INFINITY), NEG_INFINITY);
162185
assert_eq!(best_in_range_f64(NEG_INFINITY, INFINITY), 0.0);
163186
assert_eq!(best_in_range_f64(INFINITY, NEG_INFINITY), 0.0);
187+
188+
#[track_caller]
189+
fn test_f64((min, max): (f64, f64), expected: f64) {
190+
let aimed = best_in_range_f64(min, max);
191+
assert!(
192+
aimed == expected,
193+
"smart_aim({min} – {max}) => {aimed}, but expected {expected}"
194+
);
195+
}
196+
#[track_caller]
197+
fn test_i64((min, max): (i64, i64), expected: i64) {
198+
let aimed = best_in_range_f64(min as _, max as _);
199+
assert!(
200+
aimed == expected as f64,
201+
"smart_aim({min} – {max}) => {aimed}, but expected {expected}"
202+
);
203+
}
204+
205+
test_i64((99, 300), 100);
206+
test_i64((300, 99), 100);
207+
test_i64((-99, -300), -100);
208+
test_i64((-99, 123), 0); // Prefer zero
209+
test_i64((4, 9), 5); // Prefer ending on 5
210+
test_i64((14, 19), 15); // Prefer ending on 5
211+
test_i64((12, 65), 50); // Prefer leading 5
212+
test_i64((493, 879), 500); // Prefer leading 5
213+
test_i64((37, 48), 40);
214+
test_i64((100, 123), 100);
215+
test_i64((101, 1000), 1000);
216+
test_i64((999, 1000), 1000);
217+
test_i64((123, 500), 500);
218+
test_i64((500, 777), 500);
219+
test_i64((500, 999), 500);
220+
test_i64((12345, 12780), 12500);
221+
test_i64((12371, 12376), 12375);
222+
test_i64((12371, 12376), 12375);
223+
224+
test_f64((7.5, 16.3), 10.0);
225+
test_f64((7.5, 76.3), 10.0);
226+
test_f64((7.5, 763.3), 100.0);
227+
test_f64((7.5, 1_345.0), 100.0); // Geometric mean
228+
test_f64((7.5, 123_456.0), 1_000.0); // Geometric mean
229+
test_f64((-0.2, 0.0), 0.0); // Prefer zero
230+
test_f64((-10_004.23, 4.14), 0.0); // Prefer zero
231+
test_f64((-0.2, 100.0), 0.0); // Prefer zero
232+
test_f64((0.2, 0.0), 0.0); // Prefer zero
233+
test_f64((7.8, 17.8), 10.0);
234+
test_f64((14.1, 19.1), 15.0); // Prefer ending on 5
235+
test_f64((12.3, 65.9), 50.0); // Prefer leading 5
164236
}

0 commit comments

Comments
 (0)