use crate::scroll_bounce::Physics;
use gpui::TouchPhase;
use std::time::Duration;
#[cfg(not(target_family = "wasm"))]
use std::time::Instant;
#[cfg(target_family = "wasm")]
use web_time::Instant;
const GESTURE_GAP: Duration = Duration::from_millis(250);
const CARRY: f32 = 0.31;
const STILL: Duration = Duration::from_millis(50);
const FRAME: f32 = 1. / 60.;
const SPEED_SPAN: Duration = Duration::from_millis(50);
#[derive(Default)]
pub(crate) struct Overscroll {
physics: Physics,
sampled_at: Option<Instant>,
last_packet_at: Option<Instant>,
moved_at: Option<Instant>,
velocity: f32,
moves: Vec<(Instant, f32)>,
resting: bool,
spent: bool,
}
impl Overscroll {
pub(crate) fn set_motion(&mut self, motion: crate::ScrollBounceMotion) {
self.physics.set_motion(motion);
}
pub(crate) fn offset(&self) -> f32 {
self.physics.offset()
}
fn start_gesture(&mut self) {
self.spent = false;
self.velocity = 0.;
self.moves.clear();
}
fn sample(&mut self, dy: f32, now: Instant) {
if dy == 0. {
return;
}
if self.moves.last().is_some_and(|&(_, last)| last * dy < 0.) {
self.moves.clear();
}
self.moves.push((now, dy));
let start = self
.moves
.iter()
.rposition(|&(at, _)| now.saturating_duration_since(at) > SPEED_SPAN)
.unwrap_or(0);
self.moves.drain(..start);
let (first, _) = self.moves[0];
let seconds = now.saturating_duration_since(first).as_secs_f32();
self.velocity = if seconds > 0. && self.moves.len() > 1 {
self.moves[1..].iter().map(|&(_, dy)| dy).sum::<f32>() / seconds
} else {
self.moves.iter().map(|&(_, dy)| dy).sum::<f32>() / FRAME
};
self.moved_at = Some(now);
}
pub(crate) fn packet(
&mut self,
dy: f32,
phase: TouchPhase,
extent: f32,
now: Instant,
mut scroll: impl FnMut(f32) -> f32,
) -> bool {
let before = self.offset();
let mut thrown = false;
let gap = self
.last_packet_at
.replace(now)
.map(|at| now.saturating_duration_since(at))
.filter(|gap| *gap < GESTURE_GAP);
if gap.is_none() && !self.physics.is_dragging() {
self.start_gesture();
self.moved_at = None;
}
self.sample(dy, now);
if phase == TouchPhase::Started {
self.physics.begin(extent);
self.spent = false;
self.resting = dy == 0.;
} else if dy != 0. {
self.resting = false;
} else if self.resting && self.physics.is_dragging() {
self.resting = false;
self.physics.let_go(0.);
self.spent = self.offset() != 0.;
if self.spent {
self.sampled_at = Some(now);
}
return self.physics.is_moving();
}
if self.physics.is_dragging() {
let rest = if self.offset() != 0. {
self.physics.pull(dy)
} else {
dy
};
if rest != 0. {
let unused = rest - scroll(rest);
if unused != 0. {
self.physics.pull(unused);
}
}
if matches!(phase, TouchPhase::Ended | TouchPhase::Cancelled) {
let moving = self
.moved_at
.is_some_and(|at| now.saturating_duration_since(at) < STILL);
let velocity = if moving { self.velocity } else { 0. };
self.physics.let_go(velocity * CARRY);
thrown = true;
self.spent = self.offset() != 0.;
}
} else if self.spent {
scroll(dy);
} else {
let unused = dy - scroll(dy);
if unused != 0. {
self.physics.let_go(self.velocity * CARRY);
thrown = true;
self.spent = true;
}
}
let changed = (thrown && self.physics.is_moving()) || self.offset() != before;
if thrown || (changed && self.sampled_at.is_none()) {
self.sampled_at = Some(now);
}
changed
}
pub(crate) fn step(&mut self, now: Instant) -> bool {
let elapsed = self
.sampled_at
.replace(now)
.map_or(0., |at| now.saturating_duration_since(at).as_secs_f32());
let moving = self.physics.step(elapsed);
if !moving {
self.sampled_at = None;
}
moving
}
pub(crate) fn reset(&mut self) {
let mut physics = std::mem::take(&mut self.physics);
physics.reset();
*self = Self {
physics,
..Self::default()
};
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn text(room: &mut f32) -> impl FnMut(f32) -> f32 + '_ {
move |dy: f32| {
let used = dy.max(-*room);
*room += used;
used
}
}
#[test]
fn a_swipe_past_the_end_stretches_and_springs_back() {
let now = Instant::now();
let mut overscroll = Overscroll::default();
let mut room = 30.;
overscroll.packet(-10., TouchPhase::Started, 400., now, text(&mut room));
overscroll.packet(-100., TouchPhase::Moved, 400., now, text(&mut room));
assert_eq!(room, 0.);
let stretched = overscroll.offset();
assert!(stretched < 0. && stretched > -80., "{stretched}");
overscroll.packet(0., TouchPhase::Ended, 400., now, text(&mut room));
assert!(!overscroll.packet(-40., TouchPhase::Moved, 400., now, text(&mut room)));
let mut later = now;
while overscroll.step(later) {
later += Duration::from_millis(16);
}
assert_eq!(overscroll.offset(), 0.);
}
#[test]
fn pulling_back_unstretches_before_scrolling_the_text() {
let now = Instant::now();
let mut overscroll = Overscroll::default();
let mut room = 0.;
overscroll.packet(0., TouchPhase::Started, 400., now, text(&mut room));
overscroll.packet(-50., TouchPhase::Moved, 400., now, text(&mut room));
assert!(overscroll.offset() < 0.);
let mut scrolled = 0.;
overscroll.packet(70., TouchPhase::Moved, 400., now, |dy| {
scrolled += dy;
dy
});
assert_eq!(overscroll.offset(), 0.);
assert_eq!(scrolled, 20.);
}
fn fling(speed: f32) -> Vec<f32> {
fling_through(speed, 0., 0)
}
fn fling_through(speed: f32, mut room: f32, stall: u32) -> Vec<f32> {
let start = Instant::now();
let frame = Duration::from_micros(16_667);
let mut overscroll = Overscroll::default();
let mut dy = -speed / 60.;
let mut held = Vec::new();
let mut path = Vec::new();
for n in 0..120 {
let at = start + frame * n;
if n < 100 {
held.push(dy);
dy *= 0.95;
if stall == 0 || n < stall || room == 0. || -held.iter().sum::<f32>() > room {
for dy in held.drain(..) {
overscroll.packet(dy, TouchPhase::Moved, 300., at, text(&mut room));
}
}
}
overscroll.step(at);
path.push(overscroll.offset());
}
path
}
#[cfg(not(any(target_os = "ios", target_os = "android")))]
#[test]
fn a_fling_throws_the_edge_as_far_as_nsscrollview_does() {
let path = fling(1500.);
let peak = path.iter().copied().fold(0., f32::min);
assert!((-16. ..=-12.).contains(&peak), "peak {peak}");
let turned = path.iter().position(|&offset| offset == peak).unwrap();
assert!((4..=6).contains(&turned), "turned at frame {turned}");
assert_eq!(path[50], 0., "the edge settles");
assert!(path[turned..].windows(2).all(|pair| pair[1] >= pair[0]));
assert!(path[50..].iter().all(|&offset| offset == 0.));
let faster = fling(3000.).iter().copied().fold(0., f32::min);
assert!((faster / peak - 2.).abs() < 0.15, "{faster} vs {peak}");
let hardest = fling(20_000.).iter().copied().fold(0., f32::min);
assert!((-36. ..=-34.).contains(&hardest), "{hardest}");
}
#[cfg(not(any(target_os = "ios", target_os = "android")))]
#[test]
fn packets_handed_over_at_once_do_not_throw_the_edge_further() {
let peak = |path: Vec<f32>| path.iter().copied().fold(0., f32::min);
let even = peak(fling_through(4000., 500., 0));
let stalled = peak(fling_through(4000., 500., 3));
assert!(even < -10., "{even}");
assert!((stalled / even - 1.).abs() < 0.25, "{stalled} vs {even}");
}
#[test]
fn a_touch_that_lifts_without_moving_lets_go_of_the_edge() {
let start = Instant::now();
let mut overscroll = Overscroll::default();
let mut room = 0.;
overscroll.packet(-80., TouchPhase::Moved, 400., start, text(&mut room));
let later = start + Duration::from_millis(30);
overscroll.step(later);
let held = overscroll.offset();
overscroll.packet(0., TouchPhase::Started, 400., later, text(&mut room));
assert!(overscroll.packet(0., TouchPhase::Moved, 400., later, text(&mut room)));
let mut at = later;
while overscroll.step(at) {
at += Duration::from_millis(16);
}
assert!(held < 0.);
assert_eq!(overscroll.offset(), 0.);
}
#[cfg(not(any(target_os = "ios", target_os = "android")))]
#[test]
fn a_stretch_follows_the_travel_as_nsscrollview_does() {
let stretch = |travel: f32| {
let start = Instant::now();
let mut overscroll = Overscroll::default();
let mut room = 0.;
overscroll.packet(0., TouchPhase::Started, 300., start, text(&mut room));
overscroll.packet(-travel, TouchPhase::Moved, 300., start, text(&mut room));
-overscroll.offset()
};
assert!((6.5..=8.).contains(&stretch(100.)), "{}", stretch(100.));
assert!((28. ..=34.).contains(&stretch(764.)), "{}", stretch(764.));
assert!((35. ..36.).contains(&stretch(2400.)), "{}", stretch(2400.));
assert!(stretch(100_000.) < 36.);
}
#[cfg(not(any(target_os = "ios", target_os = "android")))]
#[test]
fn a_still_release_returns_at_full_speed() {
let start = Instant::now();
let mut overscroll = Overscroll::default();
let mut room = 0.;
overscroll.packet(0., TouchPhase::Started, 300., start, text(&mut room));
overscroll.packet(-800., TouchPhase::Moved, 300., start, text(&mut room));
let held = overscroll.offset();
let lifted = start + Duration::from_millis(200);
overscroll.packet(0., TouchPhase::Ended, 300., lifted, text(&mut room));
overscroll.step(lifted + Duration::from_millis(8));
assert!(overscroll.offset() / held < 0.92, "starts at once");
overscroll.step(lifted + Duration::from_millis(55));
let half = overscroll.offset() / held;
assert!((0.45..=0.55).contains(&half), "{half}");
}
#[test]
fn after_a_pause_the_next_flick_throws_the_edge_again() {
let start = Instant::now();
let mut overscroll = Overscroll::default();
let mut room = 0.;
assert!(overscroll.packet(-40., TouchPhase::Moved, 400., start, text(&mut room)));
let mut at = start;
while overscroll.step(at) {
at += Duration::from_millis(16);
}
let later = at + GESTURE_GAP;
assert!(overscroll.packet(-40., TouchPhase::Moved, 400., later, text(&mut room)));
}
#[test]
fn momentum_back_into_the_text_scrolls_while_the_edge_returns() {
let start = Instant::now();
let mut overscroll = Overscroll::default();
let mut room = 0.;
overscroll.packet(0., TouchPhase::Started, 400., start, text(&mut room));
overscroll.packet(-800., TouchPhase::Moved, 400., start, text(&mut room));
let lifted = start + Duration::from_millis(100);
overscroll.packet(0., TouchPhase::Ended, 400., lifted, text(&mut room));
let later = lifted + Duration::from_millis(16);
overscroll.step(later);
let before = overscroll.offset();
assert!(before < 0.);
overscroll.packet(20., TouchPhase::Moved, 400., later, text(&mut room));
assert_eq!(room, 20.);
assert_eq!(overscroll.offset(), before, "the edge does not jump");
overscroll.step(later + Duration::from_millis(16));
assert!(overscroll.offset() > before && overscroll.offset() < 0.);
}
#[test]
fn a_new_precise_gesture_takes_over_without_jumping() {
let start = Instant::now();
let mut overscroll = Overscroll::default();
let mut room = 0.;
overscroll.packet(-80., TouchPhase::Moved, 400., start, text(&mut room));
overscroll.step(start + Duration::from_millis(16));
let later = start + Duration::from_millis(200);
overscroll.step(later);
assert!(overscroll.offset() < 0.);
let before = overscroll.offset();
overscroll.packet(0., TouchPhase::Started, 400., later, text(&mut room));
assert!(
(overscroll.offset() - before).abs() < 0.001,
"interruption does not jump"
);
assert!(overscroll.packet(-40., TouchPhase::Moved, 400., later, text(&mut room)));
overscroll.step(later + Duration::from_millis(16));
assert!(
overscroll.offset() < before,
"the new input pushes outward again"
);
}
}