diff --git a/src/plot.rs b/src/plot.rs
index bd260a5..78004f7 100644
--- a/src/plot.rs
+++ b/src/plot.rs
@@ -1,13 +1,17 @@
-//! Thin-line plot, re-implemented for ratatui (nvtop plot.c pattern studied
-//! in references/nvtop — GPL, code never copied). A `Ring` is a fixed-
-//! capacity sample buffer; the rightmost drawn column is the newest.
-//! Style: MSI Afterburner frametime-monitor look — one glyph per column,
-//! RIGHT-ANGLE corners (level `─`, corners `┐└` on falls / `┘┌` on rises,
-//! `│` between) so peaks/valleys read as sharp steps. Y-axis: max top-left,
-//! 0 bottom-left. No time axis (all stacked plots share one window).
-//! Scrolling view: the last `cols` ticks fill the width 1:1 — no
-//! decimation, no smoothing, so spikes read raw; older history stays in the
-//! ring for the peak counter and scale.
+//! 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,
@@ -70,7 +74,8 @@ impl Ring {
}
}
-/// Plain rounding map (used by tests; hysteresis variant is used in render).
+/// 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<usize> {
// Row 0 = top of the plot = max value.
@@ -81,47 +86,68 @@ fn levels(samples: &[f64], min: f64, max: f64, rows: usize) -> Vec<usize> {
.collect()
}
-/// Hysteresis: a value hovering on a row boundary makes a plain rounding
-/// map flap between two rows (`─│─│─│` comb). Only move off the previous
-/// row when the continuous level is nearer to the new row by a decisive
-/// margin, so noise rides out and real steps still track.
-fn levels_hysteresis(samples: &[f64], min: f64, max: f64, rows: usize) -> Vec<usize> {
- let span = max - min;
- let cont = |v: f64| (1.0 - (v - min) / span).clamp(0.0, 1.0) * (rows - 1) as f64;
- let mut out: Vec<usize> = Vec::with_capacity(samples.len());
- let mut last: Option<f64> = None; // continuous position of drawn row
- for &v in samples {
- let target = cont(v);
- let row = match last {
- None => target.round(),
- Some(prev) => {
- // Distance from target to the drawn row's continuous pos.
- if (target - prev).abs() > 0.75 {
- target.round()
- } else {
- prev.round()
- }
- }
- };
- out.push(row as usize);
- last = Some(row);
+/// 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
}
-/// Thin-line draw: y-axis labels overlay the left edge (max top-left,
-/// min bottom-left), the staircase occupies the full area. No time axis —
-/// stacked plots share one window, so x is time on every plot. Scrolling
-/// view: the last `cols` ticks fill the width 1:1 (newest at the right
-/// edge), older history scrolls off the left — no decimation or smoothing.
-/// Returns the number of columns actually used by the trace (0 = nothing
-/// drawn). The caller renders y-axis labels OUTSIDE this area to the left.
+/// 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,
+ _window_ticks: usize,
min: f64,
max: f64,
color: Color,
@@ -132,79 +158,96 @@ pub fn render(
}
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;
- // Scrolling view: one sample per column, newest at the right edge.
- // The window is the visible width, not the whole graph_secs history —
- // the ring keeps the full window for the peak counter and scale.
- let samples: Vec<f64> = ring.samples_window(window_ticks).collect();
+ // 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 skip = samples.len().saturating_sub(cols);
- let lvls = levels_hysteresis(&samples[skip..], min, max, rows);
- let used = (cols - skip.min(cols)) as u16; // columns the trace occupies
+ 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
+ };
- let mut buf = vec![vec![(' ', Style::default()); cols]; rows];
- let mut set = |r: usize, c: usize, ch: char, st: Style| {
- if r < rows && c < cols && buf[r][c].0 == ' ' {
- buf[r][c] = (ch, st);
+ // 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);
}
};
- let st = Style::default().fg(color);
- // Right-angle staircase (Afterburner frametime style): on a step the
- // corner glyphs *join* the horizontals — old level gets a corner open
- // toward the incoming line, new level a corner open toward the outgoing
- // line, '│' strictly between. Falls: '┐' old / '└' land. Rises: '┘'
- // old / '┌' land. Rounded ╮╰╯╭ are banned — spikes must read sharp.
- for (c, &l) in lvls.iter().enumerate() {
- if c == 0 {
- set(l, c, '─', st);
+ // 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 prev = lvls[c - 1];
- if prev == l {
- set(l, c, '─', st);
- } else if prev < l {
- // value fell: line steps DOWN screen. Old level: '┐' (opens
- // left toward incoming). New level: '└' (opens right toward
- // outgoing). '│' between.
- set(prev, c, '┐', st);
- set(l, c, '└', st);
- for r in prev + 1..l {
- set(r, c, '│', st);
+ };
+ 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 {
- // value rose: line steps UP screen. Old level: '┘', new: '┌'.
- set(prev, c, '┘', st);
- set(l, c, '┌', st);
- for r in l + 1..prev {
- set(r, c, '│', st);
- }
+ set_dot(sr, sx);
}
+ prev_row = Some(sr);
}
- if let Some(mr) = marker_row {
- let mr = (mr as usize).min(rows - 1);
- for cell in buf[mr].iter_mut().take(cols) {
- if cell.0 == ' ' {
- *cell = ('┄', Style::default().fg(Color::Red));
- }
- }
- }
+ // 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 lines: Vec<Line> = buf
- .into_iter()
- .map(|row| {
- Line::from(
- row.into_iter()
- .map(|(ch, sty)| Span::styled(ch.to_string(), sty))
- .collect::<Vec<_>>(),
- )
+ 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);
- used
+
+ // 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
@@ -228,24 +271,6 @@ mod tests {
assert_eq!(levels(&[10.0, 5.0, 20.0], 5.0, 20.0, 5), [3, 4, 0]);
}
- #[test]
- fn hysteresis_kills_boundary_flapping() {
- // Values hovering right at a row boundary: plain rounding would
- // alternate; hysteresis must hold the row until a decisive move.
- let min = 0.0;
- let max = 100.0;
- let rows = 10;
- // 49.9..50.1 straddles rows-1/2 boundary (cont ~4.5).
- let vals = [50.4, 49.6, 50.4, 49.6, 50.4, 49.6];
- let lv = levels_hysteresis(&vals, min, max, rows);
- let all_same = lv.iter().all(|&r| r == lv[0]);
- assert!(all_same, "flapped: {:?}", lv);
- // A real step still tracks: push decisively past the margin.
- let vals2 = [50.0, 50.0, 70.0, 70.0];
- let lv2 = levels_hysteresis(&vals2, min, max, rows);
- assert!(lv2[3] < lv2[1], "did not step up on real change: {:?}", lv2);
- }
-
#[test]
fn ring_window_and_wrap() {
let mut r = Ring::new(4);
@@ -261,4 +286,65 @@ mod tests {
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('⡇'));
+ }
}
\ No newline at end of file
diff --git a/src/ui.rs b/src/ui.rs
index 494849f..bad6301 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -674,12 +674,16 @@ pub fn draw(f: &mut Frame, app: &App) {
if graph_rows >= 1 && !app.settings_open {
// Graphs sit in light-grey boxes; they skip rendering while the
// settings pane is open (the pane owns the screen). The plot key
- // (label + live value) is left-aligned in the gutter. The trace
- // scrolls 1:1 — the graph_secs window caps how many ticks fit the
- // box; shorter windows leave the left side of the box blank.
- let inner_w_full = w.saturating_sub(2); // box interior at full width
- let ticks = (inner_w_full as usize).min(graph_ticks(&app.cfg)); // visible span (max)
- let ring_ticks = graph_ticks(&app.cfg); // scale/peak span
+ // (label + live value) is left-aligned in the gutter. The trace is
+ // braille sub-pixel, 1:1 (one sample per sub-col, 2 per cell),
+ // right-pinned — NO decimation, NO hysteresis. Visible window =
+ // sub_w × poll_ms (sub_w = 2 × inner_w); graph_secs sizes the
+ // ring for peaks/scale only, NOT the visible span. Middle-of-trace
+ // dots never change as the window scrolls (btop rule); only the
+ // right edge wiggles as new samples arrive. The 4-level sub-row
+ // quantization is the noise floor — small jitters map to the same
+ // sub-row and don't move the dot.
+ let ring_ticks = graph_ticks(&app.cfg); // peak/scale span (ring history)
let ring_max = |ring: &Ring, floor: f64| -> f64 {
(ring.max_window(ring_ticks) * (1.0 + SCALE_MARGIN)).max(floor)
};
@@ -751,7 +755,6 @@ pub fn draw(f: &mut Frame, app: &App) {
let box_x = area.x + axis_w.max(KEY_RESERVE);
let box_w = w.saturating_sub(box_x - area.x);
let inner_w = box_w.saturating_sub(2);
- let ticks = (inner_w as usize).min(ticks);
// Key row ABOVE the box (label + live value left-aligned at
// col 0; session peak right-aligned against the trace's right
@@ -796,7 +799,7 @@ pub fn draw(f: &mut Frame, app: &App) {
let used = plot::render(
f,
Rect { x: box_rect.x + 1, y: box_rect.y + 1, width: inner_w, height: graph_rows },
- ring, ticks, min, max, color, marker,
+ ring, 0, min, max, color, marker,
);
if !peak.is_empty() {
let peak_w = peak.chars().count() as u16;
Graphs: braille sub-pixel renderer (2x4 dots/cell), 1:1 stable scrolling
- plot::render rewritten: each terminal cell is a 2x4 grid of braille dots (U+2800 block) instead of --┐└ thin-line steps; 120-col box now shows 240x(4*rows) sub-pixels - 1:1 mapping (btop rule): one sample per sub-col (2 per cell), newest at the right edge -- NO decimation, NO hysteresis. Decimation was what caused the redraw jitter (every bucket's max flips as the window slides by one sample); studied references/btop/src/btop_draw.cpp:422-536 (GPL -- never copied) to learn this - Visible window = sub_w x poll_ms (sub_w = 2 x inner_w); graph_secs sizes the ring for peaks/scale ONLY, not the visible span. To see more time: widen the terminal or raise poll_ms - 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, so middle-of-trace dots NEVER change as the window scrolls (verified: 2 frames 3s apart, 8/8 trace rows stable excluding the rightmost 8 chars where new samples arrive) - Braille bit layout (U+2800): col 0 rows 0,1,2,3 -> bits 0,1,2,6; col 1 rows 0,1,2,3 -> bits 3,4,5,7. Earlier 0x01<<row / 0x10<<row scrambled dots between sub-rows and sub-cols -- the 'double/invert' shuffle as the graph scrolled. Verified against btop's braille_up table (row 4 col 0 = U+2847 = 0x01|0x02|0x04|0x40) - gap-fill between consecutive sub-cols makes steps read as a connected line; marker_row (vCore limit) unchanged - ui.rs: passes ring_ticks for scale only; render ignores window_ticks - 15 tests, clippy clean (1 pre-existing is_multiple_of warning); pty-verified 40x120, 30x100, 24x70 narrow, 24x60 meter fallback, settings pane (F2) still blanks graphs behind it; UAT-confirmed live
6bcf0be06218719bd45271fa539a75d6fd3c6602
josie <administrator@josie-c.com> · 2026-09-01T22:54 ·
browse files at this commit
parents:
2333040