From 1b77d7047e72e825593be838c6b9ce2b17611f79 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 3 Nov 2025 11:53:55 +0100 Subject: [PATCH] Improve modifier handling when scrolling (#7678) ### Problem Letting go of the modifier key before the last momentum-scroll events arrive will cause the scroll direction to change. This problem can be seen by going to egui.rs and opening the "Scene" example. Hold down shift, start a momentum-scroll (on a Mac trackpad), then quickly let go of shift: you'll see the scroll direction change, which feels wrong. ### Solution Store the modifiers at the start of the event, thanks to the new `phase` info added in * https://github.com/emilk/egui/pull/7669 Note that this solution only works on native; not on web. ### Other * Break out wheel/scroll handling into own file * Simplify it a lot by deciding late on wether an input is a scroll or a zoom * Assume input is already smooth if there are `phase` events --- crates/egui/src/containers/scene.rs | 2 +- crates/egui/src/containers/scroll_area.rs | 10 +- crates/egui/src/containers/tooltip.rs | 2 +- crates/egui/src/input_state/mod.rs | 261 +++++---------------- crates/egui/src/input_state/wheel_state.rs | 233 ++++++++++++++++++ 5 files changed, 302 insertions(+), 206 deletions(-) create mode 100644 crates/egui/src/input_state/wheel_state.rs diff --git a/crates/egui/src/containers/scene.rs b/crates/egui/src/containers/scene.rs index 58739ba2a..36222b138 100644 --- a/crates/egui/src/containers/scene.rs +++ b/crates/egui/src/containers/scene.rs @@ -245,7 +245,7 @@ impl Scene { { let pointer_in_scene = to_global.inverse() * mouse_pos; let zoom_delta = ui.ctx().input(|i| i.zoom_delta()); - let pan_delta = ui.ctx().input(|i| i.smooth_scroll_delta); + let pan_delta = ui.ctx().input(|i| i.smooth_scroll_delta()); // Most of the time we can return early. This is also important to // avoid `ui_from_scene` to change slightly due to floating point errors. diff --git a/crates/egui/src/containers/scroll_area.rs b/crates/egui/src/containers/scroll_area.rs index ff2542d8d..db64ba03b 100644 --- a/crates/egui/src/containers/scroll_area.rs +++ b/crates/egui/src/containers/scroll_area.rs @@ -1127,9 +1127,9 @@ impl Prepared { let scroll_delta = ui.ctx().input(|input| { if always_scroll_enabled_direction { // no bidirectional scrolling; allow horizontal scrolling without pressing shift - input.smooth_scroll_delta[0] + input.smooth_scroll_delta[1] + input.smooth_scroll_delta()[0] + input.smooth_scroll_delta()[1] } else { - input.smooth_scroll_delta[d] + input.smooth_scroll_delta()[d] } }); let scroll_delta = scroll_delta * wheel_scroll_multiplier[d]; @@ -1143,10 +1143,10 @@ impl Prepared { // Clear scroll delta so no parent scroll will use it: ui.ctx().input_mut(|input| { if always_scroll_enabled_direction { - input.smooth_scroll_delta[0] = 0.0; - input.smooth_scroll_delta[1] = 0.0; + input.smooth_scroll_delta()[0] = 0.0; + input.smooth_scroll_delta()[1] = 0.0; } else { - input.smooth_scroll_delta[d] = 0.0; + input.smooth_scroll_delta()[d] = 0.0; } }); diff --git a/crates/egui/src/containers/tooltip.rs b/crates/egui/src/containers/tooltip.rs index 682b11fd8..c46e21d57 100644 --- a/crates/egui/src/containers/tooltip.rs +++ b/crates/egui/src/containers/tooltip.rs @@ -358,7 +358,7 @@ impl Tooltip<'_> { // We only show the tooltip when the mouse pointer is still. if !response .ctx - .input(|i| i.pointer.is_still() && i.smooth_scroll_delta == Vec2::ZERO) + .input(|i| i.pointer.is_still() && !i.is_scrolling()) { // wait for mouse to stop response.ctx.request_repaint(); diff --git a/crates/egui/src/input_state/mod.rs b/crates/egui/src/input_state/mod.rs index bca53c721..7b163a90f 100644 --- a/crates/egui/src/input_state/mod.rs +++ b/crates/egui/src/input_state/mod.rs @@ -1,14 +1,18 @@ mod touch_state; +mod wheel_state; -use crate::data::input::{ - Event, EventFilter, KeyboardShortcut, Modifiers, MouseWheelUnit, NUM_POINTER_BUTTONS, - PointerButton, RawInput, TouchDeviceId, ViewportInfo, -}; use crate::{ SafeAreaInsets, emath::{NumExt as _, Pos2, Rect, Vec2, vec2}, util::History, }; +use crate::{ + data::input::{ + Event, EventFilter, KeyboardShortcut, Modifiers, NUM_POINTER_BUTTONS, PointerButton, + RawInput, TouchDeviceId, ViewportInfo, + }, + input_state::wheel_state::WheelState, +}; use std::{ collections::{BTreeMap, HashSet}, time::Duration, @@ -221,41 +225,8 @@ pub struct InputState { // ---------------------------------------------- // Scrolling: - // - /// Time of the last scroll event. - last_scroll_time: f64, - - /// If we are currently in a scroll action. - /// - /// This is not the same as checking if [`Self::smooth_scroll_delta`], or - /// [`Self::raw_scroll_delta`] are zero. This instead relies on the - /// current touch phase received from the mouse wheel event. - /// - /// This value is only `Some` if we have ever received a [`crate::TouchPhase::Start`] event and then - /// know that the current platform supports it. - is_in_scroll_action: Option, - - /// Used for smoothing the scroll delta. - unprocessed_scroll_delta: Vec2, - - /// Used for smoothing the scroll delta when zooming. - unprocessed_scroll_delta_for_zoom: f32, - - /// You probably want to use [`Self::smooth_scroll_delta`] instead. - /// - /// The raw input of how many points the user scrolled. - /// - /// The delta dictates how the _content_ should move. - /// - /// A positive X-value indicates the content is being moved right, - /// as when swiping right on a touch-screen or track-pad with natural scrolling. - /// - /// A positive Y-value indicates the content is being moved down, - /// as when swiping down on a touch-screen or track-pad with natural scrolling. - /// - /// When using a notched scroll-wheel this will spike very large for one frame, - /// then drop to zero. For a smoother experience, use [`Self::smooth_scroll_delta`]. - pub raw_scroll_delta: Vec2, + #[cfg_attr(feature = "serde", serde(skip))] + wheel: WheelState, /// How many points the user scrolled, smoothed over a few frames. /// @@ -367,11 +338,7 @@ impl Default for InputState { pointer: Default::default(), touch_states: Default::default(), - is_in_scroll_action: None, - last_scroll_time: f64::NEG_INFINITY, - unprocessed_scroll_delta: Vec2::ZERO, - unprocessed_scroll_delta_for_zoom: 0.0, - raw_scroll_delta: Vec2::ZERO, + wheel: Default::default(), smooth_scroll_delta: Vec2::ZERO, zoom_factor_delta: 1.0, rotation_radians: 0.0, @@ -426,12 +393,8 @@ impl InputState { let mut keys_down = self.keys_down; let mut zoom_factor_delta = 1.0; // TODO(emilk): smoothing for zoom factor let mut rotation_radians = 0.0; - let mut raw_scroll_delta = Vec2::ZERO; - let mut unprocessed_scroll_delta = self.unprocessed_scroll_delta; - let mut unprocessed_scroll_delta_for_zoom = self.unprocessed_scroll_delta_for_zoom; - let mut smooth_scroll_delta = Vec2::ZERO; - let mut smooth_scroll_delta_for_zoom = 0.0; + self.wheel.smooth_wheel_delta = Vec2::ZERO; for event in &mut new.events { match event { @@ -454,67 +417,15 @@ impl InputState { phase, modifiers, } => { - match phase { - crate::TouchPhase::Start => { - self.is_in_scroll_action = Some(true); - } - crate::TouchPhase::Move => { - let mut delta = match unit { - MouseWheelUnit::Point => *delta, - MouseWheelUnit::Line => options.line_scroll_speed * *delta, - MouseWheelUnit::Page => viewport_rect.height() * *delta, - }; - - let is_horizontal = - modifiers.matches_any(options.horizontal_scroll_modifier); - let is_vertical = - modifiers.matches_any(options.vertical_scroll_modifier); - - if is_horizontal && !is_vertical { - // Treat all scrolling as horizontal scrolling. - // Note: one Mac we already get horizontal scroll events when shift is down. - delta = vec2(delta.x + delta.y, 0.0); - } - if !is_horizontal && is_vertical { - // Treat all scrolling as vertical scrolling. - delta = vec2(0.0, delta.x + delta.y); - } - - raw_scroll_delta += delta; - - // Mouse wheels often go very large steps. - // A single notch on a logitech mouse wheel connected to a Macbook returns 14.0 raw_scroll_delta. - // So we smooth it out over several frames for a nicer user experience when scrolling in egui. - // BUT: if the user is using a nice smooth mac trackpad, we don't add smoothing, - // because it adds latency. - let is_smooth = match unit { - MouseWheelUnit::Point => delta.length() < 8.0, // a bit arbitrary here - MouseWheelUnit::Line | MouseWheelUnit::Page => false, - }; - - let is_zoom = modifiers.matches_any(options.zoom_modifier); - - #[expect(clippy::collapsible_else_if)] - if is_zoom { - if is_smooth { - smooth_scroll_delta_for_zoom += delta.x + delta.y; - } else { - unprocessed_scroll_delta_for_zoom += delta.x + delta.y; - } - } else { - if is_smooth { - smooth_scroll_delta += delta; - } else { - unprocessed_scroll_delta += delta; - } - } - } - crate::TouchPhase::End | crate::TouchPhase::Cancel => { - if let Some(is_in_scroll_action) = &mut self.is_in_scroll_action { - *is_in_scroll_action = false; - } - } - } + self.wheel.on_wheel_event( + viewport_rect, + &options, + time, + *unit, + *delta, + *phase, + *modifiers, + ); } Event::Zoom(factor) => { zoom_factor_delta *= *factor; @@ -534,55 +445,28 @@ impl InputState { } } + let mut smooth_scroll_delta = Vec2::ZERO; + { let dt = stable_dt.at_most(0.1); - let t = crate::emath::exponential_smooth_factor(0.90, 0.1, dt); // reach _% in _ seconds. TODO(emilk): parameterize + self.wheel.after_events(time, dt); - if unprocessed_scroll_delta != Vec2::ZERO { - for d in 0..2 { - if unprocessed_scroll_delta[d].abs() < 1.0 { - smooth_scroll_delta[d] += unprocessed_scroll_delta[d]; - unprocessed_scroll_delta[d] = 0.0; - } else { - let applied = t * unprocessed_scroll_delta[d]; - smooth_scroll_delta[d] += applied; - unprocessed_scroll_delta[d] -= applied; - } - } - } + let is_zoom = self.wheel.modifiers.matches_any(options.zoom_modifier); - { - // Smooth scroll-to-zoom: - if unprocessed_scroll_delta_for_zoom.abs() < 1.0 { - smooth_scroll_delta_for_zoom += unprocessed_scroll_delta_for_zoom; - unprocessed_scroll_delta_for_zoom = 0.0; - } else { - let applied = t * unprocessed_scroll_delta_for_zoom; - smooth_scroll_delta_for_zoom += applied; - unprocessed_scroll_delta_for_zoom -= applied; - } - - zoom_factor_delta *= - (options.scroll_zoom_speed * smooth_scroll_delta_for_zoom).exp(); + if is_zoom { + zoom_factor_delta *= (options.scroll_zoom_speed + * (self.wheel.smooth_wheel_delta.x + self.wheel.smooth_wheel_delta.y)) + .exp(); + } else { + smooth_scroll_delta = self.wheel.smooth_wheel_delta; } } - let is_scrolling = raw_scroll_delta != Vec2::ZERO || smooth_scroll_delta != Vec2::ZERO; - let last_scroll_time = if is_scrolling || self.is_in_scroll_action.is_some_and(|b| b) { - time - } else { - self.last_scroll_time - }; - Self { pointer, touch_states: self.touch_states, - is_in_scroll_action: self.is_in_scroll_action, - last_scroll_time, - unprocessed_scroll_delta, - unprocessed_scroll_delta_for_zoom, - raw_scroll_delta, + wheel: self.wheel, smooth_scroll_delta, zoom_factor_delta, rotation_radians, @@ -654,6 +538,22 @@ impl InputState { self.safe_area_insets } + /// How many points the user scrolled, smoothed over a few frames. + /// + /// The delta dictates how the _content_ should move. + /// + /// A positive X-value indicates the content is being moved right, + /// as when swiping right on a touch-screen or track-pad with natural scrolling. + /// + /// A positive Y-value indicates the content is being moved down, + /// as when swiping down on a touch-screen or track-pad with natural scrolling. + /// + /// [`crate::ScrollArea`] will both read and write to this field, so that + /// at the end of the frame this will be zero if a scroll-area consumed the delta. + pub fn smooth_scroll_delta(&self) -> Vec2 { + self.smooth_scroll_delta + } + /// Uniform zoom scale factor this frame (e.g. from ctrl-scroll or pinch gesture). /// * `zoom = 1`: no change /// * `zoom < 1`: pinch together @@ -732,34 +632,18 @@ impl InputState { #[inline(always)] pub fn translation_delta(&self) -> Vec2 { self.multi_touch() - .map_or(self.smooth_scroll_delta, |touch| touch.translation_delta) + .map_or(self.smooth_scroll_delta(), |touch| touch.translation_delta) } /// True if there is an active scroll action that might scroll more when using [`Self::smooth_scroll_delta`]. - pub fn is_smooth_scrolling(&self) -> bool { - self.is_raw_scrolling() || self.smooth_scroll_delta != Vec2::ZERO + pub fn is_scrolling(&self) -> bool { + self.wheel.is_scrolling() } - /// True if there is an active scroll action that might scroll more. - /// - /// You probably want to use [`Self::is_smooth_scrolling`]. - pub fn is_raw_scrolling(&self) -> bool { - if let Some(is_in_scroll_action) = self.is_in_scroll_action { - is_in_scroll_action - } else { - // On certain platforms, like web, we don't get the start & stop scrolling events, so - // we rely on a timer there. - // - // Tested on a mac touchpad 2025, where the largest observed gap between scroll events - // was 68 ms. So 100 ms should most likely be good here. - self.time_since_last_scroll() < 0.1 - } - } - - /// How long has it been (in seconds) since the use last scrolled? + /// How long has it been (in seconds) since the last scroll event? #[inline(always)] pub fn time_since_last_scroll(&self) -> f32 { - (self.time - self.last_scroll_time) as f32 + (self.time - self.wheel.last_wheel_event) as f32 } /// The [`crate::Context`] will call this at the beginning of each frame to see if we need a repaint. @@ -771,8 +655,7 @@ impl InputState { /// cause a repaint. pub(crate) fn wants_repaint_after(&self) -> Option { if self.pointer.wants_repaint() - || self.unprocessed_scroll_delta.abs().max_elem() > 0.2 - || self.unprocessed_scroll_delta_for_zoom.abs() > 0.2 + || self.wheel.unprocessed_wheel_delta.abs().max_elem() > 0.2 || !self.events.is_empty() { // Immediate repaint @@ -1646,15 +1529,9 @@ impl InputState { raw, pointer, touch_states, - - is_in_scroll_action: _, - last_scroll_time, - unprocessed_scroll_delta, - unprocessed_scroll_delta_for_zoom, - raw_scroll_delta, + wheel, smooth_scroll_delta, rotation_radians, - zoom_factor_delta, viewport_rect, safe_area_insets, @@ -1691,27 +1568,13 @@ impl InputState { }); } - ui.label(format!( - "is_scrolling: raw: {}, smooth: {}", - self.is_raw_scrolling(), - self.is_smooth_scrolling() - )); - ui.label(format!( - "Time since last scroll: {:.1} s", - time - last_scroll_time - )); - if cfg!(debug_assertions) { - ui.label(format!( - "unprocessed_scroll_delta: {unprocessed_scroll_delta:?} points" - )); - ui.label(format!( - "unprocessed_scroll_delta_for_zoom: {unprocessed_scroll_delta_for_zoom:?} points" - )); - } - ui.label(format!("raw_scroll_delta: {raw_scroll_delta:?} points")); - ui.label(format!( - "smooth_scroll_delta: {smooth_scroll_delta:?} points" - )); + crate::containers::CollapsingHeader::new("⬍ Scroll") + .default_open(false) + .show(ui, |ui| { + wheel.ui(ui); + }); + + ui.label(format!("smooth_scroll_delta: {smooth_scroll_delta:4.1}x")); ui.label(format!("zoom_factor_delta: {zoom_factor_delta:4.2}x")); ui.label(format!("rotation_radians: {rotation_radians:.3} radians")); diff --git a/crates/egui/src/input_state/wheel_state.rs b/crates/egui/src/input_state/wheel_state.rs new file mode 100644 index 000000000..2efbbc1ff --- /dev/null +++ b/crates/egui/src/input_state/wheel_state.rs @@ -0,0 +1,233 @@ +use emath::{Rect, Vec2, vec2}; + +use crate::{InputOptions, Modifiers, MouseWheelUnit, TouchPhase}; + +/// The current state of scrolling. +/// +/// There are two important types of scroll input deviced: +/// * Discreen scroll wheels on a mouse +/// * Smooth scroll input from a trackpad +/// +/// Scroll wheels will usually fire one single scroll event, +/// so it is important that egui smooths it out over time. +/// +/// On the contrary, trackpads usually provide smooth scroll input, +/// and with kinetic scrolling (which on Mac is implemented by the OS) +/// scroll events can arrive _after_ the user lets go of the trackpad. +/// +/// In either case, we consider use to be scrolling until there is no more +/// scroll events expected. +/// +/// This means there are a few different states we can be in: +/// * Not scrolling +/// * "Smooth scrolling" (low-pass filter of discreet scroll events) +/// * Trackpad-scrolling (we receive begin/end phases for these) +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Status { + /// Not scrolling, + Static, + + /// We're smoothing out previous scroll events + Smoothing, + + // We're in-between [`TouchPhase::Start`] and [`TouchPhase::End`] of a trackpad scroll. + InTouch, +} + +/// Keeps track of wheel (scroll) input. +#[derive(Clone, Debug)] +pub struct WheelState { + /// Are we currently in a scroll action? + /// + /// This may be true even if no scroll events came in this frame, + /// but we are in a kinetic scroll or in a smoothed scroll. + pub status: Status, + + /// The modifiers at the start of the scroll. + pub modifiers: Modifiers, + + /// Time of the last scroll event. + pub last_wheel_event: f64, + + /// Used for smoothing the scroll delta. + pub unprocessed_wheel_delta: Vec2, + + /// How many points the user scrolled, smoothed over a few frames. + /// + /// The delta dictates how the _content_ should move. + /// + /// A positive X-value indicates the content is being moved right, + /// as when swiping right on a touch-screen or track-pad with natural scrolling. + /// + /// A positive Y-value indicates the content is being moved down, + /// as when swiping down on a touch-screen or track-pad with natural scrolling. + /// + /// [`crate::ScrollArea`] will both read and write to this field, so that + /// at the end of the frame this will be zero if a scroll-area consumed the delta. + pub smooth_wheel_delta: Vec2, +} + +impl Default for WheelState { + fn default() -> Self { + Self { + status: Status::Static, + modifiers: Default::default(), + last_wheel_event: f64::NEG_INFINITY, + unprocessed_wheel_delta: Vec2::ZERO, + smooth_wheel_delta: Vec2::ZERO, + } + } +} + +impl WheelState { + #[expect(clippy::too_many_arguments)] + pub fn on_wheel_event( + &mut self, + viewport_rect: Rect, + options: &InputOptions, + time: f64, + unit: MouseWheelUnit, + delta: Vec2, + phase: TouchPhase, + latest_modifiers: Modifiers, + ) { + self.last_wheel_event = time; + match phase { + crate::TouchPhase::Start => { + self.status = Status::InTouch; + self.modifiers = latest_modifiers; + } + crate::TouchPhase::Move => { + match self.status { + Status::Static | Status::Smoothing => { + self.modifiers = latest_modifiers; + self.status = Status::Smoothing; + } + Status::InTouch => { + // If the user lets go of a modifier - ignore it. + // More kinematic scrolling may arrive. + // But if the users presses down new modifiers - heed it! + self.modifiers |= latest_modifiers; + } + } + + let mut delta = match unit { + MouseWheelUnit::Point => delta, + MouseWheelUnit::Line => options.line_scroll_speed * delta, + MouseWheelUnit::Page => viewport_rect.height() * delta, + }; + + let is_horizontal = self + .modifiers + .matches_any(options.horizontal_scroll_modifier); + let is_vertical = self.modifiers.matches_any(options.vertical_scroll_modifier); + + if is_horizontal && !is_vertical { + // Treat all scrolling as horizontal scrolling. + // Note: one Mac we already get horizontal scroll events when shift is down. + delta = vec2(delta.x + delta.y, 0.0); + } + if !is_horizontal && is_vertical { + // Treat all scrolling as vertical scrolling. + delta = vec2(0.0, delta.x + delta.y); + } + + // Mouse wheels often go very large steps. + // A single notch on a logitech mouse wheel connected to a Macbook returns 14.0 raw scroll delta. + // So we smooth it out over several frames for a nicer user experience when scrolling in egui. + // BUT: if the user is using a nice smooth mac trackpad, we don't add smoothing, + // because it adds latency. + let is_smooth = self.status == Status::InTouch + || match unit { + MouseWheelUnit::Point => delta.length() < 8.0, // a bit arbitrary here + MouseWheelUnit::Line | MouseWheelUnit::Page => false, + }; + + if is_smooth { + self.smooth_wheel_delta += delta; + } else { + self.unprocessed_wheel_delta += delta; + } + } + crate::TouchPhase::End | crate::TouchPhase::Cancel => { + self.status = Status::Static; + self.modifiers = Default::default(); + self.unprocessed_wheel_delta = Default::default(); + self.smooth_wheel_delta = Default::default(); + } + } + } + + pub fn after_events(&mut self, time: f64, dt: f32) { + let t = crate::emath::exponential_smooth_factor(0.90, 0.1, dt); // reach _% in _ seconds. TODO(emilk): parameterize + + if self.unprocessed_wheel_delta != Vec2::ZERO { + for d in 0..2 { + if self.unprocessed_wheel_delta[d].abs() < 1.0 { + self.smooth_wheel_delta[d] += self.unprocessed_wheel_delta[d]; + self.unprocessed_wheel_delta[d] = 0.0; + } else { + let applied = t * self.unprocessed_wheel_delta[d]; + self.smooth_wheel_delta[d] += applied; + self.unprocessed_wheel_delta[d] -= applied; + } + } + } + + let time_since_last_scroll = time - self.last_wheel_event; + + if self.status == Status::Smoothing + && self.smooth_wheel_delta == Vec2::ZERO + && 0.150 < time_since_last_scroll + { + // On certain platforms, like web, we don't get the start & stop scrolling events, so + // we rely on a timer there. + // + // Tested on a mac touchpad 2025, where the largest observed gap between scroll events + // was 68 ms. But we add some margin to be safe + self.status = Status::Static; + self.modifiers = Default::default(); + } + } + + /// True if there is an active scroll action that might scroll more when using [`Self::smooth_wheel_delta`]. + pub fn is_scrolling(&self) -> bool { + self.status != Status::Static + } + + pub fn ui(&self, ui: &mut crate::Ui) { + let Self { + status, + modifiers, + last_wheel_event, + unprocessed_wheel_delta, + smooth_wheel_delta, + } = self; + + let time = ui.input(|i| i.time); + + crate::Grid::new("ScrollState") + .num_columns(2) + .show(ui, |ui| { + ui.label("status"); + ui.monospace(format!("{status:?}")); + ui.end_row(); + + ui.label("modifiers"); + ui.monospace(format!("{modifiers:?}")); + ui.end_row(); + + ui.label("last_wheel_event"); + ui.monospace(format!("{:.1}s ago", time - *last_wheel_event)); + ui.end_row(); + + ui.label("unprocessed_wheel_delta"); + ui.monospace(unprocessed_wheel_delta.to_string()); + ui.end_row(); + + ui.label("smooth_wheel_delta"); + ui.monospace(smooth_wheel_delta.to_string()); + ui.end_row(); + }); + } +}