htoprs 0.5.3

A faithful Rust port of htop — the interactive process viewer
Documentation
//! Shared glyph rendering for #1 (sparkline column) and #7 (history graph).
//!
//! htoprs already defines the braille dot tables at `src/ported/meter.rs:902`
//! for `GraphMeterMode`; this is the extension-side reusable renderer. Two
//! outputs from one place:
//! - [`spark`]: a single-row block-element sparkline (`▁▂▃▄▅▆▇█`).
//! - [`Canvas`]: a 2x4-dots-per-cell braille bitmap for real graphs.

/// Block elements by height, index 0..=8 (0 = blank).
const BLOCKS: [char; 9] = [' ', '', '', '', '', '', '', '', ''];

/// One-row sparkline of `values`, one glyph per value, scaled to `max`.
///
/// `max <= 0` renders all-blank (no data / flat zero).
pub fn spark(values: &[f32], max: f32) -> String {
    if max <= 0.0 {
        return " ".repeat(values.len());
    }
    values
        .iter()
        .map(|&v| {
            let frac = (v / max).clamp(0.0, 1.0);
            let level = (frac * 8.0).round() as usize;
            BLOCKS[level.min(8)]
        })
        .collect()
}

/// Render `values` (oldest→newest) as `height_cells` braille rows of
/// `width_cells` columns via [`Canvas`]: 2 samples per cell column, bars
/// growing up from the bottom, the newest sample anchored at the right edge,
/// scaled to `max`. `max <= 0` renders all-blank. This is the shared graph
/// renderer behind both the `G` history graph ([`crate::extensions::graph::Scalar::render`])
/// and the per-PID CPU sparklines, so they share one look.
pub fn graph_rows(
    values: &[f64],
    width_cells: usize,
    height_cells: usize,
    max: f64,
) -> Vec<String> {
    let w_dots = width_cells.max(1) * 2;
    let h_dots = height_cells.max(1) * 4;
    let mut cv = Canvas::new(w_dots, h_dots);

    // Keep only the newest `w_dots` samples and right-align them so the latest
    // sits at the right edge (older history scrolls left as it ages).
    let n = values.len().min(w_dots);
    let start = values.len() - n;
    let col_off = w_dots - n;
    for (i, &v) in values[start..].iter().enumerate() {
        let frac = if max > 0.0 {
            (v / max).clamp(0.0, 1.0)
        } else {
            0.0
        };
        let filled = (frac * h_dots as f64).round() as usize;
        for up in 0..filled {
            let y = h_dots - 1 - up; // bottom-anchored
            cv.set(col_off + i, y);
        }
    }
    cv.rows()
}

/// A braille dot bitmap. Dot resolution is `2 * w` wide by `4 * h` tall.
pub struct Canvas {
    /// width in cells
    w: usize,
    /// height in cells
    h: usize,
    cells: Vec<u8>,
}

impl Canvas {
    /// Canvas holding at least `w_dots` x `h_dots` dots (rounded up to cells).
    pub fn new(w_dots: usize, h_dots: usize) -> Self {
        let w = w_dots.div_ceil(2).max(1);
        let h = h_dots.div_ceil(4).max(1);
        Canvas {
            w,
            h,
            cells: vec![0u8; w * h],
        }
    }

    /// Light the dot at `(x, y)` (dot coordinates, origin top-left).
    pub fn set(&mut self, x: usize, y: usize) {
        let cx = x / 2;
        let cy = y / 4;
        if cx >= self.w || cy >= self.h {
            return;
        }
        // Unicode braille bit layout, [row][col]:
        //   dot1 dot4      0x01 0x08
        //   dot2 dot5  ->  0x02 0x10
        //   dot3 dot6      0x04 0x20
        //   dot7 dot8      0x40 0x80
        const MAP: [[u8; 2]; 4] = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]];
        self.cells[cy * self.w + cx] |= MAP[y % 4][x % 2];
    }

    /// Render top-to-bottom as `h` strings of `w` braille chars each.
    pub fn rows(&self) -> Vec<String> {
        (0..self.h)
            .map(|cy| {
                (0..self.w)
                    .map(|cx| {
                        let bits = self.cells[cy * self.w + cx] as u32;
                        char::from_u32(0x2800 + bits).unwrap_or('?')
                    })
                    .collect()
            })
            .collect()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn spark_scales_full_range() {
        let s = spark(&[0.0, 50.0, 100.0], 100.0);
        let chars: Vec<char> = s.chars().collect();
        assert_eq!(chars[0], ' ');
        assert_eq!(chars[2], '');
        assert_eq!(chars.len(), 3);
    }

    #[test]
    fn spark_no_data_is_blank() {
        assert_eq!(spark(&[1.0, 2.0], 0.0), "  ");
    }

    #[test]
    fn canvas_top_left_dot_is_2801() {
        let mut c = Canvas::new(2, 4);
        c.set(0, 0);
        assert_eq!(c.rows()[0].chars().next().unwrap(), '\u{2801}');
    }

    #[test]
    fn canvas_bottom_right_dot() {
        let mut c = Canvas::new(2, 4);
        c.set(1, 3); // dot8 -> 0x80
        assert_eq!(c.rows()[0].chars().next().unwrap(), '\u{2880}');
    }

    #[test]
    fn graph_rows_right_aligns_newest_when_underfilled() {
        // Two samples into a 4-cell (8-dot) row: they must land at the right
        // edge (newest at the rightmost cell), leaving the left cells blank.
        let rows = graph_rows(&[100.0, 100.0], 4, 1, 100.0);
        assert_eq!(rows.len(), 1);
        let cells: Vec<char> = rows[0].chars().collect();
        assert_eq!(cells.len(), 4);
        assert_eq!(cells[0], '\u{2800}'); // blank left
        assert_eq!(cells[1], '\u{2800}');
        assert_ne!(cells[3], '\u{2800}'); // newest at the right edge
    }

    #[test]
    fn graph_rows_zero_max_is_blank() {
        let rows = graph_rows(&[50.0, 90.0], 3, 1, 0.0);
        assert!(rows[0].chars().all(|c| c == '\u{2800}'));
    }

    #[test]
    fn canvas_out_of_bounds_is_ignored() {
        let mut c = Canvas::new(2, 4);
        c.set(99, 99);
        assert_eq!(c.rows()[0], "\u{2800}");
    }
}