1use ratatui_core::style::Color;
2
3#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
5pub enum AnimationMode {
6 Sweep,
8 #[default]
10 Breathe,
11 Plasma,
14}
15
16const SWEEP_MS: u64 = 800;
20const SWEEP_CYCLE_MS: u64 = SWEEP_MS + 2000;
21
22const SHIMMER_RADIUS: f32 = 12.0;
24
25const BREATHE_CYCLE_MS: u64 = 5000;
27
28const 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
34pub(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
83pub(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 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}