Skip to main content

retroglyph_ui/
canvas.rs

1//! Sub-cell drawing: a buffer to plot into before there are pixels to posterize.
2//!
3//! [`retroglyph_core::symbols`] already provides the *quantizers*
4//! ([`quantize_half_block`](retroglyph_core::symbols::quantize_half_block) and friends): given a
5//! block of raw pixels, they pick the best-matching glyph plus two colors. What they don't
6//! provide is anywhere to put those pixels before you have them: a caller drawing a shape,
7//! sprite, or heightmap needs a buffer sized in sub-cells, not raw cells, to plot into first.
8//! That's what this module adds.
9//!
10//! [`HalfBlockCanvas`](crate::canvas::HalfBlockCanvas), [`QuadrantCanvas`](crate::canvas::QuadrantCanvas), and
11//! [`SextantCanvas`](crate::canvas::SextantCanvas) differ only in their sub-cell layout (1x2, 2x2, and 2x3
12//! respectively); each plots full [`Color`](retroglyph_core::color::Color)s and reads a cell back as whichever
13//! [`quantize_half_block`](retroglyph_core::symbols::quantize_half_block)/
14//! [`quantize_quadrant`](retroglyph_core::symbols::quantize_quadrant)/
15//! [`quantize_sextant`](retroglyph_core::symbols::quantize_sextant) picks for it.
16//! [`BrailleCanvas`](crate::canvas::BrailleCanvas) is the odd one out: 2x4 dots per cell, but monochrome, since
17//! every dot in a braille glyph shares one foreground color, so it plots boolean dots rather than colors and
18//! reads a cell back via [`retroglyph_core::symbols::braille::glyph`] instead of a quantizer.
19//!
20//! A canvas can't choose a cell's glyph as it's plotted: the glyph depends on every sub-cell in
21//! that cell, so nothing can be decided until [`cells`](crate::canvas::HalfBlockCanvas::cells) is called after
22//! the last plot lands.
23
24use alloc::vec;
25use alloc::vec::Vec;
26
27use retroglyph_core::color::Color;
28use retroglyph_core::symbols::braille;
29use retroglyph_core::symbols::{Glyph, quantize_half_block, quantize_quadrant, quantize_sextant};
30
31/// A color plotted at a sub-cell resolution, read back one cell at a time.
32///
33/// [`HalfBlockCanvas`], [`QuadrantCanvas`], and [`SextantCanvas`] differ only in their sub-cell
34/// layout, so they share this shape: plot into a buffer sized in sub-cells, then iterate cells
35/// and let a quantizer choose each cell's glyph and two colors.
36#[derive(Debug, Clone)]
37struct SubCanvas {
38    /// Sub-cell width, i.e. `cols * sub_w`.
39    width: usize,
40    /// Sub-cell height, i.e. `rows * sub_h`.
41    height: usize,
42    pixels: Vec<Color>,
43    clear: Color,
44}
45
46impl SubCanvas {
47    fn new(width: usize, height: usize, clear: Color) -> Self {
48        Self {
49            width,
50            height,
51            pixels: vec![clear; width * height],
52            clear,
53        }
54    }
55
56    fn clear(&mut self) {
57        self.pixels.fill(self.clear);
58    }
59
60    fn set(&mut self, x: i32, y: i32, color: Color) {
61        if x < 0 || y < 0 {
62            return;
63        }
64        #[allow(clippy::cast_sign_loss)] // x, y >= 0, checked above
65        let (x, y) = (x as usize, y as usize);
66        if x >= self.width || y >= self.height {
67            return;
68        }
69        self.pixels[y * self.width + x] = color;
70    }
71
72    fn get(&self, x: usize, y: usize) -> Color {
73        self.pixels
74            .get(y * self.width + x)
75            .copied()
76            .unwrap_or(self.clear)
77    }
78
79    /// Resolves a sub-cell to the `(r, g, b)` triple the quantizers expect.
80    ///
81    /// The fallback matters: a `Color::Default` sub-cell has no intrinsic RGB, and quantizing it
82    /// as black would draw a hard black block where the caller meant "nothing here". Resolving
83    /// against the canvas' own clear color instead makes an unset sub-cell vanish into the
84    /// background, which is what "clear" should mean.
85    fn rgb_at(&self, x: usize, y: usize) -> (u8, u8, u8) {
86        let fallback = self.clear.resolve_rgb((0, 0, 0));
87        self.get(x, y).resolve_rgb(fallback)
88    }
89}
90
91/// A canvas with 1x2 sub-cells (upper and lower half of each cell).
92///
93/// The most compatible sub-cell mode: `▀` and `▄` are Unicode Block Elements, supported in
94/// essentially every monospace terminal font. Doubles vertical resolution, which is exactly what
95/// a character grid is short of, given cells are already about twice as tall as they are wide:
96/// net effect, square pixels.
97#[derive(Debug, Clone)]
98pub struct HalfBlockCanvas {
99    canvas: SubCanvas,
100    cols: usize,
101    rows: usize,
102}
103
104impl HalfBlockCanvas {
105    /// A canvas covering `cols` x `rows` cells, i.e. `cols` x `rows * 2` plottable points.
106    #[must_use]
107    pub fn new(cols: u16, rows: u16, clear: Color) -> Self {
108        let (cols, rows) = (cols as usize, rows as usize);
109        Self {
110            canvas: SubCanvas::new(cols, rows * 2, clear),
111            cols,
112            rows,
113        }
114    }
115
116    /// Plottable size, in sub-cells.
117    #[must_use]
118    pub const fn size(&self) -> (usize, usize) {
119        (self.canvas.width, self.canvas.height)
120    }
121
122    /// Resets every sub-cell to the clear color.
123    pub fn clear(&mut self) {
124        self.canvas.clear();
125    }
126
127    /// Plots one sub-cell. Out-of-bounds coordinates are ignored, so callers can draw shapes that
128    /// overhang the canvas without clipping first.
129    pub fn plot(&mut self, x: i32, y: i32, color: Color) {
130        self.canvas.set(x, y, color);
131    }
132
133    /// Yields `(col, row, glyph)` for every cell.
134    pub fn cells(&self) -> impl Iterator<Item = (u16, u16, Glyph)> + '_ {
135        (0..self.rows).flat_map(move |row| {
136            (0..self.cols).map(move |col| {
137                let glyph = quantize_half_block([
138                    self.canvas.rgb_at(col, row * 2),
139                    self.canvas.rgb_at(col, row * 2 + 1),
140                ]);
141                (
142                    u16::try_from(col).unwrap_or(u16::MAX),
143                    u16::try_from(row).unwrap_or(u16::MAX),
144                    glyph,
145                )
146            })
147        })
148    }
149}
150
151/// A canvas with 2x2 sub-cells per cell.
152///
153/// Doubles resolution on both axes over [`HalfBlockCanvas`]. The tradeoff: four sub-cells still
154/// share only two colors, so a quadrant cell containing three different colors loses one of them,
155/// where a half-block cell containing two keeps both exactly.
156#[derive(Debug, Clone)]
157pub struct QuadrantCanvas {
158    canvas: SubCanvas,
159    cols: usize,
160    rows: usize,
161}
162
163impl QuadrantCanvas {
164    /// A canvas covering `cols` x `rows` cells, i.e. `cols * 2` x `rows * 2` plottable points.
165    #[must_use]
166    pub fn new(cols: u16, rows: u16, clear: Color) -> Self {
167        let (cols, rows) = (cols as usize, rows as usize);
168        Self {
169            canvas: SubCanvas::new(cols * 2, rows * 2, clear),
170            cols,
171            rows,
172        }
173    }
174
175    /// Plottable size, in sub-cells.
176    #[must_use]
177    pub const fn size(&self) -> (usize, usize) {
178        (self.canvas.width, self.canvas.height)
179    }
180
181    /// Resets every sub-cell to the clear color.
182    pub fn clear(&mut self) {
183        self.canvas.clear();
184    }
185
186    /// Plots one sub-cell. Out-of-bounds coordinates are ignored; see [`HalfBlockCanvas::plot`].
187    pub fn plot(&mut self, x: i32, y: i32, color: Color) {
188        self.canvas.set(x, y, color);
189    }
190
191    /// Yields `(col, row, glyph)` for every cell.
192    pub fn cells(&self) -> impl Iterator<Item = (u16, u16, Glyph)> + '_ {
193        (0..self.rows).flat_map(move |row| {
194            (0..self.cols).map(move |col| {
195                let (x, y) = (col * 2, row * 2);
196                let glyph = quantize_quadrant([
197                    self.canvas.rgb_at(x, y),
198                    self.canvas.rgb_at(x + 1, y),
199                    self.canvas.rgb_at(x, y + 1),
200                    self.canvas.rgb_at(x + 1, y + 1),
201                ]);
202                (
203                    u16::try_from(col).unwrap_or(u16::MAX),
204                    u16::try_from(row).unwrap_or(u16::MAX),
205                    glyph,
206                )
207            })
208        })
209    }
210}
211
212/// A canvas with 2x3 sub-cells per cell, using Unicode sextants.
213///
214/// The highest-resolution *colored* mode: six sub-cells per glyph, still two colors. Needs a font
215/// with "Symbols for Legacy Computing" coverage (a 2022 Unicode addition); see
216/// [`quantize_sextant`](retroglyph_core::symbols::quantize_sextant)'s own docs for the font
217/// caveat and the `FontChain` setup that addresses it on the bundled pixel backends.
218#[derive(Debug, Clone)]
219pub struct SextantCanvas {
220    canvas: SubCanvas,
221    cols: usize,
222    rows: usize,
223}
224
225impl SextantCanvas {
226    /// A canvas covering `cols` x `rows` cells, i.e. `cols * 2` x `rows * 3` plottable points.
227    #[must_use]
228    pub fn new(cols: u16, rows: u16, clear: Color) -> Self {
229        let (cols, rows) = (cols as usize, rows as usize);
230        Self {
231            canvas: SubCanvas::new(cols * 2, rows * 3, clear),
232            cols,
233            rows,
234        }
235    }
236
237    /// Plottable size, in sub-cells.
238    #[must_use]
239    pub const fn size(&self) -> (usize, usize) {
240        (self.canvas.width, self.canvas.height)
241    }
242
243    /// Resets every sub-cell to the clear color.
244    pub fn clear(&mut self) {
245        self.canvas.clear();
246    }
247
248    /// Plots one sub-cell. Out-of-bounds coordinates are ignored; see [`HalfBlockCanvas::plot`].
249    pub fn plot(&mut self, x: i32, y: i32, color: Color) {
250        self.canvas.set(x, y, color);
251    }
252
253    /// Yields `(col, row, glyph)` for every cell.
254    pub fn cells(&self) -> impl Iterator<Item = (u16, u16, Glyph)> + '_ {
255        (0..self.rows).flat_map(move |row| {
256            (0..self.cols).map(move |col| {
257                let (x, y) = (col * 2, row * 3);
258                let glyph = quantize_sextant([
259                    self.canvas.rgb_at(x, y),
260                    self.canvas.rgb_at(x + 1, y),
261                    self.canvas.rgb_at(x, y + 1),
262                    self.canvas.rgb_at(x + 1, y + 1),
263                    self.canvas.rgb_at(x, y + 2),
264                    self.canvas.rgb_at(x + 1, y + 2),
265                ]);
266                (
267                    u16::try_from(col).unwrap_or(u16::MAX),
268                    u16::try_from(row).unwrap_or(u16::MAX),
269                    glyph,
270                )
271            })
272        })
273    }
274}
275
276/// A monochrome canvas with 2x4 sub-cells per cell, using Unicode Braille Patterns.
277///
278/// The densest character-grid raster there is: 8 dots per cell, which on a 100x40 grid is a
279/// 200x160 bitmap. The catch is in the name of the trade: all 8 dots share one foreground color,
280/// so this draws *shapes*, not pictures. Ideal for contour lines, a pixel-accurate outline, or a
281/// high-resolution minimap silhouette.
282///
283/// Dot addressing goes through [`retroglyph_core::symbols::braille::DOTS`] and
284/// [`retroglyph_core::symbols::braille::glyph`] rather than a locally re-derived bit table: dot
285/// bit positions follow Braille's historical numbering (down the left column, then the right,
286/// then the two 8-dot extensions appended underneath), not raster order, and that table already
287/// exists in `retroglyph-core`.
288#[derive(Debug, Clone)]
289pub struct BrailleCanvas {
290    cols: usize,
291    rows: usize,
292    /// One byte of dot bits per cell.
293    dots: Vec<u8>,
294}
295
296impl BrailleCanvas {
297    /// A canvas covering `cols` x `rows` cells, i.e. `cols * 2` x `rows * 4` plottable points.
298    #[must_use]
299    pub fn new(cols: u16, rows: u16) -> Self {
300        let (cols, rows) = (cols as usize, rows as usize);
301        Self {
302            cols,
303            rows,
304            dots: vec![0; cols * rows],
305        }
306    }
307
308    /// Plottable size, in dots.
309    #[must_use]
310    pub const fn size(&self) -> (usize, usize) {
311        (self.cols * 2, self.rows * 4)
312    }
313
314    /// Clears every dot.
315    pub fn clear(&mut self) {
316        self.dots.fill(0);
317    }
318
319    /// Sets the dot at `(x, y)`. Out-of-bounds coordinates are ignored; see
320    /// [`HalfBlockCanvas::plot`].
321    pub fn plot(&mut self, x: i32, y: i32) {
322        if let Some((index, mask)) = self.locate(x, y) {
323            self.dots[index] |= mask;
324        }
325    }
326
327    /// Clears the dot at `(x, y)`.
328    pub fn unplot(&mut self, x: i32, y: i32) {
329        if let Some((index, mask)) = self.locate(x, y) {
330            self.dots[index] &= !mask;
331        }
332    }
333
334    /// Whether the dot at `(x, y)` is set.
335    #[must_use]
336    pub fn get(&self, x: i32, y: i32) -> bool {
337        self.locate(x, y)
338            .is_some_and(|(index, mask)| self.dots[index] & mask != 0)
339    }
340
341    /// Maps a dot coordinate to its cell index and dot bitmask (a `braille::DOT_*` value, already
342    /// a single set bit, not a shift amount), or `None` if out of bounds.
343    ///
344    /// `braille::DOTS` is indexed `[row][col]` (row = `y % 4`, col = `x % 2`); see its own docs
345    /// for why the mask that comes out doesn't follow raster order.
346    const fn locate(&self, x: i32, y: i32) -> Option<(usize, u8)> {
347        if x < 0 || y < 0 {
348            return None;
349        }
350        #[allow(clippy::cast_sign_loss)] // x, y >= 0, checked above
351        let (x, y) = (x as usize, y as usize);
352        let (cell_x, cell_y) = (x / 2, y / 4);
353        if cell_x >= self.cols || cell_y >= self.rows {
354            return None;
355        }
356        Some((cell_y * self.cols + cell_x, braille::DOTS[y % 4][x % 2]))
357    }
358
359    /// Draws a line between two dot coordinates (Bresenham).
360    ///
361    /// The one drawing primitive worth having built in: contours, outlines, and route overlays
362    /// are all lines, and hand-rolling Bresenham per caller is exactly the sort of duplication a
363    /// shared crate exists to prevent.
364    pub fn line(&mut self, x0: i32, y0: i32, x1: i32, y1: i32) {
365        let (dx, dy) = ((x1 - x0).abs(), -(y1 - y0).abs());
366        let (sx, sy) = (if x0 < x1 { 1 } else { -1 }, if y0 < y1 { 1 } else { -1 });
367        let (mut x, mut y) = (x0, y0);
368        let mut err = dx + dy;
369        loop {
370            self.plot(x, y);
371            if x == x1 && y == y1 {
372                break;
373            }
374            let e2 = 2 * err;
375            if e2 >= dy {
376                err += dy;
377                x += sx;
378            }
379            if e2 <= dx {
380                err += dx;
381                y += sy;
382            }
383        }
384    }
385
386    /// The Braille character for cell `(col, row)`, or [`braille::BLANK`] if out of bounds.
387    #[must_use]
388    pub fn glyph(&self, col: usize, row: usize) -> char {
389        let bits = if col < self.cols && row < self.rows {
390            self.dots[row * self.cols + col]
391        } else {
392            0
393        };
394        braille::glyph(bits)
395    }
396
397    /// Yields `(col, row, glyph)` for every cell.
398    pub fn cells(&self) -> impl Iterator<Item = (u16, u16, char)> + '_ {
399        (0..self.rows).flat_map(move |row| {
400            (0..self.cols).map(move |col| {
401                (
402                    u16::try_from(col).unwrap_or(u16::MAX),
403                    u16::try_from(row).unwrap_or(u16::MAX),
404                    self.glyph(col, row),
405                )
406            })
407        })
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use alloc::vec;
414    use alloc::vec::Vec;
415
416    use super::{BrailleCanvas, Glyph, HalfBlockCanvas, QuadrantCanvas, SextantCanvas, braille};
417    use retroglyph_core::color::Color;
418    use retroglyph_core::symbols::{HALF_BLOCKS, QUADRANTS};
419
420    const RED: Color = Color::rgb(255, 0, 0);
421    const BLUE: Color = Color::rgb(0, 0, 255);
422    const BLACK: Color = Color::rgb(0, 0, 0);
423
424    #[test]
425    fn half_block_canvas_has_double_vertical_resolution() {
426        let canvas = HalfBlockCanvas::new(10, 5, BLACK);
427        assert_eq!(canvas.size(), (10, 10));
428        assert_eq!(canvas.cells().count(), 50);
429    }
430
431    /// Resolves a quantized [`Glyph`] back into its per-sub-cell colors.
432    ///
433    /// Necessary because a posterizer has two equally correct answers for every cell: `'▀'` with
434    /// `(fg, bg)` renders pixel-for-pixel identically to `'▄'` with `(bg, fg)`. Asserting on the
435    /// glyph alone would therefore be asserting on an implementation detail; asserting on what
436    /// actually gets drawn is both stricter and stable.
437    fn resolve(glyph: Glyph, bank: &[char]) -> Vec<Color> {
438        let mask = bank
439            .iter()
440            .position(|&c| c == glyph.ch)
441            .expect("glyph must come from its own bank");
442        // A bank of 2^n glyphs enumerates every subset of n sub-cells, so the sub-cell count is
443        // log2 of the bank length.
444        let sub_cells = bank.len().trailing_zeros() as usize;
445        (0..sub_cells)
446            .map(|i| {
447                if mask & (1 << i) != 0 {
448                    glyph.fg
449                } else {
450                    glyph.bg
451                }
452            })
453            .collect()
454    }
455
456    #[test]
457    fn half_block_renders_the_half_that_was_plotted() {
458        let mut canvas = HalfBlockCanvas::new(1, 1, BLACK);
459        canvas.plot(0, 0, RED);
460        let (_, _, glyph) = canvas.cells().next().expect("one cell");
461        assert_eq!(resolve(glyph, &HALF_BLOCKS), vec![RED, BLACK], "top set");
462
463        canvas.clear();
464        canvas.plot(0, 1, RED);
465        let (_, _, glyph) = canvas.cells().next().expect("one cell");
466        assert_eq!(resolve(glyph, &HALF_BLOCKS), vec![BLACK, RED], "bottom set");
467    }
468
469    #[test]
470    fn quadrant_canvas_has_double_resolution_on_both_axes() {
471        let canvas = QuadrantCanvas::new(8, 4, BLACK);
472        assert_eq!(canvas.size(), (16, 8));
473        assert_eq!(canvas.cells().count(), 32);
474    }
475
476    #[test]
477    fn quadrant_renders_the_corner_that_was_plotted() {
478        for (x, y, index) in [(0, 0, 0), (1, 0, 1), (0, 1, 2), (1, 1, 3)] {
479            let mut canvas = QuadrantCanvas::new(1, 1, BLACK);
480            canvas.plot(x, y, RED);
481            let (_, _, glyph) = canvas.cells().next().expect("one cell");
482            let mut expected = vec![BLACK; 4];
483            expected[index] = RED;
484            assert_eq!(resolve(glyph, &QUADRANTS), expected, "corner ({x}, {y})");
485        }
486    }
487
488    #[test]
489    fn canvases_preserve_plotted_colors_exactly() {
490        // A two-color cell must survive quantization unchanged: the whole reason to use
491        // half-blocks over a shade ramp is that they are exact.
492        let mut canvas = HalfBlockCanvas::new(1, 1, BLACK);
493        canvas.plot(0, 0, RED);
494        canvas.plot(0, 1, BLUE);
495        let (_, _, glyph) = canvas.cells().next().expect("one cell");
496        assert_eq!(resolve(glyph, &HALF_BLOCKS), vec![RED, BLUE]);
497    }
498
499    #[test]
500    fn sextant_canvas_is_two_by_three() {
501        let canvas = SextantCanvas::new(6, 3, BLACK);
502        assert_eq!(canvas.size(), (12, 9));
503        assert_eq!(canvas.cells().count(), 18);
504    }
505
506    #[test]
507    fn canvases_ignore_out_of_bounds_plots() {
508        let mut half = HalfBlockCanvas::new(2, 2, BLACK);
509        let mut quad = QuadrantCanvas::new(2, 2, BLACK);
510        let mut sext = SextantCanvas::new(2, 2, BLACK);
511        for (x, y) in [(-1, 0), (0, -1), (999, 0), (0, 999), (-999, -999)] {
512            half.plot(x, y, RED);
513            quad.plot(x, y, RED);
514            sext.plot(x, y, RED);
515        }
516        // Nothing was written, so every cell is still the clear color.
517        assert!(half.cells().all(|(_, _, g)| g.ch == ' ' || g.fg == BLACK));
518        assert!(quad.cells().all(|(_, _, g)| g.ch == ' ' || g.fg == BLACK));
519        assert!(sext.cells().all(|(_, _, g)| g.ch == ' ' || g.fg == BLACK));
520    }
521
522    #[test]
523    fn clearing_a_canvas_erases_everything() {
524        let mut canvas = QuadrantCanvas::new(2, 2, BLACK);
525        canvas.plot(0, 0, RED);
526        canvas.clear();
527        let glyphs: Vec<char> = canvas.cells().map(|(_, _, g)| g.ch).collect();
528        assert!(glyphs.iter().all(|&c| c == ' '), "left over: {glyphs:?}");
529    }
530
531    // ── Braille ─────────────────────────────────────────────────────────────
532
533    #[test]
534    fn braille_canvas_is_two_by_four() {
535        let canvas = BrailleCanvas::new(20, 10);
536        assert_eq!(canvas.size(), (40, 40));
537        assert_eq!(canvas.cells().count(), 200);
538    }
539
540    #[test]
541    fn braille_plot_round_trips_through_get() {
542        let mut canvas = BrailleCanvas::new(4, 2);
543        for y in 0..8 {
544            for x in 0..8 {
545                assert!(!canvas.get(x, y));
546                canvas.plot(x, y);
547                assert!(canvas.get(x, y), "dot ({x}, {y}) did not stick");
548                canvas.unplot(x, y);
549                assert!(!canvas.get(x, y), "dot ({x}, {y}) did not clear");
550            }
551        }
552    }
553
554    #[test]
555    fn braille_glyph_is_blank_when_empty_and_full_when_saturated() {
556        let mut canvas = BrailleCanvas::new(1, 1);
557        assert_eq!(canvas.glyph(0, 0), braille::BLANK);
558        for y in 0..4 {
559            for x in 0..2 {
560                canvas.plot(x, y);
561            }
562        }
563        assert_eq!(canvas.glyph(0, 0), '\u{28FF}', "all 8 dots set");
564    }
565
566    #[test]
567    fn braille_glyphs_stay_inside_the_unicode_block() {
568        let mut canvas = BrailleCanvas::new(3, 3);
569        canvas.line(0, 0, 5, 11);
570        for (_, _, glyph) in canvas.cells() {
571            let code = glyph as u32;
572            assert!(
573                (0x2800..0x2900).contains(&code),
574                "{glyph:?} is outside the Braille block"
575            );
576        }
577    }
578
579    #[test]
580    fn braille_out_of_bounds_is_ignored_not_panicked() {
581        let mut canvas = BrailleCanvas::new(2, 2);
582        for (x, y) in [(-1, 0), (0, -1), (999, 0), (0, 999)] {
583            canvas.plot(x, y);
584            canvas.unplot(x, y);
585            assert!(!canvas.get(x, y));
586        }
587        assert!(canvas.cells().all(|(_, _, g)| g == braille::BLANK));
588    }
589
590    /// The canary this module exists to fix: dot bit positions come from
591    /// `retroglyph_core::symbols::braille::DOTS`, not a locally re-derived table, so this stays
592    /// consistent with that module by construction rather than by convention.
593    #[test]
594    fn dot_bit_positions_match_symbols_braille_dots() {
595        let mut canvas = BrailleCanvas::new(1, 1);
596        for y in 0..4u8 {
597            for x in 0..2u8 {
598                canvas.plot(i32::from(x), i32::from(y));
599                let bit = braille::DOTS[y as usize][x as usize];
600                assert_eq!(canvas.glyph(0, 0), braille::glyph(bit), "dot ({x}, {y})");
601                canvas.unplot(i32::from(x), i32::from(y));
602            }
603        }
604    }
605}