Skip to main content

ftui_core/animation/
presets.rs

1#![forbid(unsafe_code)]
2
3//! Ready-to-use animation presets built from core primitives.
4//!
5//! Each preset composes [`Fade`], [`Slide`], [`Delayed`], [`AnimationGroup`],
6//! and [`stagger_offsets`] into common UI animation patterns. All presets
7//! return concrete types that implement [`Animation`].
8//!
9//! # Available Presets
10//!
11//! | Preset | Description |
12//! |--------|-------------|
13//! | [`cascade_in`] | Staggered top-to-bottom fade-in |
14//! | [`cascade_out`] | Staggered top-to-bottom fade-out (reverse values) |
15//! | [`fan_out`] | Spread from center outward |
16//! | [`typewriter`] | Character-by-character reveal |
17//! | [`pulse_sequence`] | Sequential attention pulses |
18//! | [`slide_in_left`] | Slide element in from left edge |
19//! | [`slide_in_right`] | Slide element in from right edge |
20//! | [`fade_through`] | Fade out then fade in (crossfade) |
21//!
22//! # Invariants
23//!
24//! 1. All presets produce deterministic output for given parameters.
25//! 2. Preset groups with `count=0` return an empty group (immediately complete).
26//! 3. Timing parameters are clamped to sane minimums (1ns) to avoid division by zero.
27//! 4. All presets compose only public primitives — no internal shortcuts.
28
29use std::time::Duration;
30
31use super::group::AnimationGroup;
32use super::stagger::{StaggerMode, stagger_offsets};
33use super::{Animation, EasingFn, Fade, Sequence, Slide, delay, ease_in, ease_out, sequence};
34
35// ---------------------------------------------------------------------------
36// Cascade
37// ---------------------------------------------------------------------------
38
39/// Staggered top-to-bottom fade-in for `count` items.
40///
41/// Each item fades in over `item_duration` with a `stagger_delay` between
42/// successive starts. The stagger follows `mode` distribution.
43///
44/// Returns an [`AnimationGroup`] with labels `"item_0"`, `"item_1"`, etc.
45#[must_use]
46pub fn cascade_in(
47    count: usize,
48    item_duration: Duration,
49    stagger_delay: Duration,
50    mode: StaggerMode,
51) -> AnimationGroup {
52    let offsets = stagger_offsets(count, stagger_delay, mode);
53    let mut group = AnimationGroup::new();
54    for (i, offset) in offsets.into_iter().enumerate() {
55        let anim = delay(offset, Fade::new(item_duration).easing(ease_out));
56        group.insert(&format!("item_{i}"), Box::new(anim));
57    }
58    group
59}
60
61/// Staggered top-to-bottom fade-out for `count` items.
62///
63/// Like [`cascade_in`] but each item's value starts at 1.0 and decreases to 0.0.
64/// Internally uses a [`Fade`] whose value is inverted: `1.0 - fade.value()`.
65#[must_use]
66pub fn cascade_out(
67    count: usize,
68    item_duration: Duration,
69    stagger_delay: Duration,
70    mode: StaggerMode,
71) -> AnimationGroup {
72    let offsets = stagger_offsets(count, stagger_delay, mode);
73    let mut group = AnimationGroup::new();
74    for (i, offset) in offsets.into_iter().enumerate() {
75        let anim = delay(
76            offset,
77            InvertedFade(Fade::new(item_duration).easing(ease_in)),
78        );
79        group.insert(&format!("item_{i}"), Box::new(anim));
80    }
81    group
82}
83
84// ---------------------------------------------------------------------------
85// Fan out
86// ---------------------------------------------------------------------------
87
88/// Spread animation from center outward.
89///
90/// Items near the center start first; items at the edges start last.
91/// Uses ease-out staggering so the spread accelerates outward.
92///
93/// Returns an [`AnimationGroup`] with labels `"item_0"` .. `"item_{count-1}"`.
94#[must_use]
95pub fn fan_out(count: usize, item_duration: Duration, total_spread: Duration) -> AnimationGroup {
96    if count == 0 {
97        return AnimationGroup::new();
98    }
99
100    let mut group = AnimationGroup::new();
101
102    for i in 0..count {
103        // Distance from center, normalized to [0.0, 1.0].
104        let center = (count as f64 - 1.0) / 2.0;
105        let dist = if count <= 1 {
106            0.0
107        } else {
108            ((i as f64 - center).abs() / center).min(1.0)
109        };
110
111        // Apply ease-out to the distance for natural spread feel.
112        let eased = 1.0 - (1.0 - dist) * (1.0 - dist);
113        let offset = total_spread.mul_f64(eased);
114
115        let anim = delay(offset, Fade::new(item_duration).easing(ease_out));
116        group.insert(&format!("item_{i}"), Box::new(anim));
117    }
118
119    group
120}
121
122// ---------------------------------------------------------------------------
123// Typewriter
124// ---------------------------------------------------------------------------
125
126/// Character-by-character reveal over `total_duration`.
127///
128/// Returns a [`TypewriterAnim`] that tracks how many characters out of
129/// `char_count` should be visible at the current time.
130#[must_use]
131pub fn typewriter(char_count: usize, total_duration: Duration) -> TypewriterAnim {
132    TypewriterAnim {
133        char_count,
134        fade: Fade::new(total_duration),
135    }
136}
137
138/// Animation that reveals characters progressively.
139///
140/// Use [`TypewriterAnim::visible_chars`] to get the count of characters
141/// that should be displayed at the current time.
142#[derive(Debug, Clone)]
143pub struct TypewriterAnim {
144    char_count: usize,
145    fade: Fade,
146}
147
148impl TypewriterAnim {
149    /// Number of characters that should be visible now.
150    pub fn visible_chars(&self) -> usize {
151        let t = self.fade.value();
152        let count = (t * self.char_count as f32).round() as usize;
153        count.min(self.char_count)
154    }
155}
156
157impl Animation for TypewriterAnim {
158    fn tick(&mut self, dt: Duration) {
159        self.fade.tick(dt);
160    }
161
162    fn is_complete(&self) -> bool {
163        self.fade.is_complete()
164    }
165
166    fn value(&self) -> f32 {
167        self.fade.value()
168    }
169
170    fn reset(&mut self) {
171        self.fade.reset();
172    }
173
174    fn overshoot(&self) -> Duration {
175        self.fade.overshoot()
176    }
177}
178
179// ---------------------------------------------------------------------------
180// Pulse sequence
181// ---------------------------------------------------------------------------
182
183/// Sequential attention pulses: each item pulses once then the next starts.
184///
185/// Each pulse is a single sine half-cycle (0→1→0) lasting `pulse_duration`,
186/// with items staggered linearly. Useful for drawing attention to a sequence
187/// of UI elements.
188///
189/// Returns an [`AnimationGroup`] with labels `"pulse_0"` .. `"pulse_{count-1}"`.
190#[must_use]
191pub fn pulse_sequence(
192    count: usize,
193    pulse_duration: Duration,
194    stagger_delay: Duration,
195) -> AnimationGroup {
196    let offsets = stagger_offsets(count, stagger_delay, StaggerMode::Linear);
197    let mut group = AnimationGroup::new();
198    for (i, offset) in offsets.into_iter().enumerate() {
199        let anim = delay(offset, PulseOnce::new(pulse_duration));
200        group.insert(&format!("pulse_{i}"), Box::new(anim));
201    }
202    group
203}
204
205/// A single pulse: ramps 0→1→0 over the duration using a sine half-cycle.
206#[derive(Debug, Clone, Copy)]
207struct PulseOnce {
208    elapsed: Duration,
209    duration: Duration,
210}
211
212impl PulseOnce {
213    fn new(duration: Duration) -> Self {
214        Self {
215            elapsed: Duration::ZERO,
216            duration: if duration.is_zero() {
217                Duration::from_nanos(1)
218            } else {
219                duration
220            },
221        }
222    }
223}
224
225impl Animation for PulseOnce {
226    fn tick(&mut self, dt: Duration) {
227        self.elapsed = self.elapsed.saturating_add(dt);
228    }
229
230    fn is_complete(&self) -> bool {
231        self.elapsed >= self.duration
232    }
233
234    fn value(&self) -> f32 {
235        let t = (self.elapsed.as_secs_f64() / self.duration.as_secs_f64()).min(1.0) as f32;
236        (t * std::f32::consts::PI).sin()
237    }
238
239    fn reset(&mut self) {
240        self.elapsed = Duration::ZERO;
241    }
242
243    fn overshoot(&self) -> Duration {
244        self.elapsed.saturating_sub(self.duration)
245    }
246}
247
248// ---------------------------------------------------------------------------
249// Slide presets
250// ---------------------------------------------------------------------------
251
252/// Slide an element in from the left edge.
253///
254/// `distance` is the starting offset in cells (positive = further left).
255/// Element slides from `-distance` to `0` over `duration` with ease-out.
256#[must_use]
257pub fn slide_in_left(distance: i16, duration: Duration) -> Slide {
258    Slide::new(-distance, 0, duration).easing(ease_out)
259}
260
261/// Slide an element in from the right edge.
262///
263/// `distance` is the starting offset in cells (positive = further right).
264/// Element slides from `+distance` to `0` over `duration` with ease-out.
265#[must_use]
266pub fn slide_in_right(distance: i16, duration: Duration) -> Slide {
267    Slide::new(distance, 0, duration).easing(ease_out)
268}
269
270// ---------------------------------------------------------------------------
271// Fade-through (crossfade)
272// ---------------------------------------------------------------------------
273
274/// Fade out then fade in, useful for content transitions.
275///
276/// Total animation is `2 * half_duration`. During the first half, value goes
277/// from 1.0 to 0.0 (fade out). During the second half, 0.0 to 1.0 (fade in).
278#[must_use]
279pub fn fade_through(half_duration: Duration) -> Sequence<InvertedFade, Fade> {
280    let out = InvertedFade(Fade::new(half_duration).easing(ease_in));
281    let into = Fade::new(half_duration).easing(ease_out);
282    sequence(out, into)
283}
284
285// ---------------------------------------------------------------------------
286// InvertedFade helper
287// ---------------------------------------------------------------------------
288
289/// A fade animation whose value is inverted: starts at 1.0, ends at 0.0.
290#[derive(Debug, Clone, Copy)]
291pub struct InvertedFade(Fade);
292
293impl InvertedFade {
294    /// Create an inverted fade (1.0 → 0.0) with the given duration.
295    pub fn new(duration: Duration) -> Self {
296        Self(Fade::new(duration))
297    }
298
299    /// Set the easing function.
300    pub fn easing(mut self, easing: EasingFn) -> Self {
301        self.0 = self.0.easing(easing);
302        self
303    }
304}
305
306impl Animation for InvertedFade {
307    fn tick(&mut self, dt: Duration) {
308        self.0.tick(dt);
309    }
310
311    fn is_complete(&self) -> bool {
312        self.0.is_complete()
313    }
314
315    fn value(&self) -> f32 {
316        1.0 - self.0.value()
317    }
318
319    fn reset(&mut self) {
320        self.0.reset();
321    }
322
323    fn overshoot(&self) -> Duration {
324        self.0.overshoot()
325    }
326}
327
328// ---------------------------------------------------------------------------
329// Tests
330// ---------------------------------------------------------------------------
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    const MS50: Duration = Duration::from_millis(50);
337    const MS100: Duration = Duration::from_millis(100);
338    const MS200: Duration = Duration::from_millis(200);
339    const MS500: Duration = Duration::from_millis(500);
340
341    // ---- cascade_in -------------------------------------------------------
342
343    #[test]
344    fn cascade_in_empty() {
345        let group = cascade_in(0, MS200, MS50, StaggerMode::Linear);
346        assert!(group.is_empty());
347        assert!(group.all_complete());
348    }
349
350    #[test]
351    fn cascade_in_single_item() {
352        let mut group = cascade_in(1, MS200, MS50, StaggerMode::Linear);
353        assert_eq!(group.len(), 1);
354        assert!(!group.all_complete());
355
356        // Tick past item duration.
357        group.tick(MS200);
358        assert!(group.all_complete());
359    }
360
361    #[test]
362    fn cascade_in_multiple_items_staggered() {
363        let mut group = cascade_in(3, MS200, MS100, StaggerMode::Linear);
364        assert_eq!(group.len(), 3);
365
366        // At t=0, only item_0 has started.
367        assert!(group.get("item_0").unwrap().value() == 0.0);
368
369        // Tick 100ms: item_0 is halfway, item_1 just started, item_2 not yet.
370        group.tick(MS100);
371        let v0 = group.get("item_0").unwrap().value();
372        let v1 = group.get("item_1").unwrap().value();
373        let v2 = group.get("item_2").unwrap().value();
374        assert!(v0 > 0.0, "item_0 should have progressed");
375        assert!(v1 == 0.0, "item_1 just started (delay elapsed)");
376        assert!(v2 == 0.0, "item_2 hasn't started yet");
377
378        // Tick to 400ms: all should be complete.
379        group.tick(Duration::from_millis(300));
380        assert!(group.all_complete());
381    }
382
383    #[test]
384    fn cascade_in_values_increase() {
385        let mut group = cascade_in(5, MS500, MS100, StaggerMode::EaseOut);
386        let mut prev = 0.0f32;
387        for _ in 0..20 {
388            group.tick(MS50);
389            let val = group.overall_progress();
390            assert!(val >= prev, "overall progress should not decrease");
391            prev = val;
392        }
393    }
394
395    // ---- cascade_out ------------------------------------------------------
396
397    #[test]
398    fn cascade_out_starts_near_one() {
399        let mut group = cascade_out(3, MS200, MS50, StaggerMode::Linear);
400        // Delayed wrapper needs a tick to start. After a tiny tick, item_0
401        // (zero delay) should be near 1.0.
402        group.tick(Duration::from_nanos(1));
403        let v0 = group.get("item_0").unwrap().value();
404        assert!(
405            (v0 - 1.0).abs() < 0.01,
406            "cascade_out should start near 1.0, got {v0}"
407        );
408    }
409
410    #[test]
411    fn cascade_out_ends_at_zero() {
412        let mut group = cascade_out(3, MS200, MS50, StaggerMode::Linear);
413        // Tick well past total duration.
414        group.tick(Duration::from_secs(1));
415        for i in 0..3 {
416            let v = group.get(&format!("item_{i}")).unwrap().value();
417            assert!(
418                v < 0.01,
419                "item_{i} should be near 0.0 after completion, got {v}"
420            );
421        }
422    }
423
424    // ---- fan_out ----------------------------------------------------------
425
426    #[test]
427    fn fan_out_empty() {
428        let group = fan_out(0, MS200, MS200);
429        assert!(group.is_empty());
430    }
431
432    #[test]
433    fn fan_out_single() {
434        let group = fan_out(1, MS200, MS200);
435        assert_eq!(group.len(), 1);
436        // Single item has zero distance from center, so no delay.
437        let v = group.get("item_0").unwrap().value();
438        assert!((v - 0.0).abs() < 0.01);
439    }
440
441    #[test]
442    fn fan_out_center_starts_first() {
443        let mut group = fan_out(5, MS200, MS200);
444        // Tick just a tiny bit — center item should progress, edges should not.
445        group.tick(Duration::from_millis(10));
446        let center = group.get("item_2").unwrap().value();
447        let edge = group.get("item_0").unwrap().value();
448        assert!(
449            center >= edge,
450            "center ({center}) should start before edges ({edge})"
451        );
452    }
453
454    #[test]
455    fn fan_out_symmetric() {
456        let group = fan_out(5, MS200, MS200);
457        // Items equidistant from center should have the same initial value.
458        let v0 = group.get("item_0").unwrap().value();
459        let v4 = group.get("item_4").unwrap().value();
460        assert!(
461            (v0 - v4).abs() < 0.01,
462            "symmetric items should match: {v0} vs {v4}"
463        );
464
465        let v1 = group.get("item_1").unwrap().value();
466        let v3 = group.get("item_3").unwrap().value();
467        assert!(
468            (v1 - v3).abs() < 0.01,
469            "symmetric items should match: {v1} vs {v3}"
470        );
471    }
472
473    // ---- typewriter -------------------------------------------------------
474
475    #[test]
476    fn typewriter_starts_at_zero() {
477        let tw = typewriter(100, MS500);
478        assert_eq!(tw.visible_chars(), 0);
479    }
480
481    #[test]
482    fn typewriter_ends_at_full() {
483        let mut tw = typewriter(100, MS500);
484        tw.tick(MS500);
485        assert_eq!(tw.visible_chars(), 100);
486        assert!(tw.is_complete());
487    }
488
489    #[test]
490    fn typewriter_progresses_monotonically() {
491        let mut tw = typewriter(50, MS500);
492        let mut prev = 0;
493        for _ in 0..20 {
494            tw.tick(Duration::from_millis(25));
495            let chars = tw.visible_chars();
496            assert!(
497                chars >= prev,
498                "visible chars should not decrease: {prev} -> {chars}"
499            );
500            prev = chars;
501        }
502    }
503
504    #[test]
505    fn typewriter_zero_chars() {
506        let mut tw = typewriter(0, MS200);
507        assert_eq!(tw.visible_chars(), 0);
508        tw.tick(MS200);
509        assert_eq!(tw.visible_chars(), 0);
510        assert!(tw.is_complete());
511    }
512
513    // ---- pulse_sequence ---------------------------------------------------
514
515    #[test]
516    fn pulse_sequence_empty() {
517        let group = pulse_sequence(0, MS200, MS100);
518        assert!(group.is_empty());
519    }
520
521    #[test]
522    fn pulse_sequence_peaks_then_returns() {
523        let mut group = pulse_sequence(1, MS200, MS100);
524        // At start: 0.
525        assert!(group.get("pulse_0").unwrap().value() < 0.01);
526
527        // At midpoint: should be near peak.
528        group.tick(MS100);
529        let mid = group.get("pulse_0").unwrap().value();
530        assert!(mid > 0.9, "pulse midpoint should be near 1.0, got {mid}");
531
532        // At end: should return near 0.
533        group.tick(MS100);
534        let end = group.get("pulse_0").unwrap().value();
535        assert!(end < 0.1, "pulse end should be near 0.0, got {end}");
536    }
537
538    #[test]
539    fn pulse_sequence_items_staggered() {
540        let mut group = pulse_sequence(3, MS200, MS200);
541        // At t=100ms: pulse_0 is at peak, pulse_1 hasn't started.
542        group.tick(MS100);
543        let p0 = group.get("pulse_0").unwrap().value();
544        let p1 = group.get("pulse_1").unwrap().value();
545        assert!(p0 > 0.9, "pulse_0 should be at peak");
546        assert!(p1 < 0.01, "pulse_1 should not have started");
547    }
548
549    // ---- slide presets ----------------------------------------------------
550
551    #[test]
552    fn slide_in_left_starts_offscreen() {
553        let slide = slide_in_left(20, MS200);
554        assert_eq!(slide.position(), -20);
555    }
556
557    #[test]
558    fn slide_in_left_ends_at_zero() {
559        let mut slide = slide_in_left(20, MS200);
560        slide.tick(MS200);
561        assert_eq!(slide.position(), 0);
562        assert!(slide.is_complete());
563    }
564
565    #[test]
566    fn slide_in_right_starts_offscreen() {
567        let slide = slide_in_right(20, MS200);
568        assert_eq!(slide.position(), 20);
569    }
570
571    #[test]
572    fn slide_in_right_ends_at_zero() {
573        let mut slide = slide_in_right(20, MS200);
574        slide.tick(MS200);
575        assert_eq!(slide.position(), 0);
576    }
577
578    // ---- fade_through -----------------------------------------------------
579
580    #[test]
581    fn fade_through_starts_at_one() {
582        let ft = fade_through(MS200);
583        assert!((ft.value() - 1.0).abs() < 0.01, "should start at 1.0");
584    }
585
586    #[test]
587    fn fade_through_midpoint_near_zero() {
588        let mut ft = fade_through(MS200);
589        ft.tick(MS200);
590        // At half_duration, the first (inverted) fade is complete → value ≈ 0.
591        // The second fade just started → value ≈ 0.
592        assert!(
593            ft.value() < 0.1,
594            "midpoint should be near 0.0, got {}",
595            ft.value()
596        );
597    }
598
599    #[test]
600    fn fade_through_ends_at_one() {
601        let mut ft = fade_through(MS200);
602        ft.tick(Duration::from_millis(400));
603        assert!(ft.is_complete());
604        assert!(
605            (ft.value() - 1.0).abs() < 0.01,
606            "should end at 1.0, got {}",
607            ft.value()
608        );
609    }
610
611    // ---- InvertedFade -----------------------------------------------------
612
613    #[test]
614    fn inverted_fade_starts_at_one() {
615        let f = InvertedFade::new(MS200);
616        assert!((f.value() - 1.0).abs() < 0.001);
617    }
618
619    #[test]
620    fn inverted_fade_ends_at_zero() {
621        let mut f = InvertedFade::new(MS200);
622        f.tick(MS200);
623        assert!(f.value() < 0.001);
624        assert!(f.is_complete());
625    }
626
627    #[test]
628    fn inverted_fade_reset() {
629        let mut f = InvertedFade::new(MS200);
630        f.tick(MS200);
631        assert!(f.is_complete());
632        f.reset();
633        assert!(!f.is_complete());
634        assert!((f.value() - 1.0).abs() < 0.001);
635    }
636
637    // ---- determinism ------------------------------------------------------
638
639    #[test]
640    fn cascade_in_deterministic() {
641        let run = || {
642            let mut group = cascade_in(5, MS200, MS50, StaggerMode::EaseInOut);
643            let mut values = Vec::new();
644            for _ in 0..10 {
645                group.tick(MS50);
646                values.push(group.overall_progress());
647            }
648            values
649        };
650        assert_eq!(run(), run(), "cascade_in must be deterministic");
651    }
652
653    #[test]
654    fn typewriter_deterministic() {
655        let run = || {
656            let mut tw = typewriter(100, MS500);
657            let mut counts = Vec::new();
658            for _ in 0..20 {
659                tw.tick(Duration::from_millis(25));
660                counts.push(tw.visible_chars());
661            }
662            counts
663        };
664        assert_eq!(run(), run(), "typewriter must be deterministic");
665    }
666
667    #[test]
668    fn fan_out_deterministic() {
669        let run = || {
670            let mut group = fan_out(7, MS200, MS200);
671            let mut values = Vec::new();
672            for _ in 0..10 {
673                group.tick(MS50);
674                values.push(group.overall_progress());
675            }
676            values
677        };
678        assert_eq!(run(), run(), "fan_out must be deterministic");
679    }
680}