josie / alder-tools

//! Braille sub-pixel plot. A `Ring` is a fixed-capacity sample buffer;
//! the rightmost drawn column is the newest. Each terminal cell is split
//! into a 2×4 grid of braille dots (U+2800 block), so a 120-col box
//! renders 240 horizontal × (4×rows) vertical sub-pixels — the highest
//! resolution a TUI offers. No hysteresis, no EMA: raw samples map
//! directly to sub-pixel positions so small jitters read.
//!
//! Window mapping (btop rule): 1:1 — one sample per sub-col, newest at
//! the right edge, NO decimation and NO hysteresis, so middle-of-trace
//! dots never change as the window scrolls (only the right edge wiggles
//! as new samples arrive). The visible span is therefore `sub_w ×
//! poll_ms` (sub_w = 2 × inner width); `graph_secs` sizes the ring for
//! peaks/scale only, it does NOT stretch the view. History shorter than
//! the width leaves the left side blank until it accrues. Consecutive
//! sub-cols are gap-filled vertically so steps read as a connected line.

use ratatui::{
    layout::Rect,
    style::{Color, Style},
    text::{Line, Span},
    Frame,
};

/// 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 to cell rows (test helper for the value→row math;
/// the braille renderer does its own sub-pixel mapping).
#[cfg(test)]
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()
}

/// Map the newest `sub_w` samples onto `sub_w` sub-columns, 1:1,
/// right-pinned (newest at the right edge). Returns one Option<f64> per
/// sub-col (None = blank, history not yet accrued on the left). No
/// decimation, no stretch — each sample occupies exactly one sub-col so
/// middle-of-trace dots never change as the window scrolls (only the
/// right edge wiggles as new samples arrive). This is the btop rule:
/// stable scrolling requires giving up configurable window-as-visible-
/// span; the visible window is `sub_w × poll_ms`, period. Older history
/// stays in the ring for the peak counter and scale.
fn map_to_subcols(samples: &[f64], sub_w: usize) -> Vec<Option<f64>> {
    let n = samples.len();
    let mut out = vec![None; sub_w];
    if n == 0 || sub_w == 0 {
        return out;
    }
    // Right-align: if we have fewer samples than sub-cols, the left side
    // stays blank until history accrues. If we have more (ring holds a
    // longer history than the visible width), take the newest sub_w.
    let start = n.saturating_sub(sub_w);
    let avail = n - start;
    let offset = sub_w - avail;
    for i in 0..avail {
        out[offset + i] = Some(samples[start + i]);
    }
    out
}

/// Braille dot bit for a sub-pixel position within a cell. Unicode
/// braille (U+2800) bit layout:
///   col 0 (sub_col%2==0): 0x01 (row 0), 0x02 (row 1), 0x04 (row 2),
///                         0x40 (row 3)
///   col 1 (sub_col%2==1): 0x08 (row 0), 0x10 (row 1), 0x20 (row 2),
///                         0x80 (row 3)
/// Rows 0..3 map to TOP..BOTTOM within the cell. (Rows 6,7 of the 8-dot
/// braille cell are 0x40/0x80 — left/right bottom dots.)
fn braille_dot(sub_row: usize, sub_col: usize) -> u8 {
    let row = sub_row % 4;
    let col = sub_col % 2;
    // Unicode braille bit indices (0..8):
    //   col 0 rows 0,1,2 → bits 0,1,2
    //   col 1 rows 0,1,2 → bits 3,4,5
    //   col 0 row 3      → bit 6
    //   col 1 row 3      → bit 7
    let bit = if row < 3 { col * 3 + row } else { 6 + col };
    1u8 << bit
}

