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