use std::time::{Duration, Instant};
use super::input_history::InputHistory;
pub(super) const DEFAULT_HALF_LIFE_MS: f64 = 40.0;
const SNAP_THRESHOLD_PX: f64 = 0.5;
const RAMP_DURATION_MS: f64 = 100.0;
const RAMP_INITIAL_SCALE: f64 = 3.0;
const DEFAULT_KINETIC_TAU_MS: f64 = 50.0;
const KINETIC_SNAP_VELOCITY: f64 = 30.0;
#[derive(Clone, Copy, Debug)]
pub(super) struct KineticParams {
pub tau_ms: f64,
}
impl KineticParams {
pub fn new() -> Self {
Self {
tau_ms: DEFAULT_KINETIC_TAU_MS,
}
}
pub fn position_at(&self, anchor: f64, history: &InputHistory, now: Instant) -> f64 {
let tau_s = self.tau_ms / 1000.0;
anchor
+ history
.iter()
.map(|r| {
let elapsed = now.saturating_duration_since(r.timestamp).as_secs_f64();
r.delta_px as f64 * (1.0 - (-elapsed / tau_s).exp())
})
.sum::<f64>()
}
pub fn velocity_at(&self, history: &InputHistory, now: Instant) -> f64 {
let tau_s = self.tau_ms / 1000.0;
history
.iter()
.map(|r| {
let elapsed = now.saturating_duration_since(r.timestamp).as_secs_f64();
(r.delta_px as f64 / tau_s) * (-elapsed / tau_s).exp()
})
.sum()
}
}
pub(super) enum ScrollAnimator {
ExpDecay {
current: f64,
half_life_ms: f64,
ramp_elapsed_ms: f64,
},
ExpDecayAdaptive {
current: f64,
base_half_life_ms: f64,
},
Kinetic(KineticParams),
}
impl ScrollAnimator {
pub(super) fn new_exp_decay(initial: f64) -> Self {
Self::ExpDecay {
current: initial,
half_life_ms: DEFAULT_HALF_LIFE_MS,
ramp_elapsed_ms: RAMP_DURATION_MS,
}
}
pub(super) fn new_exp_decay_adaptive(initial: f64) -> Self {
Self::ExpDecayAdaptive {
current: initial,
base_half_life_ms: DEFAULT_HALF_LIFE_MS,
}
}
pub(super) fn new_kinetic(_initial: f64) -> Self {
Self::Kinetic(KineticParams::new())
}
pub(super) fn from_config(initial: f64, cfg: crate::config::ScrollAnimation) -> Self {
match cfg {
crate::config::ScrollAnimation::ExpDecay => Self::new_exp_decay(initial),
crate::config::ScrollAnimation::ExpDecayAdaptive => {
Self::new_exp_decay_adaptive(initial)
}
crate::config::ScrollAnimation::Kinetic => Self::new_kinetic(initial),
}
}
pub(super) fn current_position(
&self,
anchor: f64,
history: &InputHistory,
now: Instant,
) -> f64 {
match self {
Self::ExpDecay { current, .. } | Self::ExpDecayAdaptive { current, .. } => *current,
Self::Kinetic(params) => params.position_at(anchor, history, now),
}
}
pub(super) fn is_animating(&self, anchor: f64, history: &InputHistory, now: Instant) -> bool {
let target_sum: i64 = history.iter().map(|r| r.delta_px as i64).sum();
let target = anchor + target_sum as f64;
match self {
Self::ExpDecay { current, .. } | Self::ExpDecayAdaptive { current, .. } => {
(*current - target).abs() >= SNAP_THRESHOLD_PX
}
Self::Kinetic(params) => {
let x = params.position_at(anchor, history, now);
let v = params.velocity_at(history, now);
!((target - x).abs() < SNAP_THRESHOLD_PX && v.abs() < KINETIC_SNAP_VELOCITY)
}
}
}
pub(super) fn eviction_contribution(
&self,
record: &super::input_history::InputRecord,
now: Instant,
) -> f64 {
match self {
Self::Kinetic(params) => {
let tau_s = params.tau_ms / 1000.0;
let elapsed = now
.saturating_duration_since(record.timestamp)
.as_secs_f64();
record.delta_px as f64 * (1.0 - (-elapsed / tau_s).exp())
}
_ => record.delta_px as f64,
}
}
pub(super) fn restart_ease_in_if_settled(&mut self, settled: bool) {
if let Self::ExpDecay {
ramp_elapsed_ms, ..
} = self
&& settled
{
*ramp_elapsed_ms = 0.0;
}
}
pub(super) fn tick(
&mut self,
anchor: f64,
history: &InputHistory,
viewport_px: f64,
now: Instant,
dt: Duration,
) -> f64 {
let target_sum: i64 = history.iter().map(|r| r.delta_px as i64).sum();
let target = anchor + target_sum as f64;
match self {
Self::ExpDecay {
current,
half_life_ms,
ramp_elapsed_ms,
} => {
if dt.is_zero() {
return *current;
}
let dt_ms = dt.as_secs_f64() * 1000.0;
let t = (*ramp_elapsed_ms / RAMP_DURATION_MS).min(1.0);
let smooth = t * t * (3.0 - 2.0 * t);
let hl_scale = RAMP_INITIAL_SCALE - (RAMP_INITIAL_SCALE - 1.0) * smooth;
let effective_hl = *half_life_ms * hl_scale;
*ramp_elapsed_ms = (*ramp_elapsed_ms + dt_ms).min(RAMP_DURATION_MS);
let alpha = 1.0 - 0.5_f64.powf(dt_ms / effective_hl);
apply_step(current, target, alpha)
}
Self::ExpDecayAdaptive {
current,
base_half_life_ms,
} => {
if dt.is_zero() {
return *current;
}
let d = (target - *current).abs();
let v = viewport_px.max(1.0);
let hl = *base_half_life_ms * (1.0 + (1.0 + d / v).ln());
let dt_ms = dt.as_secs_f64() * 1000.0;
let alpha = 1.0 - 0.5_f64.powf(dt_ms / hl);
apply_step(current, target, alpha)
}
Self::Kinetic(params) => {
let _ = dt;
params.position_at(anchor, history, now)
}
}
}
}
fn apply_step(current: &mut f64, target: f64, alpha: f64) -> f64 {
let next = *current + (target - *current) * alpha;
*current = if (next - target).abs() < SNAP_THRESHOLD_PX {
target
} else {
next
};
*current
}
#[cfg(test)]
mod tests {
use super::super::input_history::ScrollDirection;
use super::*;
const NO_VP: f64 = 0.0;
fn empty_history() -> InputHistory {
InputHistory::new(Duration::from_secs(5), 128)
}
#[test]
fn kinetic_params_position_is_anchor_with_no_history() {
let params = KineticParams::new();
let h = empty_history();
let x = params.position_at(123.5, &h, Instant::now());
assert_eq!(x, 123.5);
}
#[test]
fn kinetic_params_position_asymptotes_to_anchor_plus_sum() {
let params = KineticParams::new();
let mut h = empty_history();
let t0 = Instant::now();
let _ = h.record(ScrollDirection::Down, 72);
let later = t0 + Duration::from_secs(5); let x = params.position_at(0.0, &h, later);
assert!((x - 72.0).abs() < 0.01, "x = {x}");
}
#[test]
fn kinetic_params_one_tau_progress() {
let params = KineticParams::new();
let mut h = empty_history();
let t0 = Instant::now();
let _ = h.record(ScrollDirection::Down, 100);
let later = t0 + Duration::from_secs_f64(DEFAULT_KINETIC_TAU_MS / 1000.0);
let x = params.position_at(0.0, &h, later);
let expected = 100.0 * (1.0 - (-1.0_f64).exp());
assert!(
(x - expected).abs() < 0.5,
"x = {x} vs expected = {expected}"
);
}
#[test]
fn kinetic_params_velocity_decays_with_tau() {
let params = KineticParams::new();
let mut h = empty_history();
let t0 = Instant::now();
let _ = h.record(ScrollDirection::Down, 1000);
let v0 = params.velocity_at(&h, t0); let v1 = params.velocity_at(
&h,
t0 + Duration::from_secs_f64(DEFAULT_KINETIC_TAU_MS / 1000.0),
);
let ratio = v1 / v0;
let expected = (-1.0_f64).exp(); assert!(
(ratio - expected).abs() < 0.01,
"v1/v0 = {ratio} vs 1/e = {expected}"
);
}
#[test]
fn kinetic_params_impulses_accumulate_in_position() {
let params = KineticParams::new();
let mut h = empty_history();
let _ = h.record(ScrollDirection::Down, 50);
let _ = h.record(ScrollDirection::Down, 50);
let later = Instant::now() + Duration::from_secs(5);
let x = params.position_at(0.0, &h, later);
assert!((x - 100.0).abs() < 0.5, "x = {x}");
}
#[test]
fn kinetic_params_signed_deltas_can_brake() {
let params = KineticParams::new();
let mut h = empty_history();
let _ = h.record(ScrollDirection::Down, 100);
let _ = h.record(ScrollDirection::Up, -40);
let later = Instant::now() + Duration::from_secs(5);
let x = params.position_at(0.0, &h, later);
assert!((x - 60.0).abs() < 0.5, "x = {x}");
}
fn tick_chase(a: &mut ScrollAnimator, target: f64, vp: f64, dt: Duration) -> f64 {
let h = empty_history();
a.tick(target, &h, vp, Instant::now(), dt)
}
fn position_chase_current(a: &ScrollAnimator) -> f64 {
a.current_position(0.0, &empty_history(), Instant::now())
}
fn is_animating_chase(a: &ScrollAnimator, target: f64) -> bool {
a.is_animating(target, &empty_history(), Instant::now())
}
#[test]
fn exp_decay_half_life_behavior() {
let mut a = ScrollAnimator::new_exp_decay(0.0);
let c = tick_chase(
&mut a,
100.0,
NO_VP,
Duration::from_secs_f64(DEFAULT_HALF_LIFE_MS / 1000.0),
);
assert!((c - 50.0).abs() < 0.01, "expected ~50, got {c}");
}
#[test]
fn exp_decay_frame_rate_independent() {
let full = Duration::from_millis(40);
let half = Duration::from_millis(20);
let mut one = ScrollAnimator::new_exp_decay(0.0);
let one_shot = tick_chase(&mut one, 100.0, NO_VP, full);
let mut two = ScrollAnimator::new_exp_decay(0.0);
tick_chase(&mut two, 100.0, NO_VP, half);
let two_shot = tick_chase(&mut two, 100.0, NO_VP, half);
assert!(
(one_shot - two_shot).abs() < 1e-9,
"one_shot={one_shot} two_shot={two_shot}"
);
}
#[test]
fn exp_decay_zero_dt_is_noop() {
let mut a = ScrollAnimator::ExpDecay {
current: 10.0,
half_life_ms: DEFAULT_HALF_LIFE_MS,
ramp_elapsed_ms: RAMP_DURATION_MS,
};
let c = tick_chase(&mut a, 100.0, NO_VP, Duration::ZERO);
assert_eq!(c, 10.0);
assert_eq!(position_chase_current(&a), 10.0);
}
#[test]
fn exp_decay_snaps_when_residual_is_subpixel() {
let mut a = ScrollAnimator::ExpDecay {
current: 99.9,
half_life_ms: DEFAULT_HALF_LIFE_MS,
ramp_elapsed_ms: RAMP_DURATION_MS,
};
let c = tick_chase(&mut a, 100.0, NO_VP, Duration::from_millis(40));
assert_eq!(c, 100.0);
assert!(!is_animating_chase(&a, 100.0));
}
#[test]
fn exp_decay_converges_toward_target() {
let mut a = ScrollAnimator::new_exp_decay(0.0);
let dt = Duration::from_secs_f64(DEFAULT_HALF_LIFE_MS * 10.0 / 1000.0);
let c = tick_chase(&mut a, 100.0, NO_VP, dt);
assert_eq!(c, 100.0);
}
#[test]
fn exp_decay_handles_negative_direction() {
let mut a = ScrollAnimator::new_exp_decay(100.0);
let c = tick_chase(
&mut a,
0.0,
NO_VP,
Duration::from_secs_f64(DEFAULT_HALF_LIFE_MS / 1000.0),
);
assert!((c - 50.0).abs() < 0.01, "expected ~50, got {c}");
}
#[test]
fn restart_ease_in_does_not_change_position() {
let mut a = ScrollAnimator::new_exp_decay(10.0);
a.restart_ease_in_if_settled(true);
assert_eq!(position_chase_current(&a), 10.0);
}
#[test]
fn from_config_dispatches_exp_decay() {
let a = ScrollAnimator::from_config(7.0, crate::config::ScrollAnimation::ExpDecay);
assert!(matches!(a, ScrollAnimator::ExpDecay { current, .. } if current == 7.0));
}
#[test]
fn from_config_dispatches_exp_decay_adaptive() {
let a = ScrollAnimator::from_config(7.0, crate::config::ScrollAnimation::ExpDecayAdaptive);
assert!(matches!(
a,
ScrollAnimator::ExpDecayAdaptive { current, .. } if current == 7.0
));
}
#[test]
fn is_animating_threshold() {
let a = ScrollAnimator::ExpDecay {
current: 99.4,
half_life_ms: DEFAULT_HALF_LIFE_MS,
ramp_elapsed_ms: RAMP_DURATION_MS,
};
assert!(is_animating_chase(&a, 100.0));
let b = ScrollAnimator::ExpDecay {
current: 99.6,
half_life_ms: DEFAULT_HALF_LIFE_MS,
ramp_elapsed_ms: RAMP_DURATION_MS,
};
assert!(!is_animating_chase(&b, 100.0));
}
#[test]
fn exp_decay_ramp_starts_slow() {
let mut a = ScrollAnimator::new_exp_decay(0.0);
a.restart_ease_in_if_settled(true); let c = tick_chase(
&mut a,
100.0,
NO_VP,
Duration::from_secs_f64(DEFAULT_HALF_LIFE_MS / 1000.0),
);
assert!(c < 50.0, "expected ramp to slow start, got {c}");
}
#[test]
fn exp_decay_ramp_completes_after_100ms() {
let ramp_ms = RAMP_DURATION_MS as u64;
let mut a = ScrollAnimator::new_exp_decay(0.0);
a.restart_ease_in_if_settled(true);
for _ in 0..10 {
tick_chase(&mut a, 100.0, NO_VP, Duration::from_millis(ramp_ms / 10));
}
let residual_before = 100.0 - position_chase_current(&a);
let c = tick_chase(
&mut a,
100.0,
NO_VP,
Duration::from_secs_f64(DEFAULT_HALF_LIFE_MS / 1000.0),
);
let residual_after = 100.0 - c;
assert!(
(residual_after / residual_before - 0.5).abs() < 0.02,
"expected residual to halve post-ramp, ratio={:.3}",
residual_after / residual_before
);
}
#[test]
fn exp_decay_no_ramp_reset_when_already_animating() {
let mut a = ScrollAnimator::new_exp_decay(0.0);
a.restart_ease_in_if_settled(true);
tick_chase(&mut a, 100.0, NO_VP, Duration::from_millis(50));
let elapsed_mid = match &a {
ScrollAnimator::ExpDecay {
ramp_elapsed_ms, ..
} => *ramp_elapsed_ms,
_ => panic!(),
};
a.restart_ease_in_if_settled(false);
let elapsed_after = match &a {
ScrollAnimator::ExpDecay {
ramp_elapsed_ms, ..
} => *ramp_elapsed_ms,
_ => panic!(),
};
assert_eq!(elapsed_mid, elapsed_after, "ramp should not reset");
}
#[test]
fn exp_decay_ramp_resets_on_new_scroll_from_settled() {
let mut a = ScrollAnimator::new_exp_decay(0.0);
tick_chase(&mut a, 100.0, NO_VP, Duration::from_millis(200));
a.restart_ease_in_if_settled(true);
let elapsed = match &a {
ScrollAnimator::ExpDecay {
ramp_elapsed_ms, ..
} => *ramp_elapsed_ms,
_ => panic!(),
};
assert_eq!(elapsed, 0.0, "ramp_elapsed should reset to 0");
}
#[test]
fn adaptive_half_life_at_one_viewport() {
let viewport = 1000.0;
let mut a = ScrollAnimator::new_exp_decay_adaptive(0.0);
let expected_hl_ms = DEFAULT_HALF_LIFE_MS * (1.0 + 2.0_f64.ln());
let c = tick_chase(
&mut a,
1000.0,
viewport,
Duration::from_secs_f64(expected_hl_ms / 1000.0),
);
assert!(
(c - 500.0).abs() < 0.5,
"expected ~500 after one adaptive half-life, got {c}"
);
}
#[test]
fn adaptive_near_distance_matches_base_half_life() {
let viewport = 10_000.0;
let mut a = ScrollAnimator::ExpDecayAdaptive {
current: 0.0,
base_half_life_ms: DEFAULT_HALF_LIFE_MS,
};
let c = tick_chase(
&mut a,
50.0,
viewport,
Duration::from_secs_f64(DEFAULT_HALF_LIFE_MS / 1000.0),
);
assert!(
(c - 25.0).abs() < 0.25,
"expected ~25 for near-distance, got {c}"
);
}
#[test]
fn adaptive_large_jump_decays_slower_than_base() {
let viewport = 100.0;
let mut a = ScrollAnimator::new_exp_decay_adaptive(0.0);
let c = tick_chase(
&mut a,
1000.0,
viewport,
Duration::from_secs_f64(DEFAULT_HALF_LIFE_MS / 1000.0),
);
assert!(
c < 500.0,
"adaptive should under-progress vs fixed, got {c}"
);
let alpha_cap = 1.0 - 0.5_f64.powf(1.0 / 4.0);
let lower_bound = 1000.0 * alpha_cap;
assert!(c > lower_bound, "adaptive regressing too much: {c}");
}
#[test]
fn adaptive_zero_dt_is_noop() {
let mut a = ScrollAnimator::ExpDecayAdaptive {
current: 10.0,
base_half_life_ms: DEFAULT_HALF_LIFE_MS,
};
let c = tick_chase(&mut a, 100.0, 500.0, Duration::ZERO);
assert_eq!(c, 10.0);
}
#[test]
fn adaptive_snaps_when_residual_is_subpixel() {
let mut a = ScrollAnimator::ExpDecayAdaptive {
current: 99.9,
base_half_life_ms: DEFAULT_HALF_LIFE_MS,
};
let c = tick_chase(&mut a, 100.0, 500.0, Duration::from_millis(40));
assert_eq!(c, 100.0);
assert!(!is_animating_chase(&a, 100.0));
}
#[test]
fn adaptive_zero_viewport_is_safe() {
let mut a = ScrollAnimator::new_exp_decay_adaptive(0.0);
let c = tick_chase(
&mut a,
100.0,
0.0,
Duration::from_secs_f64(DEFAULT_HALF_LIFE_MS / 1000.0),
);
assert!(c > 0.0 && c < 100.0, "got {c}");
}
#[test]
fn kinetic_glides_to_anchor_plus_delta() {
let mut a = ScrollAnimator::new_kinetic(0.0);
let mut h = empty_history();
let _ = h.record(ScrollDirection::Down, 72);
let later = Instant::now() + Duration::from_secs(5);
let x = a.tick(0.0, &h, NO_VP, later, Duration::from_millis(10));
assert!((x - 72.0).abs() < 0.5, "kinetic should land at 72, got {x}");
}
#[test]
fn kinetic_frame_rate_independent() {
let mut h = empty_history();
let _ = h.record(ScrollDirection::Down, 100);
let t0 = Instant::now();
let dt_full = Duration::from_millis(20);
let dt_half = Duration::from_millis(10);
let mut one = ScrollAnimator::new_kinetic(0.0);
let one_shot = one.tick(0.0, &h, NO_VP, t0 + dt_full, dt_full);
let mut two = ScrollAnimator::new_kinetic(0.0);
two.tick(0.0, &h, NO_VP, t0 + dt_half, dt_half);
let two_shot = two.tick(0.0, &h, NO_VP, t0 + dt_full, dt_half);
assert!(
(one_shot - two_shot).abs() < 1e-9,
"one_shot={one_shot} two_shot={two_shot}"
);
}
#[test]
fn kinetic_impulses_accumulate() {
let mut single = empty_history();
let _ = single.record(ScrollDirection::Down, 50);
let mut double = empty_history();
let _ = double.record(ScrollDirection::Down, 50);
let _ = double.record(ScrollDirection::Down, 50);
let later = Instant::now() + Duration::from_secs(5);
let mut a = ScrollAnimator::new_kinetic(0.0);
let single_x = a.tick(0.0, &single, NO_VP, later, Duration::from_millis(2));
let double_x = a.tick(0.0, &double, NO_VP, later, Duration::from_millis(2));
assert!(
double_x > single_x * 1.5,
"double impulse should travel further: single={single_x}, double={double_x}"
);
}
#[test]
fn kinetic_landing_via_drain_and_repush() {
let mut a = ScrollAnimator::new_kinetic(0.0);
let mut h = empty_history();
let _ = h.record(ScrollDirection::Down, 1000);
let now = Instant::now();
let current = a.current_position(0.0, &h, now);
h.drain();
let anchor = current;
let target = -100.0;
let delta = (target as i32) - (current.round() as i32);
let _ = h.record(ScrollDirection::Up, delta);
let later = now + Duration::from_secs(5);
let x = a.tick(anchor, &h, NO_VP, later, Duration::from_millis(2));
assert!(
(x - target).abs() < 1.0,
"should land near {target}, got {x}"
);
}
#[test]
fn kinetic_settles() {
let a = ScrollAnimator::new_kinetic(0.0);
let mut h = empty_history();
let _ = h.record(ScrollDirection::Down, 100);
let later = Instant::now() + Duration::from_secs(5);
assert!(!a.is_animating(0.0, &h, later), "kinetic did not settle");
}
#[test]
fn kinetic_does_not_snap_while_fast() {
let a = ScrollAnimator::new_kinetic(0.0);
let mut h = empty_history();
let t0 = Instant::now();
let _ = h.record(ScrollDirection::Down, 1000);
assert!(a.is_animating(0.0, &h, t0));
}
#[test]
fn kinetic_zero_dt_is_noop() {
let mut a = ScrollAnimator::new_kinetic(0.0);
let h = empty_history();
let now = Instant::now();
let c = a.tick(0.0, &h, NO_VP, now, Duration::ZERO);
assert_eq!(c, 0.0);
}
#[test]
fn from_config_dispatches_kinetic() {
let a = ScrollAnimator::from_config(7.0, crate::config::ScrollAnimation::Kinetic);
assert!(matches!(a, ScrollAnimator::Kinetic(_)));
}
#[test]
fn kinetic_matches_legacy_stateful_recurrence() {
use std::thread;
let params = KineticParams::new();
let tau_s = params.tau_ms / 1000.0;
let mut h = empty_history();
let _ = h.record(ScrollDirection::Down, 72);
thread::sleep(Duration::from_millis(15));
let _ = h.record(ScrollDirection::Down, 50);
thread::sleep(Duration::from_millis(20));
let _ = h.record(ScrollDirection::Up, -30);
let records: Vec<_> = h.iter().copied().collect();
let t_first = records[0].timestamp;
let legacy_walk = |sample: Instant| -> (f64, f64) {
let mut x = 0.0_f64;
let mut v = 0.0_f64;
let mut t = t_first;
for r in &records {
let dt = r.timestamp.saturating_duration_since(t).as_secs_f64();
if dt > 0.0 {
let decay = (-dt / tau_s).exp();
let v_new = v * decay;
x += (v - v_new) * tau_s;
v = v_new;
t = r.timestamp;
}
v += r.delta_px as f64 / tau_s;
}
let dt = sample.saturating_duration_since(t).as_secs_f64();
if dt > 0.0 {
let decay = (-dt / tau_s).exp();
let v_new = v * decay;
x += (v - v_new) * tau_s;
v = v_new;
}
(x, v)
};
let last_t = records.last().unwrap().timestamp;
for offset_ms in [25_u64, 100, 500] {
let sample = last_t + Duration::from_millis(offset_ms);
let (legacy_x, legacy_v) = legacy_walk(sample);
let closed_x = params.position_at(0.0, &h, sample);
let closed_v = params.velocity_at(&h, sample);
assert!(
(closed_x - legacy_x).abs() < 1e-9,
"position diverged at +{offset_ms}ms: closed={closed_x} legacy={legacy_x}",
);
assert!(
(closed_v - legacy_v).abs() < 1e-9,
"velocity diverged at +{offset_ms}ms: closed={closed_v} legacy={legacy_v}",
);
}
}
}