Skip to main content

gpui_kit/motion/
gesture.rs

1//! Motion measured off the hand rather than off a clock.
2//!
3//! A gesture reports where the pointer is. Everything that makes a gesture
4//! feel physical — inertia after a release, a boundary that resists, a flick
5//! that means "get rid of this" — needs how fast it was going as well, and
6//! that is a measurement rather than a fact the platform hands over.
7//!
8//! [`VelocityTracker`] is that measurement. The effects built on it are
9//! [`flick`], [`rubber_band`], and, for inertia,
10//! [`Transition::release`](super::Transition::release), which hands a released
11//! speed to the spring that already knows how to carry one.
12
13use std::collections::VecDeque;
14use std::time::Duration;
15
16use gpui::{Pixels, Point, px};
17use gpui_kit_theme::Theme;
18use web_time::Instant;
19
20/// How far back a velocity is measured by default.
21///
22/// Short enough that the answer is the speed at release rather than the
23/// average of the whole gesture, long enough to span several events at any
24/// frame rate a platform delivers.
25pub const VELOCITY_WINDOW: Duration = Duration::from_millis(100);
26
27/// The shortest span two samples can be apart and still be believed.
28///
29/// Two events a fraction of a millisecond apart divide a pixel or two by
30/// almost nothing, which reports a speed no hand ever moved at. A gesture
31/// measured over less than this is not measured at all.
32const MIN_SPAN: Duration = Duration::from_millis(8);
33
34/// How fast a gesture is moving, in pixels a second on each axis.
35#[derive(Debug, Clone, Copy, PartialEq, Default)]
36pub struct Velocity {
37    pub x: f32,
38    pub y: f32,
39}
40
41impl Velocity {
42    /// A gesture that is not moving.
43    pub const ZERO: Self = Self { x: 0.0, y: 0.0 };
44
45    pub fn new(x: f32, y: f32) -> Self {
46        Self { x, y }
47    }
48
49    /// The speed, with the direction thrown away.
50    pub fn speed(self) -> f32 {
51        (self.x * self.x + self.y * self.y).sqrt()
52    }
53
54    /// Whether the gesture is, for practical purposes, standing still.
55    pub fn is_still(self) -> bool {
56        self.speed() < 1.0
57    }
58
59    /// The speed along the axis the gesture is mostly travelling on, signed.
60    fn dominant(self) -> (Axis, f32) {
61        if self.x.abs() >= self.y.abs() {
62            (Axis::Horizontal, self.x)
63        } else {
64            (Axis::Vertical, self.y)
65        }
66    }
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70enum Axis {
71    Horizontal,
72    Vertical,
73}
74
75/// The speed and direction of a pointer, measured over a short trailing
76/// window.
77///
78/// The window rather than the last two events is the whole point. Platforms
79/// deliver moves at whatever rate they please, so the last pair can be a
80/// millisecond apart and report an impossible speed, and — more importantly —
81/// a gesture that stopped before release still has old fast samples behind it.
82/// Samples older than the window are discarded, so a drag the user parked
83/// reports a stop rather than the speed it had before the pause. A tracker
84/// that reported one would fling away the thing the user deliberately put
85/// down.
86#[derive(Debug, Clone)]
87pub struct VelocityTracker {
88    window: Duration,
89    samples: VecDeque<(Instant, Point<Pixels>)>,
90}
91
92impl Default for VelocityTracker {
93    fn default() -> Self {
94        Self::new()
95    }
96}
97
98impl VelocityTracker {
99    pub fn new() -> Self {
100        Self::with_window(VELOCITY_WINDOW)
101    }
102
103    pub fn with_window(window: Duration) -> Self {
104        Self {
105            window,
106            samples: VecDeque::new(),
107        }
108    }
109
110    /// Records where the pointer was at `at`.
111    ///
112    /// A sample older than one already recorded is ignored: the tracker
113    /// measures a gesture forward in time, and reordering events would let a
114    /// late delivery invent a direction.
115    pub fn sample(&mut self, position: Point<Pixels>, at: Instant) {
116        if self.samples.back().is_some_and(|(last, _)| at < *last) {
117            return;
118        }
119        self.samples.push_back((at, position));
120        self.prune(at);
121    }
122
123    /// The speed the pointer is moving at as of `now`.
124    ///
125    /// `now` rather than the last sample, because a pointer that has stopped
126    /// sends nothing at all: the pause is visible only against a clock.
127    pub fn velocity_at(&self, now: Instant) -> Velocity {
128        let mut live = self
129            .samples
130            .iter()
131            .filter(|(at, _)| now.saturating_duration_since(*at) <= self.window);
132        let Some((first_at, first)) = live.next() else {
133            return Velocity::ZERO;
134        };
135        let Some((last_at, last)) = live.next_back() else {
136            return Velocity::ZERO;
137        };
138        let span = last_at.saturating_duration_since(*first_at);
139        if span < MIN_SPAN {
140            return Velocity::ZERO;
141        }
142        let seconds = span.as_secs_f32();
143        Velocity::new(
144            f32::from(last.x - first.x) / seconds,
145            f32::from(last.y - first.y) / seconds,
146        )
147    }
148
149    /// Forgets the gesture, for a drag that was cancelled rather than dropped.
150    pub fn clear(&mut self) {
151        self.samples.clear();
152    }
153
154    fn prune(&mut self, now: Instant) {
155        while self
156            .samples
157            .front()
158            .is_some_and(|(at, _)| now.saturating_duration_since(*at) > self.window)
159        {
160            self.samples.pop_front();
161        }
162    }
163}
164
165/// Which way a flick went.
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum Flick {
168    Left,
169    Right,
170    Up,
171    Down,
172}
173
174impl Flick {
175    pub fn name(self) -> &'static str {
176        match self {
177            Self::Left => "left",
178            Self::Right => "right",
179            Self::Up => "up",
180            Self::Down => "down",
181        }
182    }
183}
184
185/// Whether a gesture that travelled `travel` and let go at `velocity` was a
186/// flick, and which way it went.
187///
188/// A flick is a claim about intent, so it takes both numbers. Speed alone
189/// would call a twitch a flick; distance alone would call a slow deliberate
190/// drag one, and those are the two gestures a dismissal has to tell apart.
191/// The threshold is `motion.flickVelocityPxPerSec`.
192///
193/// The direction comes from the speed and the travel has to agree with it: a
194/// gesture that went out and was coming back when it was released was not
195/// flicked out.
196pub fn flick(travel: Point<Pixels>, velocity: Velocity, theme: &Theme) -> Option<Flick> {
197    let (axis, speed) = velocity.dominant();
198    if speed.abs() < theme.motion.flick_velocity {
199        return None;
200    }
201    let travelled = match axis {
202        Axis::Horizontal => f32::from(travel.x),
203        Axis::Vertical => f32::from(travel.y),
204    };
205    if travelled == 0.0 || travelled.signum() != speed.signum() {
206        return None;
207    }
208    Some(match (axis, speed < 0.0) {
209        (Axis::Horizontal, true) => Flick::Left,
210        (Axis::Horizontal, false) => Flick::Right,
211        (Axis::Vertical, true) => Flick::Up,
212        (Axis::Vertical, false) => Flick::Down,
213    })
214}
215
216/// The distance actually shown when a gesture pulls `overscroll` past a
217/// boundary.
218///
219/// Resistance grows with the pull: `tension` is the fraction of the first
220/// pixel that shows, and every pixel after it shows less, so the band tightens
221/// smoothly rather than at a point the hand can feel. The result approaches
222/// `extent` and never reaches it, however hard the pull, so a boundary can be
223/// stretched but not crossed.
224///
225/// This is a function of the pull and nothing else — no clock, no state, no
226/// frame — because the band is where the hand is holding it.
227pub fn rubber_band(overscroll: Pixels, extent: Pixels, tension: f32) -> Pixels {
228    let extent = f32::from(extent);
229    let tension = tension.max(f32::EPSILON);
230    if extent <= 0.0 {
231        return px(0.0);
232    }
233    let pull = f32::from(overscroll);
234    let damped = (1.0 - 1.0 / (pull.abs() * tension / extent + 1.0)) * extent;
235    px(damped.copysign(pull))
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use gpui::point;
242
243    fn theme() -> Theme {
244        Theme::studio_dark()
245    }
246
247    fn steady(pixels_per_second: f32, samples: usize) -> (VelocityTracker, Instant) {
248        let step = Duration::from_millis(10);
249        let mut tracker = VelocityTracker::new();
250        let start = Instant::now();
251        for index in 0..samples {
252            let elapsed = step.mul_f32(index as f32);
253            tracker.sample(
254                point(px(0.0), px(pixels_per_second * elapsed.as_secs_f32())),
255                start + elapsed,
256            );
257        }
258        (tracker, start + step.mul_f32((samples - 1) as f32))
259    }
260
261    #[test]
262    fn a_steady_drag_reports_the_speed_it_was_moving_at() {
263        let (tracker, now) = steady(600.0, 8);
264        let velocity = tracker.velocity_at(now);
265        assert!(
266            (velocity.y - 600.0).abs() < 1.0,
267            "measured {} instead of 600",
268            velocity.y
269        );
270        assert_eq!(velocity.x, 0.0);
271    }
272
273    #[test]
274    fn a_gesture_that_stopped_before_release_has_no_velocity() {
275        let (tracker, moving) = steady(600.0, 8);
276        assert!(!tracker.velocity_at(moving).is_still());
277        let paused = moving + VELOCITY_WINDOW + Duration::from_millis(50);
278        assert_eq!(
279            tracker.velocity_at(paused),
280            Velocity::ZERO,
281            "a drag the user parked must not be flung"
282        );
283    }
284
285    #[test]
286    fn two_samples_a_fraction_of_a_millisecond_apart_report_nothing() {
287        let mut tracker = VelocityTracker::new();
288        let start = Instant::now();
289        tracker.sample(point(px(0.0), px(0.0)), start);
290        let next = start + Duration::from_micros(200);
291        tracker.sample(point(px(0.0), px(3.0)), next);
292        assert_eq!(tracker.velocity_at(next), Velocity::ZERO);
293    }
294
295    #[test]
296    fn a_sample_that_arrives_out_of_order_is_ignored() {
297        let (mut tracker, now) = steady(600.0, 8);
298        let before = tracker.velocity_at(now);
299        tracker.sample(point(px(0.0), px(-400.0)), now - Duration::from_millis(30));
300        assert_eq!(tracker.velocity_at(now), before);
301    }
302
303    #[test]
304    fn a_flick_and_a_slow_drag_of_the_same_distance_are_different_gestures() {
305        let travel = point(px(120.0), px(0.0));
306        let quick = Velocity::new(theme().motion.flick_velocity * 2.0, 0.0);
307        let slow = Velocity::new(theme().motion.flick_velocity / 4.0, 0.0);
308        assert_eq!(flick(travel, quick, &theme()), Some(Flick::Right));
309        assert_eq!(flick(travel, slow, &theme()), None);
310    }
311
312    #[test]
313    fn a_flick_takes_its_direction_from_the_axis_it_travelled_on() {
314        let fast = theme().motion.flick_velocity * 2.0;
315        assert_eq!(
316            flick(
317                point(px(0.0), px(-90.0)),
318                Velocity::new(0.0, -fast),
319                &theme()
320            ),
321            Some(Flick::Up)
322        );
323        assert_eq!(
324            flick(
325                point(px(-90.0), px(0.0)),
326                Velocity::new(-fast, 0.0),
327                &theme()
328            ),
329            Some(Flick::Left)
330        );
331    }
332
333    #[test]
334    fn a_gesture_already_on_its_way_back_was_not_flicked_out() {
335        let fast = theme().motion.flick_velocity * 2.0;
336        assert_eq!(
337            flick(
338                point(px(120.0), px(0.0)),
339                Velocity::new(-fast, 0.0),
340                &theme()
341            ),
342            None
343        );
344    }
345
346    #[test]
347    fn a_band_resists_more_the_further_it_is_pulled() {
348        let extent = px(300.0);
349        let tension = theme().motion.rubber_band_tension;
350        let short = rubber_band(px(40.0), extent, tension);
351        let long = rubber_band(px(200.0), extent, tension);
352        assert!(short < long);
353        assert!(short < px(40.0) && long < px(200.0));
354        assert!(
355            f32::from(long) / 200.0 < f32::from(short) / 40.0,
356            "resistance did not grow with the pull"
357        );
358    }
359
360    #[test]
361    fn a_band_never_reaches_its_bound() {
362        let extent = px(300.0);
363        let tension = theme().motion.rubber_band_tension;
364        for pull in [10.0, 500.0, 5_000.0, 100_000.0] {
365            assert!(rubber_band(px(pull), extent, tension) < extent, "at {pull}");
366        }
367        assert_eq!(rubber_band(px(0.0), extent, tension), px(0.0));
368    }
369
370    #[test]
371    fn a_band_pulled_the_other_way_stretches_the_other_way() {
372        let extent = px(300.0);
373        let tension = theme().motion.rubber_band_tension;
374        assert_eq!(
375            rubber_band(px(-80.0), extent, tension),
376            -rubber_band(px(80.0), extent, tension)
377        );
378    }
379
380    #[test]
381    fn a_boundary_with_no_room_behind_it_does_not_stretch() {
382        assert_eq!(rubber_band(px(50.0), px(0.0), 0.55), px(0.0));
383    }
384}