//! nvtop-style line plot, re-implemented for ratatui (pattern studied in //! references/nvtop/src/plot.c — GPL, code never copied). A `Ring` is a //! fixed-capacity sample buffer; the rightmost drawn column is the newest. //! Cell glyphs: level `─`, rises/falls get corner glyphs, multi-row jumps //! get `│` between them. use ratatui::{ Frame, layout::Rect, style::{Color, Style}, text::{Line, Span}, }; /// Ring of samples, oldest first. Capacity is sized for the configured /// graph window at the current poll period (min 512); plots clip to the /// visible width anyway. pub struct Ring { data: Vec, start: usize, len: usize, } impl Ring { pub fn new(capacity: usize) -> Ring { Ring { data: vec![0.0; capacity], start: 0, len: 0, } } pub fn push(&mut self, v: f64) { let cap = self.data.len(); let end = (self.start + self.len) % cap; self.data[end] = v; if self.len < cap { self.len += 1; } else { self.start = (self.start + 1) % cap; } } pub fn samples_window(&self, window_ticks: usize) -> impl Iterator + '_ { let skip = self.len.saturating_sub(window_ticks); (skip..self.len).map(move |i| self.data[(self.start + i) % self.data.len()]) } pub fn max_window(&self, window_ticks: usize) -> f64 { self.samples_window(window_ticks).fold(f64::MIN, f64::max) } } fn levels(samples: &[f64], max: f64, rows: usize) -> Vec { // Row 0 = top of the plot = max value. samples .iter() .map(|&v| (((1.0 - v / max).clamp(0.0, 1.0)) * (rows - 1) as f64).round() as usize) .collect() } pub fn render(f: &mut Frame, area: Rect, ring: &Ring, window_ticks: usize, max: f64, color: Color, marker_row: Option) { if area.width == 0 || area.height == 0 || max <= 0.0 { return; } let rows = area.height as usize; let cols = area.width as usize; let samples: Vec = ring.samples_window(window_ticks).collect(); if samples.is_empty() { return; } // Bucket the window across the plot width (mean per column): the whole // graph_secs window maps to `cols` columns, and per-tick jitter within // a bucket averages out instead of drawing corner squiggles. let per_col = samples.len() / cols; let shown: Vec = if per_col >= 2 { samples .chunks(samples.len().div_ceil(cols)) .map(|b| b.iter().sum::() / b.len() as f64) .collect() } else { samples }; let skip = shown.len().saturating_sub(cols); let lvls = levels(&shown[skip..], max, rows); let mut buf = vec![vec![(' ', Style::default()); cols]; rows]; let mut set = |r: usize, c: usize, ch: char, st: Style| { if r < rows && c < cols && buf[r][c].0 == ' ' { buf[r][c] = (ch, st); } }; let st = Style::default().fg(color); for (c, &l) in lvls.iter().enumerate() { let prev_l = if c == 0 { l } else { lvls[c - 1] }; if prev_l == l { set(l, c, '─', st); } else { // Flat-step style (no rounded corners — they masked peaks): // both stepped rows get level dashes, verticals between. let (top, bot) = if prev_l < l { (prev_l, l) } else { (l, prev_l) }; set(top, c, '─', st); set(bot, c, '─', st); for r in top + 1..bot { set(r, c, '│', st); } } } if let Some(mr) = marker_row { let mr = (mr as usize).min(rows - 1); for cell in buf[mr].iter_mut().take(cols) { if cell.0 == ' ' { *cell = ('┄', Style::default().fg(Color::Red)); } } } let lines: Vec = buf .into_iter() .map(|row| { Line::from( row.into_iter() .map(|(ch, sty)| Span::styled(ch.to_string(), sty)) .collect::>(), ) }) .collect(); f.render_widget(ratatui::text::Text::from(lines), area); } #[cfg(test)] mod tests { use super::*; #[test] fn levels_map_value_to_rows() { assert_eq!(levels(&[10.0, 0.0], 10.0, 5), [0, 4]); assert_eq!(levels(&[5.0], 10.0, 5), [2]); } #[test] fn ring_window_and_wrap() { let mut r = Ring::new(4); for v in 1..=6 { r.push(v as f64); } // wrapped: holds 3..6 (capacity 4) let got: Vec = r.samples_window(4).collect(); assert_eq!(got, vec![3.0, 4.0, 5.0, 6.0]); let got2: Vec = r.samples_window(2).collect(); assert_eq!(got2, vec![5.0, 6.0]); assert_eq!(r.max_window(2), 6.0); } }