gpu-histop 0.1.0

High-resolution GPU history monitor for NVIDIA, AMDGPU, and Apple Silicon
Documentation
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);
    }
}