diff --git a/crates/ui-egui/src/panels/tests_strokeux.rs b/crates/ui-egui/src/panels/tests_strokeux.rs index deb01832e..4ab96f35a 100644 --- a/crates/ui-egui/src/panels/tests_strokeux.rs +++ b/crates/ui-egui/src/panels/tests_strokeux.rs @@ -222,3 +222,24 @@ fn every_weight_spinner_steps_to_the_next_preset() { } } } + +/// A weight the field shows with a preset's text has reached that preset: one click down on the +/// spinner from 2.004 pt, shown as `0.167 p` like the 2 pt preset, goes on to 1 pt. +#[test] +fn a_spinner_step_from_a_weight_shown_as_a_preset_moves_past_it() { + use super::tests_appearance::frame_raw; + + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = app_with_rect(); + app.session.prefs.units_stroke = "picas".into(); + run(&mut app, "stroke.set", json!({"weight": 2.004})); + let t = texts(&ctx, &mut app, stroke::show); + let r = text_rect(&t, "0.167 p"); + let pos = egui::pos2(r.left() - 14.0, r.center().y + 5.0); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + for event in [egui::Event::PointerMoved(pos), button(true), button(false)] { + frame_raw(&ctx, &mut app, egui::RawInput { events: vec![event], ..Default::default() }, stroke::show); + } + assert_eq!(app.session.shown_stroke().unwrap().width, 1.0); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 0f8ab9a16..c55f72c4f 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -276,17 +276,21 @@ pub(crate) mod accessibility_tests { mod spinner_modifier_tests { use super::*; + fn pt(v: f64) -> String { + Unit::Points.format(v) + } + #[test] fn shift_steps_stroke_weight_by_ten_points() { - assert_eq!(spinner_step(14.0, 1.0, 0.0, true, egui::Modifiers::SHIFT, &[]), 24.0); - assert_eq!(spinner_step(14.0, 1.0, 0.0, false, egui::Modifiers::SHIFT, &[]), 4.0); + assert_eq!(spinner_step(14.0, 1.0, 0.0, true, egui::Modifiers::SHIFT, &[], &pt), 24.0); + assert_eq!(spinner_step(14.0, 1.0, 0.0, false, egui::Modifiers::SHIFT, &[], &pt), 4.0); } #[test] fn unmodified_and_command_steps_keep_existing_behavior() { - assert_eq!(spinner_step(14.0, 1.0, 0.0, true, egui::Modifiers::NONE, &[]), 15.0); - assert_eq!(spinner_step(14.0, 1.0, 0.0, false, egui::Modifiers::COMMAND, &[]), 13.9); - assert_eq!(spinner_step(0.0, 1.0, 0.0, false, egui::Modifiers::SHIFT, &[]), 0.0); + assert_eq!(spinner_step(14.0, 1.0, 0.0, true, egui::Modifiers::NONE, &[], &pt), 15.0); + assert_eq!(spinner_step(14.0, 1.0, 0.0, false, egui::Modifiers::COMMAND, &[], &pt), 13.9); + assert_eq!(spinner_step(0.0, 1.0, 0.0, false, egui::Modifiers::SHIFT, &[], &pt), 0.0); } /// A plain click on the spinner stops at each stop; Shift and Ctrl/Cmd steps do not. @@ -294,12 +298,21 @@ mod spinner_modifier_tests { fn a_plain_spinner_step_stops_at_the_stops() { let stops = [0.25, 0.5, 0.75, 1.0, 2.0, 10.0, 20.0]; let plain = egui::Modifiers::NONE; - assert_eq!(spinner_step(0.52, 1.0, 0.0, false, plain, &stops), 0.5); - assert_eq!(spinner_step(0.52, 1.0, 0.0, true, plain, &stops), 0.75); - assert_eq!(spinner_step(0.1, 1.0, 0.0, false, plain, &stops), 0.0); - assert_eq!(spinner_step(14.0, 1.0, 0.0, true, plain, &stops), 15.0); - assert_eq!(spinner_step(14.0, 1.0, 0.0, false, egui::Modifiers::SHIFT, &stops), 4.0); - assert!((spinner_step(0.52, 1.0, 0.0, false, egui::Modifiers::COMMAND, &stops) - 0.42).abs() < 1e-9); + assert_eq!(spinner_step(0.52, 1.0, 0.0, false, plain, &stops, &pt), 0.5); + assert_eq!(spinner_step(0.52, 1.0, 0.0, true, plain, &stops, &pt), 0.75); + assert_eq!(spinner_step(0.1, 1.0, 0.0, false, plain, &stops, &pt), 0.0); + assert_eq!(spinner_step(14.0, 1.0, 0.0, true, plain, &stops, &pt), 15.0); + assert_eq!(spinner_step(14.0, 1.0, 0.0, false, egui::Modifiers::SHIFT, &stops, &pt), 4.0); + assert!((spinner_step(0.52, 1.0, 0.0, false, egui::Modifiers::COMMAND, &stops, &pt) - 0.42).abs() < 1e-9); + } + + /// A stop the spinner's text shows as the value counts as reached: 2.004 pt and the 2 pt stop + /// both show as `0.167 p`, so a click down goes on to 1 pt. + #[test] + fn a_spinner_stop_shown_as_the_value_is_passed() { + let pc = |v: f64| Unit::Picas.format(v); + assert_eq!(spinner_step(2.004, 1.0, 0.0, false, egui::Modifiers::NONE, &[1.0, 2.0, 3.0], &pc), 1.0); + assert_eq!(spinner_step(2.004, 1.0, 0.0, true, egui::Modifiers::NONE, &[1.0, 2.0, 3.0], &pc), 3.0); } } @@ -352,6 +365,9 @@ mod step_through_tests { assert_eq!(step_through(0.167, 1.0, -1.0, &picas, shown(Unit::Picas)), 1.0 / 12.0); assert_eq!(step_through(0.083, 1.0, 1.0, &picas, shown(Unit::Picas)), 2.0 / 12.0); assert_eq!(step_through(0.016, 1.0, -1.0, &[0.0078, 0.0156], shown(Unit::Inches)), 0.0156); + // The next whole step shown as the value is passed too: 2.0004 pt shows as `2 pt`. + assert_eq!(step_through(2.0004, 1.0, -1.0, &[1.0, 2.0], shown(Unit::Points)), 1.0); + assert_eq!(step_through(2.0004, 1.0, -1.0, &[], shown(Unit::Points)), 1.0); } } diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index 3b36d0a99..7fd6d7322 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -137,13 +137,17 @@ pub(super) fn step_to_multiple(v: f64, size: f64, n: f64) -> f64 { /// `v` moved `n` steps of `size` (down when negative), where each step goes to the nearer of the /// next multiple of `size` ([`step_to_multiple`]) and the next of `stops` in its direction. By 1 with /// stops at 0.25, 0.5 and 0.75, 0.52 goes down to 0.5 and up to 0.75, 1 goes down to 0.75, and 14 -/// goes up to 15. A stop that `same` finds equal to the value counts as reached: in a field, a stop -/// shown with the same text as the value (`0.167 p` for 2 pt) moves on to the next. +/// goes up to 15. A stop or multiple that `same` finds equal to the value counts as reached: a +/// stop shown with the same text as the value (`0.167 p` for 2 pt, see [`shown_alike`]) moves on +/// to the next. pub(super) fn step_through(v: f64, size: f64, n: f64, stops: &[f64], same: impl Fn(f64, f64) -> bool) -> f64 { let mut v = v; // One call takes at most 1,000 steps. for _ in 0..n.abs().min(1000.0) as u32 { - let multiple = step_to_multiple(v, size, n.signum()); + let mut multiple = step_to_multiple(v, size, n.signum()); + if same(multiple, v) { + multiple = step_to_multiple(multiple, size, n.signum()); + } let ahead = |s: f64| (if n > 0.0 { s > v } else { s < v }) && !same(s, v); let stop = stops.iter().copied().filter(|&s| ahead(s)); v = if n > 0.0 { stop.fold(multiple, f64::min) } else { stop.fold(multiple, f64::max) }; @@ -152,10 +156,17 @@ pub(super) fn step_through(v: f64, size: f64, n: f64, stops: &[f64], same: impl } /// Whether `a` and `b` are within a millionth of `size` of each other. +#[cfg(test)] pub(super) fn within_a_millionth(size: f64) -> impl Fn(f64, f64) -> bool { move |a, b| (a - b).abs() <= size.abs() * 1e-6 } +/// Whether `show` gives `a` and `b` the same text, for [`step_through`]: the field's ↑/↓ and the +/// spinner both treat a stop shown as the value as reached. +pub(super) fn shown_alike(show: impl Fn(f64) -> String) -> impl Fn(f64, f64) -> bool { + move |a, b| show(a) == show(b) +} + /// ↑/↓ in focused numeric field `resp`, or the mouse wheel turned over it, step the number `buf` /// shows (`read` parses it, `show` formats it) per press or wheel notch ([`STEPS`]): a plain step /// goes to the next whole number in its direction (5.1 up to 6, down to 5), a Ctrl/Cmd step goes to @@ -231,7 +242,7 @@ fn step_value( // The field's own steps: ten at once with Shift, one with or without Ctrl/Cmd. Some(step) => step(v, if mods == egui::Modifiers::SHIFT { 10.0 * n } else { n }), None if mods == egui::Modifiers::SHIFT => v + size * n, - None if mods == egui::Modifiers::NONE && !stops.is_empty() => step_through(v, size, n, stops, |a, b| show(a) == show(b)), + None if mods == egui::Modifiers::NONE && !stops.is_empty() => step_through(v, size, n, stops, shown_alike(&show)), None => step_to_multiple(v, size, n), }; // Each step stops at the bounds, so a step back in the same frame starts from there. @@ -604,7 +615,7 @@ fn spin_generic( && let Some(v) = value { let modifiers = ui.input(|i| i.modifiers); - out = Some(SpinPick::Value(spinner_step(v, step, min, up.contains(p), modifiers, stops))); + out = Some(SpinPick::Value(spinner_step(v, step, min, up.contains(p), modifiers, stops, fmt))); } let fw = if presets.is_empty() { width - 15.0 } else { width - 34.0 }; if let Some(v) = field(ui, fw, label) { @@ -633,11 +644,20 @@ fn spin_generic( /// Spinner buttons follow the same modifier sizes as ↑/↓ in a focused number field: /// Shift ×10, Ctrl/Cmd ÷10, otherwise the caller's normal step, which also stops at each of -/// `stops` ([`step_through`]). -pub(super) fn spinner_step(value: f64, step: f64, min: f64, up: bool, modifiers: egui::Modifiers, stops: &[f64]) -> f64 { +/// `stops` ([`step_through`]). A stop that `show` writes with the same text as the value counts as +/// reached, as in the field. +pub(super) fn spinner_step( + value: f64, + step: f64, + min: f64, + up: bool, + modifiers: egui::Modifiers, + stops: &[f64], + show: &dyn Fn(f64) -> String, +) -> f64 { let (mods, multiplier) = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)).map_or((egui::Modifiers::NONE, 1.0), |&(m, f)| (m, f)); if mods == egui::Modifiers::NONE && !stops.is_empty() { - return step_through(value, step, if up { 1.0 } else { -1.0 }, stops, within_a_millionth(step)).max(min); + return step_through(value, step, if up { 1.0 } else { -1.0 }, stops, shown_alike(show)).max(min); } let delta = step * multiplier; (value + if up { delta } else { -delta }).max(min)