use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Style},
text::Span,
Frame,
};
use crate::ui::palette as p;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GraphStyle {
Bars,
Dots,
}
impl GraphStyle {
pub fn label(self) -> &'static str {
match self {
GraphStyle::Bars => "bars",
GraphStyle::Dots => "dots",
}
}
pub fn next(self) -> GraphStyle {
match self {
GraphStyle::Bars => GraphStyle::Dots,
GraphStyle::Dots => GraphStyle::Bars,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct GraphOpts {
pub fade: bool,
}
impl Default for GraphOpts {
fn default() -> Self {
Self { fade: false }
}
}
const MIN_FADE_ALPHA: f32 = 0.30;
const MIN_ROW_FADE_ALPHA: f32 = 0.55;
const GRID_MIN_W: u16 = 16;
const GRID_MIN_H: u16 = 4;
const BLOCK_GLYPHS: [char; 8] = [
'\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}', '\u{2587}', '\u{2588}',
];
const BRAILLE_BIT: [[u8; 4]; 2] = [
[0, 1, 2, 6], [3, 4, 5, 7], ];
const BRAILLE_BASE: u32 = 0x2800;
pub fn render(
f: &mut Frame,
area: Rect,
samples: &[f32],
style: GraphStyle,
color: Color,
opts: GraphOpts,
) {
if area.width == 0 || area.height == 0 {
return;
}
if opts.fade && area.width >= GRID_MIN_W && area.height >= GRID_MIN_H {
render_grid(f.buffer_mut(), area);
}
match style {
GraphStyle::Bars => render_bars(f.buffer_mut(), area, samples, color, opts),
GraphStyle::Dots => render_dots(f.buffer_mut(), area, samples, color, opts),
}
}
fn render_bars(buf: &mut Buffer, area: Rect, samples: &[f32], base: Color, opts: GraphOpts) {
let cell_w = area.width as usize;
let cell_h = area.height as usize;
if cell_w == 0 || cell_h == 0 || samples.is_empty() {
return;
}
let take = cell_w;
let slice: &[f32] = if samples.len() > take {
&samples[samples.len() - take..]
} else {
samples
};
let n = slice.len();
let n_minus_1 = n.saturating_sub(1).max(1) as f32;
if !opts.fade {
for y in 0..cell_h {
for x in 0..cell_w {
if let Some(cell) = buf.cell_mut((area.x + x as u16, area.y + y as u16)) {
cell.set_char(' ');
cell.set_style(Style::default().bg(p::bg()));
}
}
}
}
for (i, &v) in slice.iter().enumerate() {
let v = v.clamp(0.0, 1.0);
let idx = ((v * 7.0).round() as usize).min(7);
let glyph = BLOCK_GLYPHS[idx];
let color = if opts.fade {
let alpha = MIN_FADE_ALPHA + (1.0 - MIN_FADE_ALPHA) * (i as f32 / n_minus_1);
fade_color(base, p::bg(), alpha)
} else {
base
};
let x_offset = cell_w.saturating_sub(n) + i;
let x = area.x + x_offset as u16;
for cy in 0..cell_h {
if let Some(cell) = buf.cell_mut((x, area.y + cy as u16)) {
cell.set_char(glyph);
cell.set_style(Style::default().fg(color).bg(p::bg()));
}
}
}
}
fn render_dots(buf: &mut Buffer, area: Rect, samples: &[f32], color: Color, opts: GraphOpts) {
let cell_w = area.width as usize;
let cell_h = area.height as usize;
if cell_w == 0 || cell_h == 0 || samples.is_empty() {
return;
}
let pix_h = cell_h * 4;
let take = cell_w;
let slice: &[f32] = if samples.len() > take {
&samples[samples.len() - take..]
} else {
samples
};
if !opts.fade {
for y in 0..cell_h {
for x in 0..cell_w {
if let Some(cell) = buf.cell_mut((area.x + x as u16, area.y + y as u16)) {
cell.set_char(' ');
cell.set_style(Style::default().bg(p::bg()));
}
}
}
}
let mut masks = vec![vec![0u8; cell_w]; cell_h];
for (i, &v) in slice.iter().enumerate() {
let v = v.clamp(0.0, 1.0);
if v <= 0.0 {
continue;
}
let top_pixel_from_bottom = ((v * (pix_h as f32 - 1.0)).round() as usize).min(pix_h - 1);
for fill in 0..=top_pixel_from_bottom {
let pix_y_from_top = (pix_h - 1) - fill;
let cell_y = pix_y_from_top / 4;
let row_in_cell = pix_y_from_top % 4;
masks[cell_y][i] |= 1 << BRAILLE_BIT[0][row_in_cell];
masks[cell_y][i] |= 1 << BRAILLE_BIT[1][row_in_cell];
}
}
let n = slice.len();
let n_minus_1 = n.saturating_sub(1).max(1) as f32;
for (y, row) in masks.iter().enumerate() {
for (x, &mask) in row.iter().enumerate() {
if mask == 0 {
continue;
}
let cell_color = if opts.fade {
let alpha = MIN_FADE_ALPHA + (1.0 - MIN_FADE_ALPHA) * (x as f32 / n_minus_1);
fade_color(color, p::bg(), alpha)
} else {
color
};
let ch = char::from_u32(BRAILLE_BASE | mask as u32).unwrap_or(' ');
if let Some(cell) = buf.cell_mut((area.x + x as u16, area.y + y as u16)) {
cell.set_char(ch);
cell.set_style(Style::default().fg(cell_color).bg(p::bg()));
}
}
}
}
pub fn fade_color(base: Color, bg: Color, alpha: f32) -> Color {
let alpha = alpha.clamp(0.0, 1.0);
let (br, bgc, bb) = to_rgb_or_default(base, (255, 255, 255));
let (gr, gg, gb) = to_rgb_or_default(bg, (0, 0, 0));
Color::Rgb(
lerp_u8(gr, br, alpha),
lerp_u8(gg, bgc, alpha),
lerp_u8(gb, bb, alpha),
)
}
fn to_rgb_or_default(c: Color, fallback: (u8, u8, u8)) -> (u8, u8, u8) {
match c {
Color::Rgb(r, g, b) => (r, g, b),
Color::Reset => fallback,
Color::Black => (0, 0, 0),
Color::Red => (170, 0, 0),
Color::Green => (0, 170, 0),
Color::Yellow => (170, 85, 0),
Color::Blue => (0, 0, 170),
Color::Magenta => (170, 0, 170),
Color::Cyan => (0, 170, 170),
Color::Gray => (170, 170, 170),
Color::DarkGray => (85, 85, 85),
Color::LightRed => (255, 85, 85),
Color::LightGreen => (85, 255, 85),
Color::LightYellow => (255, 255, 85),
Color::LightBlue => (85, 85, 255),
Color::LightMagenta => (255, 85, 255),
Color::LightCyan => (85, 255, 255),
Color::White => (255, 255, 255),
_ => fallback,
}
}
fn lerp_u8(a: u8, b: u8, t: f32) -> u8 {
(a as f32 + (b as f32 - a as f32) * t)
.round()
.clamp(0.0, 255.0) as u8
}
pub fn row_fade_alpha(row_idx: usize, total_rows: usize) -> f32 {
if total_rows <= 1 {
return 1.0;
}
let denom = (total_rows - 1) as f32;
1.0 - (1.0 - MIN_ROW_FADE_ALPHA) * (row_idx as f32 / denom)
}
pub fn fade_spans_fg<'a>(spans: Vec<Span<'a>>, bg: Color, alpha: f32) -> Vec<Span<'a>> {
spans
.into_iter()
.map(|mut s| {
if let Some(fg) = s.style.fg {
s.style = s.style.fg(fade_color(fg, bg, alpha));
}
s
})
.collect()
}
fn render_grid(buf: &mut Buffer, area: Rect) {
let grid_color = fade_color(Color::Rgb(150, 150, 150), p::bg(), 0.20);
let cell_w = area.width as usize;
let cell_h = area.height as usize;
let v_step = (cell_w / 4).max(2);
let h_step = (cell_h / 4).max(1);
for x in (v_step..cell_w).step_by(v_step) {
for cy in 0..cell_h {
if let Some(cell) = buf.cell_mut((area.x + x as u16, area.y + cy as u16)) {
cell.set_char('·');
cell.set_style(Style::default().fg(grid_color).bg(p::bg()));
}
}
}
for y in (h_step..cell_h).step_by(h_step) {
for cx in 0..cell_w {
if let Some(cell) = buf.cell_mut((area.x + cx as u16, area.y + y as u16)) {
if cell.symbol() != "·" {
cell.set_char('·');
cell.set_style(Style::default().fg(grid_color).bg(p::bg()));
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn next_cycles_styles() {
assert_eq!(GraphStyle::Bars.next(), GraphStyle::Dots);
assert_eq!(GraphStyle::Dots.next(), GraphStyle::Bars);
}
#[test]
fn label_is_stable() {
assert_eq!(GraphStyle::Bars.label(), "bars");
assert_eq!(GraphStyle::Dots.label(), "dots");
}
#[test]
fn dots_writes_braille_chars_for_nonzero_samples() {
let area = Rect::new(0, 0, 4, 2);
let mut buf = Buffer::empty(area);
render_dots(
&mut buf,
area,
&[1.0, 0.5, 0.25, 0.0],
Color::White,
GraphOpts::default(),
);
let top_left = buf
.cell((0u16, 0u16))
.unwrap()
.symbol()
.chars()
.next()
.unwrap();
assert!(
(top_left as u32) >= BRAILLE_BASE && (top_left as u32) < BRAILLE_BASE + 256,
"expected braille at top-left, got {:?}",
top_left
);
let zero_top = buf
.cell((3u16, 0u16))
.unwrap()
.symbol()
.chars()
.next()
.unwrap();
assert_eq!(zero_top, ' ');
}
#[test]
fn dots_handles_zero_area() {
let area = Rect::new(0, 0, 0, 0);
let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
render_dots(&mut buf, area, &[1.0], Color::White, GraphOpts::default());
}
#[test]
fn fade_color_endpoints_match_inputs() {
let base = Color::Rgb(200, 100, 50);
let bg = Color::Rgb(0, 0, 0);
assert_eq!(fade_color(base, bg, 1.0), base);
assert_eq!(fade_color(base, bg, 0.0), bg);
}
#[test]
fn fade_named_green_against_reset_bg_stays_green() {
let dim = fade_color(Color::Green, Color::Reset, 0.3);
match dim {
Color::Rgb(r, g, b) => {
assert_eq!(r, 0);
assert!(g > 0 && g < 170);
assert_eq!(b, 0);
}
_ => panic!("expected Rgb, got {:?}", dim),
}
}
#[test]
fn row_fade_alpha_endpoints_and_midpoint() {
assert!((row_fade_alpha(0, 10) - 1.0).abs() < 1e-6);
assert!((row_fade_alpha(9, 10) - MIN_ROW_FADE_ALPHA).abs() < 1e-6);
assert!((row_fade_alpha(0, 1) - 1.0).abs() < 1e-6); }
#[test]
fn bars_with_fade_produces_different_colors_left_to_right() {
let area = Rect::new(0, 0, 8, 1);
let mut buf = Buffer::empty(area);
let samples = vec![1.0_f32; 8];
render_bars(
&mut buf,
area,
&samples,
Color::Rgb(200, 100, 50),
GraphOpts { fade: true },
);
let left_fg = buf.cell((0u16, 0u16)).unwrap().fg;
let right_fg = buf.cell((7u16, 0u16)).unwrap().fg;
assert_ne!(
left_fg, right_fg,
"fade on should produce a per-column gradient; got the same fg on both ends"
);
assert_eq!(right_fg, Color::Rgb(200, 100, 50));
}
#[test]
fn bars_without_fade_uses_uniform_color() {
let area = Rect::new(0, 0, 8, 1);
let mut buf = Buffer::empty(area);
let samples = vec![1.0_f32; 8];
render_bars(
&mut buf,
area,
&samples,
Color::Rgb(200, 100, 50),
GraphOpts::default(),
);
let left_fg = buf.cell((0u16, 0u16)).unwrap().fg;
let right_fg = buf.cell((7u16, 0u16)).unwrap().fg;
assert_eq!(left_fg, right_fg);
assert_eq!(left_fg, Color::Rgb(200, 100, 50));
}
}