use ratatui_core::style::Color;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum AnimationMode {
Sweep,
#[default]
Breathe,
Plasma,
}
const SWEEP_MS: u64 = 800;
const SWEEP_CYCLE_MS: u64 = SWEEP_MS + 2000;
const SHIMMER_RADIUS: f32 = 12.0;
const BREATHE_CYCLE_MS: u64 = 5000;
const PLASMA_PERIOD_MS: f32 = 4000.0;
const PLASMA_AMPLITUDE: f32 = 0.6;
const PLASMA_FREQ_A: f32 = 0.18;
const PLASMA_FREQ_B: f32 = 0.29;
pub(crate) fn cell_intensity(mode: AnimationMode, elapsed_ms: u64, col: u16, width: u16) -> f32 {
match mode {
AnimationMode::Sweep => sweep_intensity(elapsed_ms, col, width),
AnimationMode::Breathe => breathe_intensity(elapsed_ms),
AnimationMode::Plasma => plasma_intensity(elapsed_ms, col),
}
}
fn sweep_intensity(elapsed_ms: u64, col: u16, width: u16) -> f32 {
let phase = elapsed_ms % SWEEP_CYCLE_MS;
if phase >= SWEEP_MS {
return 0.0;
}
let width = width as f32;
let sweep_span = width + SHIMMER_RADIUS * 2.0;
let progress = phase as f32 / SWEEP_MS as f32;
let center = -SHIMMER_RADIUS + progress * sweep_span;
let dist = (col as f32 - center).abs();
if dist >= SHIMMER_RADIUS {
0.0
} else {
(1.0 + (dist / SHIMMER_RADIUS * std::f32::consts::PI).cos()) * 0.5
}
}
fn breathe_intensity(elapsed_ms: u64) -> f32 {
let phase = (elapsed_ms % BREATHE_CYCLE_MS) as f32 / BREATHE_CYCLE_MS as f32;
(phase * std::f32::consts::TAU).sin().abs()
}
fn plasma_intensity(elapsed_ms: u64, col: u16) -> f32 {
let time = elapsed_ms as f32 / PLASMA_PERIOD_MS * std::f32::consts::TAU;
let x = col as f32;
let wave_a = (x * PLASMA_FREQ_A + time).sin();
let wave_b = (x * PLASMA_FREQ_B - time * 0.7).sin();
((wave_a + wave_b) * 0.25 + 0.5) * PLASMA_AMPLITUDE
}
pub(crate) fn interpolate_color(
base: Color,
highlight: Color,
mode: AnimationMode,
intensity: f32,
) -> Color {
let (br, bg, bb) = rgb_components(base);
let (hr, hg, hb) = rgb_components(highlight);
let (pr, pg, pb) = if mode == AnimationMode::Plasma {
(
hr.saturating_add(hr.saturating_sub(br)),
hg.saturating_add(hg.saturating_sub(bg)),
hb.saturating_add(hb.saturating_sub(bb)),
)
} else {
(hr, hg, hb)
};
Color::Rgb(
lerp_u8(br, pr, intensity),
lerp_u8(bg, pg, intensity),
lerp_u8(bb, pb, intensity),
)
}
fn rgb_components(color: Color) -> (u8, u8, u8) {
match color {
Color::Rgb(r, g, b) => (r, g, b),
Color::DarkGray => (128, 128, 128),
Color::Gray => (169, 169, 169),
Color::White => (255, 255, 255),
Color::Black => (0, 0, 0),
_ => (128, 128, 128),
}
}
fn lerp_u8(a: u8, b: u8, t: f32) -> u8 {
(a as f32 + (b as f32 - a as f32) * t) as u8
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn breathe_zero_starts_at_zero() {
assert_eq!(breathe_intensity(0), 0.0);
}
#[test]
fn breathe_quarter_cycle_peaks() {
let intensity = breathe_intensity(BREATHE_CYCLE_MS / 4);
assert!((intensity - 1.0).abs() < 0.01);
}
#[test]
fn sweep_rest_phase_is_zero() {
assert_eq!(sweep_intensity(SWEEP_MS + 100, 5, 40), 0.0);
}
#[test]
fn plasma_stays_bounded() {
for col in 0..80 {
let t = plasma_intensity(1234, col);
assert!((0.0..=1.0).contains(&t), "plasma out of bounds: {t}");
}
}
#[test]
fn interpolate_at_zero_returns_base() {
let base = Color::Rgb(10, 20, 30);
let highlight = Color::Rgb(100, 200, 255);
let result = interpolate_color(base, highlight, AnimationMode::Breathe, 0.0);
assert_eq!(result, Color::Rgb(10, 20, 30));
}
#[test]
fn interpolate_at_one_returns_highlight() {
let base = Color::Rgb(0, 0, 0);
let highlight = Color::Rgb(100, 100, 100);
let result = interpolate_color(base, highlight, AnimationMode::Breathe, 1.0);
assert_eq!(result, Color::Rgb(100, 100, 100));
}
#[test]
fn rgb_components_named_colors() {
assert_eq!(rgb_components(Color::Black), (0, 0, 0));
assert_eq!(rgb_components(Color::White), (255, 255, 255));
}
}