Skip to main content

unicode_plot/canvas/
dot.rs

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