unicode-plot 0.1.0

unicode-plot-rs: Unicode terminal plotting library for Rust
Documentation
use crate::canvas::{AxisTransform, Canvas, CanvasCore, Scale, Transform2D};
use crate::color::CanvasColor;

const X_PIXELS_PER_CHAR: usize = 1;
const Y_PIXELS_PER_CHAR: usize = 2;
const DOT_BITS: [[u8; Y_PIXELS_PER_CHAR]; X_PIXELS_PER_CHAR] = [[0b10, 0b01]];
const DOT_DECODE: [char; 4] = [' ', '.', '\'', ':'];

/// A 1x2 pixel-per-character canvas using dot/punctuation characters.
#[derive(Debug, Clone, PartialEq)]
pub struct DotCanvas {
    core: CanvasCore<u8>,
}

impl DotCanvas {
    /// Creates a dot canvas with identity axis scales.
    ///
    /// # Panics
    ///
    /// Panics when either axis transform cannot be constructed. This occurs if
    /// plot spans are non-positive, pixel dimensions are zero, or origins are
    /// non-finite.
    #[must_use]
    pub fn new(
        char_width: usize,
        char_height: usize,
        origin_x: f64,
        origin_y: f64,
        plot_width: f64,
        plot_height: f64,
    ) -> Self {
        let pixel_width = char_width.saturating_mul(X_PIXELS_PER_CHAR);
        let pixel_height = char_height.saturating_mul(Y_PIXELS_PER_CHAR);
        let x = AxisTransform::new(origin_x, plot_width, pixel_width, Scale::Identity, false)
            .expect("DotCanvas::new requires finite x-origin, positive span, and non-zero width");
        let y = AxisTransform::new(origin_y, plot_height, pixel_height, Scale::Identity, true)
            .expect("DotCanvas::new requires finite y-origin, positive span, and non-zero height");

        Self {
            core: CanvasCore::new(
                char_width,
                char_height,
                pixel_width,
                pixel_height,
                Transform2D::new(x, y),
            ),
        }
    }

    #[must_use]
    #[cfg(test)]
    pub(crate) fn print_row(&self, row: usize, color: bool) -> String {
        if row >= self.char_height() {
            return String::new();
        }

        let mut out = String::new();
        for col in 0..self.char_width() {
            super::write_colored_cell(
                &mut out,
                self.glyph_at(col, row),
                self.color_at(col, row),
                color,
            );
        }

        out
    }

    #[must_use]
    #[cfg(test)]
    pub(crate) fn print(&self, color: bool) -> String {
        let mut out = String::new();
        for row in 0..self.char_height() {
            for col in 0..self.char_width() {
                super::write_colored_cell(
                    &mut out,
                    self.glyph_at(col, row),
                    self.color_at(col, row),
                    color,
                );
            }
            if row + 1 < self.char_height() {
                out.push('\n');
            }
        }

        out
    }

    fn cell_index(&self, col: usize, row: usize) -> Option<usize> {
        if col >= self.core.char_width || row >= self.core.char_height {
            return None;
        }

        Some(row * self.core.char_width + col)
    }
}

impl Canvas for DotCanvas {
    fn pixel(&mut self, x: usize, y: usize, color: CanvasColor) {
        if x > self.pixel_width() || y > self.pixel_height() {
            return;
        }

        let clamped_x = if x < self.pixel_width() {
            x
        } else {
            x.saturating_sub(1)
        };
        let clamped_y = if y < self.pixel_height() {
            y
        } else {
            y.saturating_sub(1)
        };

        let col = clamped_x / X_PIXELS_PER_CHAR;
        let row = clamped_y / Y_PIXELS_PER_CHAR;
        let x_off = 0;
        let y_off = clamped_y % Y_PIXELS_PER_CHAR;

        let Some(index) = self.cell_index(col, row) else {
            return;
        };

        self.core.grid[index] |= DOT_BITS[x_off][y_off];
        self.core.colors[index] |= color.as_u8();
    }

    fn glyph_at(&self, col: usize, row: usize) -> char {
        self.cell_index(col, row)
            .and_then(|index| DOT_DECODE.get(usize::from(self.core.grid[index])).copied())
            .unwrap_or(' ')
    }

