use std::f32::consts::TAU;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::text::Span;
use ratatui::widgets::Widget;
use crate::theme::{EffectiveColorMode, Theme};
const WAVE_GLYPHS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
const ASCII_GLYPHS: [&str; 8] = [".", ".", "-", "-", "~", "~", "=", "="];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WaveState {
Idle,
Swell,
Streaming,
Tool,
Network {
sines: u8,
},
Stalled,
}
#[derive(Debug, Clone, Copy)]
struct WaveParams {
amplitude: f32,
omega: f32,
choppy: bool,
sines: u8,
}
impl WaveState {
fn params(self) -> WaveParams {
match self {
WaveState::Idle | WaveState::Stalled => WaveParams {
amplitude: 0.0,
omega: 0.0,
choppy: false,
sines: 1,
},
WaveState::Swell => WaveParams {
amplitude: 0.9,
omega: 0.35,
choppy: false,
sines: 1,
},
WaveState::Streaming => WaveParams {
amplitude: 0.85,
omega: 1.1,
choppy: false,
sines: 1,
},
WaveState::Tool => WaveParams {
amplitude: 0.7,
omega: 2.3,
choppy: true,
sines: 1,
},
WaveState::Network { sines } => WaveParams {
amplitude: 0.75,
omega: 0.95,
choppy: false,
sines: sines.clamp(1, 3),
},
}
}
}
const BAND_W: u32 = 1;
#[must_use]
pub fn band_value(state: WaveState, band_idx: u32, t: u64) -> f32 {
let p = state.params();
if p.amplitude < f32::EPSILON {
return 0.0; }
#[allow(clippy::cast_precision_loss)]
let tf = (t % 65536) as f32; #[allow(clippy::cast_precision_loss)]
let bar_phase = (band_idx as f32 * 0.618_034).fract() * TAU;
let y = if p.sines <= 1 {
let mut v = p.amplitude * (p.omega * tf + bar_phase).sin();
if p.choppy {
#[allow(clippy::cast_precision_loss)]
let bar_phase2 = (band_idx as f32 * 1.324_718).fract() * TAU;
v = (v + 0.4 * p.amplitude * (p.omega * 1.7 * tf + bar_phase2).sin())
.clamp(-p.amplitude, p.amplitude);
}
v
} else {
let omegas: [f32; 3] = [1.0, 1.618_034, 2.414_214];
let count = p.sines as usize;
let mut sum = 0.0_f32;
for (i, &om) in omegas[..count].iter().enumerate() {
#[allow(clippy::cast_precision_loss)]
let phase = (band_idx as f32 * (0.618_034 + i as f32 * 0.381_966)).fract() * TAU;
sum += (p.omega * om * tf + phase).sin();
}
#[allow(clippy::cast_precision_loss)]
{
p.amplitude * (sum / count as f32).clamp(-1.0, 1.0)
}
};
let y_norm = f32::midpoint(y.clamp(-p.amplitude, p.amplitude) / p.amplitude, 1.0);
y_norm.powi(2)
}
#[must_use]
pub fn sample(state: WaveState, x: u32, t: u64) -> usize {
let band = x / BAND_W;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let bucket = (band_value(state, band, t) * 7.0).round() as usize;
bucket.clamp(0, 7)
}
#[allow(clippy::too_many_arguments)]
pub fn glyphs<'a>(
state: WaveState,
width: u32,
t: u64,
color_mode: EffectiveColorMode,
ascii_only: bool,
buf: &'a mut Vec<Span<'static>>,
theme: &Theme,
) -> &'a [Span<'static>] {
buf.clear();
if width == 0 {
return buf.as_slice();
}
let ramp: &[&'static str; 8] = if ascii_only {
&ASCII_GLYPHS
} else {
&WAVE_GLYPHS
};
match color_mode {
EffectiveColorMode::Truecolor => {
for x in 0..width {
let b = sample(state, x, t);
let glyph = ramp[b];
let color = bucket_to_rgb(state, b);
buf.push(Span::styled(glyph, Style::default().fg(color)));
}
}
EffectiveColorMode::Ansi256 | EffectiveColorMode::Ansi16 => {
let style = if matches!(state, WaveState::Stalled) {
theme.error
} else {
theme.highlight
};
let mut row = String::with_capacity(width as usize * 3); for x in 0..width {
let b = sample(state, x, t);
row.push_str(ramp[b]);
}
buf.push(Span::styled(std::borrow::Cow::Owned(row), style));
}
EffectiveColorMode::Never => {
let mut row = String::with_capacity(width as usize * 3);
for x in 0..width {
let b = sample(state, x, t);
row.push_str(ramp[b]);
}
buf.push(Span::raw(std::borrow::Cow::Owned(row)));
}
}
buf.as_slice()
}
pub struct EqualizerWidget<'a> {
pub state: WaveState,
pub tick: u64,
pub theme: &'a Theme,
pub color_mode: EffectiveColorMode,
pub ascii_only: bool,
}
const BRAILLE_DOT: [[u8; 4]; 2] = [
[0x01, 0x02, 0x04, 0x40], [0x08, 0x10, 0x20, 0x80], ];
impl Widget for EqualizerWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
let w = usize::from(area.width);
let sub_w = area.width * 2; let sub_h = area.height * 4; let center = f32::from(sub_h) / 2.0;
let max_half = (center - 1.0).max(0.0);
let mut cells = vec![0u8; w * usize::from(area.height)];
for sx in 0..sub_w {
let amp = wave_profile(self.state, sx, sub_w, self.tick);
let half = amp * max_half;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let top = (center - half).round().clamp(0.0, f32::from(sub_h - 1)) as u16;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let bot = (center + half).round().clamp(0.0, f32::from(sub_h - 1)) as u16;
let col = usize::from(sx / 2);
let sub_col = usize::from(sx % 2);
for sy in top..=bot {
let row = usize::from(sy / 4);
let sub_row = usize::from(sy % 4);
cells[row * w + col] |= BRAILLE_DOT[sub_col][sub_row];
}
}
let mid_row = (f32::from(area.height) - 1.0) / 2.0;
let mut utf8 = [0u8; 4];
for row in 0..area.height {
for col in 0..area.width {
let bits = cells[usize::from(row) * w + usize::from(col)];
if bits == 0 {
continue;
}
let intensity = if mid_row <= 0.0 {
1.0
} else {
((f32::from(row) - mid_row).abs() / mid_row).clamp(0.0, 1.0)
};
let color = wave_color(intensity, self.state, self.color_mode, self.theme);
let symbol = if self.ascii_only {
ascii_density(bits)
} else {
char::from_u32(0x2800 + u32::from(bits)).unwrap_or(' ')
};
buf[(area.left() + col, area.top() + row)]
.set_fg(color)
.set_symbol(symbol.encode_utf8(&mut utf8));
}
}
}
}
fn wave_profile(state: WaveState, sx: u16, sub_w: u16, t: u64) -> f32 {
let p = state.params();
if p.amplitude < f32::EPSILON {
return 0.0;
}
#[allow(clippy::cast_precision_loss)]
let tf = (t % 65536) as f32; #[allow(clippy::cast_precision_loss)]
let u = if sub_w <= 1 {
0.0
} else {
f32::from(sx) / f32::from(sub_w - 1)
};
let mut s = (u * TAU * 1.5 + p.omega * tf).sin();
let mut denom = 1.0_f32;
s += 0.6 * (u * TAU * 3.0 - p.omega * 1.6 * tf).sin();
denom += 0.6;
if p.choppy {
s += 0.4 * (u * TAU * 5.0 + p.omega * 2.3 * tf).sin();
denom += 0.4;
}
if p.sines > 1 {
s += 0.5 * (u * TAU * 2.3 + p.omega * 1.27 * tf).sin();
denom += 0.5;
}
let shape = (s / denom).abs();
let beat_phase = (p.omega * 0.18 * tf).fract();
let energy = 0.3 + 0.7 * (1.0 - beat_phase).powi(3);
(p.amplitude * energy * shape).clamp(0.0, 1.0)
}
fn ascii_density(bits: u8) -> char {
match bits.count_ones() {
0 => ' ',
1..=2 => '.',
3..=4 => ':',
5..=6 => '+',
_ => '#',
}
}
fn wave_color(
intensity: f32,
state: WaveState,
color_mode: EffectiveColorMode,
theme: &Theme,
) -> Color {
match color_mode {
EffectiveColorMode::Truecolor => {
let v = intensity.clamp(0.0, 1.0);
if matches!(state, WaveState::Stalled) {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
return Color::Rgb((80.0 + v * 175.0) as u8, 10, 10);
}
if matches!(state, WaveState::Network { .. }) {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
return Color::Rgb(
(20.0 + v * 119.0) as u8, (16.0 + v * 76.0) as u8, (44.0 + v * 202.0) as u8, );
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
Color::Rgb(
(10.0 + v * 21.0) as u8, (25.0 + v * 160.0) as u8, (30.0 + v * 138.0) as u8, )
}
EffectiveColorMode::Ansi256 | EffectiveColorMode::Ansi16 => match state {
WaveState::Stalled => theme.error.fg.unwrap_or(Color::Red),
WaveState::Network { .. } => Color::Magenta,
_ => theme.highlight.fg.unwrap_or(Color::Yellow),
},
EffectiveColorMode::Never => Color::Reset,
}
}
fn bucket_to_rgb(state: WaveState, bucket: usize) -> Color {
if matches!(state, WaveState::Stalled) {
#[allow(clippy::cast_possible_truncation)]
let v = (80 + bucket * 22) as u8;
return Color::Rgb(v, 15, 15);
}
#[allow(clippy::cast_precision_loss)]
let t = (bucket as f32 / 7.0).powi(2);
if matches!(state, WaveState::Network { .. }) {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
return Color::Rgb(
(20.0_f32 + t * 119.0) as u8,
(16.0_f32 + t * 76.0) as u8,
(44.0_f32 + t * 202.0) as u8,
);
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let r = (10.0_f32 + t * 21.0) as u8; #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let g = (25.0_f32 + t * 160.0) as u8; #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let b = (30.0_f32 + t * 138.0) as u8; Color::Rgb(r, g, b)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sample_bucket_always_in_range() {
for t in [0u64, 1, 63, 127, 255, 65535, 65536, 1_000_000] {
for x in [0u32, 1, 5, 10, 40, 80, 160] {
for state in [
WaveState::Idle,
WaveState::Swell,
WaveState::Streaming,
WaveState::Tool,
WaveState::Network { sines: 2 },
WaveState::Network { sines: 3 },
WaveState::Stalled,
] {
let b = sample(state, x, t);
assert!(b <= 7, "bucket {b} out of range for {state:?} x={x} t={t}");
}
}
}
}
#[test]
fn idle_and_stalled_are_flat() {
for t in [0u64, 100, 999] {
for x in 0u32..40 {
assert_eq!(sample(WaveState::Idle, x, t), 0, "Idle must be flat");
assert_eq!(sample(WaveState::Stalled, x, t), 0, "Stalled must be flat");
}
}
}
#[test]
fn sample_is_deterministic() {
let states = [
WaveState::Swell,
WaveState::Streaming,
WaveState::Tool,
WaveState::Network { sines: 2 },
];
for state in states {
for x in [0u32, 7, 13, 40] {
for t in [0u64, 42, 1024] {
let a = sample(state, x, t);
let b = sample(state, x, t);
assert_eq!(
a, b,
"sample must be deterministic for {state:?} x={x} t={t}"
);
}
}
}
}
#[test]
fn glyphs_width_zero_returns_empty() {
let theme = Theme::default();
let mut buf = Vec::new();
let spans = glyphs(
WaveState::Streaming,
0,
42,
EffectiveColorMode::Truecolor,
false,
&mut buf,
&theme,
);
assert!(spans.is_empty(), "width=0 must return empty spans");
}
#[test]
fn glyphs_buffer_reuse() {
let theme = Theme::default();
let mut buf: Vec<Span<'static>> = Vec::new();
glyphs(
WaveState::Streaming,
40,
0,
EffectiveColorMode::Truecolor,
false,
&mut buf,
&theme,
);
let cap_after_first = buf.capacity();
assert!(
cap_after_first >= 40,
"buffer should have capacity for 40 spans"
);
glyphs(
WaveState::Streaming,
40,
1,
EffectiveColorMode::Truecolor,
false,
&mut buf,
&theme,
);
assert_eq!(
buf.capacity(),
cap_after_first,
"second call must not reallocate"
);
}
#[test]
fn glyphs_truecolor_one_span_per_column() {
let theme = Theme::default();
let mut buf = Vec::new();
let spans = glyphs(
WaveState::Streaming,
20,
5,
EffectiveColorMode::Truecolor,
false,
&mut buf,
&theme,
);
assert_eq!(
spans.len(),
20,
"Truecolor must produce one span per column"
);
}
#[test]
fn glyphs_ansi256_single_span() {
let theme = Theme::default();
let mut buf = Vec::new();
let spans = glyphs(
WaveState::Streaming,
20,
5,
EffectiveColorMode::Ansi256,
false,
&mut buf,
&theme,
);
assert_eq!(spans.len(), 1, "Ansi256 must produce a single flat span");
}
#[test]
fn idle_output_invariant_across_ticks() {
let theme = Theme::default();
let mut buf_a = Vec::new();
let mut buf_b = Vec::new();
let spans_a = glyphs(
WaveState::Idle,
40,
0,
EffectiveColorMode::Truecolor,
false,
&mut buf_a,
&theme,
);
let spans_b = glyphs(
WaveState::Idle,
40,
999,
EffectiveColorMode::Truecolor,
false,
&mut buf_b,
&theme,
);
let text_a: String = spans_a.iter().map(|s| s.content.as_ref()).collect();
let text_b: String = spans_b.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text_a, text_b, "Idle output must be tick-invariant");
}
#[test]
fn ascii_fallback_uses_ascii_ramp() {
let theme = Theme::default();
let mut buf = Vec::new();
let spans = glyphs(
WaveState::Streaming,
20,
5,
EffectiveColorMode::Truecolor,
true, &mut buf,
&theme,
);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
!text.contains('▁') && !text.contains('█'),
"ASCII mode must not contain block glyphs: {text:?}"
);
}
#[test]
fn stalled_uses_error_tint_in_truecolor() {
let theme = Theme::default();
let mut buf = Vec::new();
let spans = glyphs(
WaveState::Stalled,
10,
0,
EffectiveColorMode::Truecolor,
false,
&mut buf,
&theme,
);
for span in spans {
if let Some(Color::Rgb(r, _g, _b)) = span.style.fg {
assert!(
r >= 80,
"Stalled must have elevated R channel for error tint, got r={r}"
);
}
}
}
fn non_blank_rows(buf: &Buffer, area: Rect) -> usize {
(0..area.height)
.filter(|&row| {
(0..area.width).any(|col| {
let s = buf[(area.left() + col, area.top() + row)].symbol();
!s.trim().is_empty()
})
})
.count()
}
#[test]
fn wave_widget_idle_is_flat_line() {
let theme = Theme::default();
let area = Rect::new(0, 0, 12, 4);
let mut buf = Buffer::empty(area);
EqualizerWidget {
state: WaveState::Idle,
tick: 123,
theme: &theme,
color_mode: EffectiveColorMode::Truecolor,
ascii_only: false,
}
.render(area, &mut buf);
assert_eq!(
non_blank_rows(&buf, area),
1,
"Idle must render a single flat centre line"
);
}
#[test]
fn wave_widget_busy_spreads_vertically() {
let theme = Theme::default();
let area = Rect::new(0, 0, 16, 4);
let spread = (0u64..40).any(|tick| {
let mut buf = Buffer::empty(area);
EqualizerWidget {
state: WaveState::Streaming,
tick,
theme: &theme,
color_mode: EffectiveColorMode::Truecolor,
ascii_only: false,
}
.render(area, &mut buf);
non_blank_rows(&buf, area) > 1
});
assert!(spread, "busy wave must span >1 row for some tick");
}
#[test]
fn wave_widget_tiny_area_no_panic() {
let theme = Theme::default();
let area = Rect::new(0, 0, 1, 1);
let mut buf = Buffer::empty(area);
EqualizerWidget {
state: WaveState::Tool,
tick: 7,
theme: &theme,
color_mode: EffectiveColorMode::Truecolor,
ascii_only: false,
}
.render(area, &mut buf);
}
#[test]
fn wave_widget_ascii_has_no_braille() {
let theme = Theme::default();
let area = Rect::new(0, 0, 16, 4);
let mut buf = Buffer::empty(area);
EqualizerWidget {
state: WaveState::Swell,
tick: 11,
theme: &theme,
color_mode: EffectiveColorMode::Ansi256,
ascii_only: true,
}
.render(area, &mut buf);
for row in 0..area.height {
for col in 0..area.width {
let s = buf[(area.left() + col, area.top() + row)].symbol();
assert!(
s.chars().all(|c| !('\u{2800}'..='\u{28FF}').contains(&c)),
"ASCII mode must not emit braille: {s:?}"
);
}
}
}
#[test]
fn ascii_density_buckets() {
assert_eq!(ascii_density(0x00), ' ');
assert_eq!(ascii_density(0x01), '.'); assert_eq!(ascii_density(0x0F), ':'); assert_eq!(ascii_density(0x3F), '+'); assert_eq!(ascii_density(0xFF), '#'); }
#[test]
fn network_wave_color_is_violet_distinct_from_teal() {
let theme = Theme::default();
let net = wave_color(
1.0,
WaveState::Network { sines: 2 },
EffectiveColorMode::Truecolor,
&theme,
);
let teal = wave_color(
1.0,
WaveState::Streaming,
EffectiveColorMode::Truecolor,
&theme,
);
let Color::Rgb(nr, ng, nb) = net else {
panic!("expected Rgb for Network peak, got {net:?}");
};
let Color::Rgb(_tr, tg, tb) = teal else {
panic!("expected Rgb for Streaming peak, got {teal:?}");
};
assert!(
nb > ng && nb > nr,
"Network peak must be blue-dominant (violet); got r={nr} g={ng} b={nb}"
);
assert!(tg > tb, "Streaming (teal) peak must be green-dominant");
assert_ne!(net, teal, "Network and foreground colours must differ");
}
#[test]
fn network_wave_color_ansi_is_magenta() {
let theme = Theme::default();
let net = wave_color(
1.0,
WaveState::Network { sines: 1 },
EffectiveColorMode::Ansi16,
&theme,
);
assert_eq!(net, Color::Magenta);
}
}