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    #[inline]
149    #[must_use]
150    pub fn len(&self) -> usize {
151        self.members.len()
152    }
153
154    /// Whether the group is empty.
155    #[inline]
156    #[must_use]
157    pub fn is_empty(&self) -> bool {
158        self.members.is_empty()
159    }
160
161    /// Whether every animation in the group has completed.
162    #[inline]
163    #[must_use]
164    pub fn all_complete(&self) -> bool {
165        self.members.is_empty() || self.members.iter().all(|m| m.animation.is_complete())
166    }
167
168    /// Average progress across all animations (0.0–1.0).
169    ///
170    /// Returns 0.0 for an empty group.
171    #[inline]
172    #[must_use]
173    pub fn overall_progress(&self) -> f32 {
174        if self.members.is_empty() {
175            return 0.0;
176        }
177        let sum: f32 = self.members.iter().map(|m| m.animation.value()).sum();
178        sum / self.members.len() as f32
179    }
180
181    /// Get a reference to a named animation's value.
182    #[inline]
183    #[must_use]
184    pub fn get(&self, label: &str) -> Option<&dyn Animation> {
185        self.members
186            .iter()
187            .find(|m| m.label == label)
188            .map(|m| &*m.animation)
189    }
190
191    /// Get a mutable reference to a named animation.
192    pub fn get_mut(&mut self, label: &str) -> Option<&mut Box<dyn Animation>> {
193        for member in &mut self.members {
194            if member.label == label {
195                return Some(&mut member.animation);
196            }
197        }
198        None
199    }
200
201    /// Get a reference to an animation by index.
202    #[inline]
203    #[must_use]
204    pub fn get_at(&self, index: usize) -> Option<&dyn Animation> {
205        self.members.get(index).map(|m| &*m.animation)
206    }
207
208    /// Iterator over (label, animation) pairs.
209    pub fn iter(&self) -> impl Iterator<Item = (&str, &dyn Animation)> {
210        self.members
211            .iter()
212            .map(|m| (m.label.as_str(), &*m.animation))
213    }
214
215    /// Labels of all animations in the group.
216    pub fn labels(&self) -> impl Iterator<Item = &str> {
217        self.members.iter().map(|m| m.label.as_str())
218    }
219}
220
221// ---------------------------------------------------------------------------
222// Animation trait implementation
223// ---------------------------------------------------------------------------
224
225impl Animation for AnimationGroup {
226    fn tick(&mut self, dt: Duration) {
227        for member in &mut self.members {
228            if !member.animation.is_complete() {
229                member.animation.tick(dt);
230            }
231        }
232    }
233
234    fn is_complete(&self) -> bool {
235        self.all_complete()
236    }
237
238    fn value(&self) -> f32 {
239        self.overall_progress()
240    }
241
242    fn reset(&mut self) {
243        for member in &mut self.members {
244            member.animation.reset();
245        }
246    }
247}
248
249// ---------------------------------------------------------------------------
250// Tests
251// ---------------------------------------------------------------------------
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use crate::animation::Fade;
257
258    const MS_100: Duration = Duration::from_millis(100);
259    const MS_200: Duration = Duration::from_millis(200);
260    const MS_300: Duration = Duration::from_millis(300);
261    const MS_500: Duration = Duration::from_millis(500);
262    const SEC_1: Duration = Duration::from_secs(1);
263
264    #[test]
265    fn empty_group() {
266        let group = AnimationGroup::new();
267        assert!(group.is_empty());
268        assert_eq!(group.len(), 0);
269        assert!(group.all_complete());
270        assert_eq!(group.overall_progress(), 0.0);
271    }
272
273    #[test]
274    fn add_and_tick() {
275        let mut group = AnimationGroup::new()
276            .add("a", Fade::new(MS_500))
277            .add("b", Fade::new(SEC_1));
278
279        assert_eq!(group.len(), 2);
280        assert!(!group.all_complete());
281
282        group.tick(MS_500);
283        // "a" should be complete, "b" at 50%
284        assert!(group.get("a").unwrap().is_complete());
285        assert!(!group.get("b").unwrap().is_complete());
286        assert!((group.get("b").unwrap().value() - 0.5).abs() < 0.02);
287    }
288
289    #[test]
290    fn overall_progress() {
291        let mut group = AnimationGroup::new()
292            .add("short", Fade::new(MS_200))
293            .add("long", Fade::new(SEC_1));
294
295        group.tick(MS_200);
296        // short=1.0, long=0.2 → avg = 0.6
297        assert!((group.overall_progress() - 0.6).abs() < 0.02);
298    }
299
300    #[test]
301    fn all_complete_when_all_done() {
302        let mut group = AnimationGroup::new()
303            .add("a", Fade::new(MS_100))
304            .add("b", Fade::new(MS_200));
305
306        group.tick(MS_200);
307        assert!(group.all_complete());
308        assert!(group.is_complete());
309    }
310
311    #[test]
312    fn start_all_resets_everything() {
313        let mut group = AnimationGroup::new().add("a", Fade::new(MS_100));
314
315        group.tick(MS_100);
316        assert!(group.all_complete());
317
318        group.start_all();
319        assert!(!group.all_complete());
320        assert!((group.get("a").unwrap().value() - 0.0).abs() < f32::EPSILON);
321    }
322
323    #[test]
324    fn cancel_all_resets() {
325        let mut group = AnimationGroup::new().add("a", Fade::new(MS_100));
326
327        group.tick(MS_100);
328        group.cancel_all();
329        assert!(!group.all_complete());
330    }
331
332    #[test]
333    fn duplicate_label_replaces() {
334        let group = AnimationGroup::new()
335            .add("x", Fade::new(MS_100))
336            .add("x", Fade::new(SEC_1));
337
338        assert_eq!(group.len(), 1);
339        // The second (1s) fade replaced the first (100ms)
340    }
341
342    #[test]
343    fn remove_animation() {
344        let mut group = AnimationGroup::new()
345            .add("a", Fade::new(MS_100))
346            .add("b", Fade::new(MS_200));
347
348        assert!(group.remove("a"));
349        assert_eq!(group.len(), 1);
350        assert!(group.get("a").is_none());
351        assert!(group.get("b").is_some());
352
353        assert!(!group.remove("nonexistent"));
354    }
355
356    #[test]
357    fn get_at_index() {
358        let group = AnimationGroup::new()
359            .add("a", Fade::new(MS_100))
360            .add("b", Fade::new(MS_200));
361
362        assert!(group.get_at(0).is_some());
363        assert!(group.get_at(1).is_some());
364        assert!(group.get_at(2).is_none());
365    }
366
367    #[test]
368    fn get_mut_allows_individual_tick() {
369        let mut group = AnimationGroup::new()
370            .add("a", Fade::new(SEC_1))
371            .add("b", Fade::new(SEC_1));
372
373        // Tick only "a" individually
374        if let Some(a) = group.get_mut("a") {
375            a.tick(MS_500);
376        }
377        assert!((group.get("a").unwrap().value() - 0.5).abs() < 0.02);
378        assert!((group.get("b").unwrap().value() - 0.0).abs() < f32::EPSILON);
379    }
380
381    #[test]
382    fn labels_iterator() {
383        let group = AnimationGroup::new()
384            .add("alpha", Fade::new(MS_100))
385            .add("beta", Fade::new(MS_100));
386
387        let labels: Vec<&str> = group.labels().collect();
388        assert_eq!(labels, vec!["alpha", "beta"]);
389    }
390
391    #[test]
392    fn iter_pairs() {
393        let group = AnimationGroup::new()
394            .add("a", Fade::new(MS_100))
395            .add("b", Fade::new(MS_100));
396
397        let pairs: Vec<_> = group.iter().collect();
398        assert_eq!(pairs.len(), 2);
399        assert_eq!(pairs[0].0, "a");
400        assert_eq!(pairs[1].0, "b");
401    }
402
403    #[test]
404    fn animation_trait_reset() {
405        let mut group = AnimationGroup::new().add("a", Fade::new(MS_100));
406
407        group.tick(MS_100);
408        assert!(group.is_complete());
409
410        group.reset();
411        assert!(!group.is_complete());
412    }
413
414    #[test]
415    fn animation_trait_value_matches_overall() {
416        let mut group = AnimationGroup::new()
417            .add("a", Fade::new(MS_300))
418            .add("b", Fade::new(SEC_1));
419
420        group.tick(MS_300);
421        assert!((group.value() - group.overall_progress()).abs() < f32::EPSILON);
422    }
423
424    #[test]
425    fn skips_completed_on_tick() {
426        let mut group = AnimationGroup::new()
427            .add("a", Fade::new(MS_100))
428            .add("b", Fade::new(SEC_1));
429
430        group.tick(MS_200);
431        // "a" completed at 100ms, subsequent ticks should skip it
432        let a_val = group.get("a").unwrap().value();
433        group.tick(MS_100);
434        // "a" value should still be 1.0 (not ticked further)
435        assert!((group.get("a").unwrap().value() - a_val).abs() < f32::EPSILON);
436    }
437
438    #[test]
439    fn debug_format() {
440        let group = AnimationGroup::new().add("a", Fade::new(MS_100));
441
442        let dbg = format!("{:?}", group);
443        assert!(dbg.contains("AnimationGroup"));
444        assert!(dbg.contains("count"));
445    }
446
447    #[test]
448    fn insert_mutating() {
449        let mut group = AnimationGroup::new();
450        group.insert("x", Box::new(Fade::new(MS_100)));
451        assert_eq!(group.len(), 1);
452        assert!(group.get("x").is_some());
453    }
454
455    // ── Edge-case tests (bd-1p7ii) ──────────────────────────────────
456
457    #[test]
458    fn default_trait() {
459        let group = AnimationGroup::default();
460        assert!(group.is_empty());
461        assert_eq!(group.len(), 0);
462        assert!(group.all_complete());
463    }
464
465    #[test]
466    fn get_unknown_label_returns_none() {
467        let group = AnimationGroup::new().add("a", Fade::new(MS_100));
468        assert!(group.get("nonexistent").is_none());
469    }
470
471    #[test]
472    fn get_mut_unknown_label_returns_none() {
473        let mut group = AnimationGroup::new().add("a", Fade::new(MS_100));
474        assert!(group.get_mut("nonexistent").is_none());
475    }
476
477    #[test]
478    fn insert_replaces_existing() {
479        let mut group = AnimationGroup::new();
480        group.insert("x", Box::new(Fade::new(MS_100)));
481        group.insert("x", Box::new(Fade::new(SEC_1)));
482        assert_eq!(group.len(), 1);
483        // Tick 100ms: if it was the original 100ms fade, it'd be complete.
484        // Since it was replaced with 1s fade, it should not be complete.
485        group.tick(MS_100);
486        assert!(!group.all_complete());
487    }
488
489    #[test]
490    fn remove_from_empty_group() {
491        let mut group = AnimationGroup::new();
492        assert!(!group.remove("anything"));
493        assert_eq!(group.len(), 0);
494    }
495
496    #[test]
497    fn tick_on_empty_group_no_panic() {
498        let mut group = AnimationGroup::new();
499        group.tick(MS_500);
500        assert!(group.is_complete());
501    }
502
503    #[test]
504    fn reset_on_empty_group_no_panic() {
505        let mut group = AnimationGroup::new();
506        group.reset();
507        assert!(group.is_complete());
508    }
509
510    #[test]
511    fn single_member_progress() {
512        let mut group = AnimationGroup::new().add("only", Fade::new(MS_200));
513        group.tick(MS_100);
514        assert!((group.overall_progress() - 0.5).abs() < 0.02);
515    }
516
517    #[test]
518    fn three_members_progress() {
519        let mut group = AnimationGroup::new()
520            .add("a", Fade::new(MS_100))
521            .add("b", Fade::new(MS_200))
522            .add("c", Fade::new(MS_300));
523
524        assert_eq!(group.len(), 3);
525        group.tick(MS_300);
526        assert!(group.all_complete());
527        assert!((group.overall_progress() - 1.0).abs() < f32::EPSILON);
528    }
529
530    #[test]
531    fn add_remove_add_same_label() {
532        let mut group = AnimationGroup::new().add("x", Fade::new(MS_100));
533        assert!(group.remove("x"));
534        assert_eq!(group.len(), 0);
535        group.insert("x", Box::new(Fade::new(MS_200)));
536        assert_eq!(group.len(), 1);
537    }
538
539    #[test]
540    fn start_all_on_empty_no_panic() {
541        let mut group = AnimationGroup::new();
542        group.start_all();
543        assert!(group.is_empty());
544    }
545
546    #[test]
547    fn cancel_all_on_empty_no_panic() {
548        let mut group = AnimationGroup::new();
549        group.cancel_all();
550        assert!(group.is_empty());
551    }
552
553    #[test]
554    fn progress_mixed_complete_and_incomplete() {
555        let mut group = AnimationGroup::new()
556            .add("done", Fade::new(MS_100))
557            .add("half", Fade::new(MS_500));
558
559        group.tick(MS_200);
560        // "done" at 1.0, "half" at 0.4 → avg ≈ 0.7
561        let progress = group.overall_progress();
562        assert!(progress > 0.5 && progress < 0.9, "progress: {progress}");
563        assert!(!group.all_complete());
564    }
565
566    #[test]
567    fn iter_empty_group() {
568        let group = AnimationGroup::new();
569        assert_eq!(group.iter().count(), 0);
570    }
571
572    #[test]
573    fn labels_empty_group() {
574        let group = AnimationGroup::new();
575        assert_eq!(group.labels().count(), 0);
576    }
577
578    #[test]
579    fn get_at_after_removal() {
580        let mut group = AnimationGroup::new()
581            .add("a", Fade::new(MS_100))
582            .add("b", Fade::new(MS_200));
583
584        group.remove("a");
585        // After removing "a", index 0 should be "b".
586        assert!(group.get_at(0).is_some());
587        assert!(group.get_at(1).is_none());
588    }
589
590    #[test]
591    fn animation_value_empty_is_zero() {
592        let group = AnimationGroup::new();
593        assert!((group.value() - 0.0).abs() < f32::EPSILON);
594    }
595
596    #[test]
597    fn debug_format_includes_progress_and_complete() {
598        let group = AnimationGroup::new().add("a", Fade::new(MS_100));
599        let dbg = format!("{group:?}");
600        assert!(dbg.contains("progress"));
601        assert!(dbg.contains("complete"));
602    }
603}