Skip to main content

animato_driver/
scroll.rs

1//! Scroll-position-driven animation.
2//!
3//! [`ScrollDriver`] advances animations in proportion to scroll deltas rather
4//! than wall-clock time, so animations are tied directly to a scroll position.
5//!
6//! [`ScrollClock`] adapts scroll movement into the [`Clock`] interface so any
7//! [`AnimationDriver`](crate::driver::AnimationDriver) can be scroll-driven.
8
9use std::fmt::Debug;
10
11use crate::clock::Clock;
12use animato_core::Update;
13
14/// Drives registered animations from a normalised scroll position.
15///
16/// Each call to [`set_position`](ScrollDriver::set_position) advances every
17/// animation by `Δposition / range` — a normalised fraction ∈ `[0, 1]`.
18///
19/// # Example
20///
21/// ```rust
22/// use animato_driver::scroll::ScrollDriver;
23/// use animato_core::Update;
24///
25/// struct Counter(u32);
26/// impl Update for Counter {
27///     fn update(&mut self, _dt: f32) -> bool { self.0 += 1; self.0 < 10 }
28/// }
29///
30/// let mut driver = ScrollDriver::new(0.0, 1000.0);
31/// driver.add(Counter(0));
32/// driver.set_position(250.0);  // 25% through the scroll range
33/// driver.set_position(500.0);  // another 25%
34/// ```
35#[derive(Default)]
36pub struct ScrollDriver {
37    min: f32,
38    max: f32,
39    position: f32,
40    animations: std::vec::Vec<Box<dyn Update + Send>>,
41}
42
43fn normalized_max(min: f32, max: f32) -> f32 {
44    if max > min {
45        max
46    } else {
47        let increment = f32::EPSILON * min.abs().max(1.0);
48        let adjusted = min + increment;
49        if adjusted > min { adjusted } else { min + 1.0 }
50    }
51}
52
53impl Debug for ScrollDriver {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.debug_struct("ScrollDriver")
56            .field("min", &self.min)
57            .field("max", &self.max)
58            .field("position", &self.position)
59            .field("animations", &self.animations.len())
60            .finish()
61    }
62}
63
64impl ScrollDriver {
65    /// Create a scroll driver over the given position range.
66    ///
67    /// `min` and `max` define the full scroll extent. `max` is clamped to be
68    /// strictly greater than `min`.
69    pub fn new(min: f32, max: f32) -> Self {
70        Self {
71            min,
72            max: normalized_max(min, max),
73            position: min,
74            animations: std::vec::Vec::new(),
75        }
76    }
77
78    /// Register an animation to be driven by scroll changes.
79    pub fn add<A: Update + Send + 'static>(&mut self, animation: A) {
80        self.animations.push(Box::new(animation));
81    }
82
83    /// Update the scroll position and tick all animations by the normalised delta.
84    ///
85    /// If `pos` is outside `[min, max]` it is clamped. Animations receive a
86    /// `dt` equal to `|Δpos| / (max − min)`, i.e. a normalised `[0, 1]` delta.
87    ///
88    /// Completed animations (returning `false`) are retained so they stay at
89    /// their terminal value — call [`clear_completed`](Self::clear_completed)
90    /// if you want to remove them.
91    pub fn set_position(&mut self, pos: f32) {
92        let clamped = pos.clamp(self.min, self.max);
93        let range = self.max - self.min;
94        if range <= 0.0 {
95            return;
96        }
97
98        let delta = (clamped - self.position).abs() / range;
99        self.position = clamped;
100
101        if delta > 0.0 {
102            for animation in self.animations.iter_mut() {
103                animation.update(delta);
104            }
105        }
106    }
107
108    /// Remove all animations that have returned `false` from their last `update`.
109    ///
110    /// This is not done automatically, so completed animations stay accessible
111    /// for value reads after they finish.
112    pub fn clear_completed(&mut self) {
113        // Re-run a zero-dt tick to determine completion status.
114        self.animations.retain_mut(|a| a.update(0.0));
115    }
116
117    /// Current scroll position in user units.
118    pub fn position(&self) -> f32 {
119        self.position
120    }
121
122    /// Normalised scroll progress ∈ `[0.0, 1.0]`.
123    pub fn progress(&self) -> f32 {
124        let range = self.max - self.min;
125        if range <= 0.0 {
126            return 0.0;
127        }
128        ((self.position - self.min) / range).clamp(0.0, 1.0)
129    }
130
131    /// Minimum position of the scroll range.
132    pub fn min(&self) -> f32 {
133        self.min
134    }
135
136    /// Maximum position of the scroll range.
137    pub fn max(&self) -> f32 {
138        self.max
139    }
140
141    /// Number of registered animations.
142    pub fn animation_count(&self) -> usize {
143        self.animations.len()
144    }
145}
146
147// ── ScrollClock ───────────────────────────────────────────────────────────────
148
149/// A [`Clock`] implementation backed by scroll-position changes.
150///
151/// Each call to [`set_scroll`](ScrollClock::set_scroll) stores a normalised
152/// delta; the next call to [`Clock::delta`] consumes and returns it.
153///
154/// # Example
155///
156/// ```rust
157/// use animato_driver::{Clock, AnimationDriver};
158/// use animato_driver::scroll::ScrollClock;
159///
160/// let mut clock = ScrollClock::new(0.0, 1000.0);
161/// clock.set_scroll(250.0);
162/// let dt = clock.delta(); // ≈ 0.25
163/// assert!((dt - 0.25).abs() < 0.001);
164/// ```
165#[derive(Clone, Debug)]
166pub struct ScrollClock {
167    last: f32,
168    pending: f32,
169    min: f32,
170    max: f32,
171}
172
173impl Default for ScrollClock {
174    fn default() -> Self {
175        Self::new(0.0, 1000.0)
176    }
177}
178
179impl ScrollClock {
180    /// Create a scroll clock spanning `[min, max]`.
181    pub fn new(min: f32, max: f32) -> Self {
182        Self {
183            last: min,
184            pending: 0.0,
185            min,
186            max: normalized_max(min, max),
187        }
188    }
189
190    /// Register a new scroll position and accumulate the normalised delta.
191    ///
192    /// Multiple calls before [`Clock::delta`] accumulate (they do not cancel
193    /// each other out — only magnitudes are summed).
194    pub fn set_scroll(&mut self, pos: f32) {
195        let clamped = pos.clamp(self.min, self.max);
196        let range = self.max - self.min;
197        if range > 0.0 {
198            self.pending += (clamped - self.last).abs() / range;
199        }
200        self.last = clamped;
201    }
202
203    /// Current scroll position in user units.
204    pub fn scroll_position(&self) -> f32 {
205        self.last
206    }
207
208    /// Normalised scroll progress ∈ `[0.0, 1.0]`.
209    pub fn progress(&self) -> f32 {
210        let range = self.max - self.min;
211        if range <= 0.0 {
212            return 0.0;
213        }
214        ((self.last - self.min) / range).clamp(0.0, 1.0)
215    }
216}
217
218impl Clock for ScrollClock {
219    /// Returns the accumulated normalised delta since the last call.
220    fn delta(&mut self) -> f32 {
221        let dt = self.pending;
222        self.pending = 0.0;
223        dt
224    }
225}
226
227// ── Tests ─────────────────────────────────────────────────────────────────────
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use animato_core::{Easing, Update};
233    use animato_tween::Tween;
234
235    #[test]
236    fn scroll_driver_progress_tracks_position() {
237        let mut driver = ScrollDriver::new(0.0, 100.0);
238        assert_eq!(driver.progress(), 0.0);
239        driver.set_position(50.0);
240        assert!((driver.progress() - 0.5).abs() < 0.001);
241        driver.set_position(100.0);
242        assert!((driver.progress() - 1.0).abs() < 0.001);
243    }
244
245    #[test]
246    fn scroll_driver_clamps_position() {
247        let mut driver = ScrollDriver::new(0.0, 100.0);
248        driver.set_position(-50.0);
249        assert_eq!(driver.position(), 0.0);
250        driver.set_position(200.0);
251        assert_eq!(driver.position(), 100.0);
252    }
253
254    #[test]
255    fn scroll_driver_ticks_animations_proportionally() {
256        let mut driver = ScrollDriver::new(0.0, 1000.0);
257        // A tween with duration=1 will be driven by normalised scroll deltas.
258        driver.add(
259            Tween::new(0.0_f32, 100.0)
260                .duration(1.0)
261                .easing(Easing::Linear)
262                .build(),
263        );
264        // Scroll to 50% → tween should be ~50% complete.
265        driver.set_position(500.0);
266        assert_eq!(driver.animation_count(), 1);
267    }
268
269    #[test]
270    fn scroll_driver_zero_delta_does_not_tick() {
271        struct _PanicOnUpdate;
272        impl Update for _PanicOnUpdate {
273            fn update(&mut self, _dt: f32) -> bool {
274                panic!("should not be called")
275            }
276        }
277        let mut driver = ScrollDriver::new(0.0, 100.0);
278        // No movement — update should not be called.
279        driver.set_position(0.0); // clamped, delta = 0
280        // If we reach here without panic, test passes.
281    }
282
283    #[test]
284    fn scroll_clock_delta_is_normalised() {
285        let mut clock = ScrollClock::new(0.0, 1000.0);
286        clock.set_scroll(250.0);
287        let dt = clock.delta();
288        assert!((dt - 0.25).abs() < 0.001);
289    }
290
291    #[test]
292    fn scroll_clock_delta_consumed_after_read() {
293        let mut clock = ScrollClock::new(0.0, 100.0);
294        clock.set_scroll(30.0);
295        let _ = clock.delta();
296        assert_eq!(clock.delta(), 0.0);
297    }
298
299    #[test]
300    fn scroll_clock_accumulates_multiple_moves() {
301        let mut clock = ScrollClock::new(0.0, 100.0);
302        clock.set_scroll(10.0); // +0.1
303        clock.set_scroll(20.0); // +0.1
304        clock.set_scroll(30.0); // +0.1
305        let dt = clock.delta();
306        assert!((dt - 0.3).abs() < 0.001);
307    }
308
309    #[test]
310    fn scroll_clock_progress() {
311        let mut clock = ScrollClock::new(0.0, 200.0);
312        clock.set_scroll(100.0);
313        let _ = clock.delta();
314        assert!((clock.progress() - 0.5).abs() < 0.001);
315    }
316
317    #[test]
318    fn scroll_driver_debug_default_and_degenerate_range() {
319        let mut driver = ScrollDriver::new(10.0, 5.0);
320
321        assert_eq!(driver.min(), 10.0);
322        assert!(driver.max() > driver.min());
323        assert_eq!(driver.position(), 10.0);
324        assert_eq!(driver.progress(), 0.0);
325        assert!(format!("{driver:?}").contains("ScrollDriver"));
326
327        driver.set_position(20.0);
328        assert_eq!(driver.position(), driver.max());
329        assert_eq!(driver.progress(), 1.0);
330
331        let default_driver = ScrollDriver::default();
332        assert_eq!(default_driver.animation_count(), 0);
333    }
334
335    #[test]
336    fn clear_completed_removes_finished_animations() {
337        struct Done;
338        impl Update for Done {
339            fn update(&mut self, _dt: f32) -> bool {
340                false
341            }
342        }
343
344        let mut driver = ScrollDriver::new(0.0, 100.0);
345        driver.add(Done);
346
347        assert_eq!(driver.animation_count(), 1);
348        driver.clear_completed();
349        assert_eq!(driver.animation_count(), 0);
350    }
351
352    #[test]
353    fn scroll_clock_default_clamps_and_reports_position() {
354        let mut clock = ScrollClock::default();
355
356        clock.set_scroll(2000.0);
357
358        assert_eq!(clock.scroll_position(), 1000.0);
359        assert_eq!(clock.progress(), 1.0);
360        assert_eq!(clock.delta(), 1.0);
361    }
362}