//! 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('⡇'));
}
}