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    #[must_use]
157    pub fn inner(&self) -> &A {
158        &self.inner
159    }
160
161    /// Mutable access to the inner animation.
162    pub fn inner_mut(&mut self) -> &mut A {
163        &mut self.inner
164    }
165
166    /// Drain all pending events. Clears the event queue.
167    pub fn drain_events(&mut self) -> Vec<AnimationEvent> {
168        std::mem::take(&mut self.events)
169    }
170
171    /// Number of pending events.
172    #[must_use]
173    pub fn pending_event_count(&self) -> usize {
174        self.events.len()
175    }
176
177    /// Check events after a tick.
178    fn check_events(&mut self) {
179        let value = self.inner.value();
180
181        // Started: fires on first tick.
182        if self.config.on_start && !self.state.started_fired {
183            self.state.started_fired = true;
184            self.events.push(AnimationEvent::Started);
185        }
186
187        // Progress thresholds.
188        for (i, &threshold) in self.config.thresholds.iter().enumerate() {
189            if !self.state.thresholds_fired[i] && value >= threshold {
190                self.state.thresholds_fired[i] = true;
191                self.events.push(AnimationEvent::Progress(threshold));
192            }
193        }
194
195        // Completed: fires when animation transitions to complete.
196        if self.config.on_complete && !self.state.completed_fired && self.inner.is_complete() {
197            self.state.completed_fired = true;
198            self.events.push(AnimationEvent::Completed);
199        }
200    }
201}
202
203// ---------------------------------------------------------------------------
204// Animation trait implementation
205// ---------------------------------------------------------------------------
206
207impl<A: Animation> Animation for Callbacks<A> {
208    fn tick(&mut self, dt: Duration) {
209        self.inner.tick(dt);
210        self.check_events();
211    }
212
213    fn is_complete(&self) -> bool {
214        self.inner.is_complete()
215    }
216
217    fn value(&self) -> f32 {
218        self.inner.value()
219    }
220
221    fn reset(&mut self) {
222        self.inner.reset();
223        self.state.started_fired = false;
224        self.state.completed_fired = false;
225        for fired in &mut self.state.thresholds_fired {
226            *fired = false;
227        }
228        self.events.clear();
229    }
230
231    fn overshoot(&self) -> Duration {
232        self.inner.overshoot()
233    }
234}
235
236// ---------------------------------------------------------------------------
237// Tests
238// ---------------------------------------------------------------------------
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use crate::animation::Fade;
244
245    const MS_100: Duration = Duration::from_millis(100);
246    const MS_250: Duration = Duration::from_millis(250);
247    const MS_500: Duration = Duration::from_millis(500);
248    const SEC_1: Duration = Duration::from_secs(1);
249
250    #[test]
251    fn no_events_configured() {
252        let mut anim = Callbacks::new(Fade::new(SEC_1));
253        anim.tick(MS_500);
254        assert!(anim.drain_events().is_empty());
255    }
256
257    #[test]
258    fn started_fires_on_first_tick() {
259        let mut anim = Callbacks::new(Fade::new(SEC_1)).on_start();
260        anim.tick(MS_100);
261        let events = anim.drain_events();
262        assert_eq!(events, vec![AnimationEvent::Started]);
263
264        // Does not fire again.
265        anim.tick(MS_100);
266        assert!(anim.drain_events().is_empty());
267    }
268
269    #[test]
270    fn completed_fires_when_done() {
271        let mut anim = Callbacks::new(Fade::new(MS_500)).on_complete();
272        anim.tick(MS_250);
273        assert!(anim.drain_events().is_empty()); // Not complete yet.
274
275        anim.tick(MS_500); // Past completion.
276        let events = anim.drain_events();
277        assert_eq!(events, vec![AnimationEvent::Completed]);
278
279        // Does not fire again.
280        anim.tick(MS_100);
281        assert!(anim.drain_events().is_empty());
282    }
283
284    #[test]
285    fn progress_threshold_fires_once() {
286        let mut anim = Callbacks::new(Fade::new(SEC_1)).at_progress(0.5);
287        anim.tick(MS_250);
288        assert!(anim.drain_events().is_empty()); // At 25%.
289
290        anim.tick(MS_500); // At 75%.
291        let events = anim.drain_events();
292        assert_eq!(events, vec![AnimationEvent::Progress(0.5)]);
293
294        // Does not fire again.
295        anim.tick(MS_250);
296        assert!(anim.drain_events().is_empty());
297    }
298
299    #[test]
300    fn multiple_thresholds() {
301        let mut anim = Callbacks::new(Fade::new(SEC_1))
302            .at_progress(0.25)
303            .at_progress(0.75);
304
305        anim.tick(MS_500); // At 50% — should cross 0.25.
306        let events = anim.drain_events();
307        assert_eq!(events, vec![AnimationEvent::Progress(0.25)]);
308
309        anim.tick(MS_500); // At 100% — should cross 0.75.
310        let events = anim.drain_events();
311        assert_eq!(events, vec![AnimationEvent::Progress(0.75)]);
312    }
313
314    #[test]
315    fn all_events_in_order() {
316        let mut anim = Callbacks::new(Fade::new(MS_500))
317            .on_start()
318            .at_progress(0.5)
319            .on_complete();
320
321        anim.tick(MS_500); // Completes in one tick.
322        let events = anim.drain_events();
323        assert_eq!(
324            events,
325            vec![
326                AnimationEvent::Started,
327                AnimationEvent::Progress(0.5),
328                AnimationEvent::Completed,
329            ]
330        );
331    }
332
333    #[test]
334    fn reset_allows_events_to_fire_again() {
335        let mut anim = Callbacks::new(Fade::new(MS_500)).on_start().on_complete();
336        anim.tick(SEC_1);
337        let _ = anim.drain_events();
338
339        anim.reset();
340        anim.tick(SEC_1);
341        let events = anim.drain_events();
342        assert_eq!(
343            events,
344            vec![AnimationEvent::Started, AnimationEvent::Completed]
345        );
346    }
347
348    #[test]
349    fn drain_clears_queue() {
350        let mut anim = Callbacks::new(Fade::new(SEC_1)).on_start();
351        anim.tick(MS_100);
352        assert_eq!(anim.pending_event_count(), 1);
353
354        let _ = anim.drain_events();
355        assert_eq!(anim.pending_event_count(), 0);
356    }
357
358    #[test]
359    fn inner_access() {
360        let anim = Callbacks::new(Fade::new(SEC_1));
361        assert!(!anim.inner().is_complete());
362    }
363
364    #[test]
365    fn inner_mut_access() {
366        let mut anim = Callbacks::new(Fade::new(SEC_1));
367        anim.inner_mut().tick(SEC_1);
368        assert!(anim.inner().is_complete());
369    }
370
371    #[test]
372    fn animation_trait_value_delegates() {
373        let mut anim = Callbacks::new(Fade::new(SEC_1));
374        anim.tick(MS_500);
375        assert!((anim.value() - 0.5).abs() < 0.02);
376    }
377
378    #[test]
379    fn animation_trait_is_complete_delegates() {
380        let mut anim = Callbacks::new(Fade::new(MS_100));
381        assert!(!anim.is_complete());
382        anim.tick(MS_100);
383        assert!(anim.is_complete());
384    }
385
386    #[test]
387    fn threshold_clamped() {
388        let mut anim = Callbacks::new(Fade::new(SEC_1))
389            .at_progress(-0.5) // Clamped to 0.0
390            .at_progress(1.5); // Clamped to 1.0
391
392        anim.tick(Duration::from_nanos(1)); // Barely started.
393        let events = anim.drain_events();
394        // 0.0 threshold should fire immediately.
395        assert!(events.contains(&AnimationEvent::Progress(0.0)));
396    }
397
398    #[test]
399    fn debug_format() {
400        let anim = Callbacks::new(Fade::new(MS_100)).on_start();
401        let dbg = format!("{:?}", anim);
402        assert!(dbg.contains("Callbacks"));
403        assert!(dbg.contains("pending_events"));
404    }
405
406    #[test]
407    fn overshoot_delegates() {
408        let mut anim = Callbacks::new(Fade::new(MS_100));
409        anim.tick(MS_500);
410        assert!(anim.overshoot() > Duration::ZERO);
411    }
412}