//! 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. //! Style: continuous staircase (level `─`, vertical `│` joins — no corner //! glyphs; they masked peaks), y-axis labels on the left, time axis on the //! bottom (oldest left → 0s right), like nvtop's plot panes. 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() } /// nvtop draw: y-axis labels overlay the left edge (e.g. 100/75/50/25/0), /// the staircase occupies the full area, time labels sit on the bottom row /// (rightmost = "0s", leftward = elapsed seconds of the window). pub fn render( f: &mut Frame, area: Rect, ring: &Ring, window_ticks: usize, poll_ms: u64, max: f64, color: Color, marker_row: Option, ) { if area.width < 8 || area.height < 3 || max <= 0.0 { return; } let rows = area.height as usize; let cols = area.width as usize; // One sample per column, newest at the right; when the window holds // more samples than columns, stride-skip (take every Nth) so the whole // window is visible. Striding preserves steps better than averaging. let samples: Vec = ring.samples_window(window_ticks).collect(); if samples.is_empty() { return; } let stride = samples.len().div_ceil(cols); let shown: Vec = if stride > 1 { samples.iter().rev().step_by(stride).rev().cloned().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() { set(l, c, '─', st); if c > 0 { // Vertical join between the previous level and this one so a // step is continuous (staircase, nvtop-style). let prev = lvls[c - 1]; let (top, bot) = if prev < l { (prev, l) } else { (l, prev) }; for r in top..=bot.min(top + rows) { 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)); } } } // ---- axis labels (overlay, drawn last so they win blanks) ---- let axis_st = Style::default().fg(Color::DarkGray); // y-axis: max and half-max at top/left and mid-left. let y_labels = [ (0usize, format!("{:.0}", max)), (rows / 2, format!("{:.0}", max / 2.0)), ]; for (r, label) in y_labels { if r < rows { for (i, ch) in label.chars().take(4).enumerate() { if buf[r][i].0 == ' ' { buf[r][i] = (ch, axis_st); } } } } // x-axis on the bottom row: window length in seconds at the left, // "0s" at the right (nvtop: seconds count down to now). let total_s = (window_ticks as u64 * poll_ms / 1000).max(1); let t_label = format!("{}s", total_s); for (i, ch) in t_label.chars().enumerate() { if buf[rows - 1][i].0 == ' ' { buf[rows - 1][i] = (ch, axis_st); } } for (i, ch) in "0s".chars().enumerate() { let c = cols - 2 + i; if c < cols && buf[rows - 1][c].0 == ' ' { buf[rows - 1][c] = (ch, axis_st); } } 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); } }