Skip to main content

egui/input_state/
wheel_state.rs

1use emath::{Rect, Vec2, vec2};
2
3use crate::{InputOptions, Modifiers, MouseWheelSource, MouseWheelUnit, TouchPhase};
4
5/// If there has been no scroll event for this many seconds, the scroll action is over.
6///
7/// Tested on a mac touchpad 2025, where the largest observed gap between scroll events
8/// was 68 ms. But we add some margin to be safe.
9const SCROLL_ACTION_TIMEOUT: f64 = 0.150;
10
11/// Like [`SCROLL_ACTION_TIMEOUT`], but for fingers resting on a trackpad mid-gesture,
12/// which produce no events at all. Where we know the source is a finger (macOS),
13/// the [`TouchPhase::End`] is reliable, so this is just a safety net.
14const RESTING_FINGER_TIMEOUT: f64 = 1.0;
15
16/// The current state of scrolling.
17///
18/// There are two important types of scroll input deviced:
19/// * Discreen scroll wheels on a mouse
20/// * Smooth scroll input from a trackpad
21///
22/// Scroll wheels will usually fire one single scroll event,
23/// so it is important that egui smooths it out over time.
24///
25/// On the contrary, trackpads usually provide smooth scroll input,
26/// and with kinetic scrolling (which on Mac is implemented by the OS)
27/// scroll events can arrive _after_ the user lets go of the trackpad.
28///
29/// In either case, we consider use to be scrolling until there is no more
30/// scroll events expected.
31///
32/// This means there are a few different states we can be in:
33/// * Not scrolling
34/// * "Smooth scrolling" (low-pass filter of discreet scroll events)
35/// * Trackpad-scrolling (we receive begin/end phases for these)
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub enum Status {
38    /// Not scrolling,
39    Static,
40
41    /// We're smoothing out previous scroll events
42    Smoothing,
43
44    /// We're in-between [`TouchPhase::Start`] and [`TouchPhase::End`] of a trackpad scroll.
45    ///
46    /// The [`TouchPhase::End`] is not guaranteed to arrive (e.g. winit on Wayland sends
47    /// [`TouchPhase::Start`] for some mouse wheels, but never [`TouchPhase::End`]),
48    /// so this also ends if there are no scroll events for a while.
49    InTouch,
50}
51
52/// Keeps track of wheel (scroll) input.
53#[derive(Clone, Debug)]
54pub struct WheelState {
55    /// Are we currently in a scroll action?
56    ///
57    /// This may be true even if no scroll events came in this frame,
58    /// but we are in a kinetic scroll or in a smoothed scroll.
59    pub status: Status,
60
61    /// What drove the latest scroll event: a wheel, fingers on a trackpad, OS momentum…
62    pub source: MouseWheelSource,
63
64    /// The modifiers at the start of the scroll.
65    pub modifiers: Modifiers,
66
67    /// Time of the last scroll event.
68    pub last_wheel_event: f64,
69
70    /// Used for smoothing the scroll delta.
71    pub unprocessed_wheel_delta: Vec2,
72
73    /// How many points the user scrolled, smoothed over a few frames.
74    ///
75    /// The delta dictates how the _content_ should move.
76    ///
77    /// A positive X-value indicates the content is being moved right,
78    /// as when swiping right on a touch-screen or track-pad with natural scrolling.
79    ///
80    /// A positive Y-value indicates the content is being moved down,
81    /// as when swiping down on a touch-screen or track-pad with natural scrolling.
82    ///
83    /// [`crate::ScrollArea`] will both read and write to this field, so that
84    /// at the end of the frame this will be zero if a scroll-area consumed the delta.
85    pub smooth_wheel_delta: Vec2,
86}
87
88impl Default for WheelState {
89    fn default() -> Self {
90        Self {
91            status: Status::Static,
92            source: MouseWheelSource::Unknown,
93            modifiers: Default::default(),
94            last_wheel_event: f64::NEG_INFINITY,
95            unprocessed_wheel_delta: Vec2::ZERO,
96            smooth_wheel_delta: Vec2::ZERO,
97        }
98    }
99}
100
101impl WheelState {
102    #[expect(clippy::too_many_arguments)]
103    pub fn on_wheel_event(
104        &mut self,
105        viewport_rect: Rect,
106        options: &InputOptions,
107        time: f64,
108        unit: MouseWheelUnit,
109        delta: Vec2,
110        phase: TouchPhase,
111        source: MouseWheelSource,
112        latest_modifiers: Modifiers,
113    ) {
114        if self.is_scroll_action_over(time) {
115            // `after_events` only sees the timeout if a pass runs after it,
116            // which is not the case if the app has been idle since the last scroll event.
117            // Without this, an `InTouch` that never got its `TouchPhase::End`
118            // would latch the modifiers of that old scroll action onto this new one.
119            self.end_scroll_action();
120        }
121
122        self.last_wheel_event = time;
123        self.source = source;
124        match phase {
125            crate::TouchPhase::Start => {
126                self.status = Status::InTouch;
127                self.modifiers = latest_modifiers;
128            }
129            crate::TouchPhase::Move => {
130                match self.status {
131                    Status::Static | Status::Smoothing => {
132                        self.modifiers = latest_modifiers;
133                        self.status = Status::Smoothing;
134                    }
135                    Status::InTouch => {
136                        // If the user lets go of a modifier - ignore it.
137                        // More kinematic scrolling may arrive.
138                        // But if the users presses down new modifiers - heed it!
139                        self.modifiers |= latest_modifiers;
140                    }
141                }
142
143                let mut delta = match unit {
144                    MouseWheelUnit::Point => delta,
145                    MouseWheelUnit::Line => options.line_scroll_speed * delta,
146                    MouseWheelUnit::Page => viewport_rect.height() * delta,
147                };
148
149                let is_horizontal = self
150                    .modifiers
151                    .matches_any(options.horizontal_scroll_modifier);
152                let is_vertical = self.modifiers.matches_any(options.vertical_scroll_modifier);
153
154                if is_horizontal && !is_vertical {
155                    // Treat all scrolling as horizontal scrolling.
156                    // Note: on Mac we already get horizontal scroll events when shift is down.
157                    delta = vec2(delta.x + delta.y, 0.0);
158                }
159                if !is_horizontal && is_vertical {
160                    // Treat all scrolling as vertical scrolling.
161                    delta = vec2(0.0, delta.x + delta.y);
162                }
163
164                // Mouse wheels often go very large steps.
165                // A single notch on a logitech mouse wheel connected to a Macbook returns 14.0 raw scroll delta.
166                // So we smooth it out over several frames for a nicer user experience when scrolling in egui.
167                // BUT: if the user is using a nice smooth mac trackpad, we don't add smoothing,
168                // because it adds latency.
169                let is_smooth = self.status == Status::InTouch
170                    || match unit {
171                        MouseWheelUnit::Point => delta.length() < 8.0, // a bit arbitrary here
172                        MouseWheelUnit::Line | MouseWheelUnit::Page => false,
173                    };
174
175                if is_smooth {
176                    self.smooth_wheel_delta += delta;
177                } else {
178                    self.unprocessed_wheel_delta += delta;
179                }
180            }
181            crate::TouchPhase::End | crate::TouchPhase::Cancel => {
182                self.status = Status::Static;
183                self.modifiers = Default::default();
184                self.unprocessed_wheel_delta = Default::default();
185                self.smooth_wheel_delta = Default::default();
186            }
187        }
188    }
189
190    pub fn after_events(&mut self, time: f64, dt: f32) {
191        let t = crate::emath::exponential_smooth_factor(0.90, 0.1, dt); // reach _% in _ seconds. TODO(emilk): parameterize
192
193        if self.unprocessed_wheel_delta != Vec2::ZERO {
194            for d in 0..2 {
195                if self.unprocessed_wheel_delta[d].abs() < 1.0 {
196                    self.smooth_wheel_delta[d] += self.unprocessed_wheel_delta[d];
197                    self.unprocessed_wheel_delta[d] = 0.0;
198                } else {
199                    let applied = t * self.unprocessed_wheel_delta[d];
200                    self.smooth_wheel_delta[d] += applied;
201                    self.unprocessed_wheel_delta[d] -= applied;
202                }
203            }
204        }
205
206        if self.smooth_wheel_delta == Vec2::ZERO && self.is_scroll_action_over(time) {
207            self.end_scroll_action();
208        }
209    }
210
211    /// Has it been so long since the last scroll event that the scroll action must be over?
212    ///
213    /// On certain platforms, like web, we don't get the start & stop scrolling events,
214    /// and on others (e.g. some mouse wheels on Wayland) we get a start but no stop event,
215    /// so we rely on a timer.
216    fn is_scroll_action_over(&self, time: f64) -> bool {
217        let timeout = if self.status == Status::InTouch && self.source == MouseWheelSource::Trackpad
218        {
219            RESTING_FINGER_TIMEOUT
220        } else {
221            SCROLL_ACTION_TIMEOUT
222        };
223        self.status != Status::Static && timeout < time - self.last_wheel_event
224    }
225
226    fn end_scroll_action(&mut self) {
227        self.status = Status::Static;
228        self.modifiers = Default::default();
229    }
230
231    /// True if there is an active scroll action that might scroll more when using [`Self::smooth_wheel_delta`].
232    pub fn is_scrolling(&self) -> bool {
233        self.status != Status::Static
234    }
235
236    pub fn ui(&self, ui: &mut crate::Ui) {
237        let Self {
238            status,
239            source,
240            modifiers,
241            last_wheel_event,
242            unprocessed_wheel_delta,
243            smooth_wheel_delta,
244        } = self;
245
246        let time = ui.input(|i| i.time);
247
248        crate::Grid::new("ScrollState")
249            .num_columns(2)
250            .show(ui, |ui| {
251                ui.label("status");
252                ui.monospace(format!("{status:?}"));
253                ui.end_row();
254
255                ui.label("source");
256                ui.monospace(format!("{source:?}"));
257                ui.end_row();
258
259                ui.label("modifiers");
260                ui.monospace(format!("{modifiers:?}"));
261                ui.end_row();
262
263                ui.label("last_wheel_event");
264                ui.monospace(format!("{:.1}s ago", time - *last_wheel_event));
265                ui.end_row();
266
267                ui.label("unprocessed_wheel_delta");
268                ui.monospace(unprocessed_wheel_delta.to_string());
269                ui.end_row();
270
271                ui.label("smooth_wheel_delta");
272                ui.monospace(smooth_wheel_delta.to_string());
273                ui.end_row();
274            });
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use emath::{Vec2, vec2};
281
282    use crate::{
283        Context, Event, Modifiers, MouseWheelSource, MouseWheelUnit, RawInput, TouchPhase,
284    };
285
286    fn wheel(phase: TouchPhase, modifiers: Modifiers) -> Event {
287        wheel_from(phase, MouseWheelSource::Unknown, modifiers)
288    }
289
290    fn wheel_from(phase: TouchPhase, source: MouseWheelSource, modifiers: Modifiers) -> Event {
291        Event::MouseWheel {
292            unit: MouseWheelUnit::Point,
293            delta: vec2(0.0, -5.0),
294            phase,
295            source,
296            modifiers,
297        }
298    }
299
300    /// Runs one frame, and returns `(smooth_scroll_delta, zoom_delta, is_scrolling)`.
301    fn run_frame(ctx: &Context, time: f64, events: Vec<Event>) -> (Vec2, f32, bool) {
302        let input = RawInput {
303            time: Some(time),
304            events,
305            ..Default::default()
306        };
307        let mut result = None;
308        let output = ctx.run_ui(input, |ui| {
309            // Only the first pass sees the events:
310            if result.is_none() {
311                result =
312                    Some(ui.input(|i| (i.smooth_scroll_delta(), i.zoom_delta(), i.is_scrolling())));
313            }
314        });
315        output.drop_without_applying_deltas();
316        result.unwrap_or_default()
317    }
318
319    /// winit on Wayland sends `TouchPhase::Start` for some mouse wheels, but never `TouchPhase::End`.
320    ///
321    /// See <https://github.com/emilk/egui/issues/8325>.
322    #[test]
323    fn modifiers_should_not_stick_when_touch_phase_end_never_arrives() {
324        let ctx = Context::default();
325
326        let (scroll, zoom, _) = run_frame(
327            &ctx,
328            0.0,
329            vec![
330                wheel(TouchPhase::Start, Modifiers::COMMAND),
331                wheel(TouchPhase::Move, Modifiers::COMMAND),
332            ],
333        );
334        assert_eq!(scroll, Vec2::ZERO, "ctrl+scroll should not scroll");
335        assert!(zoom != 1.0, "ctrl+scroll should zoom");
336
337        // Let go of ctrl, and scroll again a while later.
338        // No frame runs in-between, like in a reactive app that has been idle:
339        let (scroll, zoom, _) =
340            run_frame(&ctx, 1.0, vec![wheel(TouchPhase::Move, Modifiers::NONE)]);
341        assert_eq!(zoom, 1.0, "scrolling without ctrl should not zoom");
342        assert_eq!(
343            scroll,
344            vec2(0.0, -5.0),
345            "scrolling without ctrl should scroll"
346        );
347    }
348
349    #[test]
350    fn scroll_action_should_end_when_touch_phase_end_never_arrives() {
351        let ctx = Context::default();
352
353        let (_, _, is_scrolling) = run_frame(
354            &ctx,
355            0.0,
356            vec![
357                wheel(TouchPhase::Start, Modifiers::NONE),
358                wheel(TouchPhase::Move, Modifiers::NONE),
359            ],
360        );
361        assert!(is_scrolling, "we just scrolled");
362
363        // `is_scrolling` hides tooltips, so it must not stay `true` forever:
364        let (_, _, is_scrolling) = run_frame(&ctx, 1.0, vec![]);
365        assert!(
366            !is_scrolling,
367            "no scroll events for a second: the scroll action is over"
368        );
369    }
370
371    /// Letting go of a modifier during a (momentum) scroll should not change what the scroll does.
372    ///
373    /// See <https://github.com/emilk/egui/pull/7678>.
374    #[test]
375    fn modifiers_should_stick_until_touch_phase_end() {
376        let ctx = Context::default();
377
378        let (scroll, _, _) = run_frame(
379            &ctx,
380            0.0,
381            vec![
382                wheel(TouchPhase::Start, Modifiers::SHIFT),
383                wheel(TouchPhase::Move, Modifiers::SHIFT),
384            ],
385        );
386        assert_eq!(
387            scroll,
388            vec2(-5.0, 0.0),
389            "shift+scroll should scroll horizontally"
390        );
391
392        // Let go of shift while the scroll events keep coming:
393        let (scroll, _, is_scrolling) =
394            run_frame(&ctx, 0.05, vec![wheel(TouchPhase::Move, Modifiers::NONE)]);
395        assert_eq!(scroll, vec2(-5.0, 0.0), "should still scroll horizontally");
396        assert!(is_scrolling, "the scroll action is still going");
397
398        let (_, _, is_scrolling) =
399            run_frame(&ctx, 0.1, vec![wheel(TouchPhase::End, Modifiers::NONE)]);
400        assert!(!is_scrolling, "the scroll action ended");
401
402        let (scroll, _, _) = run_frame(
403            &ctx,
404            0.2,
405            vec![
406                wheel(TouchPhase::Start, Modifiers::NONE),
407                wheel(TouchPhase::Move, Modifiers::NONE),
408            ],
409        );
410        assert_eq!(
411            scroll,
412            vec2(0.0, -5.0),
413            "a new scroll action without shift is vertical"
414        );
415    }
416
417    /// Fingers resting on a trackpad produce no events, but the scroll action is still going.
418    #[test]
419    fn resting_fingers_keep_the_scroll_action_alive() {
420        let ctx = Context::default();
421        let finger = MouseWheelSource::Trackpad;
422
423        run_frame(
424            &ctx,
425            0.0,
426            vec![
427                wheel_from(TouchPhase::Start, finger, Modifiers::NONE),
428                wheel_from(TouchPhase::Move, finger, Modifiers::NONE),
429            ],
430        );
431
432        let (_, _, is_scrolling) = run_frame(&ctx, 0.5, vec![]);
433        assert!(is_scrolling, "fingers are resting on the trackpad");
434
435        let (_, _, is_scrolling) = run_frame(&ctx, 1.5, vec![]);
436        assert!(
437            !is_scrolling,
438            "but we give up eventually, in case the end got lost"
439        );
440
441        // Without a known finger source, the short timeout applies:
442        run_frame(&ctx, 2.0, vec![wheel(TouchPhase::Start, Modifiers::NONE)]);
443        let (_, _, is_scrolling) = run_frame(&ctx, 2.5, vec![]);
444        assert!(!is_scrolling);
445    }
446}