    fn color_at(&self, col: usize, row: usize) -> CanvasColor {
        self.cell_index(col, row)
            .and_then(|index| CanvasColor::new(self.core.colors[index]))
            .unwrap_or(CanvasColor::NORMAL)
    }

    fn char_width(&self) -> usize {
        self.core.char_width
    }

    fn char_height(&self) -> usize {
        self.core.char_height
    }

    fn pixel_width(&self) -> usize {
        self.core.pixel_width
    }

    fn pixel_height(&self) -> usize {
        self.core.pixel_height
    }

    fn transform(&self) -> &Transform2D {
        &self.core.transform
    }

    fn transform_mut(&mut self) -> &mut Transform2D {
        &mut self.core.transform
    }
}

#[cfg(test)]
mod tests {
    use super::DotCanvas;
    use crate::canvas::{Canvas, draw_reference_canvas_scene, render_canvas_show};
    use crate::color::CanvasColor;
    use crate::test_util::assert_fixture_eq;

    fn fixture_canvas() -> DotCanvas {
        let mut canvas = DotCanvas::new(40, 10, 0.0, 0.0, 1.0, 1.0);
        draw_reference_canvas_scene(&mut canvas);
        canvas
    }

    #[test]
    fn print_row_matches_fixture() {
        let canvas = fixture_canvas();
        let actual = canvas.print_row(2, true);

        assert_fixture_eq(&actual, "tests/fixtures/canvas/dot_printrow.txt");
    }

    #[test]
    fn print_matches_fixture_with_color() {
        let canvas = fixture_canvas();
        let actual = canvas.print(true);

        assert_fixture_eq(&actual, "tests/fixtures/canvas/dot_print.txt");
    }

    #[test]
    fn print_matches_fixture_without_color() {
        let canvas = fixture_canvas();
        let actual = canvas.print(false);

        assert_fixture_eq(&actual, "tests/fixtures/canvas/dot_print_nocolor.txt");
    }

    #[test]
    fn show_matches_fixture_with_color() {
        let canvas = fixture_canvas();
        let actual = render_canvas_show(canvas, true);

        assert_fixture_eq(&actual, "tests/fixtures/canvas/dot_show.txt");
    }

    #[test]
    fn show_matches_fixture_without_color() {
        let canvas = fixture_canvas();
        let actual = render_canvas_show(canvas, false);

        assert_fixture_eq(&actual, "tests/fixtures/canvas/dot_show_nocolor.txt");
    }

    #[test]
    fn dot_pixel_encoding_matches_lookup_table() {
        let mut top = DotCanvas::new(1, 1, 0.0, 0.0, 1.0, 1.0);
        top.pixel(0, 0, CanvasColor::GREEN);
        assert_eq!(top.glyph_at(0, 0), '\'');

        let mut bottom = DotCanvas::new(1, 1, 0.0, 0.0, 1.0, 1.0);
        bottom.pixel(0, 1, CanvasColor::GREEN);
        assert_eq!(bottom.glyph_at(0, 0), '.');

        let mut both = DotCanvas::new(1, 1, 0.0, 0.0, 1.0, 1.0);
        both.pixel(0, 0, CanvasColor::GREEN);
        both.pixel(0, 1, CanvasColor::GREEN);
        assert_eq!(both.glyph_at(0, 0), ':');
    }

    #[test]
    fn dot_pixel_ors_color_bits_on_overlaps() {
        let mut canvas = DotCanvas::new(1, 1, 0.0, 0.0, 1.0, 1.0);
        canvas.pixel(0, 0, CanvasColor::BLUE);
        canvas.pixel(0, 0, CanvasColor::RED);

        assert_eq!(canvas.color_at(0, 0), CanvasColor::MAGENTA);
    }

    #[test]
    fn dot_pixel_clamps_upper_bounds_and_rejects_out_of_bounds() {
        let mut canvas = DotCanvas::new(1, 1, 0.0, 0.0, 1.0, 1.0);
        canvas.pixel(1, 2, CanvasColor::YELLOW);
        assert_eq!(canvas.glyph_at(0, 0), '.');

        canvas.pixel(2, 1, CanvasColor::WHITE);
        assert_eq!(canvas.glyph_at(0, 0), '.');
    }
}