From 1c3256ab65d2104fea1e58d58a1fa8ddf7bce140 Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Wed, 11 Nov 2026 09:30:26 -0300 Subject: [PATCH] ui: apply touchpad scroll multiplier once per event across hidden ticks (#6146) --- crates/ui-egui/src/app.rs | 2 ++ crates/ui-egui/src/app/control.rs | 2 ++ crates/ui-egui/src/app/input.rs | 30 ++++++++++++++-- crates/ui-egui/src/lib.rs | 5 +++ crates/ui-egui/src/scroll.rs | 16 ++++++--- crates/ui-egui/src/tests.rs | 58 +++++++++++++++++++++++++++++++ crates/ui-egui/src/window_ui.rs | 2 ++ 7 files changed, 109 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 73fa069260..6058cace84 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -114,6 +114,8 @@ impl WordApp { synthetic: Vec::new(), synthetic_pending: false, synthetic_injected: 0, + raw_scroll_events_scaled: 0, + last_scaled_scroll_events: Vec::new(), styled: false, fonts_hans: false, fonts_korean: false, diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index b1ff527c40..bc417f40ae 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -8,6 +8,8 @@ impl WordApp { // a layout anyone will see. The shot's `issued` flag makes repeated final passes harmless. if !ctx.will_discard() { control::issue_screenshots(self, ctx); + self.raw_scroll_events_scaled = 0; + self.last_scaled_scroll_events.clear(); } // The raw input was consumed by this host frame's passes; only a final UI pass proves // the step is done. A discarded pass keeps the queue for the next UI frame. diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index 5abcf20ae5..dffdb75c5a 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -24,11 +24,15 @@ impl WordApp { if cfg!(target_os = "macos") { control_click_as_secondary(&mut raw.events, &mut self.control_click); } - self.ui.touchpad_scroll_speed.apply_input_scroll(raw, self.ui.scroll_speed); + self.apply_raw_input_scroll(raw); if self.synthetic_pending || self.synthetic.is_empty() || self.fonts_frames < 2 || !self.control_input_is_roots(ctx) { + self.record_scaled_scroll_events(raw); return; } - let Some((events, n)) = self.peek_synthetic() else { return }; + let Some((events, n)) = self.peek_synthetic() else { + self.record_scaled_scroll_events(raw); + return; + }; if events.iter().any(|e| matches!(e, egui::Event::Key { .. } | egui::Event::Text(_))) { // Control input belongs to this window's remembered widget even while another app // is active. Give only this input frame egui focus, without requesting OS activation. @@ -41,6 +45,28 @@ impl WordApp { raw.events.extend(events); self.synthetic_pending = true; self.synthetic_injected = n; + self.record_scaled_scroll_events(raw); + } + + /// Scales point-wheel events in the incoming [`egui::RawInput`] by the touchpad / wheel speed multiplier. + /// When eframe or web runners replay an unconsumed [`egui::RawInput`] across hidden logic ticks, + /// already-scaled events in the pending prefix are skipped so the multiplier is applied exactly once per event (#6146). + fn apply_raw_input_scroll(&mut self, raw: &mut egui::RawInput) { + let previously_scaled = self.raw_scroll_events_scaled; + let is_replayed = previously_scaled > 0 + && raw.events.len() >= previously_scaled + && raw.events.get(..previously_scaled) == Some(&self.last_scaled_scroll_events[..]); + let start_idx = if is_replayed { previously_scaled } else { 0 }; + if let Some(new_events) = raw.events.get_mut(start_idx..) { + self.ui.touchpad_scroll_speed.apply_events_scroll(new_events, self.ui.scroll_speed); + } + } + + /// Records the current state of scaled events in the raw input batch so replay across hidden ticks + /// does not compound multipliers. + fn record_scaled_scroll_events(&mut self, raw: &egui::RawInput) { + self.raw_scroll_events_scaled = raw.events.len(); + self.last_scaled_scroll_events = raw.events.clone(); } /// Whether this pass's input came from the control channel (`ui.click`, `ui.key`, `ui.drag`, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 311284de3d..facc56a0b9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -683,6 +683,11 @@ pub struct WordApp { synthetic_pending: bool, /// How many events of `synthetic` the pending batch contains. synthetic_injected: usize, + /// Number of raw input events whose scroll delta has already been scaled in the pending host batch. + pub(crate) raw_scroll_events_scaled: usize, + /// Exact snapshot of the raw input events scaled during the current pending batch, to avoid + /// compounding scroll multipliers when eframe replays unconsumed RawInput across hidden logic ticks. + pub(crate) last_scaled_scroll_events: Vec, /// macOS: the primary button went down with Control, so it is being read as the secondary. control_click: bool, /// Badge rects recorded during layout for the current keytip phase (cleared by `logic`, painted by `show`). diff --git a/crates/ui-egui/src/scroll.rs b/crates/ui-egui/src/scroll.rs index addb72ee29..f4630dc741 100644 --- a/crates/ui-egui/src/scroll.rs +++ b/crates/ui-egui/src/scroll.rs @@ -88,17 +88,16 @@ impl ScrollSpeed { NORMAL_TOUCHPAD_FACTOR * self.factor() } - /// Scales this frame's point scrolling by [`Self::touchpad_factor`]. + /// Scales point scrolling events in the given slice by [`Self::touchpad_factor`]. /// On wasm32, browser mouse wheels and touchpads arrive as [`egui::MouseWheelUnit::Point`], /// so the user-selected `wheel_speed` is applied there directly (#6146). - /// Call this before egui or canvas reads raw input. /// Ctrl/⌘-wheel zoom is left alone so it zooms at egui's own rate. - pub fn apply_input_scroll(self, raw: &mut egui::RawInput, wheel_speed: ScrollSpeed) { + pub fn apply_events_scroll(self, events: &mut [egui::Event], wheel_speed: ScrollSpeed) { let factor = if cfg!(target_arch = "wasm32") { wheel_speed.factor() } else { self.touchpad_factor() }; if (factor - 1.0).abs() < 1e-4 { return; } - for event in &mut raw.events { + for event in events { if let egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, modifiers, .. } = event && !modifiers.command && !modifiers.ctrl @@ -109,6 +108,15 @@ impl ScrollSpeed { } } + /// Scales this frame's point scrolling by [`Self::touchpad_factor`]. + /// On wasm32, browser mouse wheels and touchpads arrive as [`egui::MouseWheelUnit::Point`], + /// so the user-selected `wheel_speed` is applied there directly (#6146). + /// Call this before egui or canvas reads raw input. + /// Ctrl/⌘-wheel zoom is left alone so it zooms at egui's own rate. + pub fn apply_input_scroll(self, raw: &mut egui::RawInput, wheel_speed: ScrollSpeed) { + self.apply_events_scroll(&mut raw.events, wheel_speed); + } + /// Scales this frame's touchpad (point) scrolling by [`Self::touchpad_factor`]. /// Call this before egui or canvas reads raw input. /// Ctrl/⌘-wheel zoom is left alone so it zooms at egui's own rate. diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index c659f05348..b47edaabb0 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -8219,3 +8219,61 @@ fn touchpad_and_mouse_scroll_speed_scaling_and_preferences() { assert_eq!(restored.touchpad_scroll_speed, crate::scroll::ScrollSpeed::Fast); assert_eq!(restored.scroll_speed, crate::scroll::ScrollSpeed::Fast); } + +/// Issue #6146 / PR #6167: unconsumed raw input is replayed by eframe across hidden logic ticks. +/// The touchpad scroll multiplier must be applied once per event, never compounded across replays. +#[test] +fn touchpad_point_scrolling_is_scaled_once_across_hidden_logic_ticks() { + let mut a = app(); + let ctx = egui::Context::default(); + let wheel_event = |unit, y: f32| egui::Event::MouseWheel { + unit, + delta: egui::vec2(0.0, y), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }; + + let mut raw = egui::RawInput { events: vec![wheel_event(egui::MouseWheelUnit::Point, -10.0)], ..Default::default() }; + + // 1. First raw_input_hook call scales the point delta by the touchpad factor. + a.raw_input_hook(&ctx, &mut raw); + let expected = -10.0 * a.ui.touchpad_scroll_speed.touchpad_factor(); + assert_eq!(raw.events[0], wheel_event(egui::MouseWheelUnit::Point, expected)); + + // 2. Hidden logic tick: eframe saves raw into pending_raw_input and invokes the hook again + // on the next tick without a UI pass having consumed it. The delta must not compound. + a.logic(&ctx); + a.raw_input_hook(&ctx, &mut raw); + if let egui::Event::MouseWheel { delta, .. } = &raw.events[0] { + assert_eq!(delta.y, expected, "hidden logic tick replay must not compound the scroll delta"); + } + + // 3. Appending a new event during a subsequent hidden tick: older events stay scaled once, + // and only the new event is scaled. + raw.events.push(wheel_event(egui::MouseWheelUnit::Point, -5.0)); + a.logic(&ctx); + a.raw_input_hook(&ctx, &mut raw); + let expected_new = -5.0 * a.ui.touchpad_scroll_speed.touchpad_factor(); + if let egui::Event::MouseWheel { delta, .. } = &raw.events[0] { + assert_eq!(delta.y, expected, "initial event still scaled once"); + } + if let egui::Event::MouseWheel { delta, .. } = &raw.events[1] { + assert_eq!(delta.y, expected_new, "new event is scaled once"); + } + + // 4. A UI pass consumes the input, acknowledging the frame. + ctx.run_ui(raw, |ui| { + a.logic(ui.ctx()); + a.ui(ui); + }) + .drop_without_applying_deltas(); + + // 5. Subsequent fresh frame scales fresh input once. + let mut fresh = egui::RawInput { events: vec![wheel_event(egui::MouseWheelUnit::Point, -20.0)], ..Default::default() }; + a.raw_input_hook(&ctx, &mut fresh); + let expected_fresh = -20.0 * a.ui.touchpad_scroll_speed.touchpad_factor(); + if let egui::Event::MouseWheel { delta, .. } = &fresh.events[0] { + assert_eq!(delta.y, expected_fresh, "fresh input is scaled once"); + } +} diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index 2dbf5f870c..1a6f92157d 100644 --- a/crates/ui-egui/src/window_ui.rs +++ b/crates/ui-egui/src/window_ui.rs @@ -178,6 +178,8 @@ impl WordApp { synthetic: _, synthetic_pending: _, synthetic_injected: _, + raw_scroll_events_scaled: _, + last_scaled_scroll_events: _, // The current command's origin, set and cleared around it. ime_origin: _, styled: _,