Skip to main content

unicode_plot/canvas/
block.rs

1use crate::canvas::{AxisTransform, Canvas, CanvasCore, Scale, Transform2D};
2use crate::color::CanvasColor;
3
4const X_PIXELS_PER_CHAR: usize = 2;
5const Y_PIXELS_PER_CHAR: usize = 2;
6const BLOCK_BITS: [[u8; Y_PIXELS_PER_CHAR]; X_PIXELS_PER_CHAR] =
7    [[0b1000, 0b0010], [0b0100, 0b0001]];
8const BLOCK_DECODE: [char; 16] = [
9    ' ', '▗', '▖', '▄', '▝', '▐', '▞', '▟', '▘', '▚', '▌', '▙', '▀', '▜', '▛', '█',
10];
11
12/// A 2x2 pixel-per-character canvas using Unicode block elements.
13#[derive(Debug, Clone, PartialEq)]
14pub struct BlockCanvas {
15    core: CanvasCore<u8>,
16}
17
18impl BlockCanvas {
19    /// Creates a block canvas with identity axis scales.
20    ///
21    /// # Panics
22    ///
23    /// Panics when either axis transform cannot be constructed. This occurs if
24    /// plot spans are non-positive, pixel dimensions are zero, or origins are
25    /// non-finite.
26    #[must_use]
27    pub fn new(
28        char_width: usize,
29        char_height: usize,
30        origin_x: f64,
31        origin_y: f64,
32        plot_width: f64,
33        plot_height: f64,
34    ) -> Self {
35        let pixel_width = char_width.saturating_mul(X_PIXELS_PER_CHAR);
36        let pixel_height = char_height.saturating_mul(Y_PIXELS_PER_CHAR);
37        let x = AxisTransform::new(origin_x, plot_width, pixel_width, Scale::Identity, false)
38            .expect("BlockCanvas::new requires finite x-origin, positive span, and non-zero width");
39        let y = AxisTransform::new(origin_y, plot_height, pixel_height, Scale::Identity, true)
40            .expect(
41                "BlockCanvas::new requires finite y-origin, positive span, and non-zero height",
42            );
43
44        Self {
45            core: CanvasCore::new(
46                char_width,
47                char_height,
48                pixel_width,
49                pixel_height,
50                Transform2D::new(x, y),
51            ),
52        }
53    }
54
55    #[must_use]
56    #[cfg(test)]
57    pub(crate) fn print_row(&self, row: usize, color: bool) -> String {
58        if row >= self.char_height() {
59            return String::new();
60        }
61
62        let mut out = String::new();
63        for col in 0..self.char_width() {
64            super::write_colored_cell(
65                &mut out,
66                self.glyph_at(col, row),
67                self.color_at(col, row),
68                color,
69            );
70        }
71
72        out
73    }
74
75    #[must_use]
76    #[cfg(test)]
77    pub(crate) fn print(&self, color: bool) -> String {
78        let mut out = String::new();
79        for row in 0..self.char_height() {
80            for col in 0..self.char_width() {
81                super::write_colored_cell(
82                    &mut out,
83                    self.glyph_at(col, row),
84                    self.color_at(col, row),
85                    color,
86                );
87            }
88            if row + 1 < self.char_height() {
89                out.push('\n');
90            }
91        }
92
93        out
94    }
95
96    fn cell_index(&self, col: usize, row: usize) -> Option<usize> {
97        if col >= self.core.char_width || row >= self.core.char_height {
98            return None;
99        }
100
101        Some(row * self.core.char_width + col)
102    }
103}
104
105impl Canvas for BlockCanvas {
106    fn pixel(&mut self, x: usize, y: usize, color: CanvasColor) {
107        if x > self.pixel_width() || y > self.pixel_height() {
108            return;
109        }
110
111        let clamped_x = if x < self.pixel_width() {
112            x
113        } else {
114            x.saturating_sub(1)
115        };
116        let clamped_y = if y < self.pixel_height() {
117            y
118        } else {
119            y.saturating_sub(1)
120        };
121
122        let col = clamped_x / X_PIXELS_PER_CHAR;
123        let row = clamped_y / Y_PIXELS_PER_CHAR;
124        let x_off = clamped_x % X_PIXELS_PER_CHAR;
125        let y_off = clamped_y % Y_PIXELS_PER_CHAR;
126
127        let Some(index) = self.cell_index(col, row) else {
128            return;
129        };
130
131        self.core.grid[index] |= BLOCK_BITS[x_off][y_off];
132        self.core.colors[index] |= color.as_u8();
133    }
134
135    fn glyph_at(&self, col: usize, row: usize) -> char {
136        self.cell_index(col, row)
137            .and_then(|index| {
138                BLOCK_DECODE
139                    .get(usize::from(self.core.grid[index]))
140                    .copied()
141            })
142            .unwrap_or(' ')
143    }
144
145    fn color_at(&self, col: usize, row: usize) -> CanvasColor {
146        self.cell_index(col, row)
147            .and_then(|index| CanvasColor::new(self.core.colors[index]))
148            .unwrap_or(CanvasColor::NORMAL)
149    }
150
151    fn char_width(&self) -> usize {
152        self.core.char_width
153    }
154
155    fn char_height(&self) -> usize {
156        self.core.char_height
157    }
158
159    fn pixel_width(&self) -> usize {
160        self.core.pixel_width
161    }
162
163    fn pixel_height(&self) -> usize {
164        self.core.pixel_height
165    }
166
167    fn transform(&self) -> &Transform2D {
168        &self.core.transform
169    }
170
171    fn transform_mut(&mut self) -> &mut Transform2D {
172        &mut self.core.transform
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::BlockCanvas;
179    use crate::canvas::{Canvas, draw_reference_canvas_scene, render_canvas_show};
180    use crate::color::CanvasColor;
181    use crate::test_util::assert_fixture_eq;
182
183    fn fixture_canvas() -> BlockCanvas {
184        let mut canvas = BlockCanvas::new(40, 10, 0.0, 0.0, 1.0, 1.0);
185        draw_reference_canvas_scene(&mut canvas);
186        canvas
187    }
188
189    #[test]
190    fn print_row_matches_fixture() {
191        let canvas = fixture_canvas();
192        let actual = canvas.print_row(2, true);
193
194        assert_fixture_eq(&actual, "tests/fixtures/canvas/block_printrow.txt");
195    }
196
197    #[test]
198    fn print_matches_fixture_with_color() {
199        let canvas = fixture_canvas();
200        let actual = canvas.print(true);
201
202        assert_fixture_eq(&actual, "tests/fixtures/canvas/block_print.txt");
203    }
204
205    #[test]
206    fn print_matches_fixture_without_color() {
207        let canvas = fixture_canvas();
208        let actual = canvas.print(false);
209
210        assert_fixture_eq(&actual, "tests/fixtures/canvas/block_print_nocolor.txt");
211    }
212
213    #[test]
214    fn show_matches_fixture_with_color() {
215        let canvas = fixture_canvas();
216        let actual = render_canvas_show(canvas, true);
217
218        assert_fixture_eq(&actual, "tests/fixtures/canvas/block_show.txt");
219    }
220
221    #[test]
222    fn show_matches_fixture_without_color() {
223        let canvas = fixture_canvas();
224        let actual = render_canvas_show(canvas, false);
225
226        assert_fixture_eq(&actual, "tests/fixtures/canvas/block_show_nocolor.txt");
227    }
228
229    #[test]
230    fn show_empty_canvas_matches_fixture() {
231        let canvas = BlockCanvas::new(40, 10, 0.0, 0.0, 1.0, 1.0);
232        let actual = render_canvas_show(canvas, true);
233
234        assert_fixture_eq(&actual, "tests/fixtures/canvas/empty_show.txt");
235    }
236
237    #[test]
238    fn block_pixel_encoding_matches_lookup_table_orientation() {
239        let cases = [(0, 0, '▘'), (1, 0, '▝'), (0, 1, '▖'), (1, 1, '▗')];
240        for (x, y, expected) in cases {
241            let mut canvas = BlockCanvas::new(1, 1, 0.0, 0.0, 1.0, 1.0);
242            canvas.pixel(x, y, CanvasColor::GREEN);
243            assert_eq!(canvas.glyph_at(0, 0), expected);
244        }
245    }
246
247    #[test]
248    fn block_pixel_ors_color_bits_on_overlaps() {
249        let mut canvas = BlockCanvas::new(1, 1, 0.0, 0.0, 1.0, 1.0);
250        canvas.pixel(0, 0, CanvasColor::BLUE);
251        canvas.pixel(0, 0, CanvasColor::RED);
252
253        assert_eq!(canvas.color_at(0, 0), CanvasColor::MAGENTA);
254    }
255
256    #[test]
257    fn block_pixel_clamps_upper_bounds_and_rejects_out_of_bounds() {
258        let mut canvas = BlockCanvas::new(1, 1, 0.0, 0.0, 1.0, 1.0);
259        canvas.pixel(2, 2, CanvasColor::YELLOW);
260        assert_eq!(canvas.glyph_at(0, 0), '▗');
261
262        canvas.pixel(3, 0, CanvasColor::WHITE);
263        assert_eq!(canvas.glyph_at(0, 0), '▗');
264    }
265}