Skip to main content

unicode_plot/canvas/
braille.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 = 4;
6const BRAILLE_BASE: u32 = 0x2800;
7const BRAILLE_BITS: [[u16; Y_PIXELS_PER_CHAR]; X_PIXELS_PER_CHAR] =
8    [[0x01, 0x02, 0x04, 0x40], [0x08, 0x10, 0x20, 0x80]];
9const BLANK_BRAILLE: char = '\u{2800}';
10
11/// A 2x4 pixel-per-character canvas using Unicode braille patterns (U+2800..U+28FF).
12#[derive(Debug, Clone, PartialEq)]
13pub struct BrailleCanvas {
14    core: CanvasCore<u16>,
15}
16
17impl BrailleCanvas {
18    /// Creates a braille canvas with identity axis scales.
19    ///
20    /// # Panics
21    ///
22    /// Panics when either axis transform cannot be constructed. This occurs if
23    /// plot spans are non-positive, pixel dimensions are zero, or origins are
24    /// non-finite.
25    #[must_use]
26    pub fn new(
27        char_width: usize,
28        char_height: usize,
29        origin_x: f64,
30        origin_y: f64,
31        plot_width: f64,
32        plot_height: f64,
33    ) -> Self {
34        let pixel_width = char_width.saturating_mul(X_PIXELS_PER_CHAR);
35        let pixel_height = char_height.saturating_mul(Y_PIXELS_PER_CHAR);
36        let x = AxisTransform::new(origin_x, plot_width, pixel_width, Scale::Identity, false)
37            .expect(
38                "BrailleCanvas::new requires finite x-origin, positive span, and non-zero width",
39            );
40        let y = AxisTransform::new(origin_y, plot_height, pixel_height, Scale::Identity, true)
41            .expect(
42                "BrailleCanvas::new requires finite y-origin, positive span, and non-zero height",
43            );
44
45        Self {
46            core: CanvasCore::new(
47                char_width,
48                char_height,
49                pixel_width,
50                pixel_height,
51                Transform2D::new(x, y),
52            ),
53        }
54    }
55
56    #[must_use]
57    #[cfg(test)]
58    pub(crate) fn print_row(&self, row: usize, color: bool) -> String {
59        if row >= self.char_height() {
60            return String::new();
61        }
62
63        let mut out = String::new();
64        for col in 0..self.char_width() {
65            super::write_colored_cell(
66                &mut out,
67                self.glyph_at(col, row),
68                self.color_at(col, row),
69                color,
70            );
71        }
72
73        out
74    }
75
76    #[must_use]
77    #[cfg(test)]
78    pub(crate) fn print(&self, color: bool) -> String {
79        let mut out = String::new();
80        for row in 0..self.char_height() {
81            for col in 0..self.char_width() {
82                super::write_colored_cell(
83                    &mut out,
84                    self.glyph_at(col, row),
85                    self.color_at(col, row),
86                    color,
87                );
88            }
89            if row + 1 < self.char_height() {
90                out.push('\n');
91            }
92        }
93
94        out
95    }
96
97    fn cell_index(&self, col: usize, row: usize) -> Option<usize> {
98        if col >= self.core.char_width || row >= self.core.char_height {
99            return None;
100        }
101
102        Some(row * self.core.char_width + col)
103    }
104}
105
106impl Canvas for BrailleCanvas {
107    fn pixel(&mut self, x: usize, y: usize, color: CanvasColor) {
108        if x > self.pixel_width() || y > self.pixel_height() {
109            return;
110        }
111
112        let clamped_x = if x < self.pixel_width() {
113            x
114        } else {
115            x.saturating_sub(1)
116        };
117        let clamped_y = if y < self.pixel_height() {
118            y
119        } else {
120            y.saturating_sub(1)
121        };
122
123        let col = clamped_x / X_PIXELS_PER_CHAR;
124        let row = clamped_y / Y_PIXELS_PER_CHAR;
125
126        let x_off = clamped_x % X_PIXELS_PER_CHAR;
127        let y_off = clamped_y % Y_PIXELS_PER_CHAR;
128
129        let Some(index) = self.cell_index(col, row) else {
130            return;
131        };
132
133        self.core.grid[index] |= BRAILLE_BITS[x_off][y_off];
134        self.core.colors[index] |= color.as_u8();
135    }
136
137    fn glyph_at(&self, col: usize, row: usize) -> char {
138        let Some(index) = self.cell_index(col, row) else {
139            return BLANK_BRAILLE;
140        };
141
142        char::from_u32(BRAILLE_BASE + u32::from(self.core.grid[index])).unwrap_or(BLANK_BRAILLE)
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::BrailleCanvas;
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() -> BrailleCanvas {
184        let mut canvas = BrailleCanvas::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 braille_glyph_encoding_matches_reference_bit_positions() {
191        let mut canvas = BrailleCanvas::new(1, 1, 0.0, 0.0, 1.0, 1.0);
192        canvas.pixel(0, 0, CanvasColor::BLUE);
193        canvas.pixel(0, 1, CanvasColor::BLUE);
194        canvas.pixel(0, 2, CanvasColor::BLUE);
195        canvas.pixel(0, 3, CanvasColor::BLUE);
196        canvas.pixel(1, 0, CanvasColor::BLUE);
197        canvas.pixel(1, 1, CanvasColor::BLUE);
198        canvas.pixel(1, 2, CanvasColor::BLUE);
199        canvas.pixel(1, 3, CanvasColor::BLUE);
200
201        assert_eq!(canvas.glyph_at(0, 0), '⣿');
202        assert_eq!(canvas.color_at(0, 0), CanvasColor::BLUE);
203    }
204
205    #[test]
206    fn braille_single_dot_encoding_matches_unicode_bit_table() {
207        let cases = [
208            (0, 0, '\u{2801}'),
209            (0, 1, '\u{2802}'),
210            (0, 2, '\u{2804}'),
211            (0, 3, '\u{2840}'),
212            (1, 0, '\u{2808}'),
213            (1, 1, '\u{2810}'),
214            (1, 2, '\u{2820}'),
215            (1, 3, '\u{2880}'),
216        ];
217
218        for (x, y, expected) in cases {
219            let mut canvas = BrailleCanvas::new(1, 1, 0.0, 0.0, 1.0, 1.0);
220            canvas.pixel(x, y, CanvasColor::GREEN);
221            assert_eq!(canvas.glyph_at(0, 0), expected);
222        }
223    }
224
225    #[test]
226    fn pixel_ors_color_bits_when_multiple_series_overlap() {
227        let mut canvas = BrailleCanvas::new(1, 1, 0.0, 0.0, 1.0, 1.0);
228        canvas.pixel(0, 0, CanvasColor::BLUE);
229        canvas.pixel(0, 0, CanvasColor::RED);
230        assert_eq!(canvas.color_at(0, 0), CanvasColor::MAGENTA);
231
232        canvas.pixel(0, 0, CanvasColor::WHITE);
233        assert_eq!(canvas.color_at(0, 0), CanvasColor::WHITE);
234    }
235
236    #[test]
237    fn print_row_matches_fixture() {
238        let canvas = fixture_canvas();
239        let actual = canvas.print_row(2, true);
240
241        assert_fixture_eq(&actual, "tests/fixtures/canvas/braille_printrow.txt");
242    }
243
244    #[test]
245    fn print_matches_fixture_with_color() {
246        let canvas = fixture_canvas();
247        let actual = canvas.print(true);
248
249        assert_fixture_eq(&actual, "tests/fixtures/canvas/braille_print.txt");
250    }
251
252    #[test]
253    fn print_matches_fixture_without_color() {
254        let canvas = fixture_canvas();
255        let actual = canvas.print(false);
256
257        assert_fixture_eq(&actual, "tests/fixtures/canvas/braille_print_nocolor.txt");
258    }
259
260    #[test]
261    fn show_matches_fixture_with_color() {
262        let canvas = fixture_canvas();
263        let actual = render_canvas_show(canvas, true);
264
265        assert_fixture_eq(&actual, "tests/fixtures/canvas/braille_show.txt");
266    }
267
268    #[test]
269    fn show_matches_fixture_without_color() {
270        let canvas = fixture_canvas();
271        let actual = render_canvas_show(canvas, false);
272
273        assert_fixture_eq(&actual, "tests/fixtures/canvas/braille_show_nocolor.txt");
274    }
275
276    #[test]
277    fn show_empty_canvas_matches_fixture() {
278        let canvas = BrailleCanvas::new(40, 10, 0.0, 0.0, 1.0, 1.0);
279        let actual = render_canvas_show(canvas, true);
280
281        assert_fixture_eq(&actual, "tests/fixtures/canvas/empty_braille_show.txt");
282    }
283}