use std::time::Instant;
const DECELERATION_RATE: f32 = 0.985;
const MIN_VELOCITY: f32 = 50.0;
const MAX_VELOCITY: f32 = 8000.0;
const MAX_SAMPLES: usize = 20;
const VELOCITY_WINDOW_SECS: f64 = 0.15;
const MIN_SAMPLES_FOR_VELOCITY: usize = 2;
#[derive(Clone, Copy, Debug)]
struct Sample {
x: f32,
y: f32,
time: Instant,
}
pub struct VelocityTracker {
samples: [Option<Sample>; MAX_SAMPLES],
index: usize,
count: usize,
}
impl VelocityTracker {
pub fn new() -> Self {
Self {
samples: [None; MAX_SAMPLES],
index: 0,
count: 0,
}
}
pub fn record(&mut self, x: f32, y: f32) {
self.samples[self.index] = Some(Sample {
x,
y,
time: Instant::now(),
});
self.index = (self.index + 1) % MAX_SAMPLES;
self.count += 1;
}
pub fn velocity(&self) -> (f32, f32) {
let now = Instant::now();
let mut recent: Vec<&Sample> = Vec::with_capacity(MAX_SAMPLES);
for slot in &self.samples {
if let Some(s) = slot {
let age = now.duration_since(s.time).as_secs_f64();
if age <= VELOCITY_WINDOW_SECS {
recent.push(s);
}
}
}
if recent.len() < MIN_SAMPLES_FOR_VELOCITY {
return (0.0, 0.0);
}
recent.sort_by(|a, b| a.time.cmp(&b.time));
let first = recent[0];
let last = recent[recent.len() - 1];
let dt = last.time.duration_since(first.time).as_secs_f64();
if dt < 1e-6 {
return (0.0, 0.0);
}
let vx = ((last.x - first.x) as f64 / dt) as f32;
let vy = ((last.y - first.y) as f64 / dt) as f32;
if recent.len() >= 4 {
let (wvx, wvy) = weighted_velocity(&recent);
return (clamp_velocity(wvx), clamp_velocity(wvy));
}
(clamp_velocity(vx), clamp_velocity(vy))
}
pub fn reset(&mut self) {
self.samples = [None; MAX_SAMPLES];
self.index = 0;
self.count = 0;
}
}
fn weighted_velocity(samples: &[&Sample]) -> (f32, f32) {
if samples.len() < 2 {
return (0.0, 0.0);
}
let t0 = samples[0].time;
let n = samples.len();
let mut sum_w = 0.0_f64;
let mut sum_wt = 0.0_f64;
let mut sum_wt2 = 0.0_f64;
let mut sum_wx = 0.0_f64;
let mut sum_wy = 0.0_f64;
let mut sum_wtx = 0.0_f64;
let mut sum_wty = 0.0_f64;
for (i, s) in samples.iter().enumerate() {
let t = s.time.duration_since(t0).as_secs_f64();
let w = (i + 1) as f64;
sum_w += w;
sum_wt += w * t;
sum_wt2 += w * t * t;
sum_wx += w * s.x as f64;
sum_wy += w * s.y as f64;
sum_wtx += w * t * s.x as f64;
sum_wty += w * t * s.y as f64;
}
let denom = sum_w * sum_wt2 - sum_wt * sum_wt;
if denom.abs() < 1e-12 {
let first = samples[0];
let last = samples[n - 1];
let dt = last.time.duration_since(first.time).as_secs_f64();
if dt < 1e-6 {
return (0.0, 0.0);
}
return (
((last.x - first.x) as f64 / dt) as f32,
((last.y - first.y) as f64 / dt) as f32,
);
}
let vx = (sum_w * sum_wtx - sum_wt * sum_wx) / denom;
let vy = (sum_w * sum_wty - sum_wt * sum_wy) / denom;
(vx as f32, vy as f32)
}
fn clamp_velocity(v: f32) -> f32 {
v.clamp(-MAX_VELOCITY, MAX_VELOCITY)
}
pub struct MomentumScroller {
vx: f32,
vy: f32,
last_x: f32,
last_y: f32,
active: bool,
last_time: Instant,
}
impl MomentumScroller {
pub fn new() -> Self {
Self {
vx: 0.0,
vy: 0.0,
last_x: 0.0,
last_y: 0.0,
active: false,
last_time: Instant::now(),
}
}
pub fn fling(&mut self, vx: f32, vy: f32, last_x: f32, last_y: f32) {
let speed = (vx * vx + vy * vy).sqrt();
if speed < MIN_VELOCITY {
self.active = false;
return;
}
self.vx = vx;
self.vy = vy;
self.last_x = last_x;
self.last_y = last_y;
self.active = true;
self.last_time = Instant::now();
}
pub fn step(&mut self) -> Option<MomentumDelta> {
if !self.active {
return None;
}
let now = Instant::now();
let dt = now.duration_since(self.last_time).as_secs_f64() as f32;
self.last_time = now;
let dt = dt.min(0.05);
if dt < 1e-6 {
return None;
}
let dt_ms = dt * 1000.0;
let decay = DECELERATION_RATE.powf(dt_ms);
let ln_r = DECELERATION_RATE.ln();
let displacement_factor = if ln_r.abs() > 1e-9 {
(decay - 1.0) / (ln_r * 1000.0) } else {
dt };
let dx = self.vx * displacement_factor;
let dy = self.vy * displacement_factor;
self.vx *= decay;
self.vy *= decay;
let speed = (self.vx * self.vx + self.vy * self.vy).sqrt();
if speed < MIN_VELOCITY {
self.active = false;
if dx.abs() < 0.1 && dy.abs() < 0.1 {
return None;
}
}
self.last_x += dx;
self.last_y += dy;
Some(MomentumDelta {
dx,
dy,
position_x: self.last_x,
position_y: self.last_y,
})
}
pub fn cancel(&mut self) {
self.active = false;
self.vx = 0.0;
self.vy = 0.0;
}
pub fn is_active(&self) -> bool {
self.active
}
}
#[derive(Clone, Copy, Debug)]
pub struct MomentumDelta {
pub dx: f32,
pub dy: f32,
pub position_x: f32,
pub position_y: f32,
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
use std::time::Duration;
#[test]
fn velocity_tracker_no_samples() {
let tracker = VelocityTracker::new();
let (vx, vy) = tracker.velocity();
assert_eq!(vx, 0.0);
assert_eq!(vy, 0.0);
}
#[test]
fn velocity_tracker_single_sample() {
let mut tracker = VelocityTracker::new();
tracker.record(100.0, 200.0);
let (vx, vy) = tracker.velocity();
assert_eq!(vx, 0.0);
assert_eq!(vy, 0.0);
}
#[test]
fn velocity_tracker_multiple_samples() {
let mut tracker = VelocityTracker::new();
for i in 0..10 {
tracker.record(100.0, 100.0 + i as f32 * 5.0);
thread::sleep(Duration::from_millis(5));
}
let (_vx, vy) = tracker.velocity();
assert!(vy.abs() > 200.0, "vy={vy} should be > 200 px/s");
assert!(vy > 0.0, "vy should be positive (scrolling down)");
}
#[test]
fn velocity_tracker_reset() {
let mut tracker = VelocityTracker::new();
tracker.record(0.0, 0.0);
thread::sleep(Duration::from_millis(5));
tracker.record(50.0, 50.0);
tracker.reset();
let (vx, vy) = tracker.velocity();
assert_eq!(vx, 0.0);
assert_eq!(vy, 0.0);
}
#[test]
fn velocity_clamped() {
let mut tracker = VelocityTracker::new();
tracker.record(0.0, 0.0);
tracker.record(10000.0, 10000.0);
thread::sleep(Duration::from_micros(100));
tracker.record(20000.0, 20000.0);
let (vx, vy) = tracker.velocity();
assert!(vx.abs() <= MAX_VELOCITY, "vx should be clamped");
assert!(vy.abs() <= MAX_VELOCITY, "vy should be clamped");
}
#[test]
fn momentum_scroller_no_fling() {
let mut scroller = MomentumScroller::new();
assert!(!scroller.is_active());
assert!(scroller.step().is_none());
}
#[test]
fn momentum_scroller_below_threshold() {
let mut scroller = MomentumScroller::new();
scroller.fling(5.0, 5.0, 100.0, 100.0);
assert!(!scroller.is_active());
assert!(scroller.step().is_none());
}
#[test]
fn momentum_scroller_decelerates() {
let mut scroller = MomentumScroller::new();
scroller.fling(0.0, 2000.0, 100.0, 100.0);
assert!(scroller.is_active());
let mut deltas = Vec::new();
let mut total_dy = 0.0_f32;
let mut frame_count = 0;
loop {
thread::sleep(Duration::from_millis(16));
match scroller.step() {
Some(delta) => {
assert!(delta.dy >= 0.0, "dy should be >= 0, got {}", delta.dy);
deltas.push(delta.dy);
total_dy += delta.dy;
frame_count += 1;
}
None => break,
}
if frame_count > 600 {
break;
}
}
assert!(!scroller.is_active());
assert!(total_dy > 50.0, "total_dy={total_dy} should be > 50 px");
assert!(frame_count > 5, "should have run for multiple frames");
let q = deltas.len() / 4;
if q > 0 {
let first_avg: f32 = deltas[..q].iter().sum::<f32>() / q as f32;
let last_avg: f32 = deltas[deltas.len() - q..].iter().sum::<f32>() / q as f32;
assert!(
first_avg > last_avg,
"first quarter avg ({first_avg}) should be > last quarter avg ({last_avg})"
);
}
}
#[test]
fn momentum_scroller_cancel() {
let mut scroller = MomentumScroller::new();
scroller.fling(0.0, 3000.0, 100.0, 100.0);
assert!(scroller.is_active());
thread::sleep(Duration::from_millis(16));
assert!(scroller.step().is_some());
scroller.cancel();
assert!(!scroller.is_active());
assert!(scroller.step().is_none());
}
#[test]
fn momentum_scroller_fling_replaces_previous() {
let mut scroller = MomentumScroller::new();
scroller.fling(0.0, 2000.0, 100.0, 100.0);
assert!(scroller.is_active());
scroller.fling(1000.0, 0.0, 50.0, 50.0);
assert!(scroller.is_active());
thread::sleep(Duration::from_millis(16));
if let Some(delta) = scroller.step() {
assert!(
delta.dx.abs() > delta.dy.abs(),
"dx={} should dominate dy={}",
delta.dx,
delta.dy
);
}
}
#[test]
fn ring_buffer_wraps() {
let mut tracker = VelocityTracker::new();
for i in 0..(MAX_SAMPLES * 2) {
tracker.record(i as f32, i as f32);
thread::sleep(Duration::from_millis(1));
}
let (vx, vy) = tracker.velocity();
assert!(vx.abs() > 0.0 || vy.abs() > 0.0);
}
}