//! 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: `window_ticks` (= graph_secs / poll_ms) samples span //! the full sub-col width, right-pinned (newest at the right edge). When //! the window holds more samples than sub-cols, each sub-col shows the //! MAX of its bucket — spikes survive decimation. When the window is //! shorter than the width, each sample stretches across multiple sub-cols //! and the left side stays blank until history accrues. Consecutive //! sub-cols are gap-filled vertically so steps read as a connected line. 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, /// 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 + '_ { 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 (used by tests; the braille renderer /// does its own sub-pixel mapping). #[allow(dead_code)] fn levels(samples: &[f64], min: f64, max: f64, rows: usize) -> Vec { // 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 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> { 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, _window_ticks: usize, min: f64, max: f64, color: Color, marker_row: Option, ) -> 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 = 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. 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![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 = 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 (first-blank-wins, as before). 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 = (0..rows) .map(|r| { let mut spans: Vec = 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 = 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); 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('⡇')); } }