Skip to main content

gpui/
gestures.rs

1//! Touch gesture recognition vocabulary.
2//!
3//! GPUI recognizes gestures from raw [`TouchEvent`](crate::TouchEvent)s in a
4//! single, portable arena in gpui core: recognizers compete for in-flight
5//! touches, winners claim them, and losers are cancelled. Recognized gestures
6//! are surfaced through *existing* semantic events wherever possible, a tap
7//! becomes [`ClickEvent::Touch`](crate::ClickEvent), a pan becomes
8//! [`ScrollWheelEvent`](crate::ScrollWheelEvent)s carrying a
9//! [`TouchPhase`](crate::TouchPhase), and a pinch becomes
10//! [`PinchEvent`](crate::PinchEvent)s — so components written against
11//! `on_click` and scroll containers work untouched on mobile.
12
13use std::time::{Duration, Instant};
14
15use crate::{Axis, IsZero, Pixels, Point, TouchPhase, px};
16
17const SCROLL_EVENT_SEPARATION: Duration = Duration::from_millis(28);
18
19/// Tracks the dominant axis across the events in a scroll gesture.
20#[derive(Clone, Copy, Debug, Default)]
21pub struct OngoingScroll {
22    last_event: Option<Instant>,
23    axis: Option<Axis>,
24}
25
26impl OngoingScroll {
27    /// Filters the given delta to the dominant axis of the current scroll gesture.
28    ///
29    /// Gestures are delimited by their touch phase when available, with a timeout
30    /// fallback for platforms that only emit [`TouchPhase::Moved`].
31    pub fn filter(&mut self, delta: &mut Point<Pixels>, touch_phase: TouchPhase) {
32        self.filter_at(delta, touch_phase, Instant::now())
33    }
34
35    fn filter_at(&mut self, delta: &mut Point<Pixels>, touch_phase: TouchPhase, now: Instant) {
36        const UNLOCK_PERCENT: f32 = 1.9;
37        const UNLOCK_LOWER_BOUND: Pixels = px(6.);
38
39        if matches!(touch_phase, TouchPhase::Ended | TouchPhase::Cancelled) {
40            self.last_event = None;
41            self.axis = None;
42            return;
43        }
44
45        let x = delta.x.abs();
46        let y = delta.y.abs();
47        if x.is_zero() && y.is_zero() {
48            if touch_phase == TouchPhase::Started {
49                self.last_event = None;
50                self.axis = None;
51            }
52            return;
53        }
54
55        let starts_new_gesture = touch_phase == TouchPhase::Started
56            || self
57                .last_event
58                .is_none_or(|last_event| now.duration_since(last_event) >= SCROLL_EVENT_SEPARATION);
59        let mut axis = self.axis;
60        if starts_new_gesture {
61            axis = if x <= y {
62                Some(Axis::Vertical)
63            } else {
64                Some(Axis::Horizontal)
65            };
66        } else if x.max(y) >= UNLOCK_LOWER_BOUND {
67            match axis {
68                Some(Axis::Vertical) if x > y && x >= y * UNLOCK_PERCENT => {
69                    axis = None;
70                }
71                Some(Axis::Horizontal) if y > x && y >= x * UNLOCK_PERCENT => {
72                    axis = None;
73                }
74                _ => {}
75            }
76        }
77
78        self.last_event = Some(now);
79        self.axis = axis;
80        match axis {
81            Some(Axis::Vertical) => delta.x = Pixels::ZERO,
82            Some(Axis::Horizontal) => delta.y = Pixels::ZERO,
83            None => {}
84        }
85    }
86}
87
88/// Feel constants consumed by gesture recognizers. Provided on a best-effort
89/// basis, depending on each platform's support, defaulting to GPUI's own
90/// (iOS flavored) values
91#[derive(Clone, Copy, Debug, PartialEq)]
92pub struct GestureTuning {
93    /// Distance a touch may travel before it stops being a potential tap and
94    /// becomes a pan/drag.
95    pub touch_slop: Pixels,
96    /// Maximum interval between taps for them to accumulate a tap count.
97    pub multi_tap_interval: Duration,
98    /// Maximum distance between taps for them to accumulate a tap count.
99    pub multi_tap_slop: Pixels,
100    /// How long a touch must remain within [`Self::touch_slop`] to be
101    /// recognized as a long press.
102    pub long_press_duration: Duration,
103    /// Per-millisecond decay factor applied to scroll momentum after a fling.
104    /// (`UIScrollView` uses `0.998` per millisecond for its normal
105    /// deceleration rate.)
106    pub momentum_decay_per_ms: f32,
107    /// Minimum release velocity, in pixels per second, required to start
108    /// scroll momentum.
109    pub min_fling_velocity: f32,
110}
111
112impl Default for GestureTuning {
113    fn default() -> Self {
114        Self {
115            touch_slop: px(8.),
116            multi_tap_interval: Duration::from_millis(400),
117            multi_tap_slop: px(16.),
118            long_press_duration: Duration::from_millis(500),
119            momentum_decay_per_ms: 0.998,
120            min_fling_velocity: 50.,
121        }
122    }
123}
124
125/// The set of gesture kinds that participate in recognition.
126///
127/// Used by [`PlatformGestures::native_recognizers`] to declare which gestures
128/// the platform recognizes natively rather than leaving to gpui core's
129/// portable recognizers.
130#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
131pub struct GestureKinds {
132    /// Tap (and multi-tap), surfaced as [`ClickEvent::Touch`](crate::ClickEvent).
133    pub tap: bool,
134    /// Long press, surfaced as [`LongPressEvent`].
135    pub long_press: bool,
136    /// Pan/scroll (including fling momentum), surfaced as
137    /// [`ScrollWheelEvent`](crate::ScrollWheelEvent)s.
138    pub pan: bool,
139    /// Pinch to zoom, surfaced as [`PinchEvent`](crate::PinchEvent)s.
140    pub pinch: bool,
141}
142
143impl GestureKinds {
144    /// No gestures; gpui core's portable recognizers handle everything.
145    pub const NONE: Self = Self {
146        tap: false,
147        long_press: false,
148        pan: false,
149        pinch: false,
150    };
151
152    /// All gesture kinds.
153    pub const ALL: Self = Self {
154        tap: true,
155        long_press: true,
156        pan: true,
157        pinch: true,
158    };
159}
160
161/// A long-press gesture, mobile's context-menu trigger.
162///
163/// A bare long press is surfaced as a [`ClickEvent`](crate::ClickEvent) with
164/// `long_press: true`, delivered to aux-click listeners alongside right
165/// clicks. This event is the raw hook for elements that need the gesture
166/// itself (e.g. long-press to start a drag); the registration API ships
167/// together with the gesture arena.
168#[derive(Clone, Debug, Default)]
169pub struct LongPressEvent {
170    /// The position of the touch that was recognized as a long press.
171    pub position: Point<Pixels>,
172}
173
174/// Platform gesture recognition services.
175///
176/// If your mobile platform supports native gesture recognition, use this
177/// to share it with GPUI.
178pub trait PlatformGestures {
179    /// Feel constants for the portable recognizers on this platform.
180    fn tuning(&self) -> GestureTuning {
181        GestureTuning::default()
182    }
183
184    /// The gesture kinds this platform recognizes natively.
185    fn native_recognizers(&self) -> GestureKinds {
186        GestureKinds::NONE
187    }
188}
189
190/// A no-op [`PlatformGestures`] implementation: no native recognizers and
191/// default tuning. Suitable for desktop platforms and tests.
192pub struct NullPlatformGestures;
193
194impl PlatformGestures for NullPlatformGestures {}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::point;
200
201    #[test]
202    fn ongoing_scroll_locks_to_dominant_axis() {
203        let now = Instant::now();
204        let mut ongoing_scroll = OngoingScroll::default();
205        let mut horizontal_delta = point(px(10.), px(2.));
206        ongoing_scroll.filter_at(&mut horizontal_delta, TouchPhase::Started, now);
207        assert_eq!(ongoing_scroll.axis, Some(Axis::Horizontal));
208        assert_eq!(horizontal_delta, point(px(10.), px(0.)));
209
210        let mut continued_delta = point(px(3.), px(2.));
211        ongoing_scroll.filter_at(
212            &mut continued_delta,
213            TouchPhase::Moved,
214            now + Duration::from_millis(1),
215        );
216        assert_eq!(ongoing_scroll.axis, Some(Axis::Horizontal));
217        assert_eq!(continued_delta, point(px(3.), px(0.)));
218    }
219
220    #[test]
221    fn ongoing_scroll_unlocks_when_direction_changes() {
222        let now = Instant::now();
223        let mut ongoing_scroll = OngoingScroll::default();
224        let mut horizontal_delta = point(px(10.), px(2.));
225        ongoing_scroll.filter_at(&mut horizontal_delta, TouchPhase::Started, now);
226
227        let mut vertical_delta = point(px(2.), px(10.));
228        ongoing_scroll.filter_at(
229            &mut vertical_delta,
230            TouchPhase::Moved,
231            now + Duration::from_millis(1),
232        );
233        assert_eq!(ongoing_scroll.axis, None);
234        assert_eq!(vertical_delta, point(px(2.), px(10.)));
235    }
236
237    #[test]
238    fn ongoing_scroll_starts_new_gesture_at_timeout_boundary() {
239        let now = Instant::now();
240        let mut ongoing_scroll = OngoingScroll::default();
241        let mut horizontal_delta = point(px(10.), px(2.));
242        ongoing_scroll.filter_at(&mut horizontal_delta, TouchPhase::Moved, now);
243
244        let mut vertical_delta = point(px(2.), px(10.));
245        ongoing_scroll.filter_at(
246            &mut vertical_delta,
247            TouchPhase::Moved,
248            now + SCROLL_EVENT_SEPARATION,
249        );
250        assert_eq!(ongoing_scroll.axis, Some(Axis::Vertical));
251        assert_eq!(vertical_delta, point(px(0.), px(10.)));
252    }
253
254    #[test]
255    fn ongoing_scroll_ignores_zero_delta_and_resets_when_ended() {
256        let now = Instant::now();
257        let mut ongoing_scroll = OngoingScroll::default();
258        let mut horizontal_delta = point(px(10.), px(2.));
259        ongoing_scroll.filter_at(&mut horizontal_delta, TouchPhase::Started, now);
260
261        let mut zero_delta = Point::default();
262        ongoing_scroll.filter_at(
263            &mut zero_delta,
264            TouchPhase::Ended,
265            now + Duration::from_millis(1),
266        );
267        assert_eq!(ongoing_scroll.axis, None);
268
269        let mut vertical_delta = point(px(2.), px(3.));
270        ongoing_scroll.filter_at(
271            &mut vertical_delta,
272            TouchPhase::Moved,
273            now + Duration::from_millis(2),
274        );
275        assert_eq!(ongoing_scroll.axis, Some(Axis::Vertical));
276        assert_eq!(vertical_delta, point(px(0.), px(3.)));
277    }
278
279    #[test]
280    fn ongoing_scroll_ignores_zero_delta_movement() {
281        let now = Instant::now();
282        let mut ongoing_scroll = OngoingScroll::default();
283        let mut horizontal_delta = point(px(10.), px(2.));
284        ongoing_scroll.filter_at(&mut horizontal_delta, TouchPhase::Started, now);
285
286        let mut zero_delta = Point::default();
287        ongoing_scroll.filter_at(
288            &mut zero_delta,
289            TouchPhase::Moved,
290            now + SCROLL_EVENT_SEPARATION,
291        );
292
293        let mut vertical_delta = point(px(2.), px(10.));
294        ongoing_scroll.filter_at(
295            &mut vertical_delta,
296            TouchPhase::Moved,
297            now + SCROLL_EVENT_SEPARATION,
298        );
299        assert_eq!(ongoing_scroll.axis, Some(Axis::Vertical));
300        assert_eq!(vertical_delta, point(px(0.), px(10.)));
301    }
302
303    #[test]
304    fn ongoing_scroll_supports_moved_only_platforms() {
305        let now = Instant::now();
306        let mut ongoing_scroll = OngoingScroll::default();
307        let mut horizontal_delta = point(px(10.), px(2.));
308        ongoing_scroll.filter_at(&mut horizontal_delta, TouchPhase::Moved, now);
309        assert_eq!(ongoing_scroll.axis, Some(Axis::Horizontal));
310        assert_eq!(horizontal_delta, point(px(10.), px(0.)));
311    }
312}