josie / alder-tools

//! Thin-line plot, re-implemented for ratatui (nvtop plot.c pattern studied
//! in references/nvtop — GPL, code never copied). A `Ring` is a fixed-
//! capacity sample buffer; the rightmost drawn column is the newest.
//! Style: MSI Afterburner frametime-monitor look — one glyph per column,
//! RIGHT-ANGLE corners (level `─`, corners `┐└` on falls / `┘┌` on rises,
//! `│` between) so peaks/valleys read as sharp steps. Y-axis: max top-left,
//! 0 bottom-left. No time axis (all stacked plots share one window).
//! Scrolling view: the last `cols` ticks fill the width 1:1 — no
//! decimation, no smoothing, so spikes read raw; older history stays in the
//! ring for the peak counter and scale.

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,
    /// All-time (session) maximum pushed.
    peak: f64,
}

impl Ring {
    pub fn new(capacity: usize) -> Ring {
        Ring {
            data: vec![0.0; capacity],
            start: 0,
            len: 0,
            peak: f64::MIN,
        }
    }

    pub fn push(&mut self, v: f64) {
        let cap = self.data.len();
        let end = (self.start + self.len) % cap;
        self.data[end] = v;
        if v > self.peak {
            self.peak = v;
        }
        if self.len < cap {
            self.len += 1;
        } else {
            self.start = (self.start + 1) % cap;
        }
    }

    /// Session all-time max (f64::MIN before the first push).
    pub fn peak(&self) -> f64 {
        self.peak
    }

    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)
    }

    pub fn min_window(&self, window_ticks: usize) -> f64 {
        self.samples_window(window_ticks).fold(f64::MAX, f64::min)
    }
}

/// Plain rounding map (used by tests; hysteresis variant is used in render).
#[allow(dead_code)]
fn levels(samples: &[f64], min: f64, max: f64, rows: usize) -> Vec<usize> {
    // Row 0 = top of the plot = max value.
    let span = max - min;
    samples
        .iter()
        .map(|&v| (((1.0 - (v - min) / span).clamp(0.0, 1.0)) * (rows - 1) as f64).round() as usize)
        .collect()
}

/// Hysteresis: a value hovering on a row boundary makes a plain rounding
/// map flap between two rows (`─│─│─│` comb). Only move off the previous
/// row when the continuous level is nearer to the new row by a decisive
/// margin, so noise rides out and real steps still track.
fn levels_hysteresis(samples: &[f64], min: f64, max: f64, rows: usize) -> Vec<usize> {
    let span = max - min;
    let cont = |v: f64| (1.0 - (v - min) / span).clamp(0.0, 1.0) * (rows - 1) as f64;
    let mut out: Vec<usize> = Vec::with_capacity(samples.len());
    let mut last: Option<f64> = None; // continuous position of drawn row
    for &v in samples {
        let target = cont(v);
        let row = match last {
            None => target.round(),
            Some(prev) => {
                // Distance from target to the drawn row's continuous pos.
                if (target - prev).abs() > 0.75 {
                    target.round()
                } else {
                    prev.round()
                }
            }
        };
        out.push(row as usize);
        last = Some(row);
    }
    out
}

/// Thin-line draw: y-axis labels overlay the left edge (max top-left,
/// min bottom-left), the staircase occupies the full area. No time axis —
/// stacked plots share one window, so x is time on every plot. Scrolling
/// view: the last `cols` ticks fill the width 1:1 (newest at the right
/// edge), older history scrolls off the left — no decimation or smoothing.
/// Returns the number of columns actually used by the trace (0 = nothing
/// drawn). The caller renders y-axis labels OUTSIDE this area to the left.
#[allow(clippy::too_many_arguments)]
pub fn render(
    f: &mut Frame,
    area: Rect,
    ring: &Ring,
    window_ticks: usize,
    min: f64,
    max: f64,
    color: Color,
    marker_row: Option<u16>,
) -> u16 {
    if area.width < 4 || area.height < 2 || max <= min {
        return 0;
    }
    let rows = area.height as usize;
    let cols = area.width as usize;

    // Scrolling view: one sample per column, newest at the right edge.
    // The window is the visible width, not the whole graph_secs history —
    // the ring keeps the full window for the peak counter and scale.
    let samples: Vec<f64> = ring.samples_window(window_ticks).collect();
    if samples.is_empty() {
        return 0;
    }
    let skip = samples.len().saturating_sub(cols);
    let lvls = levels_hysteresis(&samples[skip..], min, max, rows);
    let used = (cols - skip.min(cols)) as u16; // columns the trace occupies

    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);
    // Right-angle staircase (Afterburner frametime style): on a step the
    // corner glyphs *join* the horizontals — old level gets a corner open
    // toward the incoming line, new level a corner open toward the outgoing
    // line, '│' strictly between. Falls: '┐' old / '└' land. Rises: '┘'
    // old / '┌' land. Rounded ╮╰╯╭ are banned — spikes must read sharp.
    for (c, &l) in lvls.iter().enumerate() {
        if c == 0 {
            set(l, c, '─', st);
            continue;
        }
        let prev = lvls[c - 1];
        if prev == l {
            set(l, c, '─', st);
        } else if prev < l {
            // value fell: line steps DOWN screen. Old level: '┐' (opens
            // left toward incoming). New level: '└' (opens right toward
            // outgoing). '│' between.
            set(prev, c, '┐', st);
            set(l, c, '└', st);
            for r in prev + 1..l {
                set(r, c, '│', st);
            }
        } else {
            // value rose: line steps UP screen. Old level: '┘', new: '┌'.
            set(prev, c, '┘', st);
            set(l, c, '┌', st);
            for r in l + 1..prev {
                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);
    used
}

/// Compact axis label: integers stay short ("200", "0"), fractional values
/// keep up to 3 decimals, trimmed ("0.5", "1.25").
pub fn fmt_axis(v: f64) -> String {
    if v.fract() == 0.0 {
        return format!("{v:.0}");
    }
    let s = format!("{v:.3}");
    s.trim_end_matches('0').trim_end_matches('.').to_string()
}
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn levels_map_value_to_rows() {
        assert_eq!(levels(&[10.0, 0.0], 0.0, 10.0, 5), [0, 4]);
        assert_eq!(levels(&[5.0], 0.0, 10.0, 5), [2]);
        // Non-zero floor: min sits at the bottom row, span maps over min..max.
        assert_eq!(levels(&[10.0, 5.0, 20.0], 5.0, 20.0, 5), [3, 4, 0]);
    }

    #[test]
    fn hysteresis_kills_boundary_flapping() {
        // Values hovering right at a row boundary: plain rounding would
        // alternate; hysteresis must hold the row until a decisive move.
        let min = 0.0;
        let max = 100.0;
        let rows = 10;
        // 49.9..50.1 straddles rows-1/2 boundary (cont ~4.5).
        let vals = [50.4, 49.6, 50.4, 49.6, 50.4, 49.6];
        let lv = levels_hysteresis(&vals, min, max, rows);
        let all_same = lv.iter().all(|&r| r == lv[0]);
        assert!(all_same, "flapped: {:?}", lv);
        // A real step still tracks: push decisively past the margin.
        let vals2 = [50.0, 50.0, 70.0, 70.0];
        let lv2 = levels_hysteresis(&vals2, min, max, rows);
        assert!(lv2[3] < lv2[1], "did not step up on real change: {:?}", lv2);
    }

    #[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);
        assert_eq!(r.min_window(2), 5.0);
        assert_eq!(r.min_window(4), 3.0);
    }
}