ui: apply touchpad scroll multiplier once per event across hidden ticks (#6146)

This commit is contained in:
batistalucasv committed 2026-11-11 13:57:51 -03:00
1 parent fa43ecc4bf
commit 1c3256ab65
7 files changed
+109 -6

No files matched your search

+2
View File
@@ -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,
+2
View File
@@ -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.
+28 -2
View File
@@ -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`,
+5
View File
@@ -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<egui::Event>,
/// 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`).
+12 -4
View File
@@ -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.
+58
View File
@@ -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");
}
}
+2
View File
@@ -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: _,