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#[derive(Debug, Clone, PartialEq)]
13pub struct BrailleCanvas {
14 core: CanvasCore<u16>,
15}
16
17impl BrailleCanvas {
18 #[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}