1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-28 07:23:13 -04:00

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
This commit is contained in:
Emil Ernerfeldt
2025-11-03 11:53:55 +01:00
committed by GitHub
parent fa3457f21c
commit 1b77d7047e
5 changed files with 302 additions and 206 deletions

View File

@@ -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.

View File

@@ -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;
}
});

View File

@@ -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();

View File

@@ -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<bool>,
/// 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<Duration> {
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"));

View File

@@ -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();
});
}
}