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    /// Random braille dot patterns that change every frame — TV noise.
15    /// Implies braille fill regardless of the `braille` flag.
16    Noise,
17}
18
19// ── Timing constants ────────────────────────────────────────────────
20
21/// Sweep: 800ms travel + 2s rest.
22const SWEEP_MS: u64 = 800;
23const SWEEP_CYCLE_MS: u64 = SWEEP_MS + 2000;
24
25/// Half-width of the cosine brightness window (cells from center).
26const SHIMMER_RADIUS: f32 = 12.0;
27
28/// Breathe: 5s full sine cycle.
29const BREATHE_CYCLE_MS: u64 = 5000;
30
31/// Plasma wave parameters.
32const PLASMA_PERIOD_MS: f32 = 4000.0;
33const PLASMA_AMPLITUDE: f32 = 0.6;
34const PLASMA_FREQ_A: f32 = 0.18;
35const PLASMA_FREQ_B: f32 = 0.29;
36
37/// Noise mode resting intensity — dim but visible.
38const NOISE_INTENSITY: f32 = 0.3;
39
40// ── Fill ────────────────────────────────────────────────────────────
41
42/// Braille blank (U+2800). Adding 0..255 yields all dot patterns.
43const BRAILLE_BASE: u32 = 0x2800;
44
45const SOLID_FILL: char = '█';
46const BRAILLE_FILL: char = '⣿'; // U+28FF — full braille block
47
48/// Return the fill character for a cell.
49///
50/// - [`AnimationMode::Noise`]: random braille glyph per cell per frame
51/// - `braille: true`: solid braille block (`⣿`)
52/// - Otherwise: solid block (`█`)
53pub(crate) fn cell_glyph(
54    braille: bool,
55    mode: AnimationMode,
56    elapsed_ms: u64,
57    row: u16,
58    col: u16,
59) -> char {
60    if mode == AnimationMode::Noise {
61        let h = cell_hash(elapsed_ms, row, col);
62        return char::from_u32(BRAILLE_BASE + h as u32).unwrap_or(BRAILLE_FILL);
63    }
64
65    if braille { BRAILLE_FILL } else { SOLID_FILL }
66}
67
68/// Simple hash — enough entropy to look random, not cryptographic.
69fn cell_hash(elapsed_ms: u64, row: u16, col: u16) -> u8 {
70    let mut h = elapsed_ms
71        .wrapping_mul(2654435761)
72        .wrapping_add(row as u64 * 131)
73        .wrapping_add(col as u64 * 65537);
74
75    h ^= h >> 13;
76    h = h.wrapping_mul(0x5bd1e995);
77    h ^= h >> 15;
78
79    h as u8
80}
81
82// ── Intensity ───────────────────────────────────────────────────────
83
84/// Compute animation intensity for a single cell.
85///
86/// Returns a value in `[0.0, 1.0]` representing brightness progression
87/// from `base` toward `highlight`.
88pub(crate) fn cell_intensity(mode: AnimationMode, elapsed_ms: u64, col: u16, width: u16) -> f32 {
89    match mode {
90        AnimationMode::Sweep => sweep_intensity(elapsed_ms, col, width),
91        AnimationMode::Breathe => breathe_intensity(elapsed_ms),
92        AnimationMode::Plasma => plasma_intensity(elapsed_ms, col),
93        AnimationMode::Noise => NOISE_INTENSITY,
94    }
95}
96
97/// Returns true when the mode uses uniform (non-positional) intensity.
98pub(crate) fn is_uniform(mode: AnimationMode) -> bool {
99    matches!(mode, AnimationMode::Breathe | AnimationMode::Noise)
100}
101
102fn sweep_intensity(elapsed_ms: u64, col: u16, width: u16) -> f32 {
103    let phase = elapsed_ms % SWEEP_CYCLE_MS;
104
105    if phase >= SWEEP_MS {
106        return 0.0;
107    }
108
109    let width = width as f32;
110    let sweep_span = width + SHIMMER_RADIUS * 2.0;
111    let progress = phase as f32 / SWEEP_MS as f32;
112    let center = -SHIMMER_RADIUS + progress * sweep_span;
113    let dist = (col as f32 - center).abs();
114
115    if dist >= SHIMMER_RADIUS {
116        0.0
117    } else {
118        (1.0 + (dist / SHIMMER_RADIUS * std::f32::consts::PI).cos()) * 0.5
119    }
120}
121
122fn breathe_intensity(elapsed_ms: u64) -> f32 {
123    let phase = (elapsed_ms % BREATHE_CYCLE_MS) as f32 / BREATHE_CYCLE_MS as f32;
124    (phase * std::f32::consts::TAU).sin().abs()
125}
126
127fn plasma_intensity(elapsed_ms: u64, col: u16) -> f32 {
128    let time = elapsed_ms as f32 / PLASMA_PERIOD_MS * std::f32::consts::TAU;
129    let x = col as f32;
130
131    let wave_a = (x * PLASMA_FREQ_A + time).sin();
132    let wave_b = (x * PLASMA_FREQ_B - time * 0.7).sin();
133
134    ((wave_a + wave_b) * 0.25 + 0.5) * PLASMA_AMPLITUDE
135}
136
137// ── Color interpolation ─────────────────────────────────────────────
138
139/// Interpolate between `base` and `highlight` at the given intensity.
140///
141/// For [`AnimationMode::Plasma`], the highlight is extrapolated 2× past
142/// the base→highlight distance so peaks are clearly visible.
143pub(crate) fn interpolate_color(
144    base: Color,
145    highlight: Color,
146    mode: AnimationMode,
147    intensity: f32,
148) -> Color {
149    let (br, bg, bb) = rgb_components(base);
150    let (hr, hg, hb) = rgb_components(highlight);
151
152    // Plasma doubles the contrast range.
153    let (pr, pg, pb) = if mode == AnimationMode::Plasma {
154        (
155            hr.saturating_add(hr.saturating_sub(br)),
156            hg.saturating_add(hg.saturating_sub(bg)),
157            hb.saturating_add(hb.saturating_sub(bb)),
158        )
159    } else {
160        (hr, hg, hb)
161    };
162
163    Color::Rgb(
164        lerp_u8(br, pr, intensity),
165        lerp_u8(bg, pg, intensity),
166        lerp_u8(bb, pb, intensity),
167    )
168}
169
170fn rgb_components(color: Color) -> (u8, u8, u8) {
171    match color {
172        Color::Rgb(r, g, b) => (r, g, b),
173        Color::DarkGray => (128, 128, 128),
174        Color::Gray => (169, 169, 169),
175        Color::White => (255, 255, 255),
176        Color::Black => (0, 0, 0),
177        _ => (128, 128, 128),
178    }
179}
180
181fn lerp_u8(a: u8, b: u8, t: f32) -> u8 {
182    (a as f32 + (b as f32 - a as f32) * t) as u8
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn breathe_zero_starts_at_zero() {
191        assert_eq!(breathe_intensity(0), 0.0);
192    }
193
194    #[test]
195    fn breathe_quarter_cycle_peaks() {
196        let intensity = breathe_intensity(BREATHE_CYCLE_MS / 4);
197        assert!((intensity - 1.0).abs() < 0.01);
198    }
199
200    #[test]
201    fn sweep_rest_phase_is_zero() {
202        assert_eq!(sweep_intensity(SWEEP_MS + 100, 5, 40), 0.0);
203    }
204
205    #[test]
206    fn plasma_stays_bounded() {
207        for col in 0..80 {
208            let t = plasma_intensity(1234, col);
209            assert!((0.0..=1.0).contains(&t), "plasma out of bounds: {t}");
210        }
211    }
212
213    #[test]
214    fn noise_is_constant_intensity() {
215        let a = cell_intensity(AnimationMode::Noise, 0, 0, 80);
216        let b = cell_intensity(AnimationMode::Noise, 5000, 40, 80);
217        assert_eq!(a, b);
218        assert_eq!(a, NOISE_INTENSITY);
219    }
220
221    #[test]
222    fn cell_glyph_solid_default() {
223        assert_eq!(cell_glyph(false, AnimationMode::Breathe, 1000, 0, 0), '█');
224        assert_eq!(cell_glyph(false, AnimationMode::Sweep, 1000, 0, 0), '█');
225        assert_eq!(cell_glyph(false, AnimationMode::Plasma, 1000, 0, 0), '█');
226    }
227
228    #[test]
229    fn cell_glyph_braille_fill() {
230        assert_eq!(cell_glyph(true, AnimationMode::Breathe, 1000, 0, 0), '⣿');
231        assert_eq!(cell_glyph(true, AnimationMode::Sweep, 1000, 0, 0), '⣿');
232        assert_eq!(cell_glyph(true, AnimationMode::Plasma, 1000, 0, 0), '⣿');
233    }
234
235    #[test]
236    fn cell_glyph_noise_is_random_braille() {
237        let ch = cell_glyph(false, AnimationMode::Noise, 1000, 0, 0);
238        assert!((0x2800..=0x28FF).contains(&(ch as u32)));
239    }
240
241    #[test]
242    fn is_uniform_modes() {
243        assert!(is_uniform(AnimationMode::Breathe));
244        assert!(is_uniform(AnimationMode::Noise));
245        assert!(!is_uniform(AnimationMode::Sweep));
246        assert!(!is_uniform(AnimationMode::Plasma));
247    }
248
249    #[test]
250    fn interpolate_at_zero_returns_base() {
251        let base = Color::Rgb(10, 20, 30);
252        let highlight = Color::Rgb(100, 200, 255);
253        let result = interpolate_color(base, highlight, AnimationMode::Breathe, 0.0);
254        assert_eq!(result, Color::Rgb(10, 20, 30));
255    }
256
257    #[test]
258    fn interpolate_at_one_returns_highlight() {
259        let base = Color::Rgb(0, 0, 0);
260        let highlight = Color::Rgb(100, 100, 100);
261        let result = interpolate_color(base, highlight, AnimationMode::Breathe, 1.0);
262        assert_eq!(result, Color::Rgb(100, 100, 100));
263    }
264
265    #[test]
266    fn rgb_components_named_colors() {
267        assert_eq!(rgb_components(Color::Black), (0, 0, 0));
268        assert_eq!(rgb_components(Color::White), (255, 255, 255));
269    }
270}