Merge pull request #2916 from dotCooCoo/fix/issue-2881

Fields: arrow keys and the wheel step to the next whole unit, and Ctrl/Cmd steps to the next tenth
This commit is contained in:
BFlat
2026-10-19 00:06:26 +07:00
committed by GitHub
3 changed files with 175 additions and 34 deletions
+84 -5
View File
@@ -447,16 +447,95 @@ fn arrow_keys_step_a_focused_field() {
(Key::ArrowUp, Modifiers::NONE, 5.0),
(Key::ArrowUp, Modifiers::SHIFT, 15.0),
(Key::ArrowDown, Modifiers::COMMAND, 14.9),
(Key::ArrowDown, Modifiers::NONE, 13.9),
(Key::ArrowDown, Modifiers::NONE, 14.0),
] {
assert!(f.frame(vec![key(k, mods)], &draw).is_some(), "{k:?} {mods:?}");
assert!((mm.get() - want).abs() < 1e-6, "{k:?} {mods:?}: {}", mm.get());
}
assert_eq!(f.selection(), Some((0, "13.9 mm".len())), "the new value is selected");
assert_eq!(f.selection(), Some((0, "14 mm".len())), "the new value is selected");
// Enter after the steps commits nothing more.
assert_eq!(f.frame(vec![enter()], &draw), None);
}
/// #2881, #2793: ↑/↓ and the wheel step a fractional value to the next whole unit in their
/// direction (5.1 mm: up to 6, down to 5), and Ctrl/Cmd steps to the next tenth; Shift adds or takes
/// ten and keeps the fraction. In each field's own unit: millimeters, points, percent, degrees.
#[test]
fn steps_go_to_the_next_whole_unit_or_tenth() {
use egui::MouseWheelUnit::Line;
let u = Unit::Millimeters;
let mm = Cell::new(0.0);
let mm_field = |ui: &mut egui::Ui| widgets::num_field(ui, "f", Some(u.to_pt(mm.get())), u, 80.0).inspect(|&v| mm.set(u.from_pt(v)));
let mut f = Field::new();
f.frame(vec![], &mm_field);
f.frame(f.click(1), &mm_field);
f.frame(vec![], &mm_field);
let at = f.rect.get().center();
// From `from` (set from outside, which the focused field follows), the value `events` give.
let mut step = |from: f64, events: Vec<Event>| {
mm.set(from);
f.frame(vec![], &mm_field);
assert!(f.frame(events, &mm_field).is_some(), "from {from}");
// The field shows the new value before the next one is set.
f.frame(vec![], &mm_field);
mm.get()
};
let arrow = |k, mods| vec![key(k, mods)];
let turn = |dy, mods| wheel(at, Line, dy, mods);
for (from, events, want) in [
(5.1, arrow(Key::ArrowUp, Modifiers::NONE), 6.0),
(5.1, arrow(Key::ArrowDown, Modifiers::NONE), 5.0),
(5.0, arrow(Key::ArrowUp, Modifiers::NONE), 6.0),
(5.0, arrow(Key::ArrowDown, Modifiers::NONE), 4.0),
(-2.5, arrow(Key::ArrowUp, Modifiers::NONE), -2.0),
(-2.5, arrow(Key::ArrowDown, Modifiers::NONE), -3.0),
(5.13, arrow(Key::ArrowUp, Modifiers::COMMAND), 5.2),
(5.13, arrow(Key::ArrowDown, Modifiers::COMMAND), 5.1),
(5.1, arrow(Key::ArrowUp, Modifiers::SHIFT), 15.1),
(5.1, arrow(Key::ArrowDown, Modifiers::SHIFT), -4.9),
(5.1, vec![key(Key::ArrowUp, Modifiers::NONE), key(Key::ArrowUp, Modifiers::NONE)], 7.0),
// Several presses in one frame step in the order they came.
(5.1, vec![key(Key::ArrowUp, Modifiers::NONE), key(Key::ArrowDown, Modifiers::NONE)], 5.0),
(5.13, vec![key(Key::ArrowUp, Modifiers::NONE), key(Key::ArrowUp, Modifiers::COMMAND)], 6.1),
(24.8, turn(1.0, Modifiers::NONE), 25.0),
(24.8, turn(-1.0, Modifiers::NONE), 24.0),
(24.8, turn(2.0, Modifiers::NONE), 26.0),
(24.8, turn(-2.0, Modifiers::NONE), 23.0),
(24.0, turn(1.0, Modifiers::NONE), 25.0),
(24.83, turn(1.0, Modifiers::COMMAND), 24.9),
(24.8, turn(1.0, Modifiers::SHIFT), 34.8),
] {
let got = step(from, events);
assert!((got - want).abs() < 1e-6, "{from} mm to {got}, not {want}");
}
// A points field steps whole points.
let pt = Cell::new(12.25);
let pt_field = |ui: &mut egui::Ui| widgets::num_field(ui, "f", Some(pt.get()), Unit::Points, 80.0).inspect(|&v| pt.set(v));
let mut f = Field::new();
f.frame(vec![], &pt_field);
f.frame(f.click(1), &pt_field);
f.frame(vec![], &pt_field);
assert_eq!(f.frame(vec![key(Key::ArrowUp, Modifiers::NONE)], &pt_field), Some(13.0));
// Percent and degree fields step whole percents and degrees.
for suffix in ["%", "°"] {
let mut f = Field::new();
let value = Cell::new(37.5);
let plain = |ui: &mut egui::Ui| widgets::plain_field(ui, "f", value.get(), suffix, 2, 80.0).inspect(|&v| value.set(v));
f.frame(vec![], &plain);
f.frame(f.click(1), &plain);
f.frame(vec![], &plain);
for (k, mods, want) in [
(Key::ArrowDown, Modifiers::NONE, 37.0),
(Key::ArrowUp, Modifiers::NONE, 38.0),
(Key::ArrowUp, Modifiers::COMMAND, 38.1),
(Key::ArrowUp, Modifiers::NONE, 39.0),
] {
assert_eq!(f.frame(vec![key(k, mods)], &plain), Some(want), "{suffix} {k:?} {mods:?}");
f.frame(vec![], &plain);
}
}
}
/// Plain fields (degrees, percent, counts) step too, at their precision.
#[test]
fn arrow_keys_step_plain_fields() {
@@ -498,17 +577,17 @@ fn the_wheel_steps_a_focused_field_under_the_pointer() {
f.frame(f.click(1), &draw);
f.frame(vec![], &draw);
for (dy, mods, want) in
[(1.0, Modifiers::NONE, 5.0), (2.0, Modifiers::SHIFT, 25.0), (-1.0, Modifiers::COMMAND, 24.9), (-1.0, Modifiers::NONE, 23.9)]
[(1.0, Modifiers::NONE, 5.0), (2.0, Modifiers::SHIFT, 25.0), (-1.0, Modifiers::COMMAND, 24.9), (-1.0, Modifiers::NONE, 24.0)]
{
assert!(f.frame(wheel(at, Line, dy, mods), &draw).is_some(), "{dy} {mods:?}");
assert!((mm.get() - want).abs() < 1e-6, "{dy} {mods:?}: {}", mm.get());
}
assert_eq!(f.selection(), Some((0, "23.9 mm".len())), "the new value is selected");
assert_eq!(f.selection(), Some((0, "24 mm".len())), "the new value is selected");
// Points: a step for each line's worth, what is left carried to the next turn.
let line = f.ctx.options(|o| o.input_options.line_scroll_speed);
assert_eq!(f.frame(wheel(at, Point, line * 0.6, Modifiers::NONE), &draw), None);
assert!(f.frame(wheel(at, Point, line * 0.6, Modifiers::NONE), &draw).is_some());
assert!((mm.get() - 24.9).abs() < 1e-6, "{}", mm.get());
assert!((mm.get() - 25.0).abs() < 1e-6, "{}", mm.get());
assert_eq!(f.frame(wheel(Pos2::new(390.0, 190.0), Line, 1.0, Modifiers::NONE), &draw), None, "the pointer elsewhere");
// Plain fields (degrees, percent, counts) too.
let mut f = Field::new();
+90 -29
View File
@@ -260,15 +260,31 @@ pub(crate) fn round_to(v: f64, decimals: i32) -> f64 {
}
/// One step of a numeric field with each modifier, most specific first (a plain pattern would also
/// match Shift): ten with Shift, a tenth with Ctrl/Cmd, else one.
/// match Shift): ten with Shift, a tenth with Ctrl/Cmd, else one. A Ctrl/Cmd or plain step goes to
/// the next multiple of its size ([`step_to_multiple`]); a Shift step adds or takes ten.
const STEPS: [(egui::Modifiers, f64); 3] = [(egui::Modifiers::SHIFT, 10.0), (egui::Modifiers::COMMAND, 0.1), (egui::Modifiers::NONE, 1.0)];
/// `v` moved `n` steps of `size` (down when `n` is negative): the first step goes to the next
/// multiple of `size` in its direction and each further step adds or takes one `size`. By 1, 5.1
/// goes up to 6 and down to 5, and 5 goes up to 6 and down to 4. A value within a millionth of a
/// step of a multiple counts as that multiple.
fn step_to_multiple(v: f64, size: f64, n: f64) -> f64 {
if n == 0.0 {
return v;
}
let q = v / size;
let from = if n > 0.0 { (q + 1e-6).floor() } else { (q - 1e-6).ceil() };
(from + n) * size
}
/// ↑/↓ in focused numeric field `resp`, or the mouse wheel turned over it, step the number `buf`
/// shows (`read` parses it, `show` formats it) by one per press or wheel notch ([`STEPS`]: Shift
/// ten, Ctrl/Cmd a tenth), applied at once as in Illustrator's panels and dialogs. The new text
/// replaces `buf`, all selected so typing replaces it. Returns the new number when a step was
/// taken. While the pointer is over the focused field the wheel is the field's (the panel under it
/// doesn't scroll); an unfocused field leaves the wheel to the panel and the canvas.
/// 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
/// the next tenth, and a Shift step adds or takes ten. The new value is applied at once, as in
/// Illustrator's panels and dialogs, and the new text replaces `buf`, all selected so typing
/// replaces it. Returns the new number when a step was taken. While the pointer is over the focused
/// field the wheel is the field's (the panel under it doesn't scroll); an unfocused field leaves the
/// wheel to the panel and the canvas.
fn step_value(ui: &Ui, resp: &Response, buf: &mut String, read: impl Fn(&str) -> Option<f64>, show: impl Fn(f64) -> String) -> Option<f64> {
// Memory focus, as the field's highlight and its typing use: `Response::has_focus` also needs the
// window to report keyboard focus.
@@ -281,41 +297,51 @@ fn step_value(ui: &Ui, resp: &Response, buf: &mut String, read: impl Fn(&str) ->
// left of a notch carries over to the next turn.
let (line, notch_id) = (ui.ctx().options(|o| o.input_options.line_scroll_speed), resp.id.with("wheel"));
let mut notches: f32 = if wheel { ui.data(|d| d.get_temp(notch_id)).unwrap_or(0.0) } else { 0.0 };
let mut steps = 0.0;
// Each press and each turn of whole notches in the order they came: its modifiers and size from
// [`STEPS`], and how many steps up (down when negative).
let mut steps: Vec<(egui::Modifiers, f64, f64)> = Vec::new();
ui.input_mut(|i| {
for (mods, size) in STEPS {
let up = i.count_and_consume_key(mods, Key::ArrowUp) as f64;
let down = i.count_and_consume_key(mods, Key::ArrowDown) as f64;
steps += size * (up - down);
}
if !wheel {
return;
}
i.events.retain(|e| {
let Event::MouseWheel { unit, delta, modifiers, .. } = e else { return true };
let Some((_, size)) = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)) else { return true };
// Shift may turn the wheel sideways (the Mac does).
let d = if modifiers.shift { delta.x + delta.y } else { delta.y };
notches += match unit {
MouseWheelUnit::Point => d / line.max(1.0),
MouseWheelUnit::Line | MouseWheelUnit::Page => d,
let (Event::Key { modifiers, .. } | Event::MouseWheel { modifiers, .. }) = e else { return true };
let Some(&(mods, size)) = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)) else { return true };
let n = match e {
Event::Key { key: Key::ArrowUp, pressed: true, .. } => 1.0,
Event::Key { key: Key::ArrowDown, pressed: true, .. } => -1.0,
Event::MouseWheel { unit, delta, .. } if wheel => {
// Shift may turn the wheel sideways (the Mac does).
let d = if modifiers.shift { delta.x + delta.y } else { delta.y };
notches += match unit {
MouseWheelUnit::Point => d / line.max(1.0),
MouseWheelUnit::Line | MouseWheelUnit::Page => d,
};
let whole = notches.trunc();
notches -= whole;
f64::from(whole)
}
_ => return true,
};
let whole = notches.trunc();
notches -= whole;
steps += size * f64::from(whole);
if n != 0.0 {
steps.push((mods, size, n));
}
false
});
// The rest of a turn egui spreads over the next frames scrolls nothing either.
i.smooth_scroll_delta = egui::Vec2::ZERO;
if wheel {
// The rest of a turn egui spreads over the next frames scrolls nothing either.
i.smooth_scroll_delta = egui::Vec2::ZERO;
}
});
if wheel {
ui.data_mut(|d| d.insert_temp(notch_id, notches));
}
if steps == 0.0 {
if steps.is_empty() {
return None;
}
let mut v = read(buf)?;
for (mods, size, n) in steps {
v = if mods == egui::Modifiers::SHIFT { v + size * n } else { step_to_multiple(v, size, n) };
}
// Rounded as fields show it.
let v = round_to(read(buf)? + steps, STEP_DECIMALS);
let v = round_to(v, STEP_DECIMALS);
*buf = show(v);
ui.data_mut(|d| d.insert_temp(resp.id, buf.clone()));
select_all(ui, resp.id, buf);
@@ -2554,6 +2580,41 @@ mod spinner_modifier_tests {
}
}
#[cfg(test)]
mod step_to_multiple_tests {
use super::*;
/// #2881: the first step goes to the next multiple of its size in its direction, and each further
/// step adds or takes one size.
#[test]
fn a_step_goes_to_the_next_multiple_of_its_size() {
for (v, size, n, want) in [
(5.1, 1.0, 1.0, 6.0),
(5.1, 1.0, -1.0, 5.0),
(5.0, 1.0, 1.0, 6.0),
(5.0, 1.0, -1.0, 4.0),
(-2.5, 1.0, 1.0, -2.0),
(-2.5, 1.0, -1.0, -3.0),
(24.8, 1.0, 2.0, 26.0),
(24.8, 1.0, -2.0, 23.0),
(5.13, 0.1, 1.0, 5.2),
(5.13, 0.1, -1.0, 5.1),
(5.1, 1.0, 0.0, 5.1),
] {
assert!((step_to_multiple(v, size, n) - want).abs() < 1e-9, "{v} by {size} × {n}: {}", step_to_multiple(v, size, n));
}
}
/// A value a float error away from a multiple moves a whole step from it: 0.7 is 6.999… tenths.
#[test]
fn a_value_next_to_a_multiple_moves_a_whole_step() {
assert!((step_to_multiple(0.7, 0.1, 1.0) - 0.8).abs() < 1e-9);
assert!((step_to_multiple(0.3, 0.1, -1.0) - 0.2).abs() < 1e-9);
assert!((step_to_multiple(5.000_000_1, 1.0, 1.0) - 6.0).abs() < 1e-9);
assert!((step_to_multiple(4.999_999_9, 1.0, -1.0) - 4.0).abs() < 1e-9);
}
}
#[cfg(test)]
mod menu_room_tests {
use super::*;
File diff suppressed because one or more lines are too long