facett_core/
scroll_engine.rs1use serde::{Deserialize, Serialize};
9
10#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
15pub struct SmoothScroll {
16 pub offset: f32,
18 pub target: f32,
20 pub velocity: f32,
22 pub max: f32,
24 pub stiffness: f32,
26 pub friction: f32,
28 pub smooth: bool,
30}
31
32impl Default for SmoothScroll {
33 fn default() -> Self {
34 Self {
35 offset: 0.0,
36 target: 0.0,
37 velocity: 0.0,
38 max: 0.0,
39 stiffness: 16.0,
40 friction: 6.0,
41 smooth: true,
42 }
43 }
44}
45
46impl SmoothScroll {
47 pub fn with_max(mut self, max: f32) -> Self {
48 self.max = max.max(0.0);
49 self
50 }
51
52 pub fn set_max(&mut self, max: f32) {
55 self.max = max.max(0.0);
56 self.target = self.target.clamp(0.0, self.max);
57 }
58
59 pub fn scroll_to(&mut self, target: f32) {
61 self.target = target.clamp(0.0, self.max);
62 if !self.smooth {
63 self.offset = self.target;
64 self.velocity = 0.0;
65 }
66 }
67
68 pub fn scroll_by(&mut self, delta: f32) {
71 self.target = (self.target + delta).clamp(0.0, self.max);
72 self.velocity += delta * 6.0;
73 if !self.smooth {
74 self.offset = self.target;
75 self.velocity = 0.0;
76 }
77 }
78
79 pub fn flick(&mut self, velocity: f32) {
81 self.velocity = velocity;
82 }
83
84 pub fn advance(&mut self, dt: f32) {
88 let dt = dt.clamp(0.0, 0.1); if !self.smooth {
90 self.offset = self.target;
91 self.velocity = 0.0;
92 return;
93 }
94
95 if self.velocity.abs() > 0.01 {
97 self.target = (self.target + self.velocity * dt).clamp(0.0, self.max);
98 self.velocity *= (1.0 - self.friction * dt).clamp(0.0, 1.0);
100 if self.target <= 0.0 || self.target >= self.max {
101 self.velocity = 0.0; }
103 } else {
104 self.velocity = 0.0;
105 }
106
107 let k = (self.stiffness * dt).clamp(0.0, 1.0);
109 self.offset += (self.target - self.offset) * k;
110 if (self.target - self.offset).abs() < 0.05 && self.velocity == 0.0 {
112 self.offset = self.target;
113 }
114 self.offset = self.offset.clamp(0.0, self.max);
115 }
116
117 pub fn animating(&self) -> bool {
119 self.smooth && ((self.target - self.offset).abs() > 0.05 || self.velocity.abs() > 0.01)
120 }
121
122 pub fn first_row_and_frac(&self, row_h: f32) -> (usize, f32) {
126 if row_h <= 0.0 {
127 return (0, 0.0);
128 }
129 let row = (self.offset / row_h).floor();
130 let frac = self.offset - row * row_h;
131 (row.max(0.0) as usize, frac)
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 #[test]
140 fn smooth_scroll_converges_to_target_under_injected_clock() {
141 let mut s = SmoothScroll::default().with_max(1000.0);
142 s.scroll_to(500.0);
143 for _ in 0..120 {
145 s.advance(1.0 / 60.0);
146 }
147 assert!((s.offset - 500.0).abs() < 0.5, "should converge near target, got {}", s.offset);
148 }
149
150 #[test]
151 fn deterministic_same_input_same_result() {
152 let mut a = SmoothScroll::default().with_max(1000.0);
153 let mut b = SmoothScroll::default().with_max(1000.0);
154 a.scroll_by(120.0);
155 b.scroll_by(120.0);
156 for _ in 0..30 {
157 a.advance(1.0 / 60.0);
158 b.advance(1.0 / 60.0);
159 }
160 assert_eq!(a, b, "identical state + dt sequence → identical result (FC-7)");
161 }
162
163 #[test]
164 fn instant_mode_jumps_with_no_animation() {
165 let mut s = SmoothScroll { smooth: false, ..SmoothScroll::default().with_max(1000.0) };
166 s.scroll_to(400.0);
167 assert_eq!(s.offset, 400.0, "instant mode snaps");
168 assert!(!s.animating());
169 }
170
171 #[test]
172 fn momentum_decays_and_stops() {
173 let mut s = SmoothScroll::default().with_max(10000.0);
174 s.flick(2000.0);
175 let mut moved = 0.0;
176 for _ in 0..300 {
177 let before = s.offset;
178 s.advance(1.0 / 60.0);
179 moved += (s.offset - before).abs();
180 }
181 assert!(moved > 0.0, "momentum scrolled");
182 assert!(!s.animating(), "momentum eventually settles");
183 }
184
185 #[test]
186 fn fractional_offset_is_decoupled_from_row_height() {
187 let mut s = SmoothScroll::default().with_max(10000.0);
188 s.offset = 53.0; let (row, frac) = s.first_row_and_frac(20.0);
190 assert_eq!(row, 2, "53/20 → row 2");
191 assert!((frac - 13.0).abs() < 1e-3, "sub-pixel remainder 13px");
192 }
193
194 #[test]
195 fn clamps_to_extent() {
196 let mut s = SmoothScroll::default().with_max(100.0);
197 s.scroll_by(9999.0);
198 for _ in 0..300 {
199 s.advance(1.0 / 60.0);
200 }
201 assert!(s.offset <= 100.0 + 1e-3, "cannot scroll past max");
202 assert!(s.offset >= 99.0, "reaches the bottom");
203 }
204}