Skip to main content

ftui_core/animation/
callbacks.rs

1#![forbid(unsafe_code)]
2
3//! Animation callbacks: event hooks at animation milestones.
4//!
5//! [`Callbacks`] wraps any [`Animation`] and tracks milestone events
6//! (start, completion, progress thresholds) that can be polled via
7//! [`drain_events`](Callbacks::drain_events).
8//!
9//! # Usage
10//!
11//! ```ignore
12//! use std::time::Duration;
13//! use ftui_core::animation::{Fade, callbacks::{Callbacks, AnimationEvent}};
14//!
15//! let mut anim = Callbacks::new(Fade::new(Duration::from_millis(500)))
16//!     .on_start()
17//!     .on_complete()
18//!     .at_progress(0.5);
19//!
20//! anim.tick(Duration::from_millis(300));
21//! for event in anim.drain_events() {
22//!     match event {
23//!         AnimationEvent::Started => { /* ... */ }
24//!         AnimationEvent::Progress(pct) => { /* crossed 50% */ }
25//!         AnimationEvent::Completed => { /* ... */ }
26//!         _ => {}
27//!     }
28//! }
29//! ```
30//!
31//! # Design
32//!
33//! Events are collected into an internal queue during `tick()` and drained
34//! by the caller. This avoids closures/callbacks (which don't compose well
35//! in Elm architectures) and keeps the API pure.
36//!
37//! # Invariants
38//!
39//! 1. `Started` fires at most once per play-through (after first `tick()`).
40//! 2. `Completed` fires at most once (when `is_complete()` transitions to true).
41//! 3. Progress thresholds fire at most once each, in ascending order.
42//! 4. `drain_events()` clears the queue — events are not replayed.
43//! 5. `reset()` resets all tracking state so events can fire again.
44//!
45//! # Failure Modes
46//!
47//! - Threshold out of range (< 0 or > 1): clamped to [0.0, 1.0].
48//! - Duplicate thresholds: each fires independently.
49
50use std::time::Duration;
51
52use super::Animation;
53
54// ---------------------------------------------------------------------------
55// Types
56// ---------------------------------------------------------------------------
57
58/// An event emitted by a [`Callbacks`]-wrapped animation.
59#[derive(Debug, Clone, PartialEq)]
60pub enum AnimationEvent {
61    /// The animation received its first tick.
62    Started,
63    /// The animation crossed a progress threshold (value in [0.0, 1.0]).
64    Progress(f32),
65    /// The animation completed.
66    Completed,
67}
68
69/// Configuration for which events to track.
70#[derive(Debug, Clone, Default)]
71struct EventConfig {
72    on_start: bool,
73    on_complete: bool,
74    /// Sorted thresholds in [0.0, 1.0].
75    thresholds: Vec<f32>,
76}
77
78/// Tracking state for fired events.
79#[derive(Debug, Clone, Default)]
80struct EventState {
81    started_fired: bool,
82    completed_fired: bool,
83    /// Which thresholds have been crossed (parallel to config.thresholds).
84    thresholds_fired: Vec<bool>,
85}
86
87/// An animation wrapper that emits events at milestones.
88///
89/// Wraps any `Animation` and queues [`AnimationEvent`]s during `tick()`.
90/// Call [`drain_events`](Self::drain_events) to retrieve and clear them.
91pub struct Callbacks<A> {
92    inner: A,
93    config: EventConfig,
94    state: EventState,
95    events: Vec<AnimationEvent>,
96}
97
98impl<A: std::fmt::Debug> std::fmt::Debug for Callbacks<A> {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        f.debug_struct("Callbacks")
101            .field("inner", &self.inner)
102            .field("pending_events", &self.events.len())
103            .finish()
104    }
105}
106
107// ---------------------------------------------------------------------------
108// Construction
109// ---------------------------------------------------------------------------
110
111impl<A: Animation> Callbacks<A> {
112    /// Wrap an animation with callback tracking.
113    #[must_use]
114    pub fn new(inner: A) -> Self {
115        Self {
116            inner,
117            config: EventConfig::default(),
118            state: EventState::default(),
119            events: Vec::new(),
120        }
121    }
122
123    /// Enable the `Started` event (builder pattern).
124    #[must_use]
125    pub fn on_start(mut self) -> Self {
126        self.config.on_start = true;
127        self
128    }
129
130    /// Enable the `Completed` event (builder pattern).
131    #[must_use]
132    pub fn on_complete(mut self) -> Self {
133        self.config.on_complete = true;
134        self
135    }
136
137    /// Add a progress threshold event (builder pattern).
138    ///
139    /// Fires when the animation's value crosses `threshold` (clamped to [0.0, 1.0]).
140    #[must_use]
141    pub fn at_progress(mut self, threshold: f32) -> Self {
142        if !threshold.is_finite() {
143            return self;
144        }
145        let clamped = threshold.clamp(0.0, 1.0);
146        let idx = self
147            .config
148            .thresholds
149            .partition_point(|&value| value <= clamped);
150        self.config.thresholds.insert(idx, clamped);
151        self.state.thresholds_fired.insert(idx, false);
152        self
153    }
154
155    /// Access the inner animation.
156    #[inline]
157    #[must_use]
158    pub fn inner(&self) -> &A {
159        &self.inner
160    }
161
162    /// Mutable access to the inner animation.
163    #[inline]
164    pub fn inner_mut(&mut self) -> &mut A {
165        &mut self.inner
166    }
167
168    /// Drain all pending events. Clears the event queue.
169    pub fn drain_events(&mut self) -> Vec<AnimationEvent> {
170        std::mem::take(&mut self.events)
171    }
172
173    /// Number of pending events.
174    #[inline]
175    #[must_use]
176    pub fn pending_event_count(&self) -> usize {
177        self.events.len()
178    }
179
180    /// Check events after a tick.
181    fn check_events(&mut self) {
182        let value = self.inner.value();
183
184        // Started: fires on first tick.
185        if self.config.on_start && !self.state.started_fired {
186            self.state.started_fired = true;
187            self.events.push(AnimationEvent::Started);
188        }
189
190        // Progress thresholds.
191        for (i, &threshold) in self.config.thresholds.iter().enumerate() {
192            if !self.state.thresholds_fired[i] && value >= threshold {
193                self.state.thresholds_fired[i] = true;
194                self.events.push(AnimationEvent::Progress(threshold));
195            }
196        }
197
198        // Completed: fires when animation transitions to complete.
199        if self.config.on_complete && !self.state.completed_fired && self.inner.is_complete() {
200            self.state.completed_fired = true;
201            self.events.push(AnimationEvent::Completed);
202        }
203    }
204}
205
206// ---------------------------------------------------------------------------
207// Animation trait implementation
208// ---------------------------------------------------------------------------
209
210impl<A: Animation> Animation for Callbacks<A> {
211    fn tick(&mut self, dt: Duration) {
212        self.inner.tick(dt);
213        self.check_events();
214    }
215
216    fn is_complete(&self) -> bool {
217        self.inner.is_complete()
218    }
219
220    fn value(&self) -> f32 {
221        self.inner.value()
222    }
223
224    fn reset(&mut self) {
225        self.inner.reset();
226        self.state.started_fired = false;
227        self.state.completed_fired = false;
228        self.state.thresholds_fired.fill(false);
229        self.events.clear();
230    }
231
232    fn overshoot(&self) -> Duration {
233        self.inner.overshoot()
234    }
235}
236
237// ---------------------------------------------------------------------------
238// Tests
239// ---------------------------------------------------------------------------
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use crate::animation::Fade;
245
246    const MS_100: Duration = Duration::from_millis(100);
247    const MS_250: Duration = Duration::from_millis(250);
248    const MS_500: Duration = Duration::from_millis(500);
249    const SEC_1: Duration = Duration::from_secs(1);
250
251    #[test]
252    fn no_events_configured() {
253        let mut anim = Callbacks::new(Fade::new(SEC_1));
254        anim.tick(MS_500);
255        assert!(anim.drain_events().is_empty());
256    }
257
258    #[test]
259    fn started_fires_on_first_tick() {
260        let mut anim = Callbacks::new(Fade::new(SEC_1)).on_start();
261        anim.tick(MS_100);
262        let events = anim.drain_events();
263        assert_eq!(events, vec![AnimationEvent::Started]);
264
265        // Does not fire again.
266        anim.tick(MS_100);
267        assert!(anim.drain_events().is_empty());
268    }
269
270    #[test]
271    fn completed_fires_when_done() {
272        let mut anim = Callbacks::new(Fade::new(MS_500)).on_complete();
273        anim.tick(MS_250);
274        assert!(anim.drain_events().is_empty()); // Not complete yet.
275
276        anim.tick(MS_500); // Past completion.
277        let events = anim.drain_events();
278        assert_eq!(events, vec![AnimationEvent::Completed]);
279
280        // Does not fire again.
281        anim.tick(MS_100);
282        assert!(anim.drain_events().is_empty());
283    }
284
285    #[test]
286    fn progress_threshold_fires_once() {
287        let mut anim = Callbacks::new(Fade::new(SEC_1)).at_progress(0.5);
288        anim.tick(MS_250);
289        assert!(anim.drain_events().is_empty()); // At 25%.
290
291        anim.tick(MS_500); // At 75%.
292        let events = anim.drain_events();
293        assert_eq!(events, vec![AnimationEvent::Progress(0.5)]);
294
295        // Does not fire again.
296        anim.tick(MS_250);
297        assert!(anim.drain_events().is_empty());
298    }
299
300    #[test]
301    fn multiple_thresholds() {
302        let mut anim = Callbacks::new(Fade::new(SEC_1))
303            .at_progress(0.25)
304            .at_progress(0.75);
305
306        anim.tick(MS_500); // At 50% — should cross 0.25.
307        let events = anim.drain_events();
308        assert_eq!(events, vec![AnimationEvent::Progress(0.25)]);
309
310        anim.tick(MS_500); // At 100% — should cross 0.75.
311        let events = anim.drain_events();
312        assert_eq!(events, vec![AnimationEvent::Progress(0.75)]);
313    }
314
315    #[test]
316    fn all_events_in_order() {
317        let mut anim = Callbacks::new(Fade::new(MS_500))
318            .on_start()
319            .at_progress(0.5)
320            .on_complete();
321
322        anim.tick(MS_500); // Completes in one tick.
323        let events = anim.drain_events();
324        assert_eq!(
325            events,
326            vec![
327                AnimationEvent::Started,
328                AnimationEvent::Progress(0.5),
329                AnimationEvent::Completed,
330            ]
331        );
332    }
333
334    #[test]
335    fn reset_allows_events_to_fire_again() {
336        let mut anim = Callbacks::new(Fade::new(MS_500)).on_start().on_complete();
337        anim.tick(SEC_1);
338        let _ = anim.drain_events();
339
340        anim.reset();
341        anim.tick(SEC_1);
342        let events = anim.drain_events();
343        assert_eq!(
344            events,
345            vec![AnimationEvent::Started, AnimationEvent::Completed]
346        );
347    }
348
349    #[test]
350    fn drain_clears_queue() {
351        let mut anim = Callbacks::new(Fade::new(SEC_1)).on_start();
352        anim.tick(MS_100);
353        assert_eq!(anim.pending_event_count(), 1);
354
355        let _ = anim.drain_events();
356        assert_eq!(anim.pending_event_count(), 0);
357    }
358
359    #[test]
360    fn inner_access() {
361        let anim = Callbacks::new(Fade::new(SEC_1));
362        assert!(!anim.inner().is_complete());
363    }
364
365    #[test]
366    fn inner_mut_access() {
367        let mut anim = Callbacks::new(Fade::new(SEC_1));
368        anim.inner_mut().tick(SEC_1);
369        assert!(anim.inner().is_complete());
370    }
371
372    #[test]
373    fn animation_trait_value_delegates() {
374        let mut anim = Callbacks::new(Fade::new(SEC_1));
375        anim.tick(MS_500);
376        assert!((anim.value() - 0.5).abs() < 0.02);
377    }
378
379    #[test]
380    fn animation_trait_is_complete_delegates() {
381        let mut anim = Callbacks::new(Fade::new(MS_100));
382        assert!(!anim.is_complete());
383        anim.tick(MS_100);
384        assert!(anim.is_complete());
385    }
386
387    #[test]
388    fn threshold_clamped() {
389        let mut anim = Callbacks::new(Fade::new(SEC_1))
390            .at_progress(-0.5) // Clamped to 0.0
391            .at_progress(1.5); // Clamped to 1.0
392
393        anim.tick(Duration::from_nanos(1)); // Barely started.
394        let events = anim.drain_events();
395        // 0.0 threshold should fire immediately.
396        assert!(events.contains(&AnimationEvent::Progress(0.0)));
397    }
398
399    #[test]
400    fn debug_format() {
401        let anim = Callbacks::new(Fade::new(MS_100)).on_start();
402        let dbg = format!("{:?}", anim);
403        assert!(dbg.contains("Callbacks"));
404        assert!(dbg.contains("pending_events"));
405    }
406
407    #[test]
408    fn overshoot_delegates() {
409        let mut anim = Callbacks::new(Fade::new(MS_100));
410        anim.tick(MS_500);
411        assert!(anim.overshoot() > Duration::ZERO);
412    }
413}