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
43impl Debug for ScrollDriver {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.debug_struct("ScrollDriver")
46            .field("min", &self.min)
47            .field("max", &self.max)
48            .field("position", &self.position)
49            .field("animations", &self.animations.len())
50            .finish()
51    }
52}
53
54impl ScrollDriver {
55    /// Create a scroll driver over the given position range.
56    ///
57    /// `min` and `max` define the full scroll extent. `max` is clamped to be
58    /// strictly greater than `min`.
59    pub fn new(min: f32, max: f32) -> Self {
60        Self {
61            min,
62            max: max.max(min + f32::EPSILON),
63            position: min,
64            animations: std::vec::Vec::new(),
65        }
66    }
67
68    /// Register an animation to be driven by scroll changes.
69    pub fn add<A: Update + Send + 'static>(&mut self, animation: A) {
70        self.animations.push(Box::new(animation));
71    }
72
73    /// Update the scroll position and tick all animations by the normalised delta.
74    ///
75    /// If `pos` is outside `[min, max]` it is clamped. Animations receive a
76    /// `dt` equal to `|Δpos| / (max − min)`, i.e. a normalised `[0, 1]` delta.
77    ///
78    /// Completed animations (returning `false`) are retained so they stay at
79    /// their terminal value — call [`clear_completed`](Self::clear_completed)
80    /// if you want to remove them.
81    pub fn set_position(&mut self, pos: f32) {
82        let clamped = pos.clamp(self.min, self.max);
83        let range = self.max - self.min;
84        if range <= 0.0 {
85            return;
86        }
87
88        let delta = (clamped - self.position).abs() / range;
89        self.position = clamped;
90
91        if delta > 0.0 {
92            for animation in self.animations.iter_mut() {
93                animation.update(delta);
94            }
95        }
96    }
97
98    /// Remove all animations that have returned `false` from their last `update`.
99    ///
100    /// This is not done automatically, so completed animations stay accessible
101    /// for value reads after they finish.
102    pub fn clear_completed(&mut self) {
103        // Re-run a zero-dt tick to determine completion status.
104        self.animations.retain_mut(|a| a.update(0.0));
105    }
106
107    /// Current scroll position in user units.
108    pub fn position(&self) -> f32 {
109        self.position
110    }
111
112    /// Normalised scroll progress ∈ `[0.0, 1.0]`.
113    pub fn progress(&self) -> f32 {
114        let range = self.max - self.min;
115        if range <= 0.0 {
116            return 0.0;
117        }
118        ((self.position - self.min) / range).clamp(0.0, 1.0)
119    }
120
121    /// Minimum position of the scroll range.
122    pub fn min(&self) -> f32 {
123        self.min
124    }
125
126    /// Maximum position of the scroll range.
127    pub fn max(&self) -> f32 {
128        self.max
129    }
130
131    /// Number of registered animations.
132    pub fn animation_count(&self) -> usize {
133        self.animations.len()
134    }
135}
136
137// ── ScrollClock ───────────────────────────────────────────────────────────────
138
139/// A [`Clock`] implementation backed by scroll-position changes.
140///
141/// Each call to [`set_scroll`](ScrollClock::set_scroll) stores a normalised
142/// delta; the next call to [`Clock::delta`] consumes and returns it.
143///
144/// # Example
145///
146/// ```rust
147/// use animato_driver::{Clock, AnimationDriver};
148/// use animato_driver::scroll::ScrollClock;
149///
150/// let mut clock = ScrollClock::new(0.0, 1000.0);
151/// clock.set_scroll(250.0);
152/// let dt = clock.delta(); // ≈ 0.25
153/// assert!((dt - 0.25).abs() < 0.001);
154/// ```
155#[derive(Clone, Debug)]
156pub struct ScrollClock {
157    last: f32,
158    pending: f32,
159    min: f32,
160    max: f32,
161}
162
163impl Default for ScrollClock {
164    fn default() -> Self {
165        Self::new(0.0, 1000.0)
166    }
167}
168
169impl ScrollClock {
170    /// Create a scroll clock spanning `[min, max]`.
171    pub fn new(min: f32, max: f32) -> Self {
172        Self {
173            last: min,
174            pending: 0.0,
175            min,
176            max: max.max(min + f32::EPSILON),
177        }
178    }
179
180    /// Register a new scroll position and accumulate the normalised delta.
181    ///
182    /// Multiple calls before [`Clock::delta`] accumulate (they do not cancel
183    /// each other out — only magnitudes are summed).
184    pub fn set_scroll(&mut self, pos: f32) {
185        let clamped = pos.clamp(self.min, self.max);
186        let range = self.max - self.min;
187        if range > 0.0 {
188            self.pending += (clamped - self.last).abs() / range;
189        }
190        self.last = clamped;
191    }
192
193    /// Current scroll position in user units.
194    pub fn scroll_position(&self) -> f32 {
195        self.last
196    }
197
198    /// Normalised scroll progress ∈ `[0.0, 1.0]`.
199    pub fn progress(&self) -> f32 {
200        let range = self.max - self.min;
201        if range <= 0.0 {
202            return 0.0;
203        }
204        ((self.last - self.min) / range).clamp(0.0, 1.0)
205    }
206}
207
208impl Clock for ScrollClock {
209    /// Returns the accumulated normalised delta since the last call.
210    fn delta(&mut self) -> f32 {
211        let dt = self.pending;
212        self.pending = 0.0;
213        dt
214    }
215}
216
217// ── Tests ─────────────────────────────────────────────────────────────────────
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use animato_core::{Easing, Update};
223    use animato_tween::Tween;
224
225    #[test]
226    fn scroll_driver_progress_tracks_position() {
227        let mut driver = ScrollDriver::new(0.0, 100.0);
228        assert_eq!(driver.progress(), 0.0);
229        driver.set_position(50.0);
230        assert!((driver.progress() - 0.5).abs() < 0.001);
231        driver.set_position(100.0);
232        assert!((driver.progress() - 1.0).abs() < 0.001);
233    }
234
235    #[test]
236    fn scroll_driver_clamps_position() {
237        let mut driver = ScrollDriver::new(0.0, 100.0);
238        driver.set_position(-50.0);
239        assert_eq!(driver.position(), 0.0);
240        driver.set_position(200.0);
241        assert_eq!(driver.position(), 100.0);
242    }
243
244    #[test]
245    fn scroll_driver_ticks_animations_proportionally() {
246        let mut driver = ScrollDriver::new(0.0, 1000.0);
247        // A tween with duration=1 will be driven by normalised scroll deltas.
248        driver.add(
249            Tween::new(0.0_f32, 100.0)
250                .duration(1.0)
251                .easing(Easing::Linear)
252                .build(),
253        );
254        // Scroll to 50% → tween should be ~50% complete.
255        driver.set_position(500.0);
256        assert_eq!(driver.animation_count(), 1);
257    }
258
259    #[test]
260    fn scroll_driver_zero_delta_does_not_tick() {
261        struct _PanicOnUpdate;
262        impl Update for _PanicOnUpdate {
263            fn update(&mut self, _dt: f32) -> bool {
264                panic!("should not be called")
265            }
266        }
267        let mut driver = ScrollDriver::new(0.0, 100.0);
268        // No movement — update should not be called.
269        driver.set_position(0.0); // clamped, delta = 0
270        // If we reach here without panic, test passes.
271    }
272
273    #[test]
274    fn scroll_clock_delta_is_normalised() {
275        let mut clock = ScrollClock::new(0.0, 1000.0);
276        clock.set_scroll(250.0);
277        let dt = clock.delta();
278        assert!((dt - 0.25).abs() < 0.001);
279    }
280
281    #[test]
282    fn scroll_clock_delta_consumed_after_read() {
283        let mut clock = ScrollClock::new(0.0, 100.0);
284        clock.set_scroll(30.0);
285        let _ = clock.delta();
286        assert_eq!(clock.delta(), 0.0);
287    }
288
289    #[test]
290    fn scroll_clock_accumulates_multiple_moves() {
291        let mut clock = ScrollClock::new(0.0, 100.0);
292        clock.set_scroll(10.0); // +0.1
293        clock.set_scroll(20.0); // +0.1
294        clock.set_scroll(30.0); // +0.1
295        let dt = clock.delta();
296        assert!((dt - 0.3).abs() < 0.001);
297    }
298
299    #[test]
300    fn scroll_clock_progress() {
301        let mut clock = ScrollClock::new(0.0, 200.0);
302        clock.set_scroll(100.0);
303        let _ = clock.delta();
304        assert!((clock.progress() - 0.5).abs() < 0.001);
305    }
306}