Merge pull request #6897 from dotCooCoo/fix/number-wheel-smoothing-tail

Number fields: the rest of a wheel notch a field took no longer scrolls the panel
This commit is contained in:
BFlat
2026-11-12 23:08:54 +07:00
committed by GitHub
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).