//! 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, 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 (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 { // 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 } /// 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>, 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>| { 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 = 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, 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 = 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 = 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>>| 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 = (0..rows) .map(|r| { let mut spans: Vec = 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 = 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('⡇')); } }