Number fields: the rest of a wheel notch a field took no longer scrolls the panel

egui spreads a wheel notch over the next frames. When a number field took
a notch and the pointer then left the field, the rest of that notch
scrolled the panel under the pointer, or a panel drawn before the field.

- A begin-pass plugin (`WheelTailPlugin`) keeps, per viewport, how much of
  the notches number fields took egui has not let out yet, and takes what
  egui lets out of them out of each pass's scrolling before any panel
  scrolls.
- It reads each pass's wheel events in order with egui's touch rules
  (start, end, cancel, and the timeout of a touch that never ends), so a
  turn inside a touch, or one ended in the same pass, leaves nothing owed.
- A turn the other way on the panel while the rest runs out scrolls the
  panel by its whole size.
This commit is contained in:
dotCooCoo
2026-11-12 06:51:42 -08:00
parent 81247fdae9
commit 32611d8f54
2 changed files with 396 additions and 3 deletions
+228
View File
@@ -1145,6 +1145,234 @@ fn a_frame_with_turns_on_and_off_a_number_field_scrolls_the_panel() {
assert_eq!(value.get(), 10.0);
}
/// The rest of a notch that a number field took, which egui spreads over the next frames, scrolls
/// no panel after the pointer leaves the field: neither the field's own panel nor a panel drawn
/// before it. A notch turned after that scrolls the panel under the pointer by one whole notch.
#[test]
fn the_rest_of_a_notch_a_number_field_took_scrolls_no_panel_after_the_pointer_leaves() {
for to_other_panel in [false, true] {
let mut f = Field::new();
let (left, right, value, field) = (Cell::new(0.0), Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING));
let draw = |ui: &mut egui::Ui| {
ui.horizontal_top(|ui| {
let out = egui::ScrollArea::vertical().id_salt("left").max_height(120.0).show(ui, |ui| {
ui.allocate_space(vec2(100.0, 600.0));
});
left.set(out.state.clamped_offset().y);
let out = egui::ScrollArea::vertical().id_salt("right").max_height(120.0).show(ui, |ui| {
ui.vertical(|ui| {
let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v));
field.set(ui.min_rect());
ui.add_space(600.0);
v
})
.inner
});
right.set(out.state.clamped_offset().y);
out.inner
})
.inner
};
f.frame(vec![], &draw);
f.frame(vec![], &draw);
let on = field.get().center();
let off = if to_other_panel { Pos2::new(50.0, 60.0) } else { Pos2::new(on.x, field.get().bottom() + 40.0) };
assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), Some(9.0));
f.frame(vec![Event::PointerMoved(off)], &draw);
for _ in 0..20 {
f.frame(vec![], &draw);
}
assert_eq!((left.get(), right.get(), value.get()), (0.0, 0.0, 9.0), "pointer moved to the other panel: {to_other_panel}");
assert_eq!(f.frame(vec![notch_down()], &draw), None);
for _ in 0..30 {
f.frame(vec![], &draw);
}
let line = f.ctx.options(|o| o.input_options.line_scroll_speed);
let (scrolled, still) = if to_other_panel { (left.get(), right.get()) } else { (right.get(), left.get()) };
assert!((scrolled - line).abs() < 0.01 && still == 0.0, "other panel {to_other_panel}: {scrolled} {still}");
assert_eq!(value.get(), 9.0);
}
}
/// A wheel touch that starts and never ends (some mouse wheels send no end) stops counting as a
/// touch once no wheel event comes for a while, as egui stops it, so the rest of a notch a number
/// field takes after that still scrolls no panel after the pointer leaves.
#[test]
fn a_wheel_touch_with_no_end_does_not_let_the_rest_of_a_later_notch_scroll_the_panel() {
let mut f = Field::new();
let (offset, value, field) = (Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING));
let draw = |ui: &mut egui::Ui| {
let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| {
ui.vertical(|ui| {
let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v));
field.set(ui.min_rect());
ui.add_space(600.0);
v
})
.inner
});
offset.set(out.state.clamped_offset().y);
out.inner
};
f.frame(vec![], &draw);
f.frame(vec![], &draw);
let on = field.get().center();
let off = Pos2::new(on.x, field.get().bottom() + 40.0);
assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), Some(9.0));
for _ in 0..20 {
f.frame(vec![], &draw);
}
let start = Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: egui::Vec2::ZERO,
phase: egui::TouchPhase::Start,
modifiers: Modifiers::NONE,
source: egui::MouseWheelSource::Wheel,
};
f.frame(vec![start], &draw);
for _ in 0..10 {
f.frame(vec![], &draw);
}
assert_eq!(f.frame(vec![notch_down()], &draw), Some(8.0));
f.frame(vec![Event::PointerMoved(off)], &draw);
for _ in 0..20 {
f.frame(vec![], &draw);
}
assert_eq!((offset.get(), value.get()), (0.0, 8.0));
}
/// A whole wheel touch (start, turn and end) in one pass that a number field takes leaves nothing
/// for egui to let out later, so the next notch on the panel scrolls it by one whole notch.
#[test]
fn a_whole_touch_in_one_pass_on_a_number_field_leaves_the_next_panel_notch_whole() {
let mut f = Field::new();
let (offset, value, field) = (Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING));
let draw = |ui: &mut egui::Ui| {
let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| {
let v = ui.vertical(|ui| {
let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v));
field.set(ui.min_rect());
v
});
ui.add_space(600.0);
v.inner
});
offset.set(out.state.clamped_offset().y);
out.inner
};
f.frame(vec![], &draw);
f.frame(vec![], &draw);
let on = field.get().center();
let off = Pos2::new(on.x, field.get().bottom() + 40.0);
assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), Some(9.0));
for _ in 0..20 {
f.frame(vec![], &draw);
}
let touch = |phase| Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: if phase == egui::TouchPhase::Move { vec2(0.0, -1.0) } else { egui::Vec2::ZERO },
phase,
modifiers: Modifiers::NONE,
source: egui::MouseWheelSource::Wheel,
};
let events = vec![touch(egui::TouchPhase::Start), touch(egui::TouchPhase::Move), touch(egui::TouchPhase::End)];
assert_eq!(f.frame(events, &draw), Some(8.0));
f.frame(vec![Event::PointerMoved(off), notch_down()], &draw);
for _ in 0..30 {
f.frame(vec![], &draw);
}
let line = f.ctx.options(|o| o.input_options.line_scroll_speed);
assert!((offset.get() - line).abs() < 0.01, "{} {line}", offset.get());
assert_eq!(value.get(), 8.0);
}
/// A turn and the end of the wheel stream in one pass that a number field takes leave nothing for
/// egui to let out later, so the next notch on the panel scrolls it by one whole notch.
#[test]
fn a_turn_ended_in_its_own_pass_on_a_number_field_leaves_the_next_panel_notch_whole() {
let mut f = Field::new();
let (offset, value, field) = (Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING));
let draw = |ui: &mut egui::Ui| {
let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| {
let v = ui.vertical(|ui| {
let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v));
field.set(ui.min_rect());
v
});
ui.add_space(600.0);
v.inner
});
offset.set(out.state.clamped_offset().y);
out.inner
};
f.frame(vec![], &draw);
f.frame(vec![], &draw);
let on = field.get().center();
let off = Pos2::new(on.x, field.get().bottom() + 40.0);
let end = Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: egui::Vec2::ZERO,
phase: egui::TouchPhase::End,
modifiers: Modifiers::NONE,
source: egui::MouseWheelSource::Wheel,
};
assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down(), end], &draw), Some(9.0));
f.frame(vec![Event::PointerMoved(off), notch_down()], &draw);
for _ in 0..30 {
f.frame(vec![], &draw);
}
let line = f.ctx.options(|o| o.input_options.line_scroll_speed);
assert!((offset.get() - line).abs() < 0.01, "{} {line}", offset.get());
assert_eq!(value.get(), 9.0);
}
/// A small turn the other way on the panel while the rest of a notch a number field took is still
/// being let out scrolls the panel by the whole turn.
#[test]
fn a_turn_the_other_way_while_a_number_field_notch_runs_out_scrolls_the_panel_by_the_whole_turn() {
let mut f = Field::new();
let (offset, value, field) = (Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING));
let draw = |ui: &mut egui::Ui| {
let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| {
ui.add_space(150.0);
let v = ui.vertical(|ui| {
let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v));
field.set(ui.min_rect());
v
});
ui.add_space(600.0);
v.inner
});
offset.set(out.state.clamped_offset().y);
out.inner
};
f.frame(vec![], &draw);
f.frame(vec![], &draw);
let line = f.ctx.options(|o| o.input_options.line_scroll_speed);
let above = |field: Rect| Pos2::new(field.center().x, field.top() - 30.0);
f.frame(vec![Event::PointerMoved(Pos2::new(field.get().center().x, 10.0)), notch_down(), notch_down()], &draw);
for _ in 0..30 {
f.frame(vec![], &draw);
}
let scrolled = offset.get();
assert!((scrolled - 2.0 * line).abs() < 0.01, "{scrolled}");
f.frame(vec![Event::PointerMoved(field.get().center())], &draw);
assert_eq!(f.frame(vec![notch_down()], &draw), Some(9.0));
let up = Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, 10.0),
phase: egui::TouchPhase::Move,
modifiers: Modifiers::NONE,
source: egui::MouseWheelSource::Wheel,
};
f.frame(vec![Event::PointerMoved(above(field.get())), up], &draw);
for _ in 0..30 {
f.frame(vec![], &draw);
}
assert!((offset.get() - (scrolled - 10.0)).abs() < 0.01, "{} {scrolled}", offset.get());
assert_eq!(value.get(), 9.0);
}
/// A number field that missed a pass (its panel was hidden) waits for the pointer to move onto it
/// again before it takes the wheel, with or without another number field drawn meanwhile.
#[test]
+168 -3
View File
@@ -302,13 +302,29 @@ fn step_value(
}
// Wheel turns in notches: a line each, or `line` points of a trackpad or a fine wheel; what is
// 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 (options, notch_id) = (ui.ctx().options(|o| o.input_options), resp.id.with("wheel"));
let line = options.line_scroll_speed;
let mut notches: f32 = if wheel { ui.data(|d| d.get_temp(notch_id)).unwrap_or(0.0) } else { 0.0 };
let tail_key = wheel_tail_id(ui.ctx().viewport_id());
let mut tail: WheelTail = if wheel { ui.data(|d| d.get_temp(tail_key)).unwrap_or_default() } else { WheelTail::default() };
let pass = ui.ctx().cumulative_pass_nr();
let stored: Option<(u64, Vec<(Event, egui::Vec2)>)> = if wheel { ui.data(|d| d.get_temp(wheel_turns_id(tail_key))) } else { None };
let mut measured = stored.filter(|(at, _)| *at == pass).map(|(_, turns)| turns);
let mut turns = Vec::new();
let mut took = egui::Vec2::ZERO;
// 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();
let mut taken = taken;
ui.input_mut(|i| {
let (page, dt) = (i.viewport_rect().height(), i.stable_dt);
// In a pass [`WheelTailPlugin`] did not run in (it is added in this one), the field
// measures the pass's turns itself.
turns = match measured.take() {
Some(turns) => turns,
None if wheel => wheel_turns(&mut tail, &i.events, &options, page, i.time),
None => Vec::new(),
};
i.events.retain(|e| {
// Each taken event is matched once by its value, so wheel events that other code adds
// or removes before the field runs do not shift which events the field takes.
@@ -318,7 +334,15 @@ fn step_value(
let n = match e {
Event::Key { key: Key::ArrowUp, pressed: true, .. } if focused => 1.0,
Event::Key { key: Key::ArrowDown, pressed: true, .. } if focused => -1.0,
Event::MouseWheel { unit, delta, .. } if mine => {
Event::MouseWheel { unit, delta, phase, .. } if mine => {
if *phase == egui::TouchPhase::Move {
// Each turn of the pass was measured in the order egui read them; a turn
// other code added after that falls back on the touch state.
took += match turns.iter().position(|(t, _)| t == e) {
Some(at) => turns.remove(at).1,
None => unsmoothed_points(&options, page, tail.touch, *unit, *delta, *modifiers),
};
}
// Shift may turn the wheel sideways (the Mac does).
let d = if modifiers.shift { delta.x + delta.y } else { delta.y };
notches += match unit {
@@ -340,9 +364,16 @@ fn step_value(
// The rest of a turn egui spreads over the next frames scrolls nothing either.
i.smooth_scroll_delta = egui::Vec2::ZERO;
}
// What egui has not let out of the turns taken here yet stays out of scrolling after the
// pointer leaves the field ([`WheelTailPlugin`]).
tail.owed += took - tail_release(took, dt);
});
if wheel {
ui.data_mut(|d| d.insert_temp(notch_id, notches));
ui.data_mut(|d| {
d.insert_temp(notch_id, notches);
d.insert_temp(tail_key, tail);
d.insert_temp(wheel_turns_id(tail_key), (pass, turns));
});
}
if steps.is_empty() {
return None;
@@ -442,6 +473,9 @@ fn number_wheel_turns(ui: &Ui, resp: &Response) -> (Vec<egui::Event>, bool) {
Some((state.walk, state.before.clone()))
});
let walked = fresh.map(|(walk, before)| {
if ctx.with_plugin(|_: &mut WheelTailPlugin| ()).is_none() {
ctx.add_plugin(WheelTailPlugin);
}
let (events, now) = ui.input(|i| (i.events.clone(), i.time));
walk_wheel(ctx, walk, &events, &before, now, pass)
});
@@ -557,6 +591,137 @@ fn walk_wheel(
(walk, taken)
}
/// The wheel turns that number fields took in one viewport and egui has not let out yet (points),
/// whether a trackpad touch is running there, and the time and source of the last wheel event.
#[derive(Clone, Copy, Default)]
struct WheelTail {
owed: egui::Vec2,
touch: bool,
last: f64,
trackpad: bool,
}
impl WheelTail {
/// Ends a touch that egui ends at `time` because no wheel event came for a while (its
/// `WheelState::is_scroll_action_over`): 1 s for a trackpad, 0.15 s for any other source.
fn expire_touch(&mut self, time: f64) {
let timeout = if self.trackpad { 1.0 } else { 0.150 };
if self.touch && timeout < time - self.last {
self.touch = false;
}
}
}
fn wheel_tail_id(viewport: egui::ViewportId) -> egui::Id {
egui::Id::unique("number-field-wheel-tail").with(viewport)
}
/// Where the wheel turns of a pass are kept with their [`unsmoothed_points`] and the pass number,
/// from [`wheel_turns`].
fn wheel_turns_id(tail: egui::Id) -> egui::Id {
tail.with("turns")
}
/// Reads the wheel events of a pass at `time` in order, as egui does, into `tail`'s touch state
/// (a start begins a touch; an end or cancel ends it and drops what is owed; a touch with no event
/// for a while ends), and returns each turn with its [`unsmoothed_points`] at that point.
fn wheel_turns(tail: &mut WheelTail, events: &[egui::Event], options: &egui::InputOptions, page: f32, time: f64) -> Vec<(egui::Event, egui::Vec2)> {
let mut turns = Vec::new();
for event in events {
if let egui::Event::MouseWheel { unit, delta, phase, modifiers, source } = event {
tail.expire_touch(time);
tail.last = time;
tail.trackpad = *source == egui::MouseWheelSource::Trackpad;
match phase {
egui::TouchPhase::Start => tail.touch = true,
egui::TouchPhase::End | egui::TouchPhase::Cancel => {
tail.touch = false;
tail.owed = egui::Vec2::ZERO;
// egui drops what the pass's earlier turns left as well.
turns.iter_mut().for_each(|(_, points)| *points = egui::Vec2::ZERO);
}
egui::TouchPhase::Move => turns.push((event.clone(), unsmoothed_points(options, page, tail.touch, *unit, *delta, *modifiers))),
}
}
}
tail.expire_touch(time);
turns
}
/// The points of wheel turn `delta` in `unit` that egui spreads over the next passes instead of
/// scrolling at once (its `WheelState::on_wheel_event`): a turn sideways with the horizontal
/// scroll modifier, up and down with the vertical one, and nothing for a zoom turn, a touch or a
/// turn under 8 points. `page` is the viewport's height. `modifiers` are the turn's own: egui keeps a
/// touch's modifiers only inside the touch, where nothing is spread out.
fn unsmoothed_points(
options: &egui::InputOptions,
page: f32,
touch: bool,
unit: egui::MouseWheelUnit,
delta: egui::Vec2,
modifiers: egui::Modifiers,
) -> egui::Vec2 {
let mut d = match unit {
egui::MouseWheelUnit::Point => delta,
egui::MouseWheelUnit::Line => options.line_scroll_speed * delta,
egui::MouseWheelUnit::Page => page * delta,
};
let (sideways, upright) = (modifiers.matches_any(options.horizontal_scroll_modifier), modifiers.matches_any(options.vertical_scroll_modifier));
if sideways && !upright {
d = egui::vec2(d.x + d.y, 0.0);
}
if upright && !sideways {
d = egui::vec2(0.0, d.x + d.y);
}
let smooth = touch || unit == egui::MouseWheelUnit::Point && d.length() < 8.0;
if smooth || modifiers.matches_any(options.zoom_modifier) { egui::Vec2::ZERO } else { d }
}
/// What egui lets out in a pass `dt` seconds long of `owed` points of wheel input it has not let
/// out yet (its `WheelState::after_events`): all of an axis under a point, otherwise the share that
/// lets out 90% in 0.1 s.
fn tail_release(owed: egui::Vec2, dt: f32) -> egui::Vec2 {
let t = egui::emath::exponential_smooth_factor(0.90, 0.1, dt.min(0.1));
let axis = |v: f32| if v.abs() < 1.0 { v } else { t * v };
egui::vec2(axis(owed.x), axis(owed.y))
}
/// Takes out of each pass's scrolling what egui lets out of the wheel turns number fields took
/// ([`WheelTail`]), before any panel scrolls, so the rest of a turn a field took scrolls nothing
/// after the pointer leaves the field. A turn's end or cancel drops what is left. It also measures
/// each wheel turn of the pass with the touch state egui had at that turn, for [`step_value`].
#[derive(Default)]
struct WheelTailPlugin;
impl egui::Plugin for WheelTailPlugin {
fn debug_name(&self) -> &'static str {
"number field wheel tail"
}
fn on_begin_pass(&mut self, ui: &mut Ui) {
let key = wheel_tail_id(ui.ctx().viewport_id());
let (options, pass) = (ui.ctx().options(|o| o.input_options), ui.ctx().cumulative_pass_nr());
let mut tail = ui.data(|d| d.get_temp::<WheelTail>(key)).unwrap_or_default();
let mut turns = Vec::new();
ui.input_mut(|i| {
turns = wheel_turns(&mut tail, &i.events, &options, i.viewport_rect().height(), i.time);
if tail.owed == egui::Vec2::ZERO {
return;
}
let release = tail_release(tail.owed, i.stable_dt);
tail.owed -= release;
// A zoom turn takes what egui lets out, so nothing of it reaches scrolling.
if i.smooth_scroll_delta != egui::Vec2::ZERO || i.zoom_delta() == 1.0 {
i.smooth_scroll_delta -= release;
}
});
ui.data_mut(|d| {
d.insert_temp(key, tail);
d.insert_temp(wheel_turns_id(key), (pass, turns));
});
}
}
/// A [`num_field`] for a width or height (Transform panel, Properties panel, Control bar): Ctrl/Cmd+Enter
/// also commits it and ends its editing, as Enter does, and [`enter_modifiers`] reports Command. A
/// percentage typed alone (`50%`) is that share of the size shown, as in Illustrator (#2401).