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        for fired in &mut self.state.thresholds_fired {
229            *fired = false;
230        }
231        self.events.clear();
232    }
233
234    fn overshoot(&self) -> Duration {
235        self.inner.overshoot()
236    }
237}
238
239// ---------------------------------------------------------------------------
240// Tests
241// ---------------------------------------------------------------------------
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::animation::Fade;
247
248    const MS_100: Duration = Duration::from_millis(100);
249    const MS_250: Duration = Duration::from_millis(250);
250    const MS_500: Duration = Duration::from_millis(500);
251    const SEC_1: Duration = Duration::from_secs(1);
252
253    #[test]
254    fn no_events_configured() {
255        let mut anim = Callbacks::new(Fade::new(SEC_1));
256        anim.tick(MS_500);
257        assert!(anim.drain_events().is_empty());
258    }
259
260    #[test]
261    fn started_fires_on_first_tick() {
262        let mut anim = Callbacks::new(Fade::new(SEC_1)).on_start();
263        anim.tick(MS_100);
264        let events = anim.drain_events();
265        assert_eq!(events, vec![AnimationEvent::Started]);
266
267        // Does not fire again.
268        anim.tick(MS_100);
269        assert!(anim.drain_events().is_empty());
270    }
271
272    #[test]
273    fn completed_fires_when_done() {
274        let mut anim = Callbacks::new(Fade::new(MS_500)).on_complete();
275        anim.tick(MS_250);
276        assert!(anim.drain_events().is_empty()); // Not complete yet.
277
278        anim.tick(MS_500); // Past completion.
279        let events = anim.drain_events();
280        assert_eq!(events, vec![AnimationEvent::Completed]);
281
282        // Does not fire again.
283        anim.tick(MS_100);
284        assert!(anim.drain_events().is_empty());
285    }
286
287    #[test]
288    fn progress_threshold_fires_once() {
289        let mut anim = Callbacks::new(Fade::new(SEC_1)).at_progress(0.5);
290        anim.tick(MS_250);
291        assert!(anim.drain_events().is_empty()); // At 25%.
292
293        anim.tick(MS_500); // At 75%.
294        let events = anim.drain_events();
295        assert_eq!(events, vec![AnimationEvent::Progress(0.5)]);
296
297        // Does not fire again.
298        anim.tick(MS_250);
299        assert!(anim.drain_events().is_empty());
300    }
301
302    #[test]
303    fn multiple_thresholds() {
304        let mut anim = Callbacks::new(Fade::new(SEC_1))
305            .at_progress(0.25)
306            .at_progress(0.75);
307
308        anim.tick(MS_500); // At 50% — should cross 0.25.
309        let events = anim.drain_events();
310        assert_eq!(events, vec![AnimationEvent::Progress(0.25)]);
311
312        anim.tick(MS_500); // At 100% — should cross 0.75.
313        let events = anim.drain_events();
314        assert_eq!(events, vec![AnimationEvent::Progress(0.75)]);
315    }
316
317    #[test]
318    fn all_events_in_order() {
319        let mut anim = Callbacks::new(Fade::new(MS_500))
320            .on_start()
321            .at_progress(0.5)
322            .on_complete();
323
324        anim.tick(MS_500); // Completes in one tick.
325        let events = anim.drain_events();
326        assert_eq!(
327            events,
328            vec![
329                AnimationEvent::Started,
330                AnimationEvent::Progress(0.5),
331                AnimationEvent::Completed,
332            ]
333        );
334    }
335
336    #[test]
337    fn reset_allows_events_to_fire_again() {
338        let mut anim = Callbacks::new(Fade::new(MS_500)).on_start().on_complete();
339        anim.tick(SEC_1);
340        let _ = anim.drain_events();
341
342        anim.reset();
343        anim.tick(SEC_1);
344        let events = anim.drain_events();
345        assert_eq!(
346            events,
347            vec![AnimationEvent::Started, AnimationEvent::Completed]
348        );
349    }
350
351    #[test]
352    fn drain_clears_queue() {
353        let mut anim = Callbacks::new(Fade::new(SEC_1)).on_start();
354        anim.tick(MS_100);
355        assert_eq!(anim.pending_event_count(), 1);
356
357        let _ = anim.drain_events();
358        assert_eq!(anim.pending_event_count(), 0);
359    }
360
361    #[test]
362    fn inner_access() {
363        let anim = Callbacks::new(Fade::new(SEC_1));
364        assert!(!anim.inner().is_complete());
365    }
366
367    #[test]
368    fn inner_mut_access() {
369        let mut anim = Callbacks::new(Fade::new(SEC_1));
370        anim.inner_mut().tick(SEC_1);
371        assert!(anim.inner().is_complete());
372    }
373
374    #[test]
375    fn animation_trait_value_delegates() {
376        let mut anim = Callbacks::new(Fade::new(SEC_1));
377        anim.tick(MS_500);
378        assert!((anim.value() - 0.5).abs() < 0.02);
379    }
380
381    #[test]
382    fn animation_trait_is_complete_delegates() {
383        let mut anim = Callbacks::new(Fade::new(MS_100));
384        assert!(!anim.is_complete());
385        anim.tick(MS_100);
386        assert!(anim.is_complete());
387    }
388
389    #[test]
390    fn threshold_clamped() {
391        let mut anim = Callbacks::new(Fade::new(SEC_1))
392            .at_progress(-0.5) // Clamped to 0.0
393            .at_progress(1.5); // Clamped to 1.0
394
395        anim.tick(Duration::from_nanos(1)); // Barely started.
396        let events = anim.drain_events();
397        // 0.0 threshold should fire immediately.
398        assert!(events.contains(&AnimationEvent::Progress(0.0)));
399    }
400
401    #[test]
402    fn debug_format() {
403        let anim = Callbacks::new(Fade::new(MS_100)).on_start();
404        let dbg = format!("{:?}", anim);
405        assert!(dbg.contains("Callbacks"));
406        assert!(dbg.contains("pending_events"));
407    }
408
409    #[test]
410    fn overshoot_delegates() {
411        let mut anim = Callbacks::new(Fade::new(MS_100));
412        anim.tick(MS_500);
413        assert!(anim.overshoot() > Duration::ZERO);
414    }
415}