Skip to main content

ftui_core/animation/
group.rs

1#![forbid(unsafe_code)]
2
3//! Animation group: shared lifecycle management for multiple animations.
4//!
5//! An [`AnimationGroup`] holds a collection of named [`Animation`] handles
6//! that can be controlled together (play all, cancel all) or individually.
7//! The group itself implements [`Animation`], reporting the average progress
8//! of all members.
9//!
10//! # Usage
11//!
12//! ```ignore
13//! use std::time::Duration;
14//! use ftui_core::animation::{Fade, AnimationGroup};
15//!
16//! let mut group = AnimationGroup::new()
17//!     .add("fade_in", Fade::new(Duration::from_millis(300)))
18//!     .add("fade_out", Fade::new(Duration::from_millis(500)));
19//!
20//! group.start_all();
21//! group.tick(Duration::from_millis(100));
22//! let fade_in_val = group.get("fade_in").unwrap().value();
23//! ```
24//!
25//! # Invariants
26//!
27//! 1. Each member has a unique string label; duplicate labels overwrite.
28//! 2. `start_all()` / `cancel_all()` affect every member simultaneously.
29//! 3. `overall_progress()` returns the mean of all members' `value()`.
30//! 4. An empty group has progress 0.0 and is immediately complete.
31//! 5. `is_complete()` is true iff every member is complete.
32//!
33//! # Failure Modes
34//!
35//! - Empty group: `overall_progress()` returns 0.0, `is_complete()` returns true.
36//! - Unknown label in `get()` / `get_mut()`: returns `None`.
37
38use std::time::Duration;
39
40use super::Animation;
41
42// ---------------------------------------------------------------------------
43// Types
44// ---------------------------------------------------------------------------
45
46/// A named animation member in the group.
47struct GroupMember {
48    label: String,
49    animation: Box<dyn Animation>,
50}
51
52impl std::fmt::Debug for GroupMember {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        f.debug_struct("GroupMember")
55            .field("label", &self.label)
56            .field("value", &self.animation.value())
57            .field("complete", &self.animation.is_complete())
58            .finish()
59    }
60}
61
62/// A collection of named animations with shared lifecycle control.
63///
64/// Implements [`Animation`] — `value()` returns the average progress of all
65/// members, and `is_complete()` is true when every member has finished.
66pub struct AnimationGroup {
67    members: Vec<GroupMember>,
68}
69
70impl std::fmt::Debug for AnimationGroup {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        f.debug_struct("AnimationGroup")
73            .field("count", &self.members.len())
74            .field("progress", &self.overall_progress())
75            .field("complete", &self.all_complete())
76            .finish()
77    }
78}
79
80// ---------------------------------------------------------------------------
81// Construction
82// ---------------------------------------------------------------------------
83
84impl AnimationGroup {
85    /// Create an empty animation group.
86    #[must_use]
87    pub fn new() -> Self {
88        Self {
89            members: Vec::new(),
90        }
91    }
92
93    /// Add a named animation to the group (builder pattern).
94    ///
95    /// If `label` already exists, the previous animation is replaced.
96    #[must_use]
97    pub fn add(mut self, label: &str, animation: impl Animation + 'static) -> Self {
98        self.insert(label, Box::new(animation));
99        self
100    }
101
102    /// Insert a named animation (mutating).
103    ///
104    /// If `label` already exists, the previous animation is replaced.
105    pub fn insert(&mut self, label: &str, animation: Box<dyn Animation>) {
106        if let Some(existing) = self.members.iter_mut().find(|m| m.label == label) {
107            existing.animation = animation;
108        } else {
109            self.members.push(GroupMember {
110                label: label.to_string(),
111                animation,
112            });
113        }
114    }
115
116    /// Remove a named animation. Returns `true` if found and removed.
117    pub fn remove(&mut self, label: &str) -> bool {
118        let len_before = self.members.len();
119        self.members.retain(|m| m.label != label);
120        self.members.len() < len_before
121    }
122}
123
124impl Default for AnimationGroup {
125    fn default() -> Self {
126        Self::new()
127    }
128}
129
130// ---------------------------------------------------------------------------
131// Lifecycle control
132// ---------------------------------------------------------------------------
133
134impl AnimationGroup {
135    /// Reset all animations to their initial state.
136    pub fn start_all(&mut self) {
137        for member in &mut self.members {
138            member.animation.reset();
139        }
140    }
141
142    /// Reset all animations (alias for consistency with "cancel" semantics).
143    pub fn cancel_all(&mut self) {
144        self.start_all();
145    }
146
147    /// Number of animations in the group.
148    #[must_use]
149    pub fn len(&self) -> usize {
150        self.members.len()
151    }
152
153    /// Whether the group is empty.
154    #[must_use]
155    pub fn is_empty(&self) -> bool {
156        self.members.is_empty()
157    }
158
159    /// Whether every animation in the group has completed.
160    #[must_use]
161    pub fn all_complete(&self) -> bool {
162        self.members.is_empty() || self.members.iter().all(|m| m.animation.is_complete())
163    }
164
165    /// Average progress across all animations (0.0–1.0).
166    ///
167    /// Returns 0.0 for an empty group.
168    #[must_use]
169    pub fn overall_progress(&self) -> f32 {
170        if self.members.is_empty() {
171            return 0.0;
172        }
173        let sum: f32 = self.members.iter().map(|m| m.animation.value()).sum();
174        sum / self.members.len() as f32
175    }
176
177    /// Get a reference to a named animation's value.
178    #[must_use]
179    pub fn get(&self, label: &str) -> Option<&dyn Animation> {
180        self.members
181            .iter()
182            .find(|m| m.label == label)
183            .map(|m| &*m.animation)
184    }
185
186    /// Get a mutable reference to a named animation.
187    pub fn get_mut(&mut self, label: &str) -> Option<&mut Box<dyn Animation>> {
188        for member in &mut self.members {
189            if member.label == label {
190                return Some(&mut member.animation);
191            }
192        }
193        None
194    }
195
196    /// Get a reference to an animation by index.
197    #[must_use]
198    pub fn get_at(&self, index: usize) -> Option<&dyn Animation> {
199        self.members.get(index).map(|m| &*m.animation)
200    }
201
202    /// Iterator over (label, animation) pairs.
203    pub fn iter(&self) -> impl Iterator<Item = (&str, &dyn Animation)> {
204        self.members
205            .iter()
206            .map(|m| (m.label.as_str(), &*m.animation))
207    }
208
209    /// Labels of all animations in the group.
210    pub fn labels(&self) -> impl Iterator<Item = &str> {
211        self.members.iter().map(|m| m.label.as_str())
212    }
213}
214
215// ---------------------------------------------------------------------------
216// Animation trait implementation
217// ---------------------------------------------------------------------------
218
219impl Animation for AnimationGroup {
220    fn tick(&mut self, dt: Duration) {
221        for member in &mut self.members {
222            if !member.animation.is_complete() {
223                member.animation.tick(dt);
224            }
225        }
226    }
227
228    fn is_complete(&self) -> bool {
229        self.all_complete()
230    }
231
232    fn value(&self) -> f32 {
233        self.overall_progress()
234    }
235
236    fn reset(&mut self) {
237        for member in &mut self.members {
238            member.animation.reset();
239        }
240    }
241}
242
243// ---------------------------------------------------------------------------
244// Tests
245// ---------------------------------------------------------------------------
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use crate::animation::Fade;
251
252    const MS_100: Duration = Duration::from_millis(100);
253    const MS_200: Duration = Duration::from_millis(200);
254    const MS_300: Duration = Duration::from_millis(300);
255    const MS_500: Duration = Duration::from_millis(500);
256    const SEC_1: Duration = Duration::from_secs(1);
257
258    #[test]
259    fn empty_group() {
260        let group = AnimationGroup::new();
261        assert!(group.is_empty());
262        assert_eq!(group.len(), 0);
263        assert!(group.all_complete());
264        assert_eq!(group.overall_progress(), 0.0);
265    }
266
267    #[test]
268    fn add_and_tick() {
269        let mut group = AnimationGroup::new()
270            .add("a", Fade::new(MS_500))
271            .add("b", Fade::new(SEC_1));
272
273        assert_eq!(group.len(), 2);
274        assert!(!group.all_complete());
275
276        group.tick(MS_500);
277        // "a" should be complete, "b" at 50%
278        assert!(group.get("a").unwrap().is_complete());
279        assert!(!group.get("b").unwrap().is_complete());
280        assert!((group.get("b").unwrap().value() - 0.5).abs() < 0.02);
281    }
282
283    #[test]
284    fn overall_progress() {
285        let mut group = AnimationGroup::new()
286            .add("short", Fade::new(MS_200))
287            .add("long", Fade::new(SEC_1));
288
289        group.tick(MS_200);
290        // short=1.0, long=0.2 → avg = 0.6
291        assert!((group.overall_progress() - 0.6).abs() < 0.02);
292    }
293
294    #[test]
295    fn all_complete_when_all_done() {
296        let mut group = AnimationGroup::new()
297            .add("a", Fade::new(MS_100))
298            .add("b", Fade::new(MS_200));
299
300        group.tick(MS_200);
301        assert!(group.all_complete());
302        assert!(group.is_complete());
303    }
304
305    #[test]
306    fn start_all_resets_everything() {
307        let mut group = AnimationGroup::new().add("a", Fade::new(MS_100));
308
309        group.tick(MS_100);
310        assert!(group.all_complete());
311
312        group.start_all();
313        assert!(!group.all_complete());
314        assert!((group.get("a").unwrap().value() - 0.0).abs() < f32::EPSILON);
315    }
316
317    #[test]
318    fn cancel_all_resets() {
319        let mut group = AnimationGroup::new().add("a", Fade::new(MS_100));
320
321        group.tick(MS_100);
322        group.cancel_all();
323        assert!(!group.all_complete());
324    }
325
326    #[test]
327    fn duplicate_label_replaces() {
328        let group = AnimationGroup::new()
329            .add("x", Fade::new(MS_100))
330            .add("x", Fade::new(SEC_1));
331
332        assert_eq!(group.len(), 1);
333        // The second (1s) fade replaced the first (100ms)
334    }
335
336    #[test]
337    fn remove_animation() {
338        let mut group = AnimationGroup::new()
339            .add("a", Fade::new(MS_100))
340            .add("b", Fade::new(MS_200));
341
342        assert!(group.remove("a"));
343        assert_eq!(group.len(), 1);
344        assert!(group.get("a").is_none());
345        assert!(group.get("b").is_some());
346
347        assert!(!group.remove("nonexistent"));
348    }
349
350    #[test]
351    fn get_at_index() {
352        let group = AnimationGroup::new()
353            .add("a", Fade::new(MS_100))
354            .add("b", Fade::new(MS_200));
355
356        assert!(group.get_at(0).is_some());
357        assert!(group.get_at(1).is_some());
358        assert!(group.get_at(2).is_none());
359    }
360
361    #[test]
362    fn get_mut_allows_individual_tick() {
363        let mut group = AnimationGroup::new()
364            .add("a", Fade::new(SEC_1))
365            .add("b", Fade::new(SEC_1));
366
367        // Tick only "a" individually
368        if let Some(a) = group.get_mut("a") {
369            a.tick(MS_500);
370        }
371        assert!((group.get("a").unwrap().value() - 0.5).abs() < 0.02);
372        assert!((group.get("b").unwrap().value() - 0.0).abs() < f32::EPSILON);
373    }
374
375    #[test]
376    fn labels_iterator() {
377        let group = AnimationGroup::new()
378            .add("alpha", Fade::new(MS_100))
379            .add("beta", Fade::new(MS_100));
380
381        let labels: Vec<&str> = group.labels().collect();
382        assert_eq!(labels, vec!["alpha", "beta"]);
383    }
384
385    #[test]
386    fn iter_pairs() {
387        let group = AnimationGroup::new()
388            .add("a", Fade::new(MS_100))
389            .add("b", Fade::new(MS_100));
390
391        let pairs: Vec<_> = group.iter().collect();
392        assert_eq!(pairs.len(), 2);
393        assert_eq!(pairs[0].0, "a");
394        assert_eq!(pairs[1].0, "b");
395    }
396
397    #[test]
398    fn animation_trait_reset() {
399        let mut group = AnimationGroup::new().add("a", Fade::new(MS_100));
400
401        group.tick(MS_100);
402        assert!(group.is_complete());
403
404        group.reset();
405        assert!(!group.is_complete());
406    }
407
408    #[test]
409    fn animation_trait_value_matches_overall() {
410        let mut group = AnimationGroup::new()
411            .add("a", Fade::new(MS_300))
412            .add("b", Fade::new(SEC_1));
413
414        group.tick(MS_300);
415        assert!((group.value() - group.overall_progress()).abs() < f32::EPSILON);
416    }
417
418    #[test]
419    fn skips_completed_on_tick() {
420        let mut group = AnimationGroup::new()
421            .add("a", Fade::new(MS_100))
422            .add("b", Fade::new(SEC_1));
423
424        group.tick(MS_200);
425        // "a" completed at 100ms, subsequent ticks should skip it
426        let a_val = group.get("a").unwrap().value();
427        group.tick(MS_100);
428        // "a" value should still be 1.0 (not ticked further)
429        assert!((group.get("a").unwrap().value() - a_val).abs() < f32::EPSILON);
430    }
431
432    #[test]
433    fn debug_format() {
434        let group = AnimationGroup::new().add("a", Fade::new(MS_100));
435
436        let dbg = format!("{:?}", group);
437        assert!(dbg.contains("AnimationGroup"));
438        assert!(dbg.contains("count"));
439    }
440
441    #[test]
442    fn insert_mutating() {
443        let mut group = AnimationGroup::new();
444        group.insert("x", Box::new(Fade::new(MS_100)));
445        assert_eq!(group.len(), 1);
446        assert!(group.get("x").is_some());
447    }
448}