Skip to main content

gpu_histop/
chart.rs

1use std::time::{Duration, Instant};
2
3use ratatui::buffer::Buffer;
4use ratatui::layout::Rect;
5use ratatui::style::{Color, Style};
6use ratatui::widgets::{Block, Borders, Widget};
7
8use crate::history::History;
9use crate::model::{GpuSample, MetricKind};
10
11pub struct BrailleChart<'a> {
12    pub history: &'a History,
13    pub metric: MetricKind,
14    pub now: Instant,
15    pub window: Duration,
16}
17
18impl Widget for BrailleChart<'_> {
19    fn render(self, area: Rect, buf: &mut Buffer) {
20        if area.width == 0 || area.height == 0 {
21            return;
22        }
23
24        let latest = self.history.latest();
25        let scale = scale_for(self.history, self.metric, latest, self.now, self.window);
26        let title = chart_title(
27            self.metric,
28            latest.and_then(|s| self.metric.value(s)),
29            scale,
30        );
31        let block = Block::default()
32            .borders(Borders::ALL)
33            .border_style(Style::default().fg(Color::DarkGray))
34            .title(title);
35        let inner = block.inner(area);
36        block.render(area, buf);
37
38        if inner.width < 4 || inner.height < 2 {
39            return;
40        }
41
42        let label_width = if inner.width >= 18 { 7 } else { 0 };
43        if label_width > 0 {
44            write_str(
45                buf,
46                inner.x,
47                inner.y,
48                &format_axis(scale.1, self.metric.unit()),
49                Style::default().fg(Color::DarkGray),
50                label_width,
51            );
52            write_str(
53                buf,
54                inner.x,
55                inner.y + inner.height.saturating_sub(1),
56                &format_axis(scale.0, self.metric.unit()),
57                Style::default().fg(Color::DarkGray),
58                label_width,
59            );
60        }
61
62        let graph_area = Rect {
63            x: inner.x + label_width,
64            y: inner.y,
65            width: inner.width.saturating_sub(label_width),
66            height: inner.height,
67        };
68
69        draw_braille_series(
70            buf,
71            graph_area,
72            self.history.iter_window(self.now, self.window),
73            self.metric,
74            self.now,
75            self.window,
76            scale,
77            metric_color(self.metric),
78        );
79    }
80}
81
82fn chart_title(metric: MetricKind, latest: Option<f64>, scale: (f64, f64)) -> String {
83    let value = latest
84        .map(|v| format!("{v:>5.1}{}", metric.unit()))
85        .unwrap_or_else(|| "   n/a".to_owned());
86    format!(
87        " {} {} [{:.0}-{:.0}] ",
88        metric.title(),
89        value,
90        scale.0,
91        scale.1
92    )
93}
94
95fn format_axis(value: f64, unit: &str) -> String {
96    if value >= 1000.0 {
97        format!("{:>4.0}k{unit}", value / 1000.0)
98    } else {
99        format!("{value:>5.0}{unit}")
100    }
101}
102
103fn metric_color(metric: MetricKind) -> Color {
104    match metric {
105        MetricKind::GpuUtil => Color::Cyan,
106        MetricKind::MemUtil => Color::Green,
107        MetricKind::VramUsed => Color::Yellow,
108        MetricKind::Power => Color::Magenta,
109        MetricKind::Temperature => Color::Red,
110        MetricKind::Fan => Color::Blue,
111    }
112}
113
114fn scale_for(
115    history: &History,
116    metric: MetricKind,
117    latest: Option<&GpuSample>,
118    now: Instant,
119    window: Duration,
120) -> (f64, f64) {
121    if let Some(range) = metric.fixed_range(latest) {
122        return range;
123    }
124
125    let mut max_seen: f64 = 0.0;
126    for sample in history.iter_window(now, window) {
127        if let Some(value) = metric.value(sample) {
128            max_seen = max_seen.max(value);
129        }
130    }
131
132    let max = nice_ceiling((max_seen * 1.15).max(1.0));
133    (0.0, max)
134}
135
136fn nice_ceiling(value: f64) -> f64 {
137    if value <= 10.0 {
138        return 10.0;
139    }
140
141    let magnitude = 10_f64.powf(value.log10().floor());
142    let normalized = value / magnitude;
143    let rounded = if normalized <= 2.0 {
144        2.0
145    } else if normalized <= 5.0 {
146        5.0
147    } else {
148        10.0
149    };
150    rounded * magnitude
151}
152
153#[allow(clippy::too_many_arguments)]
154fn draw_braille_series<'a>(
155    buf: &mut Buffer,
156    area: Rect,
157    samples: impl Iterator<Item = &'a GpuSample>,
158    metric: MetricKind,
159    now: Instant,
160    window: Duration,
161    scale: (f64, f64),
162    color: Color,
163) {
164    if area.width == 0 || area.height == 0 {
165        return;
166    }
167
168    let pixel_width = area.width as usize * 2;
169    let pixel_height = area.height as usize * 4;
170    if pixel_width == 0 || pixel_height == 0 {
171        return;
172    }
173
174    let mut bins = vec![Bin::default(); pixel_width];
175    let window_secs = window.as_secs_f64().max(0.001);
176    let value_span = (scale.1 - scale.0).max(f64::EPSILON);
177
178    for sample in samples {
179        let Some(value) = metric.value(sample) else {
180            continue;
181        };
182        let age = if sample.at <= now {
183            now.duration_since(sample.at).as_secs_f64()
184        } else {
185            0.0
186        };
187        if age > window_secs {
188            continue;
189        }
190
191        let x =
192            ((1.0 - age / window_secs) * (pixel_width.saturating_sub(1)) as f64).round() as usize;
193        let normalized = ((value - scale.0) / value_span).clamp(0.0, 1.0);
194        let y = ((1.0 - normalized) * (pixel_height.saturating_sub(1)) as f64).round() as usize;
195        bins[x.min(pixel_width - 1)].add(y.min(pixel_height - 1));
196    }
197
198    let mut pixels = vec![0_u8; area.width as usize * area.height as usize];
199    let mut previous = None;
200    for (x, bin) in bins.iter().enumerate() {
201        if bin.count == 0 {
202            continue;
203        }
204
205        for y in bin.min_y..=bin.max_y {
206            set_pixel(&mut pixels, area.width as usize, x, y);
207        }
208
209        let y = (bin.sum_y / bin.count as f64).round() as usize;
210        if let Some((prev_x, prev_y)) = previous {
211            draw_line(&mut pixels, area.width as usize, prev_x, prev_y, x, y);
212        }
213        previous = Some((x, y));
214    }
215
216    for cell_y in 0..area.height as usize {
217        for cell_x in 0..area.width as usize {
218            let mask = pixels[cell_y * area.width as usize + cell_x];
219            let symbol = if mask == 0 {
220                " ".to_owned()
221            } else {
222                char::from_u32(0x2800 + mask as u32)
223                    .unwrap_or(' ')
224                    .to_string()
225            };
226            buf[(area.x + cell_x as u16, area.y + cell_y as u16)]
227                .set_symbol(&symbol)
228                .set_fg(color);
229        }
230    }
231}
232
233#[derive(Debug, Clone, Copy)]
234struct Bin {
235    min_y: usize,
236    max_y: usize,
237    sum_y: f64,
238    count: usize,
239}
240
241impl Default for Bin {
242    fn default() -> Self {
243        Self {
244            min_y: usize::MAX,
245            max_y: 0,
246            sum_y: 0.0,
247            count: 0,
248        }
249    }
250}
251
252impl Bin {
253    fn add(&mut self, y: usize) {
254        self.min_y = self.min_y.min(y);
255        self.max_y = self.max_y.max(y);
256        self.sum_y += y as f64;
257        self.count += 1;
258    }
259}
260
261fn draw_line(pixels: &mut [u8], width_cells: usize, x0: usize, y0: usize, x1: usize, y1: usize) {
262    let mut x0 = x0 as isize;
263    let mut y0 = y0 as isize;
264    let x1 = x1 as isize;
265    let y1 = y1 as isize;
266    let dx = (x1 - x0).abs();
267    let sx = if x0 < x1 { 1 } else { -1 };
268    let dy = -(y1 - y0).abs();
269    let sy = if y0 < y1 { 1 } else { -1 };
270    let mut err = dx + dy;
271
272    loop {
273        set_pixel(pixels, width_cells, x0 as usize, y0 as usize);
274        if x0 == x1 && y0 == y1 {
275            break;
276        }
277        let e2 = 2 * err;
278        if e2 >= dy {
279            err += dy;
280            x0 += sx;
281        }
282        if e2 <= dx {
283            err += dx;
284            y0 += sy;
285        }
286    }
287}
288
289fn set_pixel(pixels: &mut [u8], width_cells: usize, px: usize, py: usize) {
290    let cell_x = px / 2;
291    let cell_y = py / 4;
292    let cell_index = cell_y * width_cells + cell_x;
293    if cell_index >= pixels.len() {
294        return;
295    }
296
297    let sub_x = px % 2;
298    let sub_y = py % 4;
299    pixels[cell_index] |= braille_bit(sub_x, sub_y);
300}
301
302fn braille_bit(sub_x: usize, sub_y: usize) -> u8 {
303    match (sub_x, sub_y) {
304        (0, 0) => 0b0000_0001,
305        (0, 1) => 0b0000_0010,
306        (0, 2) => 0b0000_0100,
307        (0, 3) => 0b0100_0000,
308        (1, 0) => 0b0000_1000,
309        (1, 1) => 0b0001_0000,
310        (1, 2) => 0b0010_0000,
311        (1, 3) => 0b1000_0000,
312        _ => 0,
313    }
314}
315
316fn write_str(buf: &mut Buffer, x: u16, y: u16, value: &str, style: Style, width: u16) {
317    for (offset, ch) in value.chars().take(width as usize).enumerate() {
318        buf[(x + offset as u16, y)].set_char(ch).set_style(style);
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn braille_bits_match_unicode_layout() {
328        assert_eq!(braille_bit(0, 0), 0x01);
329        assert_eq!(braille_bit(1, 0), 0x08);
330        assert_eq!(braille_bit(0, 3), 0x40);
331        assert_eq!(braille_bit(1, 3), 0x80);
332    }
333
334    #[test]
335    fn nice_ceiling_uses_readable_steps() {
336        assert_eq!(nice_ceiling(9.0), 10.0);
337        assert_eq!(nice_ceiling(19.0), 20.0);
338        assert_eq!(nice_ceiling(21.0), 50.0);
339        assert_eq!(nice_ceiling(501.0), 1000.0);
340    }
341}