diff --git a/crates/egui/src/containers/scene.rs b/crates/egui/src/containers/scene.rs index 36222b138877..501980dd9feb 100644 --- a/crates/egui/src/containers/scene.rs +++ b/crates/egui/src/containers/scene.rs @@ -48,6 +48,7 @@ pub struct Scene { sense: Sense, max_inner_size: Vec2, drag_pan_buttons: DragPanButtons, + scroll_zooms: bool, } /// Specifies which pointer buttons can be used to pan the scene by dragging. @@ -80,6 +81,7 @@ impl Default for Scene { sense: Sense::click_and_drag(), max_inner_size: Vec2::splat(1000.0), drag_pan_buttons: DragPanButtons::all(), + scroll_zooms: false, } } } @@ -130,6 +132,16 @@ impl Scene { self } + /// Specify whether scrolling the mousewheel without a modifier pans or + /// zooms the Scene. Touch input is not affected by this setting. + /// + /// By default, this is `false`. + #[inline] + pub fn scroll_zooms(mut self, scroll_zooms: bool) -> Self { + self.scroll_zooms = scroll_zooms; + self + } + /// `scene_rect` contains the view bounds of the inner [`Ui`]. /// /// `scene_rect` will be mutated by any panning/zooming done by the user. @@ -244,8 +256,21 @@ impl Scene { && resp.contains_pointer() { 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 mut zoom_delta = ui.ctx().input(|i| i.zoom_delta()); + let mut pan_delta = Vec2::ZERO; + + // If scrolling_zooms is set to true the scroll input will be consumed and + // added to any zoom input. This is required to support both mouse wheel + // and touch events. + if self.scroll_zooms { + let scroll_zoom_speed = ui.ctx().options(|opt| opt.input_options.scroll_zoom_speed); + let scroll_delta = ui + .ctx() + .input(|i| i.smooth_scroll_delta.x + i.smooth_scroll_delta.y); + zoom_delta += scroll_delta * scroll_zoom_speed; + } else { + 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/input_state/mod.rs b/crates/egui/src/input_state/mod.rs index 0c99b6ac3f03..6ab741723436 100644 --- a/crates/egui/src/input_state/mod.rs +++ b/crates/egui/src/input_state/mod.rs @@ -225,8 +225,41 @@ pub struct InputState { // ---------------------------------------------- // Scrolling: - #[cfg_attr(feature = "serde", serde(skip))] - wheel: WheelState, + // + /// 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, /// How many points the user scrolled, smoothed over a few frames. /// @@ -338,7 +371,11 @@ impl Default for InputState { pointer: Default::default(), touch_states: Default::default(), - wheel: 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, smooth_scroll_delta: Vec2::ZERO, zoom_factor_delta: 1.0, rotation_radians: 0.0, @@ -417,15 +454,67 @@ impl InputState { phase, modifiers, } => { - self.wheel.on_wheel_event( - viewport_rect, - &options, - time, - *unit, - *delta, - *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; + } + } + } } Event::Zoom(factor) => { zoom_factor_delta *= *factor; @@ -462,11 +551,22 @@ impl InputState { } } + 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, - wheel: self.wheel, + is_in_scroll_action: self.is_in_scroll_action, + last_scroll_time, + unprocessed_scroll_delta, + unprocessed_scroll_delta_for_zoom, + raw_scroll_delta, smooth_scroll_delta, zoom_factor_delta, rotation_radians, @@ -636,11 +736,27 @@ impl InputState { } /// True if there is an active scroll action that might scroll more when using [`Self::smooth_scroll_delta`]. - pub fn is_scrolling(&self) -> bool { - self.wheel.is_scrolling() + pub fn is_smooth_scrolling(&self) -> bool { + self.is_raw_scrolling() || self.smooth_scroll_delta != Vec2::ZERO } - /// How long has it been (in seconds) since the last scroll event? + /// 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 { (self.time - self.wheel.last_wheel_event) as f32 @@ -1530,7 +1646,12 @@ impl InputState { raw, pointer, touch_states, - wheel, + + is_in_scroll_action: _, + last_scroll_time, + unprocessed_scroll_delta, + unprocessed_scroll_delta_for_zoom, + raw_scroll_delta, smooth_scroll_delta, rotation_radians, zoom_factor_delta, @@ -1569,13 +1690,27 @@ impl InputState { }); } - 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!( + "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" + )); 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_demo_lib/src/demo/scene.rs b/crates/egui_demo_lib/src/demo/scene.rs index ba0dc5974619..7bc68b541c4f 100644 --- a/crates/egui_demo_lib/src/demo/scene.rs +++ b/crates/egui_demo_lib/src/demo/scene.rs @@ -6,6 +6,7 @@ use super::widget_gallery; pub struct SceneDemo { widget_gallery: widget_gallery::WidgetGallery, scene_rect: Rect, + scroll_zooms: bool, } impl Default for SceneDemo { @@ -13,6 +14,7 @@ impl Default for SceneDemo { Self { widget_gallery: widget_gallery::WidgetGallery::default().with_date_button(false), // disable date button so that we don't fail the snapshot test scene_rect: Rect::ZERO, // `egui::Scene` will initialize this to something valid + scroll_zooms: false, } } } @@ -39,6 +41,10 @@ impl crate::View for SceneDemo { "You can pan by scrolling, and zoom using cmd-scroll. \ Double click on the background to reset view.", ); + ui.checkbox( + &mut self.scroll_zooms, + "Change mousewheel behaviour to zoom instead of scroll", + ); ui.vertical_centered(|ui| { ui.add(crate::egui_github_link_file!()); }); @@ -53,6 +59,7 @@ impl crate::View for SceneDemo { .show(ui, |ui| { let scene = Scene::new() .max_inner_size([350.0, 1000.0]) + .scroll_zooms(self.scroll_zooms) .zoom_range(0.1..=2.0); let mut reset_view = false;