//! 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<f64>,
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<Item = f64> + '_ {
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<usize> {
// 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 corner convention for one column where the level steps from
/// `prev` to `now` (row numbers; smaller = higher on screen):
/// falling value (line steps down screen): `╮` at prev (open left,
/// toward the incoming line) + `╰` at now (open right, toward the
/// outgoing line); rising value: `╭` at now + `╯` at prev.
fn step_glyphs(prev: usize, now: usize) -> (usize, usize, char, char) {
if prev < now {
(prev, now, '╮', '╰') // falling on screen
} else {
(now, prev, '╭', '╯') // rising on screen
}
}
pub fn render(f: &mut Frame, area: Rect, ring: &Ring, window_ticks: usize, max: f64, color: Color, marker_row: Option<u16>) {
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<f64> = 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<f64> = if per_col >= 2 {
samples
.chunks(samples.len().div_ceil(cols))
.map(|b| b.iter().sum::<f64>() / 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 {
// nvtop corner convention: a step connects the previous row
// (open side toward the incoming line) to the new row (open
// side toward the outgoing line).
// falling value = line steps DOWN screen (prev above, new
// below): top cell '╮' opens left, bottom cell '╭' opens
// right. Rising value (line steps UP screen): top '╭',
// bottom '╯'.
let (top, bot, top_ch, bot_ch) = step_glyphs(prev_l, l);
set(top, c, top_ch, st);
set(bot, c, bot_ch, 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<Line> = buf
.into_iter()
.map(|row| {
Line::from(
row.into_iter()
.map(|(ch, sty)| Span::styled(ch.to_string(), sty))
.collect::<Vec<_>>(),
)
})
.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 falling_step_uses_drop_glyphs() {
// prev row 1, now row 3 = value fell -> '╮' top, '╰' bottom
assert_eq!(step_glyphs(1, 3), (1, 3, '╮', '╰'));
// prev row 3, now row 1 = value rose -> '╭' top, '╯' bottom
assert_eq!(step_glyphs(3, 1), (1, 3, '╭', '╯'));
}
#[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<f64> = r.samples_window(4).collect();
assert_eq!(got, vec![3.0, 4.0, 5.0, 6.0]);
let got2: Vec<f64> = r.samples_window(2).collect();
assert_eq!(got2, vec![5.0, 6.0]);
assert_eq!(r.max_window(2), 6.0);
}
}