use std::collections::VecDeque;
use std::time::Duration;
use gpui::{Pixels, Point, px};
use gpui_kit_theme::Theme;
use web_time::Instant;
pub const VELOCITY_WINDOW: Duration = Duration::from_millis(100);
const MIN_SPAN: Duration = Duration::from_millis(8);
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Velocity {
pub x: f32,
pub y: f32,
}
impl Velocity {
pub const ZERO: Self = Self { x: 0.0, y: 0.0 };
pub fn new(x: f32, y: f32) -> Self {
Self { x, y }
}
pub fn speed(self) -> f32 {
(self.x * self.x + self.y * self.y).sqrt()
}
pub fn is_still(self) -> bool {
self.speed() < 1.0
}
fn dominant(self) -> (Axis, f32) {
if self.x.abs() >= self.y.abs() {
(Axis::Horizontal, self.x)
} else {
(Axis::Vertical, self.y)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Axis {
Horizontal,
Vertical,
}
#[derive(Debug, Clone)]
pub struct VelocityTracker {
window: Duration,
samples: VecDeque<(Instant, Point<Pixels>)>,
}
impl Default for VelocityTracker {
fn default() -> Self {
Self::new()
}
}
impl VelocityTracker {
pub fn new() -> Self {
Self::with_window(VELOCITY_WINDOW)
}
pub fn with_window(window: Duration) -> Self {
Self {
window,
samples: VecDeque::new(),
}
}
pub fn sample(&mut self, position: Point<Pixels>, at: Instant) {
if self.samples.back().is_some_and(|(last, _)| at < *last) {
return;
}
self.samples.push_back((at, position));
self.prune(at);
}
pub fn velocity_at(&self, now: Instant) -> Velocity {
let mut live = self
.samples
.iter()
.filter(|(at, _)| now.saturating_duration_since(*at) <= self.window);
let Some((first_at, first)) = live.next() else {
return Velocity::ZERO;
};
let Some((last_at, last)) = live.next_back() else {
return Velocity::ZERO;
};
let span = last_at.saturating_duration_since(*first_at);
if span < MIN_SPAN {
return Velocity::ZERO;
}
let seconds = span.as_secs_f32();
Velocity::new(
f32::from(last.x - first.x) / seconds,
f32::from(last.y - first.y) / seconds,
)
}
pub fn clear(&mut self) {
self.samples.clear();
}
fn prune(&mut self, now: Instant) {
while self
.samples
.front()
.is_some_and(|(at, _)| now.saturating_duration_since(*at) > self.window)
{
self.samples.pop_front();
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Flick {
Left,
Right,
Up,
Down,
}
impl Flick {
pub fn name(self) -> &'static str {
match self {
Self::Left => "left",
Self::Right => "right",
Self::Up => "up",
Self::Down => "down",
}
}
}
pub fn flick(travel: Point<Pixels>, velocity: Velocity, theme: &Theme) -> Option<Flick> {
let (axis, speed) = velocity.dominant();
if speed.abs() < theme.motion.flick_velocity {
return None;
}
let travelled = match axis {
Axis::Horizontal => f32::from(travel.x),
Axis::Vertical => f32::from(travel.y),
};
if travelled == 0.0 || travelled.signum() != speed.signum() {
return None;
}
Some(match (axis, speed < 0.0) {
(Axis::Horizontal, true) => Flick::Left,
(Axis::Horizontal, false) => Flick::Right,
(Axis::Vertical, true) => Flick::Up,
(Axis::Vertical, false) => Flick::Down,
})
}
pub fn rubber_band(overscroll: Pixels, extent: Pixels, tension: f32) -> Pixels {
let extent = f32::from(extent);
let tension = tension.max(f32::EPSILON);
if extent <= 0.0 {
return px(0.0);
}
let pull = f32::from(overscroll);
let damped = (1.0 - 1.0 / (pull.abs() * tension / extent + 1.0)) * extent;
px(damped.copysign(pull))
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::point;
fn theme() -> Theme {
Theme::studio_dark()
}
fn steady(pixels_per_second: f32, samples: usize) -> (VelocityTracker, Instant) {
let step = Duration::from_millis(10);
let mut tracker = VelocityTracker::new();
let start = Instant::now();
for index in 0..samples {
let elapsed = step.mul_f32(index as f32);
tracker.sample(
point(px(0.0), px(pixels_per_second * elapsed.as_secs_f32())),
start + elapsed,
);
}
(tracker, start + step.mul_f32((samples - 1) as f32))
}
#[test]
fn a_steady_drag_reports_the_speed_it_was_moving_at() {
let (tracker, now) = steady(600.0, 8);
let velocity = tracker.velocity_at(now);
assert!(
(velocity.y - 600.0).abs() < 1.0,
"measured {} instead of 600",
velocity.y
);
assert_eq!(velocity.x, 0.0);
}
#[test]
fn a_gesture_that_stopped_before_release_has_no_velocity() {
let (tracker, moving) = steady(600.0, 8);
assert!(!tracker.velocity_at(moving).is_still());
let paused = moving + VELOCITY_WINDOW + Duration::from_millis(50);
assert_eq!(
tracker.velocity_at(paused),
Velocity::ZERO,
"a drag the user parked must not be flung"
);
}
#[test]
fn two_samples_a_fraction_of_a_millisecond_apart_report_nothing() {
let mut tracker = VelocityTracker::new();
let start = Instant::now();
tracker.sample(point(px(0.0), px(0.0)), start);
let next = start + Duration::from_micros(200);
tracker.sample(point(px(0.0), px(3.0)), next);
assert_eq!(tracker.velocity_at(next), Velocity::ZERO);
}
#[test]
fn a_sample_that_arrives_out_of_order_is_ignored() {
let (mut tracker, now) = steady(600.0, 8);
let before = tracker.velocity_at(now);
tracker.sample(point(px(0.0), px(-400.0)), now - Duration::from_millis(30));
assert_eq!(tracker.velocity_at(now), before);
}
#[test]
fn a_flick_and_a_slow_drag_of_the_same_distance_are_different_gestures() {
let travel = point(px(120.0), px(0.0));
let quick = Velocity::new(theme().motion.flick_velocity * 2.0, 0.0);
let slow = Velocity::new(theme().motion.flick_velocity / 4.0, 0.0);
assert_eq!(flick(travel, quick, &theme()), Some(Flick::Right));
assert_eq!(flick(travel, slow, &theme()), None);
}
#[test]
fn a_flick_takes_its_direction_from_the_axis_it_travelled_on() {
let fast = theme().motion.flick_velocity * 2.0;
assert_eq!(
flick(
point(px(0.0), px(-90.0)),
Velocity::new(0.0, -fast),
&theme()
),
Some(Flick::Up)
);
assert_eq!(
flick(
point(px(-90.0), px(0.0)),
Velocity::new(-fast, 0.0),
&theme()
),
Some(Flick::Left)
);
}
#[test]
fn a_gesture_already_on_its_way_back_was_not_flicked_out() {
let fast = theme().motion.flick_velocity * 2.0;
assert_eq!(
flick(
point(px(120.0), px(0.0)),
Velocity::new(-fast, 0.0),
&theme()
),
None
);
}
#[test]
fn a_band_resists_more_the_further_it_is_pulled() {
let extent = px(300.0);
let tension = theme().motion.rubber_band_tension;
let short = rubber_band(px(40.0), extent, tension);
let long = rubber_band(px(200.0), extent, tension);
assert!(short < long);
assert!(short < px(40.0) && long < px(200.0));
assert!(
f32::from(long) / 200.0 < f32::from(short) / 40.0,
"resistance did not grow with the pull"
);
}
#[test]
fn a_band_never_reaches_its_bound() {
let extent = px(300.0);
let tension = theme().motion.rubber_band_tension;
for pull in [10.0, 500.0, 5_000.0, 100_000.0] {
assert!(rubber_band(px(pull), extent, tension) < extent, "at {pull}");
}
assert_eq!(rubber_band(px(0.0), extent, tension), px(0.0));
}
#[test]
fn a_band_pulled_the_other_way_stretches_the_other_way() {
let extent = px(300.0);
let tension = theme().motion.rubber_band_tension;
assert_eq!(
rubber_band(px(-80.0), extent, tension),
-rubber_band(px(80.0), extent, tension)
);
}
#[test]
fn a_boundary_with_no_room_behind_it_does_not_stretch() {
assert_eq!(rubber_band(px(50.0), px(0.0), 0.55), px(0.0));
}
}