Fields: arrow keys and the wheel step to the next whole unit, and Ctrl/Cmd steps to the next tenth

In number fields, Up/Down and a wheel notch added a whole step and kept
the fraction, so 5.1 mm stepped up to 6.1 mm. A plain step now goes to
the next whole unit in its direction (5.1 goes up to 6 and down to 5; 5
goes up to 6 and down to 4), and a Ctrl/Cmd step goes to the next tenth,
in the field's own unit. Repeated presses or notches of one size add or
take one step each, and presses and notches are applied in the order
they came. A Shift step still adds or takes ten and keeps the fraction.

The number fields that widgets.rs draws for the panels, the Control bar
and the dialogs (unit, percent, degree and count fields) all use this
step (widgets::step_value).
This commit is contained in:
dotCooCoo
2026-10-18 23:50:57 +07:00
committed by bflatastic
parent 1b8e3d9ae1
commit b30d80e056
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