/// Accumulate one series' braille dots into a `rows × cols` cell grid.
/// Samples map 1:1 onto sub-columns (newest at the right edge); consecutive
/// filled sub-cols gap-fill vertically toward the previous sample so steps
/// read as a connected line. Returns the grid and the rightmost filled
/// sub-col (0 = nothing drawn).
fn series_bits(
    samples: &[f64],
    sub_w: usize,
    rows: usize,
    cols: usize,
    to_sub_row: &impl Fn(f64) -> usize,
) -> (Vec<Vec<u8>>, usize) {
    let col_vals = map_to_subcols(samples, sub_w);
    let mut bits = vec![vec![0u8; cols]; rows];
    let set = |sub_row: usize, sub_col: usize, bits: &mut Vec<Vec<u8>>| {
        let cr = sub_row / 4;
        let cc = sub_col / 2;
        if cr < rows && cc < cols {
            bits[cr][cc] |= braille_dot(sub_row, sub_col);
        }
    };
    let mut prev_row: Option<usize> = None;
    let mut rightmost_sub = 0usize;
    for (sx, &cv) in col_vals.iter().enumerate() {
        let Some(v) = cv.filter(|v| v.is_finite()) else {
            prev_row = None; // gap in history / missing read; restart after it
            continue;
        };
        let sr = to_sub_row(v);
        rightmost_sub = sx;
        // Fill the vertical span between the previous sample and this one
        // in THIS column so the step reads as happening at the new sample.
        match prev_row {
            Some(pr) if sr > pr => {
                for r in pr..=sr {
                    set(r, sx, &mut bits);
                }
            }
            Some(pr) if sr < pr => {
                for r in sr..=pr {
                    set(r, sx, &mut bits);
                }
            }
            _ => set(sr, sx, &mut bits),
        }
        prev_row = Some(sr);
    }
    (bits, rightmost_sub)
}

/// Braille sub-pixel draw. One sample per sub-col (2 per cell), newest at
/// the right edge — NO decimation, NO hysteresis. The 4-level vertical
/// quantization (sub-rows within each cell) is the noise floor: sub-row
/// jitter maps to the same sub-row and doesn't move the dot, so middle-of-
/// trace dots never change as the window scrolls (only the right edge
/// wiggles as new samples arrive). Visible window = sub_w × poll_ms.
/// Consecutive sub-cols gap-fill vertically so steps read as a connected
/// line. `overlay` draws a second series over the SAME bounds in its own
/// color (VID vs vCore); where both occupy one cell the primary wins — a
/// braille glyph holds a single color. Returns the number of CELL columns
/// occupied by either trace (0 = nothing drawn); the caller uses it to
/// right-align the peak text.
#[allow(clippy::too_many_arguments)]
pub fn render(
    f: &mut Frame,
    area: Rect,
    ring: &Ring,
    min: f64,
    max: f64,
    color: Color,
    marker_row: Option<u16>,
    overlay: Option<(&Ring, Color)>,
) -> u16 {
    if area.width < 4 || area.height < 2 || max <= min {
        return 0;
    }
    let rows = area.height as usize;
    let cols = area.width as usize;
    let sub_h = rows * 4;
    let sub_w = cols * 2;
    let span = max - min;

    // Sub-row (0=top=max) for a value, mapped over the full [0, sub_h-1]
    // band. The caller (ui.rs pad()) adds scale headroom in auto mode so
    // the trace stays clear of the borders; in fixed mode values at the
    // bounds correctly sit at the box edge. No hysteresis — the 4-level
    // sub-row quantization is the noise floor (sub-row jitter maps to the
    // same sub-row and doesn't move the dot).
    let to_sub_row = |v: f64| -> usize {
        let cont = (1.0 - (v - min) / span).clamp(0.0, 1.0) * (sub_h - 1) as f64;
        cont.round() as usize
    };

    // 1:1 — take the newest sub_w samples (or fewer if history is short).
    let primary: Vec<f64> = ring.samples_window(sub_w).collect();
    let (base_bits, base_right) = series_bits(&primary, sub_w, rows, cols, &to_sub_row);
    let mut rightmost_sub = base_right;

    let (overlay_bits, overlay_st) = match overlay {
        Some((r, c)) => {
            let s: Vec<f64> = r.samples_window(sub_w).collect();
            let (b, rs) = series_bits(&s, sub_w, rows, cols, &to_sub_row);
            rightmost_sub = rightmost_sub.max(rs);
            (Some(b), Some(Style::default().fg(c)))
        }
        None => (None, None),
    };
    let drawn = |grid: &Option<Vec<Vec<u8>>>| match grid {
        None => false,
        Some(b) => b.iter().any(|row| row.iter().any(|&c| c != 0)),
    };
    let have_base = base_bits.iter().any(|row| row.iter().any(|&b| b != 0));
    if !have_base && !drawn(&overlay_bits) {
        return 0;
    }

    // Marker row: red '┄' on blank cells, in the caller's [0, rows-1]
    // cell-row (no padding — the caller's scale headroom keeps it clear
    // of the borders in auto mode; in fixed mode a limit at the bound
    // correctly sits at the box edge).
    let marker_cell_row = marker_row.map(|m| (m as usize).min(rows - 1));

    let st = Style::default().fg(color);
    let marker_st = Style::default().fg(Color::Red);
    let lines: Vec<Line> = (0..rows)
        .map(|r| {
            let mut spans: Vec<Span> = Vec::with_capacity(cols);
            for cc in 0..cols {
                let base = base_bits[r][cc];
                let over = overlay_bits.as_ref().map_or(0, |b| b[r][cc]);
                let (bits, style) = if base != 0 {
                    (base, st)
                } else if over != 0 {
                    (over, overlay_st.unwrap_or(st))
                } else if marker_cell_row == Some(r) {
                    spans.push(Span::styled("┄".to_string(), marker_st));
                    continue;
                } else {
                    spans.push(Span::raw(" "));
                    continue;
                };
                spans.push(Span::styled(
                    char::from_u32(0x2800 + bits as u32).unwrap().to_string(),
                    style,
                ));
            }
            Line::from(spans)
        })
        .collect();

    f.render_widget(ratatui::text::Text::from(lines), area);

    // Cell columns occupied: round the rightmost filled sub-col up to a
    // cell boundary, +1 to convert index→count.
    (rightmost_sub / 2 + 1) as u16
}

