use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use unicode_width::UnicodeWidthStr;
use crate::ui::graph::{fade_color_inner, BRAILLE_BASE, BRAILLE_BIT};
use crate::ui::theme::Theme;
#[derive(Debug, Clone)]
pub struct Ramp {
stops: Vec<Color>,
}
impl Ramp {
pub fn at(&self, f: f32) -> Color {
match self.stops.len() {
0 => Color::Reset,
1 => self.stops[0],
n => {
let t = f.clamp(0.0, 1.0) * (n - 1) as f32;
let i = t.floor() as usize;
if i >= n - 1 {
return self.stops[n - 1];
}
let (a, b) = (self.stops[i], self.stops[i + 1]);
let frac = t - i as f32;
match (rgb(a), rgb(b)) {
(Some(_), Some(_)) => lerp(a, b, frac),
_ if frac >= 0.5 => b,
_ => a,
}
}
}
}
pub fn mid(&self) -> Color {
self.at(0.55)
}
}
#[derive(Debug, Clone)]
pub struct Ramps {
pub primary: Ramp,
pub secondary: Ramp,
pub load: Ramp,
pub dim: Ramp,
}
impl Ramps {
pub fn from_theme(t: &Theme) -> Self {
Self {
primary: magnitude_ramp(t.rx_rate, t.bg),
secondary: magnitude_ramp(t.brand, t.bg),
load: severity_ramp(t),
dim: dim_ramp(t),
}
}
}
fn magnitude_ramp(base: Color, bg: Color) -> Ramp {
if rgb(base).is_none() {
return Ramp {
stops: vec![base, bright_of(base)],
};
}
let peak = if luminance(bg) > 0.5 {
Color::Rgb(0, 0, 0)
} else {
Color::Rgb(255, 255, 255)
};
Ramp {
stops: vec![
fade_color_inner(base, bg, 0.30, false),
fade_color_inner(base, bg, 0.62, false),
base,
lerp(base, peak, 0.42),
lerp(base, peak, 0.76),
],
}
}
fn luminance(c: Color) -> f32 {
match rgb(c) {
Some((r, g, b)) => (0.2126 * r as f32 + 0.7152 * g as f32 + 0.0722 * b as f32) / 255.0,
None => 0.0,
}
}
fn severity_ramp(t: &Theme) -> Ramp {
if rgb(t.status_good).is_none() {
return Ramp {
stops: vec![t.status_good, t.status_warn, t.status_error],
};
}
Ramp {
stops: vec![
t.status_good,
lerp(t.status_good, t.status_warn, 0.5),
t.status_warn,
lerp(t.status_warn, t.status_error, 0.5),
t.status_error,
],
}
}
fn dim_ramp(t: &Theme) -> Ramp {
if rgb(t.text_muted).is_none() {
return Ramp {
stops: vec![t.text_muted, t.text_secondary],
};
}
Ramp {
stops: vec![
fade_color_inner(t.text_muted, t.bg, 0.45, false),
t.text_muted,
fade_color_inner(t.text_secondary, t.bg, 0.85, false),
],
}
}
fn bright_of(c: Color) -> Color {
match c {
Color::Black => Color::DarkGray,
Color::Red => Color::LightRed,
Color::Green => Color::LightGreen,
Color::Yellow => Color::LightYellow,
Color::Blue => Color::LightBlue,
Color::Magenta => Color::LightMagenta,
Color::Cyan => Color::LightCyan,
Color::Gray => Color::White,
Color::DarkGray => Color::Gray,
Color::Indexed(n) if n < 8 => Color::Indexed(n + 8),
other => other,
}
}
fn rgb(c: Color) -> Option<(u8, u8, u8)> {
match c {
Color::Rgb(r, g, b) => Some((r, g, b)),
_ => None,
}
}
fn lerp(a: Color, b: Color, f: f32) -> Color {
match (rgb(a), rgb(b)) {
(Some((ar, ag, ab)), Some((br, bg_, bb))) => {
let f = f.clamp(0.0, 1.0);
let mix = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * f).round() as u8;
Color::Rgb(mix(ar, br), mix(ag, bg_), mix(ab, bb))
}
_ => a,
}
}
fn sub_height(v: u64, max: u64, sub_h: usize) -> usize {
if max == 0 {
return 0;
}
let v = v.min(max) as u128;
((v * sub_h as u128 * 2 + max as u128) / (max as u128 * 2)) as usize
}
pub fn area_graph(
buf: &mut Buffer,
area: Rect,
samples: &[u64],
max: u64,
ramp: &Ramp,
flip: bool,
) {
if area.width == 0 || area.height == 0 || max == 0 {
return;
}
let w = area.width as usize;
let h = area.height as usize;
let sub_h = h * 4;
let want = w * 2;
let off = samples.len() as isize - want as isize;
let sample = |i: usize| -> Option<u64> {
let idx = off + i as isize;
if idx < 0 {
None
} else {
samples.get(idx as usize).copied()
}
};
for cx in 0..w {
let (Some(lv), Some(rv)) = (sample(cx * 2), sample(cx * 2 + 1)) else {
continue;
};
let lh = sub_height(lv, max, sub_h).max(1);
let rh = sub_height(rv, max, sub_h).max(1);
for cy in 0..h {
let mut bits: u8 = 0;
for (s, (l_dot, r_dot)) in BRAILLE_BIT[0].iter().zip(BRAILLE_BIT[1]).enumerate() {
let from_top = cy * 4 + s;
let depth = if flip { from_top + 1 } else { sub_h - from_top };
if lh >= depth {
bits |= 1 << l_dot;
}
if rh >= depth {
bits |= 1 << r_dot;
}
}
if bits == 0 {
continue;
}
let mid = (cy * 4 + 2) as f32;
let f = if flip {
mid / sub_h as f32
} else {
(sub_h as f32 - mid) / sub_h as f32
};
let Some(ch) = char::from_u32(BRAILLE_BASE | bits as u32) else {
continue;
};
if let Some(cell) = buf.cell_mut((area.x + cx as u16, area.y + cy as u16)) {
cell.set_char(ch);
cell.set_style(Style::default().fg(ramp.at(f)));
}
}
}
}
pub fn spark(buf: &mut Buffer, x: u16, y: u16, w: u16, samples: &[u64], max: u64, ramp: &Ramp) {
area_graph(buf, Rect::new(x, y, w, 1), samples, max, ramp, false);
}
pub fn perceptual(frac: f32, scale: u64) -> u64 {
(frac.clamp(0.0, 1.0).sqrt() * scale as f32).round() as u64
}
pub fn baseline(buf: &mut Buffer, x: u16, y: u16, w: u16, color: Color) {
for i in 0..w {
if let Some(cell) = buf.cell_mut((x + i, y)) {
cell.set_char('⣀');
cell.set_style(Style::default().fg(color));
}
}
}
pub fn meter(buf: &mut Buffer, x: u16, y: u16, w: u16, frac: f32, ramp: &Ramp, empty: Color) {
if w == 0 {
return;
}
let filled = (frac.clamp(0.0, 1.0) * w as f32).round() as u16;
let span = (w.saturating_sub(1)).max(1) as f32;
for i in 0..w {
let on = i < filled;
if let Some(cell) = buf.cell_mut((x + i, y)) {
cell.set_char(if on { '■' } else { '·' });
cell.set_style(Style::default().fg(if on { ramp.at(i as f32 / span) } else { empty }));
}
}
}
const TL: char = '╭';
const TR: char = '╮';
const BL: char = '╰';
const BR: char = '╯';
const H: char = '─';
const V: char = '│';
pub type Bind<'a> = (&'a str, &'a str);
#[derive(Default)]
pub struct PanelOpts<'a> {
pub key: Option<&'a str>,
pub title: Option<&'a str>,
pub sub: Option<&'a str>,
pub right: Option<&'a str>,
pub right_style: Option<Style>,
pub foot_left: &'a [Bind<'a>],
pub foot_right: Option<&'a str>,
}
pub fn panel(buf: &mut Buffer, area: Rect, t: &Theme, o: &PanelOpts) -> Rect {
if area.width < 2 || area.height < 2 {
return area;
}
let border = Style::default().fg(t.border);
let x0 = area.x;
let y0 = area.y;
let x1 = area.x + area.width - 1;
let y1 = area.y + area.height - 1;
for x in (x0 + 1)..x1 {
set(buf, x, y0, H, border);
set(buf, x, y1, H, border);
}
for y in (y0 + 1)..y1 {
set(buf, x0, y, V, border);
set(buf, x1, y, V, border);
}
set(buf, x0, y0, TL, border);
set(buf, x1, y0, TR, border);
set(buf, x0, y1, BL, border);
set(buf, x1, y1, BR, border);
let mut cx = x0 + 1;
if o.key.is_some() || o.title.is_some() {
cx = put(buf, cx, y0, "─", border);
}
if let Some(k) = o.key {
cx = put(buf, cx, y0, "┤", border);
cx = put(
buf,
cx,
y0,
k,
Style::default().fg(t.key_hint).add_modifier(Modifier::BOLD),
);
cx = put(buf, cx, y0, "├─", border);
}
if let Some(title) = o.title {
cx = put(buf, cx, y0, "┤ ", border);
cx = put(
buf,
cx,
y0,
title,
Style::default().fg(t.brand).add_modifier(Modifier::BOLD),
);
cx = put(buf, cx, y0, " ├", border);
}
if let Some(sub) = o.sub {
cx = put(buf, cx, y0, "─┤ ", border);
cx = put(buf, cx, y0, sub, Style::default().fg(t.text_muted));
let _ = put(buf, cx, y0, " ├", border);
}
if let Some(right) = o.right {
let style = o
.right_style
.unwrap_or_else(|| Style::default().fg(t.text_muted));
insert_right(buf, x1, y0, right, style, border);
}
if !o.foot_left.is_empty() {
let mut fx = x0 + 2;
fx = put(buf, fx, y1, "┤ ", border);
for (i, (key, rest)) in o.foot_left.iter().enumerate() {
if i > 0 {
fx = put(buf, fx, y1, " ", border);
}
fx = put(
buf,
fx,
y1,
key,
Style::default().fg(t.key_hint).add_modifier(Modifier::BOLD),
);
fx = put(buf, fx, y1, rest, Style::default().fg(t.text_muted));
}
let _ = put(buf, fx, y1, " ├", border);
}
if let Some(fr) = o.foot_right {
insert_right(buf, x1, y1, fr, Style::default().fg(t.text_muted), border);
}
Rect::new(x0 + 1, y0 + 1, area.width - 2, area.height - 2)
}
fn insert_right(buf: &mut Buffer, x1: u16, y: u16, text: &str, style: Style, border: Style) {
let w = text.width() as u16 + 4; if w + 2 > x1 {
return;
}
let x = x1 - 1 - w;
let mut cx = put(buf, x, y, "┤ ", border);
cx = put(buf, cx, y, text, style);
let _ = put(buf, cx, y, " ├", border);
}
pub fn set(buf: &mut Buffer, x: u16, y: u16, ch: char, style: Style) {
if x >= buf.area.right() || y >= buf.area.bottom() {
return;
}
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_char(ch);
cell.set_style(style);
}
}
pub fn put(buf: &mut Buffer, x: u16, y: u16, s: &str, style: Style) -> u16 {
if x >= buf.area.right() || y >= buf.area.bottom() {
return x;
}
let max = (buf.area.right() - x) as usize;
buf.set_stringn(x, y, s, max, style);
x + s.width() as u16
}
pub fn put_right(buf: &mut Buffer, x_end: u16, y: u16, s: &str, style: Style) -> u16 {
let w = s.width() as u16;
if w > x_end + 1 {
return x_end;
}
put(buf, x_end + 1 - w, y, s, style)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::theme;
fn buf(w: u16, h: u16) -> Buffer {
Buffer::empty(Rect::new(0, 0, w, h))
}
fn ch(b: &Buffer, x: u16, y: u16) -> char {
b[(x, y)].symbol().chars().next().unwrap_or(' ')
}
fn row(b: &Buffer, y: u16) -> String {
(0..b.area.width).map(|x| ch(b, x, y)).collect()
}
fn ramp() -> Ramp {
Ramp {
stops: vec![Color::Green],
}
}
#[test]
fn two_samples_share_one_cell() {
let mut b = buf(1, 1);
area_graph(&mut b, Rect::new(0, 0, 1, 1), &[4, 0], 4, &ramp(), false);
let c = ch(&b, 0, 0) as u32;
let bits = c - BRAILLE_BASE;
assert_eq!(bits & 0b0100_0111, 0b0100_0111, "left column not full");
assert_eq!(bits & 0b0011_1000, 0, "right column should not be filled");
}
#[test]
fn window_is_anchored_to_the_newest_samples() {
let mut b = buf(2, 1);
area_graph(
&mut b,
Rect::new(0, 0, 2, 1),
&[4, 4, 4, 4, 0, 0, 4, 4],
4,
&ramp(),
false,
);
let left = ch(&b, 0, 0) as u32 - BRAILLE_BASE;
let right = ch(&b, 1, 0) as u32 - BRAILLE_BASE;
assert_eq!(
left.count_ones(),
2,
"oldest visible pair should be baseline"
);
assert_eq!(right.count_ones(), 8, "newest pair should be full");
}
#[test]
fn partial_history_leaves_the_left_blank() {
let mut b = buf(4, 1);
area_graph(&mut b, Rect::new(0, 0, 4, 1), &[4, 4], 4, &ramp(), false);
assert_eq!(ch(&b, 0, 0), ' ');
assert_eq!(ch(&b, 1, 0), ' ');
assert_eq!(ch(&b, 2, 0), ' ');
assert_ne!(
ch(&b, 3, 0),
' ',
"newest pair should occupy the right edge"
);
}
#[test]
fn flip_grows_downward() {
let mut up = buf(1, 2);
let mut down = buf(1, 2);
area_graph(&mut up, Rect::new(0, 0, 1, 2), &[2, 2], 8, &ramp(), false);
area_graph(&mut down, Rect::new(0, 0, 1, 2), &[2, 2], 8, &ramp(), true);
assert_eq!(ch(&up, 0, 0), ' ', "unflipped must not touch the top row");
assert_eq!(
ch(&down, 0, 1),
' ',
"flipped must not touch the bottom row"
);
}
#[test]
fn magnitude_ramp_gains_contrast_against_the_background() {
for name in ["dark", "light", "ocean", "solarized", "dracula", "nord"] {
let t = theme::by_name(name);
let r = Ramps::from_theme(&t);
let bg = t.bg;
let low = contrast(r.primary.at(0.0), bg);
let high = contrast(r.primary.at(1.0), bg);
assert!(
high > low,
"{name}: peak contrast {high:.2} not above baseline {low:.2}"
);
assert!(
high > 2.0,
"{name}: peak contrast {high:.2} too low to read"
);
}
}
fn contrast(a: Color, b: Color) -> f32 {
let l = |c: Color| match c {
Color::Rgb(r, g, bl) => {
(0.2126 * r as f32 + 0.7152 * g as f32 + 0.0722 * bl as f32) / 255.0
}
_ => 0.0,
};
let (x, y) = (l(a), l(b));
let (hi, lo) = if x > y { (x, y) } else { (y, x) };
(hi + 0.05) / (lo + 0.05)
}
#[test]
fn meter_far_end_is_alarm_coloured_before_the_value_reaches_it() {
let t = theme::active();
let ramps = Ramps::from_theme(&t);
let mut b = buf(10, 1);
meter(&mut b, 0, 0, 10, 0.3, &ramps.load, t.text_muted);
assert_eq!(row(&b, 0), "■■■·······");
let mut full = buf(10, 1);
meter(&mut full, 0, 0, 10, 1.0, &ramps.load, t.text_muted);
assert_ne!(b[(2, 0)].fg, full[(9, 0)].fg);
}
#[test]
fn meter_of_width_one_does_not_divide_by_zero() {
let t = theme::active();
let ramps = Ramps::from_theme(&t);
let mut b = buf(1, 1);
meter(&mut b, 0, 0, 1, 0.5, &ramps.load, t.text_muted);
assert_eq!(ch(&b, 0, 0), '■');
}
#[test]
fn panel_carries_its_metadata_in_the_border() {
let t = theme::active();
let mut b = buf(40, 3);
let inner = panel(
&mut b,
Rect::new(0, 0, 40, 3),
&t,
&PanelOpts {
key: Some("1"),
title: Some("cpu"),
right: Some("up 4d"),
..Default::default()
},
);
let top = row(&b, 0);
assert!(top.starts_with("╭─┤1├─┤ cpu ├"), "got {top:?}");
assert!(top.contains("┤ up 4d ├"), "got {top:?}");
assert!(top.ends_with('╮'), "got {top:?}");
assert_eq!(inner, Rect::new(1, 1, 38, 1));
}
#[test]
fn palette_theme_ramps_step_without_inventing_rgb() {
let t = theme::by_name("terminal");
let r = Ramps::from_theme(&t);
for ramp in [&r.primary, &r.secondary, &r.load, &r.dim] {
let seen: Vec<Color> = (0..=10).map(|i| ramp.at(i as f32 / 10.0)).collect();
assert!(
seen.iter().any(|c| *c != seen[0]),
"ramp collapsed to a single colour on a palette theme"
);
assert!(
!seen.iter().any(|c| matches!(c, Color::Rgb(..))),
"palette theme must never emit synthesised RGB, got {seen:?}"
);
}
assert_eq!(r.load.at(0.0), t.status_good);
assert_eq!(r.load.at(1.0), t.status_error);
}
#[test]
fn rgb_theme_ramps_interpolate() {
let t = theme::by_name("dark");
let r = Ramps::from_theme(&t);
let stops: Vec<Color> = (0..=8).map(|i| r.primary.at(i as f32 / 8.0)).collect();
let distinct: std::collections::BTreeSet<String> =
stops.iter().map(|c| format!("{c:?}")).collect();
assert!(distinct.len() >= 7, "expected a smooth ramp, got {stops:?}");
}
#[test]
fn secondary_ramp_follows_the_accent_not_tx_rate() {
let t = theme::by_name("dark");
let r = Ramps::from_theme(&t);
assert_ne!(r.secondary.at(0.5), t.tx_rate);
assert_eq!(r.secondary.at(0.5), t.brand);
}
#[test]
fn perceptual_is_monotonic_and_lifts_the_low_end() {
assert!(perceptual(0.01, 8) <= perceptual(0.18, 8));
assert!(perceptual(0.18, 8) < perceptual(0.9, 8));
assert_ne!(perceptual(0.01, 8), perceptual(0.18, 8));
}
}