From 999e943e594ccb60846b98d1376f61c82d638e78 Mon Sep 17 00:00:00 2001 From: Isse Date: Mon, 27 Oct 2025 11:46:51 +0100 Subject: [PATCH] Add `is_scrolling`/`is_smooth_scrolling` util, checking for active scroll action. (#7669) * Closes #7657 * [x] I have followed the instructions in the PR template On native this uses a new "touch phase" parameter of the mouse wheel event to know if a scroll action is done. --------- Co-authored-by: Emil Ernerfeldt --- crates/eframe/src/web/events.rs | 1 + crates/egui-winit/src/lib.rs | 32 +++++-- crates/egui/src/data/input.rs | 5 + crates/egui/src/input_state/mod.rs | 144 ++++++++++++++++++++--------- 4 files changed, 127 insertions(+), 55 deletions(-) diff --git a/crates/eframe/src/web/events.rs b/crates/eframe/src/web/events.rs index eb0d848e0..c61a80012 100644 --- a/crates/eframe/src/web/events.rs +++ b/crates/eframe/src/web/events.rs @@ -831,6 +831,7 @@ fn install_wheel(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsV unit, delta, modifiers, + phase: egui::TouchPhase::Move, } }; let should_stop_propagation = (runner.web_options.should_stop_propagation)(&egui_event); diff --git a/crates/egui-winit/src/lib.rs b/crates/egui-winit/src/lib.rs index ed293e39a..d72c44245 100644 --- a/crates/egui-winit/src/lib.rs +++ b/crates/egui-winit/src/lib.rs @@ -312,8 +312,8 @@ impl State { consumed: self.egui_ctx.wants_pointer_input(), } } - WindowEvent::MouseWheel { delta, .. } => { - self.on_mouse_wheel(window, *delta); + WindowEvent::MouseWheel { delta, phase, .. } => { + self.on_mouse_wheel(window, *delta, *phase); EventResponse { repaint: true, consumed: self.egui_ctx.wants_pointer_input(), @@ -545,12 +545,13 @@ impl State { } } - WindowEvent::PanGesture { delta, .. } => { + WindowEvent::PanGesture { delta, phase, .. } => { let pixels_per_point = pixels_per_point(&self.egui_ctx, window); self.egui_input.events.push(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: Vec2::new(delta.x, delta.y) / pixels_per_point, + phase: to_egui_touch_phase(*phase), modifiers: self.egui_input.modifiers, }); EventResponse { @@ -680,12 +681,7 @@ impl State { self.egui_input.events.push(egui::Event::Touch { device_id: egui::TouchDeviceId(egui::epaint::util::hash(touch.device_id)), id: egui::TouchId::from(touch.id), - phase: match touch.phase { - winit::event::TouchPhase::Started => egui::TouchPhase::Start, - winit::event::TouchPhase::Moved => egui::TouchPhase::Move, - winit::event::TouchPhase::Ended => egui::TouchPhase::End, - winit::event::TouchPhase::Cancelled => egui::TouchPhase::Cancel, - }, + phase: to_egui_touch_phase(touch.phase), pos: egui::pos2( touch.location.x as f32 / pixels_per_point, touch.location.y as f32 / pixels_per_point, @@ -738,7 +734,12 @@ impl State { } } - fn on_mouse_wheel(&mut self, window: &Window, delta: winit::event::MouseScrollDelta) { + fn on_mouse_wheel( + &mut self, + window: &Window, + delta: winit::event::MouseScrollDelta, + phase: winit::event::TouchPhase, + ) { let pixels_per_point = pixels_per_point(&self.egui_ctx, window); { @@ -754,10 +755,12 @@ impl State { egui::vec2(x as f32, y as f32) / pixels_per_point, ), }; + let phase = to_egui_touch_phase(phase); let modifiers = self.egui_input.modifiers; self.egui_input.events.push(egui::Event::MouseWheel { unit, delta, + phase, modifiers, }); } @@ -970,6 +973,15 @@ impl State { } } +fn to_egui_touch_phase(phase: winit::event::TouchPhase) -> egui::TouchPhase { + match phase { + winit::event::TouchPhase::Started => egui::TouchPhase::Start, + winit::event::TouchPhase::Moved => egui::TouchPhase::Move, + winit::event::TouchPhase::Ended => egui::TouchPhase::End, + winit::event::TouchPhase::Cancelled => egui::TouchPhase::Cancel, + } +} + fn to_egui_theme(theme: winit::window::Theme) -> Theme { match theme { winit::window::Theme::Dark => Theme::Dark, diff --git a/crates/egui/src/data/input.rs b/crates/egui/src/data/input.rs index 8eecd979d..869945ece 100644 --- a/crates/egui/src/data/input.rs +++ b/crates/egui/src/data/input.rs @@ -535,6 +535,11 @@ pub enum Event { /// as when swiping down on a touch-screen or track-pad with natural scrolling. delta: Vec2, + /// The phase of the scroll, useful for trackpads. + /// + /// If unknown set this to [`TouchPhase::Move`]. + phase: TouchPhase, + /// The state of the modifier keys at the time of the event. modifiers: Modifiers, }, diff --git a/crates/egui/src/input_state/mod.rs b/crates/egui/src/input_state/mod.rs index b23a47828..bca53c721 100644 --- a/crates/egui/src/input_state/mod.rs +++ b/crates/egui/src/input_state/mod.rs @@ -225,6 +225,16 @@ pub struct InputState { /// 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, @@ -357,6 +367,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, @@ -440,53 +451,68 @@ impl InputState { Event::MouseWheel { unit, delta, + phase, 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 = 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; + match phase { + crate::TouchPhase::Start => { + self.is_in_scroll_action = Some(true); } - } else { - if is_smooth { - smooth_scroll_delta += delta; - } else { - unprocessed_scroll_delta += delta; + 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; + } } } } @@ -542,7 +568,7 @@ impl InputState { } let is_scrolling = raw_scroll_delta != Vec2::ZERO || smooth_scroll_delta != Vec2::ZERO; - let last_scroll_time = if is_scrolling { + let last_scroll_time = if is_scrolling || self.is_in_scroll_action.is_some_and(|b| b) { time } else { self.last_scroll_time @@ -552,6 +578,7 @@ impl InputState { 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, @@ -708,6 +735,27 @@ impl InputState { .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 + } + + /// 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? #[inline(always)] pub fn time_since_last_scroll(&self) -> f32 { @@ -1599,6 +1647,7 @@ impl InputState { pointer, touch_states, + is_in_scroll_action: _, last_scroll_time, unprocessed_scroll_delta, unprocessed_scroll_delta_for_zoom, @@ -1642,6 +1691,11 @@ 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