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
}

/// 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. Returns the number of CELL columns the trace occupied (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>,
) -> 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;

    // 1:1 — take the newest sub_w samples (or fewer if history is short).
    let samples: Vec<f64> = ring.samples_window(sub_w).collect();
    if samples.is_empty() {
        return 0;
    }
    let col_vals = map_to_subcols(&samples, sub_w);

    // 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
    };

    // Accumulate braille bits per cell. cells[row][col] = u8; 0 = blank.
    let mut bits: Vec<Vec<u8>> = vec![vec![0u8; cols]; rows];
    let mut set_dot = |sub_row: usize, sub_col: usize| {
        let cr = sub_row / 4;
        let cc = sub_col / 2;
        if cr < rows && cc < cols {
            bits[cr][cc] |= braille_dot(sub_row, sub_col);
        }
    };

    // Plot each filled sub-col, gap-filling vertically toward the previous
    // filled sub-col so steps read as a connected line.
    let mut prev_row: Option<usize> = None;
    let mut rightmost_sub: usize = 0;
    for (sx, &cv) in col_vals.iter().enumerate() {
        let Some(v) = cv else {
            prev_row = None; // gap in history; restart the line after it
            continue;
        };
        let sr = to_sub_row(v);
        rightmost_sub = sx;
        if let Some(pr) = prev_row {
            // Fill the vertical span between pr and sr in THIS column so
            // the step reads as happening at the new sample.
            if sr > pr {
                for r in pr..=sr {
                    set_dot(r, sx);
                }
            } else if sr < pr {
                for r in sr..=pr {
                    set_dot(r, sx);
                }
            } else {
                set_dot(sr, sx);
            }
        } else {
            set_dot(sr, sx);
        }
        prev_row = Some(sr);
    }

    // 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 &b in bits[r].iter().take(cols) {
                if b != 0 {
                    spans.push(Span::styled(
                        char::from_u32(0x2800 + b as u32).unwrap().to_string(),
                        st,
                    ));
                } else if marker_cell_row == Some(r) {
                    spans.push(Span::styled("┄".to_string(), marker_st));
                } else {
                    spans.push(Span::raw(" "));
                }
            }
            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.
    if rightmost_sub == 0 && col_vals[0].is_none() {
        0
    } else {
        (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::*;

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