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 pub fn follow(&mut self, max: f32, resolved: f32, dt: f32) -> bool {
146 self.set_max(max);
147 self.scroll_to(resolved);
148 self.advance(dt);
149 self.animating()
150 }
151}
152
153pub fn smooth_scroll_area<R>(
164 ui: &mut egui::Ui,
165 area: egui::ScrollArea,
166 dt: f32,
167 vert: &mut SmoothScroll,
168 horiz: Option<&mut SmoothScroll>,
169 content: impl FnOnce(&mut egui::Ui) -> R,
170) -> R {
171 let mut area = area.vertical_scroll_offset(vert.offset);
172 if let Some(h) = &horiz {
173 area = area.horizontal_scroll_offset(h.offset);
174 }
175 let out = area.show(ui, content);
176
177 let max_y = (out.content_size.y - out.inner_rect.height()).max(0.0);
178 let mut animating = vert.follow(max_y, out.state.offset.y, dt);
179 if let Some(h) = horiz {
180 let max_x = (out.content_size.x - out.inner_rect.width()).max(0.0);
181 animating |= h.follow(max_x, out.state.offset.x, dt);
182 }
183 if animating {
184 ui.ctx().request_repaint();
185 }
186 out.inner
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 #[test]
194 fn smooth_scroll_converges_to_target_under_injected_clock() {
195 let mut s = SmoothScroll::default().with_max(1000.0);
196 s.scroll_to(500.0);
197 for _ in 0..120 {
199 s.advance(1.0 / 60.0);
200 }
201 assert!((s.offset - 500.0).abs() < 0.5, "should converge near target, got {}", s.offset);
202 }
203
204 #[test]
205 fn deterministic_same_input_same_result() {
206 let mut a = SmoothScroll::default().with_max(1000.0);
207 let mut b = SmoothScroll::default().with_max(1000.0);
208 a.scroll_by(120.0);
209 b.scroll_by(120.0);
210 for _ in 0..30 {
211 a.advance(1.0 / 60.0);
212 b.advance(1.0 / 60.0);
213 }
214 assert_eq!(a, b, "identical state + dt sequence → identical result (FC-7)");
215 }
216
217 #[test]
218 fn instant_mode_jumps_with_no_animation() {
219 let mut s = SmoothScroll { smooth: false, ..SmoothScroll::default().with_max(1000.0) };
220 s.scroll_to(400.0);
221 assert_eq!(s.offset, 400.0, "instant mode snaps");
222 assert!(!s.animating());
223 }
224
225 #[test]
226 fn momentum_decays_and_stops() {
227 let mut s = SmoothScroll::default().with_max(10000.0);
228 s.flick(2000.0);
229 let mut moved = 0.0;
230 for _ in 0..300 {
231 let before = s.offset;
232 s.advance(1.0 / 60.0);
233 moved += (s.offset - before).abs();
234 }
235 assert!(moved > 0.0, "momentum scrolled");
236 assert!(!s.animating(), "momentum eventually settles");
237 }
238
239 #[test]
240 fn fractional_offset_is_decoupled_from_row_height() {
241 let mut s = SmoothScroll::default().with_max(10000.0);
242 s.offset = 53.0; let (row, frac) = s.first_row_and_frac(20.0);
244 assert_eq!(row, 2, "53/20 → row 2");
245 assert!((frac - 13.0).abs() < 1e-3, "sub-pixel remainder 13px");
246 }
247
248 #[test]
249 fn clamps_to_extent() {
250 let mut s = SmoothScroll::default().with_max(100.0);
251 s.scroll_by(9999.0);
252 for _ in 0..300 {
253 s.advance(1.0 / 60.0);
254 }
255 assert!(s.offset <= 100.0 + 1e-3, "cannot scroll past max");
256 assert!(s.offset >= 99.0, "reaches the bottom");
257 }
258
259 #[test]
260 fn follow_eases_toward_egui_resolved_offset() {
261 let mut s = SmoothScroll::default();
264 let mut animating = false;
265 for _ in 0..120 {
266 animating = s.follow(1000.0, 300.0, 1.0 / 60.0);
267 }
268 assert!((s.offset - 300.0).abs() < 0.5, "follows the resolved target, got {}", s.offset);
269 assert!(!animating, "settles (no repaint) once arrived");
270 let mut a = SmoothScroll::default();
272 let mut b = SmoothScroll::default();
273 for _ in 0..20 {
274 a.follow(500.0, 250.0, 1.0 / 60.0);
275 b.follow(500.0, 250.0, 1.0 / 60.0);
276 }
277 assert_eq!(a, b, "deterministic follow (FC-7)");
278 }
279}