Skip to main content

egui/input_state/
wheel_state.rs

1use emath::{Rect, Vec2, vec2};
2
3use crate::{InputOptions, Modifiers, MouseWheelUnit, TouchPhase};
4
5/// The current state of scrolling.
6///
7/// There are two important types of scroll input deviced:
8/// * Discreen scroll wheels on a mouse
9/// * Smooth scroll input from a trackpad
10///
11/// Scroll wheels will usually fire one single scroll event,
12/// so it is important that egui smooths it out over time.
13///
14/// On the contrary, trackpads usually provide smooth scroll input,
15/// and with kinetic scrolling (which on Mac is implemented by the OS)
16/// scroll events can arrive _after_ the user lets go of the trackpad.
17///
18/// In either case, we consider use to be scrolling until there is no more
19/// scroll events expected.
20///
21/// This means there are a few different states we can be in:
22/// * Not scrolling
23/// * "Smooth scrolling" (low-pass filter of discreet scroll events)
24/// * Trackpad-scrolling (we receive begin/end phases for these)
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub enum Status {
27    /// Not scrolling,
28    Static,
29
30    /// We're smoothing out previous scroll events
31    Smoothing,
32
33    // We're in-between [`TouchPhase::Start`] and [`TouchPhase::End`] of a trackpad scroll.
34    InTouch,
35}
36
37/// Keeps track of wheel (scroll) input.
38#[derive(Clone, Debug)]
39pub struct WheelState {
40    /// Are we currently in a scroll action?
41    ///
42    /// This may be true even if no scroll events came in this frame,
43    /// but we are in a kinetic scroll or in a smoothed scroll.
44    pub status: Status,
45
46    /// The modifiers at the start of the scroll.
47    pub modifiers: Modifiers,
48
49    /// Time of the last scroll event.
50    pub last_wheel_event: f64,
51
52    /// Used for smoothing the scroll delta.
53    pub unprocessed_wheel_delta: Vec2,
54
55    /// How many points the user scrolled, smoothed over a few frames.
56    ///
57    /// The delta dictates how the _content_ should move.
58    ///
59    /// A positive X-value indicates the content is being moved right,
60    /// as when swiping right on a touch-screen or track-pad with natural scrolling.
61    ///
62    /// A positive Y-value indicates the content is being moved down,
63    /// as when swiping down on a touch-screen or track-pad with natural scrolling.
64    ///
65    /// [`crate::ScrollArea`] will both read and write to this field, so that
66    /// at the end of the frame this will be zero if a scroll-area consumed the delta.
67    pub smooth_wheel_delta: Vec2,
68}
69
70impl Default for WheelState {
71    fn default() -> Self {
72        Self {
73            status: Status::Static,
74            modifiers: Default::default(),
75            last_wheel_event: f64::NEG_INFINITY,
76            unprocessed_wheel_delta: Vec2::ZERO,
77            smooth_wheel_delta: Vec2::ZERO,
78        }
79    }
80}
81
82impl WheelState {
83    #[expect(clippy::too_many_arguments)]
84    pub fn on_wheel_event(
85        &mut self,
86        viewport_rect: Rect,
87        options: &InputOptions,
88        time: f64,
89        unit: MouseWheelUnit,
90        delta: Vec2,
91        phase: TouchPhase,
92        latest_modifiers: Modifiers,
93    ) {
94        self.last_wheel_event = time;
95        match phase {
96            crate::TouchPhase::Start => {
97                self.status = Status::InTouch;
98                self.modifiers = latest_modifiers;
99            }
100            crate::TouchPhase::Move => {
101                match self.status {
102                    Status::Static | Status::Smoothing => {
103                        self.modifiers = latest_modifiers;
104                        self.status = Status::Smoothing;
105                    }
106                    Status::InTouch => {
107                        // If the user lets go of a modifier - ignore it.
108                        // More kinematic scrolling may arrive.
109                        // But if the users presses down new modifiers - heed it!
110                        self.modifiers |= latest_modifiers;
111                    }
112                }
113
114                let mut delta = match unit {
115                    MouseWheelUnit::Point => delta,
116                    MouseWheelUnit::Line => options.line_scroll_speed * delta,
117                    MouseWheelUnit::Page => viewport_rect.height() * delta,
118                };
119
120                let is_horizontal = self
121                    .modifiers
122                    .matches_any(options.horizontal_scroll_modifier);
123                let is_vertical = self.modifiers.matches_any(options.vertical_scroll_modifier);
124
125                if is_horizontal && !is_vertical {
126                    // Treat all scrolling as horizontal scrolling.
127                    // Note: one Mac we already get horizontal scroll events when shift is down.
128                    delta = vec2(delta.x + delta.y, 0.0);
129                }
130                if !is_horizontal && is_vertical {
131                    // Treat all scrolling as vertical scrolling.
132                    delta = vec2(0.0, delta.x + delta.y);
133                }
134
135                // Mouse wheels often go very large steps.
136                // A single notch on a logitech mouse wheel connected to a Macbook returns 14.0 raw scroll delta.
137                // So we smooth it out over several frames for a nicer user experience when scrolling in egui.
138                // BUT: if the user is using a nice smooth mac trackpad, we don't add smoothing,
139                // because it adds latency.
140                let is_smooth = self.status == Status::InTouch
141                    || match unit {
142                        MouseWheelUnit::Point => delta.length() < 8.0, // a bit arbitrary here
143                        MouseWheelUnit::Line | MouseWheelUnit::Page => false,
144                    };
145
146                if is_smooth {
147                    self.smooth_wheel_delta += delta;
148                } else {
149                    self.unprocessed_wheel_delta += delta;
150                }
151            }
152            crate::TouchPhase::End | crate::TouchPhase::Cancel => {
153                self.status = Status::Static;
154                self.modifiers = Default::default();
155                self.unprocessed_wheel_delta = Default::default();
156                self.smooth_wheel_delta = Default::default();
157            }
158        }
159    }
160
161    pub fn after_events(&mut self, time: f64, dt: f32) {
162        let t = crate::emath::exponential_smooth_factor(0.90, 0.1, dt); // reach _% in _ seconds. TODO(emilk): parameterize
163
164        if self.unprocessed_wheel_delta != Vec2::ZERO {
165            for d in 0..2 {
166                if self.unprocessed_wheel_delta[d].abs() < 1.0 {
167                    self.smooth_wheel_delta[d] += self.unprocessed_wheel_delta[d];
168                    self.unprocessed_wheel_delta[d] = 0.0;
169                } else {
170                    let applied = t * self.unprocessed_wheel_delta[d];
171                    self.smooth_wheel_delta[d] += applied;
172                    self.unprocessed_wheel_delta[d] -= applied;
173                }
174            }
175        }
176
177        let time_since_last_scroll = time - self.last_wheel_event;
178
179        if self.status == Status::Smoothing
180            && self.smooth_wheel_delta == Vec2::ZERO
181            && 0.150 < time_since_last_scroll
182        {
183            // On certain platforms, like web, we don't get the start & stop scrolling events, so
184            // we rely on a timer there.
185            //
186            // Tested on a mac touchpad 2025, where the largest observed gap between scroll events
187            // was 68 ms. But we add some margin to be safe
188            self.status = Status::Static;
189            self.modifiers = Default::default();
190        }
191    }
192
193    /// True if there is an active scroll action that might scroll more when using [`Self::smooth_wheel_delta`].
194    pub fn is_scrolling(&self) -> bool {
195        self.status != Status::Static
196    }
197
198    pub fn ui(&self, ui: &mut crate::Ui) {
199        let Self {
200            status,
201            modifiers,
202            last_wheel_event,
203            unprocessed_wheel_delta,
204            smooth_wheel_delta,
205        } = self;
206
207        let time = ui.input(|i| i.time);
208
209        crate::Grid::new("ScrollState")
210            .num_columns(2)
211            .show(ui, |ui| {
212                ui.label("status");
213                ui.monospace(format!("{status:?}"));
214                ui.end_row();
215
216                ui.label("modifiers");
217                ui.monospace(format!("{modifiers:?}"));
218                ui.end_row();
219
220                ui.label("last_wheel_event");
221                ui.monospace(format!("{:.1}s ago", time - *last_wheel_event));
222                ui.end_row();
223
224                ui.label("unprocessed_wheel_delta");
225                ui.monospace(unprocessed_wheel_delta.to_string());
226                ui.end_row();
227
228                ui.label("smooth_wheel_delta");
229                ui.monospace(smooth_wheel_delta.to_string());
230                ui.end_row();
231            });
232    }
233}