use std::time::{Duration, Instant};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::widgets::{Block, Borders, Widget};
use crate::history::History;
use crate::model::{GpuSample, MetricKind};
pub struct BrailleChart<'a> {
pub history: &'a History,
pub metric: MetricKind,
pub now: Instant,
pub window: Duration,
}
impl Widget for BrailleChart<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
let latest = self.history.latest();
let scale = scale_for(self.history, self.metric, latest, self.now, self.window);
let title = chart_title(
self.metric,
latest.and_then(|s| self.metric.value(s)),
scale,
);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
.title(title);
let inner = block.inner(area);
block.render(area, buf);
if inner.width < 4 || inner.height < 2 {
return;
}
let label_width = if inner.width >= 18 { 7 } else { 0 };
if label_width > 0 {
write_str(
buf,
inner.x,
inner.y,
&format_axis(scale.1, self.metric.unit()),
Style::default().fg(Color::DarkGray),
label_width,
);
write_str(
buf,
inner.x,
inner.y + inner.height.saturating_sub(1),
&format_axis(scale.0, self.metric.unit()),
Style::default().fg(Color::DarkGray),
label_width,
);
}
let graph_area = Rect {
x: inner.x + label_width,
y: inner.y,
width: inner.width.saturating_sub(label_width),
height: inner.height,
};
draw_braille_series(
buf,
graph_area,
self.history.iter_window(self.now, self.window),
self.metric,
self.now,
self.window,
scale,
metric_color(self.metric),
);
}
}
fn chart_title(metric: MetricKind, latest: Option<f64>, scale: (f64, f64)) -> String {
let value = latest
.map(|v| format!("{v:>5.1}{}", metric.unit()))
.unwrap_or_else(|| " n/a".to_owned());
format!(
" {} {} [{:.0}-{:.0}] ",
metric.title(),
value,
scale.0,
scale.1
)
}
fn format_axis(value: f64, unit: &str) -> String {
if value >= 1000.0 {
format!("{:>4.0}k{unit}", value / 1000.0)
} else {
format!("{value:>5.0}{unit}")
}
}
fn metric_color(metric: MetricKind) -> Color {
match metric {
MetricKind::GpuUtil => Color::Cyan,
MetricKind::MemUtil => Color::Green,
MetricKind::VramUsed => Color::Yellow,
MetricKind::Power => Color::Magenta,
MetricKind::Temperature => Color::Red,
MetricKind::Fan => Color::Blue,
}
}
fn scale_for(
history: &History,
metric: MetricKind,
latest: Option<&GpuSample>,
now: Instant,
window: Duration,
) -> (f64, f64) {
if let Some(range) = metric.fixed_range(latest) {
return range;
}
let mut max_seen: f64 = 0.0;
for sample in history.iter_window(now, window) {
if let Some(value) = metric.value(sample) {
max_seen = max_seen.max(value);
}
}
let max = nice_ceiling((max_seen * 1.15).max(1.0));
(0.0, max)
}
fn nice_ceiling(value: f64) -> f64 {
if value <= 10.0 {
return 10.0;
}
let magnitude = 10_f64.powf(value.log10().floor());
let normalized = value / magnitude;
let rounded = if normalized <= 2.0 {
2.0
} else if normalized <= 5.0 {
5.0
} else {
10.0
};
rounded * magnitude
}
#[allow(clippy::too_many_arguments)]
fn draw_braille_series<'a>(
buf: &mut Buffer,
area: Rect,
samples: impl Iterator<Item = &'a GpuSample>,
metric: MetricKind,
now: Instant,
window: Duration,
scale: (f64, f64),
color: Color,
) {
if area.width == 0 || area.height == 0 {
return;
}
let pixel_width = area.width as usize * 2;
let pixel_height = area.height as usize * 4;
if pixel_width == 0 || pixel_height == 0 {
return;
}
let mut bins = vec![Bin::default(); pixel_width];
let window_secs = window.as_secs_f64().max(0.001);
let value_span = (scale.1 - scale.0).max(f64::EPSILON);
for sample in samples {
let Some(value) = metric.value(sample) else {
continue;
};
let age = if sample.at <= now {
now.duration_since(sample.at).as_secs_f64()
} else {
0.0
};
if age > window_secs {
continue;
}
let x =
((1.0 - age / window_secs) * (pixel_width.saturating_sub(1)) as f64).round() as usize;
let normalized = ((value - scale.0) / value_span).clamp(0.0, 1.0);
let y = ((1.0 - normalized) * (pixel_height.saturating_sub(1)) as f64).round() as usize;
bins[x.min(pixel_width - 1)].add(y.min(pixel_height - 1));
}
let mut pixels = vec![0_u8; area.width as usize * area.height as usize];
let mut previous = None;
for (x, bin) in bins.iter().enumerate() {
if bin.count == 0 {
continue;
}
for y in bin.min_y..=bin.max_y {
set_pixel(&mut pixels, area.width as usize, x, y);
}
let y = (bin.sum_y / bin.count as f64).round() as usize;
if let Some((prev_x, prev_y)) = previous {
draw_line(&mut pixels, area.width as usize, prev_x, prev_y, x, y);
}
previous = Some((x, y));
}
for cell_y in 0..area.height as usize {
for cell_x in 0..area.width as usize {
let mask = pixels[cell_y * area.width as usize + cell_x];
let symbol = if mask == 0 {
" ".to_owned()
} else {
char::from_u32(0x2800 + mask as u32)
.unwrap_or(' ')
.to_string()
};
buf[(area.x + cell_x as u16, area.y + cell_y as u16)]
.set_symbol(&symbol)
.set_fg(color);
}
}
}
#[derive(Debug, Clone, Copy)]
struct Bin {
min_y: usize,
max_y: usize,
sum_y: f64,
count: usize,
}
impl Default for Bin {
fn default() -> Self {
Self {
min_y: usize::MAX,
max_y: 0,
sum_y: 0.0,
count: 0,
}
}
}
impl Bin {
fn add(&mut self, y: usize) {
self.min_y = self.min_y.min(y);
self.max_y = self.max_y.max(y);
self.sum_y += y as f64;
self.count += 1;
}
}
fn draw_line(pixels: &mut [u8], width_cells: usize, x0: usize, y0: usize, x1: usize, y1: usize) {
let mut x0 = x0 as isize;
let mut y0 = y0 as isize;
let x1 = x1 as isize;
let y1 = y1 as isize;
let dx = (x1 - x0).abs();
let sx = if x0 < x1 { 1 } else { -1 };
let dy = -(y1 - y0).abs();
let sy = if y0 < y1 { 1 } else { -1 };
let mut err = dx + dy;
loop {
set_pixel(pixels, width_cells, x0 as usize, y0 as usize);
if x0 == x1 && y0 == y1 {
break;
}
let e2 = 2 * err;
if e2 >= dy {
err += dy;
x0 += sx;
}
if e2 <= dx {
err += dx;
y0 += sy;
}
}
}
fn set_pixel(pixels: &mut [u8], width_cells: usize, px: usize, py: usize) {
let cell_x = px / 2;
let cell_y = py / 4;
let cell_index = cell_y * width_cells + cell_x;
if cell_index >= pixels.len() {
return;
}
let sub_x = px % 2;
let sub_y = py % 4;
pixels[cell_index] |= braille_bit(sub_x, sub_y);
}
fn braille_bit(sub_x: usize, sub_y: usize) -> u8 {
match (sub_x, sub_y) {
(0, 0) => 0b0000_0001,
(0, 1) => 0b0000_0010,
(0, 2) => 0b0000_0100,
(0, 3) => 0b0100_0000,
(1, 0) => 0b0000_1000,
(1, 1) => 0b0001_0000,
(1, 2) => 0b0010_0000,
(1, 3) => 0b1000_0000,
_ => 0,
}
}
fn write_str(buf: &mut Buffer, x: u16, y: u16, value: &str, style: Style, width: u16) {
for (offset, ch) in value.chars().take(width as usize).enumerate() {
buf[(x + offset as u16, y)].set_char(ch).set_style(style);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn braille_bits_match_unicode_layout() {
assert_eq!(braille_bit(0, 0), 0x01);
assert_eq!(braille_bit(1, 0), 0x08);
assert_eq!(braille_bit(0, 3), 0x40);
assert_eq!(braille_bit(1, 3), 0x80);
}
#[test]
fn nice_ceiling_uses_readable_steps() {
assert_eq!(nice_ceiling(9.0), 10.0);
assert_eq!(nice_ceiling(19.0), 20.0);
assert_eq!(nice_ceiling(21.0), 50.0);
assert_eq!(nice_ceiling(501.0), 1000.0);
}
}