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 Noise,
17}
18
19const SWEEP_MS: u64 = 800;
23const SWEEP_CYCLE_MS: u64 = SWEEP_MS + 2000;
24
25const SHIMMER_RADIUS: f32 = 12.0;
27
28const BREATHE_CYCLE_MS: u64 = 5000;
30
31const 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
37const NOISE_INTENSITY: f32 = 0.3;
39
40const BRAILLE_BASE: u32 = 0x2800;
44
45const SOLID_FILL: char = '█';
46const BRAILLE_FILL: char = '⣿'; pub(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
68fn 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
82pub(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
97pub(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
137pub(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 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}