1use std::collections::VecDeque;
14use std::time::Duration;
15
16use gpui::{Pixels, Point, px};
17use gpui_kit_theme::Theme;
18use web_time::Instant;
19
20pub const VELOCITY_WINDOW: Duration = Duration::from_millis(100);
26
27const MIN_SPAN: Duration = Duration::from_millis(8);
33
34#[derive(Debug, Clone, Copy, PartialEq, Default)]
36pub struct Velocity {
37 pub x: f32,
38 pub y: f32,
39}
40
41impl Velocity {
42 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 pub fn speed(self) -> f32 {
51 (self.x * self.x + self.y * self.y).sqrt()
52 }
53
54 pub fn is_still(self) -> bool {
56 self.speed() < 1.0
57 }
58
59 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#[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 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 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 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#[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
185pub 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
216pub 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}