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}