use ratatui_core::{
buffer::Buffer,
layout::Rect,
style::{Color, Style},
widgets::Widget,
};
use crate::animation::{cell_intensity, interpolate_color, AnimationMode};
use crate::defaults;
const DOT: [[u8; 4]; 2] = [[0x01, 0x02, 0x04, 0x40], [0x08, 0x10, 0x20, 0x80]];
const BRAILLE_BLANK: u32 = 0x2800;
const DEFAULT_AMPLITUDES: [f32; 3] = [0.7, 0.45, 0.85];
const DEFAULT_FREQUENCIES: [f32; 3] = [1.0, 1.7, 0.6];
const DEFAULT_OFFSETS: [f32; 3] = [0.5, 0.35, 0.65];
const DRIFT_PERIOD_MS: f32 = 20_000.0;
#[must_use]
#[derive(Debug, Clone)]
pub struct SkeletonLineChart<'a> {
elapsed_ms: u64,
mode: AnimationMode,
base: Color,
highlight: Color,
lines: u16,
filled: bool,
block: Option<ratatui_widgets::block::Block<'a>>,
}
impl<'a> SkeletonLineChart<'a> {
pub fn new(elapsed_ms: u64) -> Self {
Self {
elapsed_ms,
mode: AnimationMode::default(),
base: defaults::BASE,
highlight: defaults::HIGHLIGHT,
lines: 2,
filled: true,
block: None,
}
}
pub fn mode(mut self, mode: AnimationMode) -> Self {
self.mode = mode;
self
}
pub fn base(mut self, color: impl Into<Color>) -> Self {
self.base = color.into();
self
}
pub fn highlight(mut self, color: impl Into<Color>) -> Self {
self.highlight = color.into();
self
}
pub fn lines(mut self, lines: u16) -> Self {
self.lines = lines;
self
}
pub fn filled(mut self, filled: bool) -> Self {
self.filled = filled;
self
}
pub fn block(mut self, block: ratatui_widgets::block::Block<'a>) -> Self {
self.block = Some(block);
self
}
}
struct Coloring {
mode: AnimationMode,
elapsed_ms: u64,
base: Color,
highlight: Color,
breathe_t: Option<f32>,
}
impl Coloring {
fn color_at(&self, col: u16, width: u16) -> Color {
let t = self
.breathe_t
.unwrap_or_else(|| cell_intensity(self.mode, self.elapsed_ms, col, width));
interpolate_color(self.base, self.highlight, self.mode, t)
}
}
impl Widget for SkeletonLineChart<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let inner = if let Some(ref block) = self.block {
let inner_area = block.inner(area);
block.render(area, buf);
inner_area
} else {
area
};
if inner.is_empty() {
return;
}
let pixel_w = inner.width as usize * 2;
let pixel_h = inner.height as usize * 4;
let line_count = self.lines.min(DEFAULT_AMPLITUDES.len() as u16) as usize;
let drift = self.elapsed_ms as f32 / DRIFT_PERIOD_MS * std::f32::consts::TAU;
let mut fill_top = vec![pixel_h; pixel_w];
let mut dots = vec![vec![false; pixel_w]; pixel_h];
for line_idx in 0..line_count {
let amplitude = DEFAULT_AMPLITUDES[line_idx];
let freq = DEFAULT_FREQUENCIES[line_idx];
let offset = DEFAULT_OFFSETS[line_idx];
let line_drift = drift * (1.0 + line_idx as f32 * 0.3);
plot_wave(
&mut dots,
&mut fill_top,
pixel_w,
pixel_h,
amplitude,
freq,
offset,
line_drift,
);
}
let coloring = Coloring {
mode: self.mode,
elapsed_ms: self.elapsed_ms,
base: self.base,
highlight: self.highlight,
breathe_t: matches!(self.mode, AnimationMode::Breathe)
.then(|| cell_intensity(self.mode, self.elapsed_ms, 0, inner.width)),
};
if self.filled {
render_fill(inner, buf, &fill_top, pixel_h, &coloring);
}
render_braille(inner, buf, &dots, pixel_w, pixel_h, &coloring);
}
}
fn render_fill(
inner: Rect,
buf: &mut Buffer,
fill_top: &[usize],
pixel_h: usize,
color: &Coloring,
) {
for cx in 0..inner.width as usize {
let top_pixel = fill_top
.get(cx * 2)
.copied()
.unwrap_or(pixel_h)
.min(fill_top.get(cx * 2 + 1).copied().unwrap_or(pixel_h));
let fill_start = top_pixel / 4 + 1;
let col = cx as u16;
let fg = color.color_at(col, inner.width);
for cy in fill_start..inner.height as usize {
let x = inner.x + col;
let y = inner.y + cy as u16;
buf[(x, y)].set_char('█').set_style(Style::default().fg(fg));
}
}
}
fn render_braille(
inner: Rect,
buf: &mut Buffer,
dots: &[Vec<bool>],
pixel_w: usize,
pixel_h: usize,
color: &Coloring,
) {
for cy in 0..inner.height as usize {
for cx in 0..inner.width as usize {
let mut pattern: u8 = 0;
for (dx, dot_col) in DOT.iter().enumerate() {
for (dy, &bit) in dot_col.iter().enumerate() {
let px = cx * 2 + dx;
let py = cy * 4 + dy;
if px < pixel_w && py < pixel_h && dots[py][px] {
pattern |= bit;
}
}
}
if pattern == 0 {
continue;
}
let braille = char::from_u32(BRAILLE_BLANK + pattern as u32).unwrap_or('⠀');
let col = cx as u16;
let fg = color.color_at(col, inner.width);
let x = inner.x + col;
let y = inner.y + cy as u16;
buf[(x, y)]
.set_symbol(&braille.to_string())
.set_style(Style::default().fg(fg));
}
}
}
#[expect(clippy::too_many_arguments)]
fn plot_wave(
dots: &mut [Vec<bool>],
fill_top: &mut [usize],
pixel_w: usize,
pixel_h: usize,
amplitude: f32,
freq: f32,
offset: f32,
drift: f32,
) {
let mut prev_y: Option<usize> = None;
for px in 0..pixel_w {
let phase = px as f32 / pixel_w as f32 * std::f32::consts::TAU * freq + drift;
let normalized = offset + amplitude * 0.5 * phase.sin();
let py = ((1.0 - normalized.clamp(0.0, 1.0)) * (pixel_h - 1) as f32) as usize;
dots[py][px] = true;
fill_top[px] = fill_top[px].min(py);
if let Some(prev) = prev_y {
let (lo, hi) = if prev < py { (prev, py) } else { (py, prev) };
for row in dots.iter_mut().take(hi + 1).skip(lo) {
row[px] = true;
}
}
prev_y = Some(py);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renders_braille_characters() {
let area = Rect::new(0, 0, 20, 5);
let mut buf = Buffer::empty(area);
SkeletonLineChart::new(1000).lines(1).render(area, &mut buf);
let has_braille = (0..20)
.flat_map(|x| (0..5).map(move |y| (x, y)))
.any(|(x, y)| {
let sym = buf[(x as u16, y as u16)].symbol();
sym.chars()
.next()
.is_some_and(|c| (0x2800..=0x28FF).contains(&(c as u32)))
});
assert!(has_braille, "expected braille characters in output");
}
#[test]
fn filled_area_below_wave() {
let area = Rect::new(0, 0, 20, 10);
let mut buf = Buffer::empty(area);
SkeletonLineChart::new(1000)
.lines(1)
.filled(true)
.render(area, &mut buf);
let bottom_filled = (0..20).any(|x| buf[(x as u16, 9u16)].symbol() == "█");
assert!(bottom_filled, "bottom row should have fill");
}
#[test]
fn unfilled_has_no_blocks() {
let area = Rect::new(0, 0, 20, 10);
let mut buf = Buffer::empty(area);
SkeletonLineChart::new(1000)
.lines(1)
.filled(false)
.render(area, &mut buf);
let has_block = (0..20)
.flat_map(|x| (0..10).map(move |y| (x, y)))
.any(|(x, y)| buf[(x as u16, y as u16)].symbol() == "█");
assert!(!has_block, "unfilled mode should have no █ characters");
}
#[test]
fn drift_changes_output() {
let area = Rect::new(0, 0, 20, 5);
let mut buf_a = Buffer::empty(area);
let mut buf_b = Buffer::empty(area);
SkeletonLineChart::new(0).lines(1).render(area, &mut buf_a);
SkeletonLineChart::new(5000)
.lines(1)
.render(area, &mut buf_b);
assert_ne!(
buf_a, buf_b,
"different timestamps should produce different output"
);
}
#[test]
fn empty_area_is_noop() {
let area = Rect::new(0, 0, 0, 0);
let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
let expected = buf.clone();
SkeletonLineChart::new(0).render(area, &mut buf);
assert_eq!(buf, expected);
}
}