Skip to main content

tui_skeleton/
animation.rs

1use ratatui_core::style::Color;
2
3/// Animation style for skeleton loading widgets.
4#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
5pub enum AnimationMode {
6    /// Single brightness sweep left-to-right, then rest.
7    Sweep,
8    /// Uniform pulse: entire bar fades between dim and bright.
9    #[default]
10    Breathe,
11    /// Two sine waves at different frequencies drift in opposite directions,
12    /// creating organic shifting brightness patterns.
13    Plasma,
14}
15
16// ── Timing constants ────────────────────────────────────────────────
17
18/// Sweep: 800ms travel + 2s rest.
19const SWEEP_MS: u64 = 800;
20const SWEEP_CYCLE_MS: u64 = SWEEP_MS + 2000;
21
22/// Half-width of the cosine brightness window (cells from center).
23const SHIMMER_RADIUS: f32 = 12.0;
24
25/// Breathe: 5s full sine cycle.
26const BREATHE_CYCLE_MS: u64 = 5000;
27
28/// Plasma wave parameters.
29const PLASMA_PERIOD_MS: f32 = 4000.0;
30const PLASMA_AMPLITUDE: f32 = 0.6;
31const PLASMA_FREQ_A: f32 = 0.18;
32const PLASMA_FREQ_B: f32 = 0.29;
33
34// ── Intensity ───────────────────────────────────────────────────────
35
36/// Compute animation intensity for a single cell.
37///
38/// Returns a value in `[0.0, 1.0]` representing brightness progression
39/// from `base` toward `highlight`.
40pub(crate) fn cell_intensity(mode: AnimationMode, elapsed_ms: u64, col: u16, width: u16) -> f32 {
41    match mode {
42        AnimationMode::Sweep => sweep_intensity(elapsed_ms, col, width),
43        AnimationMode::Breathe => breathe_intensity(elapsed_ms),
44        AnimationMode::Plasma => plasma_intensity(elapsed_ms, col),
45    }
46}
47
48fn sweep_intensity(elapsed_ms: u64, col: u16, width: u16) -> f32 {
49    let phase = elapsed_ms % SWEEP_CYCLE_MS;
50
51    if phase >= SWEEP_MS {
52        return 0.0;
53    }
54
55    let width = width as f32;
56    let sweep_span = width + SHIMMER_RADIUS * 2.0;
57    let progress = phase as f32 / SWEEP_MS as f32;
58    let center = -SHIMMER_RADIUS + progress * sweep_span;
59    let dist = (col as f32 - center).abs();
60
61    if dist >= SHIMMER_RADIUS {
62        0.0
63    } else {
64        (1.0 + (dist / SHIMMER_RADIUS * std::f32::consts::PI).cos()) * 0.5
65    }
66}
67
68fn breathe_intensity(elapsed_ms: u64) -> f32 {
69    let phase = (elapsed_ms % BREATHE_CYCLE_MS) as f32 / BREATHE_CYCLE_MS as f32;
70    (phase * std::f32::consts::TAU).sin().abs()
71}
72
73fn plasma_intensity(elapsed_ms: u64, col: u16) -> f32 {
74    let time = elapsed_ms as f32 / PLASMA_PERIOD_MS * std::f32::consts::TAU;
75    let x = col as f32;
76
77    let wave_a = (x * PLASMA_FREQ_A + time).sin();
78    let wave_b = (x * PLASMA_FREQ_B - time * 0.7).sin();
79
80    ((wave_a + wave_b) * 0.25 + 0.5) * PLASMA_AMPLITUDE
81}
82
83// ── Color interpolation ─────────────────────────────────────────────
84
85/// Interpolate between `base` and `highlight` at the given intensity.
86///
87/// For [`AnimationMode::Plasma`], the highlight is extrapolated 2× past
88/// the base→highlight distance so peaks are clearly visible.
89pub(crate) fn interpolate_color(
90    base: Color,
91    highlight: Color,
92    mode: AnimationMode,
93    intensity: f32,
94) -> Color {
95    let (br, bg, bb) = rgb_components(base);
96    let (hr, hg, hb) = rgb_components(highlight);
97
98    // Plasma doubles the contrast range.
99    let (pr, pg, pb) = if mode == AnimationMode::Plasma {
100        (
101            hr.saturating_add(hr.saturating_sub(br)),
102            hg.saturating_add(hg.saturating_sub(bg)),
103            hb.saturating_add(hb.saturating_sub(bb)),
104        )
105    } else {
106        (hr, hg, hb)
107    };
108
109    Color::Rgb(
110        lerp_u8(br, pr, intensity),
111        lerp_u8(bg, pg, intensity),
112        lerp_u8(bb, pb, intensity),
113    )
114}
115
116fn rgb_components(color: Color) -> (u8, u8, u8) {
117    match color {
118        Color::Rgb(r, g, b) => (r, g, b),
119        Color::DarkGray => (128, 128, 128),
120        Color::Gray => (169, 169, 169),
121        Color::White => (255, 255, 255),
122        Color::Black => (0, 0, 0),
123        _ => (128, 128, 128),
124    }
125}
126
127fn lerp_u8(a: u8, b: u8, t: f32) -> u8 {
128    (a as f32 + (b as f32 - a as f32) * t) as u8
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn breathe_zero_starts_at_zero() {
137        assert_eq!(breathe_intensity(0), 0.0);
138    }
139
140    #[test]
141    fn breathe_quarter_cycle_peaks() {
142        let intensity = breathe_intensity(BREATHE_CYCLE_MS / 4);
143        assert!((intensity - 1.0).abs() < 0.01);
144    }
145
146    #[test]
147    fn sweep_rest_phase_is_zero() {
148        assert_eq!(sweep_intensity(SWEEP_MS + 100, 5, 40), 0.0);
149    }
150
151    #[test]
152    fn plasma_stays_bounded() {
153        for col in 0..80 {
154            let t = plasma_intensity(1234, col);
155            assert!((0.0..=1.0).contains(&t), "plasma out of bounds: {t}");
156        }
157    }
158
159    #[test]
160    fn interpolate_at_zero_returns_base() {
161        let base = Color::Rgb(10, 20, 30);
162        let highlight = Color::Rgb(100, 200, 255);
163        let result = interpolate_color(base, highlight, AnimationMode::Breathe, 0.0);
164        assert_eq!(result, Color::Rgb(10, 20, 30));
165    }
166
167    #[test]
168    fn interpolate_at_one_returns_highlight() {
169        let base = Color::Rgb(0, 0, 0);
170        let highlight = Color::Rgb(100, 100, 100);
171        let result = interpolate_color(base, highlight, AnimationMode::Breathe, 1.0);
172        assert_eq!(result, Color::Rgb(100, 100, 100));
173    }
174
175    #[test]
176    fn rgb_components_named_colors() {
177        assert_eq!(rgb_components(Color::Black), (0, 0, 0));
178        assert_eq!(rgb_components(Color::White), (255, 255, 255));
179    }
180}