josie / alder-tools

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

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;