use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use std::sync::RwLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GraphStyle {
Bars,
Dots,
}
pub const GRAPH_STYLE_NAMES: &[&str] = &["bars", "dots"];
pub fn by_name(name: &str) -> GraphStyle {
match name.to_lowercase().as_str() {
"dots" | "braille" | "btop" => GraphStyle::Dots,
_ => GraphStyle::Bars,
}
}
impl GraphStyle {
pub fn name(self) -> &'static str {
match self {
GraphStyle::Bars => "bars",
GraphStyle::Dots => "dots",
}
}
}
static ACTIVE: RwLock<GraphStyle> = RwLock::new(GraphStyle::Bars);
static FADE: RwLock<bool> = RwLock::new(false);
pub fn active() -> GraphStyle {
*ACTIVE.read().expect("graph style lock poisoned")
}
pub fn set_by_name(name: &str) {
*ACTIVE.write().expect("graph style lock poisoned") = by_name(name);
}
pub fn name() -> &'static str {
active().name()
}
pub fn cycle() -> &'static str {
let next = match active() {
GraphStyle::Bars => GraphStyle::Dots,
GraphStyle::Dots => GraphStyle::Bars,
};
*ACTIVE.write().expect("graph style lock poisoned") = next;
next.name()
}
pub fn fade_enabled() -> bool {
*FADE.read().expect("graph fade lock poisoned")
}
pub fn set_fade(on: bool) {
*FADE.write().expect("graph fade lock poisoned") = on;
}
pub fn toggle_fade() -> bool {
let next = !fade_enabled();
set_fade(next);
next
}
#[derive(Debug, Clone, Copy)]
pub struct GraphOpts {
pub fade: bool,
pub bg: Color,
pub terminal_palette: bool,
pub baseline: bool,
}
impl Default for GraphOpts {
fn default() -> Self {
Self {
fade: false,
bg: Color::Reset,
terminal_palette: false,
baseline: true,
}
}
}
pub fn opts() -> GraphOpts {
let theme = crate::ui::theme::active();
GraphOpts {
fade: fade_enabled(),
bg: theme.bg,
terminal_palette: theme.name == "terminal",
baseline: true,
}
}
const MIN_FADE_ALPHA: f32 = 0.30;
const GRID_MIN_W: u16 = 16;
const GRID_MIN_H: u16 = 4;
pub fn render(buf: &mut Buffer, area: Rect, data: &[f64], color: Color, opts: GraphOpts) {
let start = data.len().saturating_sub(area.width as usize);
let max = data[start..].iter().copied().fold(0.0_f64, f64::max);
render_with_max(buf, area, data, max, color, opts);
}
pub fn render_with_max(
buf: &mut Buffer,
area: Rect,
data: &[f64],
max: f64,
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(buf, area, opts.bg, opts.terminal_palette);
}
match active() {
GraphStyle::Bars => render_bars(buf, area, data, max, color, opts),
GraphStyle::Dots => render_dots(buf, area, data, max, color, opts),
}
}
fn column_color(base: Color, i: usize, n: usize, opts: GraphOpts) -> Color {
if !opts.fade {
return base;
}
let denom = n.saturating_sub(1).max(1) as f32;
let alpha = MIN_FADE_ALPHA + (1.0 - MIN_FADE_ALPHA) * (i as f32 / denom);
fade_color(base, opts.bg, alpha, opts.terminal_palette)
}
fn window(data: &[f64], width: usize) -> (&[f64], usize) {
let start = data.len().saturating_sub(width);
let visible = &data[start..];
(visible, width.saturating_sub(visible.len()))
}
const BAR_GLYPHS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
fn render_bars(buf: &mut Buffer, area: Rect, data: &[f64], max: f64, base: Color, opts: GraphOpts) {
let cell_w = area.width as usize;
let cell_h = area.height as usize;
let (visible, leading) = window(data, cell_w);
let max = if max > 0.0 { max } else { f64::INFINITY };
for x in 0..cell_w {
let v = if x < leading {
0.0
} else {
visible[x - leading]
};
let color = column_color(base, x, cell_w, opts);
let normalized = (v / max).clamp(0.0, 1.0);
let total_eighths = (normalized * cell_h as f64 * 8.0).round() as usize;
for cy in 0..cell_h {
let from_bottom = cell_h - 1 - cy;
let eighths = total_eighths.saturating_sub(from_bottom * 8).min(8);
let glyph = if eighths > 0 {
BAR_GLYPHS[eighths]
} else if from_bottom == 0 && opts.baseline {
BAR_GLYPHS[1]
} else {
continue;
};
if let Some(cell) = buf.cell_mut((area.x + x as u16, area.y + cy as u16)) {
cell.set_char(glyph).set_fg(color).set_bg(opts.bg);
}
}
}
}
const BRAILLE_BIT: [[u8; 4]; 2] = [
[0, 1, 2, 6], [3, 4, 5, 7], ];
const BRAILLE_BASE: u32 = 0x2800;
fn row_mask(row_in_cell: usize) -> u8 {
(1 << BRAILLE_BIT[0][row_in_cell]) | (1 << BRAILLE_BIT[1][row_in_cell])
}
fn render_dots(buf: &mut Buffer, area: Rect, data: &[f64], max: f64, base: Color, opts: GraphOpts) {
let cell_w = area.width as usize;
let cell_h = area.height as usize;
let pix_h = cell_h * 4;
let (visible, leading) = window(data, cell_w);
let max = if max > 0.0 { max } else { f64::INFINITY };
let mut masks = vec![vec![0u8; cell_w]; cell_h];
for x in 0..cell_w {
let v = if x < leading {
0.0
} else {
visible[x - leading]
};
let normalized = (v / max).clamp(0.0, 1.0);
let top = (normalized * (pix_h - 1) as f64).round() as usize;
if v <= 0.0 && !opts.baseline {
continue;
}
for fill in 0..=top {
let pix_y_from_top = (pix_h - 1) - fill;
masks[pix_y_from_top / 4][x] |= row_mask(pix_y_from_top % 4);
}
}
for (y, row) in masks.iter().enumerate() {
for (x, &mask) in row.iter().enumerate() {
if mask == 0 {
continue;
}
let ch = char::from_u32(BRAILLE_BASE | mask as u32).unwrap_or(' ');
let color = column_color(base, x, cell_w, opts);
if let Some(cell) = buf.cell_mut((area.x + x as u16, area.y + y as u16)) {
cell.set_char(ch).set_fg(color).set_bg(opts.bg);
}
}
}
}
pub fn fade_color(base: Color, bg: Color, alpha: f32, defer_to_terminal: bool) -> Color {
if defer_to_terminal {
return base;
}
let alpha = alpha.clamp(0.0, 1.0);
let (br, bgn, 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, bgn, 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
}
fn render_grid(buf: &mut Buffer, area: Rect, bg: Color, defer_to_terminal: bool) {
let grid_color = if defer_to_terminal {
Color::DarkGray
} else {
fade_color(Color::Rgb(150, 150, 150), bg, 0.20, false)
};
let cell_w = area.width as usize;
let cell_h = area.height as usize;
if cell_w < GRID_MIN_W as usize || cell_h < GRID_MIN_H as usize {
return;
}
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('·').set_fg(grid_color).set_bg(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('·').set_fg(grid_color).set_bg(bg);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_style<T>(style: GraphStyle, f: impl FnOnce() -> T) -> T {
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prev = active();
set_by_name(style.name());
let out = f();
set_by_name(prev.name());
out
}
fn draw(style: GraphStyle, area: Rect, data: &[f64], opts: GraphOpts) -> Buffer {
with_style(style, || {
let mut buf = Buffer::empty(area);
render(&mut buf, area, data, Color::Green, opts);
buf
})
}
fn draw_scaled(
style: GraphStyle,
area: Rect,
data: &[f64],
max: f64,
opts: GraphOpts,
) -> Buffer {
with_style(style, || {
let mut buf = Buffer::empty(area);
render_with_max(&mut buf, area, data, max, Color::Green, opts);
buf
})
}
fn symbol(buf: &Buffer, x: u16, y: u16) -> String {
buf.cell((x, y)).unwrap().symbol().to_string()
}
#[test]
fn dots_fill_both_braille_sub_columns() {
let area = Rect::new(0, 0, 1, 1);
let buf = draw(GraphStyle::Dots, area, &[10.0], GraphOpts::default());
assert_eq!(symbol(&buf, 0, 0), "⣿");
}
#[test]
fn dots_partial_height_still_spans_the_cell() {
let area = Rect::new(0, 0, 1, 1);
let buf = draw_scaled(GraphStyle::Dots, area, &[1.0], 10.0, GraphOpts::default());
assert_eq!(symbol(&buf, 0, 0), "⣀");
}
#[test]
fn both_styles_ground_a_zero_series_on_the_baseline() {
let area = Rect::new(0, 0, 4, 2);
let zeros = [0.0; 4];
let bars = draw(GraphStyle::Bars, area, &zeros, GraphOpts::default());
assert_eq!(symbol(&bars, 0, 1), "▁", "bars lost its baseline");
assert_eq!(symbol(&bars, 0, 0), " ", "bars filled above the baseline");
let dots = draw(GraphStyle::Dots, area, &zeros, GraphOpts::default());
assert_eq!(symbol(&dots, 0, 1), "⣀", "dots lost its baseline");
assert_eq!(symbol(&dots, 0, 0), " ", "dots filled above the baseline");
}
#[test]
fn a_short_series_anchors_to_the_right_edge() {
let area = Rect::new(0, 0, 4, 1);
for style in [GraphStyle::Bars, GraphStyle::Dots] {
let buf = draw(style, area, &[0.0, 10.0], GraphOpts::default());
let last = symbol(&buf, 3, 0);
let first = symbol(&buf, 0, 0);
assert_ne!(last, first, "{style:?} did not anchor to the right edge");
}
}
#[test]
fn dots_resolve_four_levels_where_bars_resolve_one() {
let area = Rect::new(0, 0, 4, 1);
let buf = draw_scaled(
GraphStyle::Dots,
area,
&[0.0, 1.0, 2.0, 3.0],
3.0,
GraphOpts::default(),
);
let glyphs: Vec<String> = (0..4).map(|x| symbol(&buf, x, 0)).collect();
let unique: std::collections::HashSet<&String> = glyphs.iter().collect();
assert_eq!(
unique.len(),
4,
"expected 4 distinct levels, got {glyphs:?}"
);
assert_eq!(glyphs, vec!["⣀", "⣤", "⣶", "⣿"], "levels out of order");
}
#[test]
fn fade_off_paints_every_column_the_same_colour() {
let area = Rect::new(0, 0, 8, 2);
let data: Vec<f64> = (1..=8).map(|v| v as f64).collect();
for style in [GraphStyle::Bars, GraphStyle::Dots] {
let buf = draw(style, area, &data, GraphOpts::default());
let left = buf.cell((0, 1)).unwrap().fg;
let right = buf.cell((7, 1)).unwrap().fg;
assert_eq!(left, right, "{style:?} faded with fade off");
}
}
#[test]
fn fade_on_dims_the_oldest_column() {
let area = Rect::new(0, 0, 8, 2);
let data = [5.0; 8];
let opts = GraphOpts {
fade: true,
bg: Color::Rgb(0, 0, 0),
..GraphOpts::default()
};
for style in [GraphStyle::Bars, GraphStyle::Dots] {
let buf = draw(style, area, &data, opts);
let left = buf.cell((0, 1)).unwrap().fg;
let right = buf.cell((7, 1)).unwrap().fg;
assert_ne!(left, right, "{style:?} ignored fade");
match (left, right) {
(Color::Rgb(_, lg, _), Color::Rgb(_, rg, _)) => {
assert!(lg < rg, "{style:?} faded the wrong direction");
}
other => panic!("expected Rgb pair, got {other:?}"),
}
}
}
#[test]
fn fade_passes_base_through_untouched_under_the_terminal_theme() {
let base = Color::Cyan;
for alpha in [0.0, 0.3, 0.55, 1.0] {
assert_eq!(fade_color(base, Color::Reset, alpha, true), base);
}
}
#[test]
fn fade_color_endpoints_and_midpoint() {
let base = Color::Rgb(200, 100, 50);
let bg = Color::Rgb(0, 0, 0);
assert_eq!(fade_color(base, bg, 1.0, false), base);
assert_eq!(fade_color(base, bg, 0.0, false), bg);
assert_eq!(fade_color(base, bg, 0.5, false), Color::Rgb(100, 50, 25));
assert_eq!(fade_color(base, bg, 2.0, false), base);
assert_eq!(fade_color(base, bg, -1.0, false), bg);
}
#[test]
fn named_colours_keep_their_hue_when_faded() {
let dim = fade_color(Color::Green, Color::Reset, 0.3, false);
match dim {
Color::Rgb(r, g, b) => {
assert_eq!((r, b), (0, 0), "hue drifted off green");
assert!(g > 0 && g < 170, "green should dim, not vanish");
}
other => panic!("expected Rgb, got {other:?}"),
}
}
#[test]
fn by_name_falls_back_to_bars_and_accepts_aliases() {
assert_eq!(by_name("nonsense"), GraphStyle::Bars);
assert_eq!(by_name(""), GraphStyle::Bars);
assert_eq!(by_name("bars"), GraphStyle::Bars);
assert_eq!(by_name("DOTS"), GraphStyle::Dots);
assert_eq!(by_name("braille"), GraphStyle::Dots);
assert_eq!(by_name("btop"), GraphStyle::Dots);
}
#[test]
fn name_roundtrips_and_cycle_returns_home() {
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
for n in GRAPH_STYLE_NAMES {
assert_eq!(by_name(n).name(), *n);
}
let start = name();
cycle();
assert_ne!(name(), start);
cycle();
assert_eq!(name(), start, "cycling twice must return to the start");
}
#[test]
fn zero_sized_areas_are_a_no_op() {
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
let mut buf = Buffer::empty(Rect::new(0, 0, 4, 2));
for area in [Rect::new(0, 0, 0, 2), Rect::new(0, 0, 4, 0)] {
render(
&mut buf,
area,
&[1.0, 2.0],
Color::Green,
GraphOpts::default(),
);
}
}
}