/// 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::*;
    use ratatui::backend::TestBackend;
    use ratatui::Terminal;

    /// Render one ring (+ optional overlay) into a TestBackend and return
    /// the buffer so tests can inspect glyphs/colors.
    fn render_to_buf(
        w: u16,
        h: u16,
        ring: &Ring,
        overlay: Option<(&Ring, Color)>,
    ) -> ratatui::buffer::Buffer {
        let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
        t.draw(|f| {
            render(
                f,
                Rect::new(0, 0, w, h),
                ring,
                0.0,
                1.0,
                Color::Green,
                None,
                overlay,
            );
        })
        .unwrap();
        t.backend().buffer().clone()
    }

    #[test]
    fn series_bits_breaks_line_at_nan_gap() {
        // 2 cells = 4 sub-cols; sample 2 is NaN → its sub-col stays blank
        // and the line does not connect across the gap.
        let to_row = |v: f64| if v > 0.5 { 0 } else { 3 };
        let (bits, right) = series_bits(&[1.0, 1.0, f64::NAN, 1.0], 4, 1, 2, &to_row);
        assert_eq!(bits[0][0] & 0x01, 0x01); // sub-col 0 row 0 (left cell)
        assert_eq!(bits[0][0] & 0x08, 0x08); // sub-col 1 row 0 (left cell)
        assert_eq!(bits[0][1] & 0x01, 0x00); // sub-col 2 blank (gap)
        assert_eq!(bits[0][1] & 0x08, 0x08); // sub-col 3 row 0 (right cell)
        assert_eq!(right, 3);
    }

    #[test]
    fn overlay_draws_in_its_own_color_and_shared_scale() {
        let mut base = Ring::new(8);
        let mut over = Ring::new(8);
        // VID (overlay) above vCore (base) on a 0..1 scale: base row lower
        // (0.25), overlay row upper (0.75).
        for _ in 0..4 {
            base.push(0.25);
            over.push(0.75);
        }
        let buf = render_to_buf(8, 4, &base, Some((&over, Color::Blue)));
        let colored = |c: Color| buf.content().iter().filter(|cell| cell.fg == c).count();
        assert!(colored(Color::Green) > 0, "base trace drawn green");
        assert!(colored(Color::Blue) > 0, "overlay trace drawn blue");
    }

    #[test]
    fn overlay_renders_when_primary_ring_is_empty() {
        // The vCore box may have VID history before any vCore sample.
        let base = Ring::new(8);
        let mut over = Ring::new(8);
        over.push(0.5);
        let cols = {
            let mut t = Terminal::new(TestBackend::new(8, 4)).unwrap();
            let mut n = 0;
            t.draw(|f| {
                n = render(
                    f,
                    Rect::new(0, 0, 8, 4),
                    &base,
                    0.0,
                    1.0,
                    Color::Green,
                    None,
                    Some((&over, Color::Blue)),
                );
            })
            .unwrap();
            n
        };
        assert!(cols > 0, "overlay alone still returns occupied columns");
    }

    #[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 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);
    }

    #[test]
    fn map_1to1_fills_width_when_history_full() {
        // 8 samples, 8 sub-cols → 1:1, right-aligned (n == sub_w).
        let s = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
        let out = map_to_subcols(&s, 8);
        assert_eq!(
            out,
            vec![
                Some(1.0),
                Some(2.0),
                Some(3.0),
                Some(4.0),
                Some(5.0),
                Some(6.0),
                Some(7.0),
                Some(8.0)
            ]
        );
    }

    #[test]
    fn map_1to1_right_aligns_partial_history() {
        // 3 samples accrued, 8 sub-cols → right-aligned, left side blank.
        let s = vec![10.0, 20.0, 30.0];
        let out = map_to_subcols(&s, 8);
        let mut expected = vec![None; 8];
        expected[5] = Some(10.0);
        expected[6] = Some(20.0);
        expected[7] = Some(30.0);
        assert_eq!(out, expected);
    }

    #[test]
    fn map_1to1_drops_oldest_when_history_exceeds_width() {
        // 10 samples, 6 sub-cols → take the newest 6 (drop oldest 4),
        // 1:1. This is the stable-scrolling case: as new samples arrive,
        // the oldest visible sample scrolls off the left; middle dots
        // never change.
        let s = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
        let out = map_to_subcols(&s, 6);
        assert_eq!(
            out,
            vec![
                Some(5.0),
                Some(6.0),
                Some(7.0),
                Some(8.0),
                Some(9.0),
                Some(10.0)
            ]
        );
    }

    #[test]
    fn braille_dot_bits() {
        // Unicode braille (U+2800) bit layout — verified against btop's
        // symbol table (references/btop/src/btop_draw.cpp:90-96):
        //   col 0 rows 0..3 → 0x01, 0x02, 0x04, 0x40
        //   col 1 rows 0..3 → 0x08, 0x10, 0x20, 0x80
        assert_eq!(braille_dot(0, 0), 0x01);
        assert_eq!(braille_dot(1, 0), 0x02);
        assert_eq!(braille_dot(2, 0), 0x04);
        assert_eq!(braille_dot(3, 0), 0x40);
        assert_eq!(braille_dot(0, 1), 0x08);
        assert_eq!(braille_dot(1, 1), 0x10);
        assert_eq!(braille_dot(2, 1), 0x20);
        assert_eq!(braille_dot(3, 1), 0x80);
        // Sub-row wraps within the cell (sub_row 4 = row 0 of next cell).
        assert_eq!(braille_dot(4, 0), 0x01);
        // Cross-check: a full column (col 0, all 4 rows) = 0x01|0x02|0x04|
        // 0x40 = 0x47 = "⡇"; btop's braille_up table row 4 col 0 is "⡇".
        let full_col0 =
            braille_dot(0, 0) | braille_dot(1, 0) | braille_dot(2, 0) | braille_dot(3, 0);
        assert_eq!(full_col0, 0x47);
        assert_eq!(char::from_u32(0x2800 + full_col0 as u32), Some('⡇'));
    }
}