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}