use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct SmoothScroll {
pub offset: f32,
pub target: f32,
pub velocity: f32,
pub max: f32,
pub stiffness: f32,
pub friction: f32,
pub smooth: bool,
}
impl Default for SmoothScroll {
fn default() -> Self {
Self {
offset: 0.0,
target: 0.0,
velocity: 0.0,
max: 0.0,
stiffness: 16.0,
friction: 6.0,
smooth: true,
}
}
}
impl SmoothScroll {
pub fn with_max(mut self, max: f32) -> Self {
self.max = max.max(0.0);
self
}
pub fn set_max(&mut self, max: f32) {
self.max = max.max(0.0);
self.target = self.target.clamp(0.0, self.max);
}
pub fn scroll_to(&mut self, target: f32) {
self.target = target.clamp(0.0, self.max);
if !self.smooth {
self.offset = self.target;
self.velocity = 0.0;
}
}
pub fn scroll_by(&mut self, delta: f32) {
self.target = (self.target + delta).clamp(0.0, self.max);
self.velocity += delta * 6.0;
if !self.smooth {
self.offset = self.target;
self.velocity = 0.0;
}
}
pub fn flick(&mut self, velocity: f32) {
self.velocity = velocity;
}
pub fn advance(&mut self, dt: f32) {
let dt = dt.clamp(0.0, 0.1); if !self.smooth {
self.offset = self.target;
self.velocity = 0.0;
return;
}
if self.velocity.abs() > 0.01 {
self.target = (self.target + self.velocity * dt).clamp(0.0, self.max);
self.velocity *= (1.0 - self.friction * dt).clamp(0.0, 1.0);
if self.target <= 0.0 || self.target >= self.max {
self.velocity = 0.0; }
} else {
self.velocity = 0.0;
}
let k = (self.stiffness * dt).clamp(0.0, 1.0);
self.offset += (self.target - self.offset) * k;
if (self.target - self.offset).abs() < 0.05 && self.velocity == 0.0 {
self.offset = self.target;
}
self.offset = self.offset.clamp(0.0, self.max);
}
pub fn animating(&self) -> bool {
self.smooth && ((self.target - self.offset).abs() > 0.05 || self.velocity.abs() > 0.01)
}
pub fn first_row_and_frac(&self, row_h: f32) -> (usize, f32) {
if row_h <= 0.0 {
return (0, 0.0);
}
let row = (self.offset / row_h).floor();
let frac = self.offset - row * row_h;
(row.max(0.0) as usize, frac)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn smooth_scroll_converges_to_target_under_injected_clock() {
let mut s = SmoothScroll::default().with_max(1000.0);
s.scroll_to(500.0);
for _ in 0..120 {
s.advance(1.0 / 60.0);
}
assert!((s.offset - 500.0).abs() < 0.5, "should converge near target, got {}", s.offset);
}
#[test]
fn deterministic_same_input_same_result() {
let mut a = SmoothScroll::default().with_max(1000.0);
let mut b = SmoothScroll::default().with_max(1000.0);
a.scroll_by(120.0);
b.scroll_by(120.0);
for _ in 0..30 {
a.advance(1.0 / 60.0);
b.advance(1.0 / 60.0);
}
assert_eq!(a, b, "identical state + dt sequence → identical result (FC-7)");
}
#[test]
fn instant_mode_jumps_with_no_animation() {
let mut s = SmoothScroll { smooth: false, ..SmoothScroll::default().with_max(1000.0) };
s.scroll_to(400.0);
assert_eq!(s.offset, 400.0, "instant mode snaps");
assert!(!s.animating());
}
#[test]
fn momentum_decays_and_stops() {
let mut s = SmoothScroll::default().with_max(10000.0);
s.flick(2000.0);
let mut moved = 0.0;
for _ in 0..300 {
let before = s.offset;
s.advance(1.0 / 60.0);
moved += (s.offset - before).abs();
}
assert!(moved > 0.0, "momentum scrolled");
assert!(!s.animating(), "momentum eventually settles");
}
#[test]
fn fractional_offset_is_decoupled_from_row_height() {
let mut s = SmoothScroll::default().with_max(10000.0);
s.offset = 53.0; let (row, frac) = s.first_row_and_frac(20.0);
assert_eq!(row, 2, "53/20 → row 2");
assert!((frac - 13.0).abs() < 1e-3, "sub-pixel remainder 13px");
}
#[test]
fn clamps_to_extent() {
let mut s = SmoothScroll::default().with_max(100.0);
s.scroll_by(9999.0);
for _ in 0..300 {
s.advance(1.0 / 60.0);
}
assert!(s.offset <= 100.0 + 1e-3, "cannot scroll past max");
assert!(s.offset >= 99.0, "reaches the bottom");
}
}