1use std::sync::OnceLock;
2
3use crate::canvas::{AxisTransform, Canvas, CanvasCore, Scale, Transform2D};
4use crate::color::CanvasColor;
5
6const PIXELS_PER_CHAR: usize = 3;
7const ASCII_BITS: [[u16; PIXELS_PER_CHAR]; PIXELS_PER_CHAR] = [
8 [0b100_000_000, 0b000_100_000, 0b000_000_100],
9 [0b010_000_000, 0b000_010_000, 0b000_000_010],
10 [0b001_000_000, 0b000_001_000, 0b000_000_001],
11];
12
13const ASCII_LOOKUP_KEY_ORDER: [u16; 46] = [
14 0x0002, 0x00d2, 0x0113, 0x00a0, 0x0088, 0x002a, 0x0100, 0x0197, 0x0012, 0x0193, 0x0092, 0x0082,
15 0x008a, 0x0054, 0x0004, 0x01d2, 0x01ff, 0x0124, 0x00a8, 0x0056, 0x0001, 0x01c7, 0x0052, 0x0080,
16 0x0009, 0x00cb, 0x0007, 0x003c, 0x0111, 0x0140, 0x0024, 0x0127, 0x0192, 0x0010, 0x019e, 0x01a6,
17 0x01d7, 0x0155, 0x00a2, 0x00ba, 0x0112, 0x0049, 0x00f3, 0x0152, 0x0038, 0x016a,
18];
19
20#[derive(Debug, Clone, PartialEq)]
22pub struct AsciiCanvas {
23 core: CanvasCore<u16>,
24}
25
26impl AsciiCanvas {
27 #[must_use]
35 pub fn new(
36 char_width: usize,
37 char_height: usize,
38 origin_x: f64,
39 origin_y: f64,
40 plot_width: f64,
41 plot_height: f64,
42 ) -> Self {
43 let pixel_width = char_width.saturating_mul(PIXELS_PER_CHAR);
44 let pixel_height = char_height.saturating_mul(PIXELS_PER_CHAR);
45 let x = AxisTransform::new(origin_x, plot_width, pixel_width, Scale::Identity, false)
46 .expect("AsciiCanvas::new requires finite x-origin, positive span, and non-zero width");
47 let y = AxisTransform::new(origin_y, plot_height, pixel_height, Scale::Identity, true)
48 .expect(
49 "AsciiCanvas::new requires finite y-origin, positive span, and non-zero height",
50 );
51
52 Self {
53 core: CanvasCore::new(
54 char_width,
55 char_height,
56 pixel_width,
57 pixel_height,
58 Transform2D::new(x, y),
59 ),
60 }
61 }
62
63 #[must_use]
64 #[cfg(test)]
65 pub(crate) fn print_row(&self, row: usize, color: bool) -> String {
66 if row >= self.char_height() {
67 return String::new();
68 }
69
70 let mut out = String::new();
71 for col in 0..self.char_width() {
72 super::write_colored_cell(
73 &mut out,
74 self.glyph_at(col, row),
75 self.color_at(col, row),
76 color,
77 );
78 }
79
80 out
81 }
82
83 #[must_use]
84 #[cfg(test)]
85 pub(crate) fn print(&self, color: bool) -> String {
86 let mut out = String::new();
87 for row in 0..self.char_height() {
88 for col in 0..self.char_width() {
89 super::write_colored_cell(
90 &mut out,
91 self.glyph_at(col, row),
92 self.color_at(col, row),
93 color,
94 );
95 }
96 if row + 1 < self.char_height() {
97 out.push('\n');
98 }
99 }
100
101 out
102 }
103
104 fn cell_index(&self, col: usize, row: usize) -> Option<usize> {
105 if col >= self.core.char_width || row >= self.core.char_height {
106 return None;
107 }
108
109 Some(row * self.core.char_width + col)
110 }
111}
112
113impl Canvas for AsciiCanvas {
114 fn pixel(&mut self, x: usize, y: usize, color: CanvasColor) {
115 if x > self.pixel_width() || y > self.pixel_height() {
116 return;
117 }
118
119 let clamped_x = if x < self.pixel_width() {
120 x
121 } else {
122 x.saturating_sub(1)
123 };
124 let clamped_y = if y < self.pixel_height() {
125 y
126 } else {
127 y.saturating_sub(1)
128 };
129
130 let col = clamped_x / PIXELS_PER_CHAR;
131 let row = clamped_y / PIXELS_PER_CHAR;
132 let x_off = clamped_x % PIXELS_PER_CHAR;
133 let y_off = clamped_y % PIXELS_PER_CHAR;
134
135 let Some(index) = self.cell_index(col, row) else {
136 return;
137 };
138
139 self.core.grid[index] |= ASCII_BITS[x_off][y_off];
140 self.core.colors[index] |= color.as_u8();
141 }
142
143 fn glyph_at(&self, col: usize, row: usize) -> char {
144 self.cell_index(col, row).map_or(' ', |index| {
145 ascii_decode()[usize::from(self.core.grid[index])]
146 })
147 }
148
149 fn color_at(&self, col: usize, row: usize) -> CanvasColor {
150 self.cell_index(col, row)
151 .and_then(|index| CanvasColor::new(self.core.colors[index]))
152 .unwrap_or(CanvasColor::NORMAL)
153 }
154
155 fn char_width(&self) -> usize {
156 self.core.char_width
157 }
158
159 fn char_height(&self) -> usize {
160 self.core.char_height
161 }
162
163 fn pixel_width(&self) -> usize {
164 self.core.pixel_width
165 }
166
167 fn pixel_height(&self) -> usize {
168 self.core.pixel_height
169 }
170
171 fn transform(&self) -> &Transform2D {
172 &self.core.transform
173 }
174
175 fn transform_mut(&mut self) -> &mut Transform2D {
176 &mut self.core.transform
177 }
178}
179
180fn ascii_decode() -> &'static [char; 512] {
181 static TABLE: OnceLock<[char; 512]> = OnceLock::new();
182 TABLE.get_or_init(|| {
183 let mut decode = [' '; 512];
184 for (value, slot) in decode.iter_mut().enumerate().skip(1) {
185 let code = u16::try_from(value).unwrap_or(0);
186 let mut best_key = ASCII_LOOKUP_KEY_ORDER[0];
187 let mut best_distance = (code ^ best_key).count_ones();
188 for key in &ASCII_LOOKUP_KEY_ORDER[1..] {
189 let distance = (code ^ *key).count_ones();
190 if distance < best_distance {
191 best_distance = distance;
192 best_key = *key;
193 }
194 }
195 *slot = ascii_lookup(best_key);
196 }
197 decode
198 })
199}
200
201fn ascii_lookup(key: u16) -> char {
202 match key {
203 0x0140 => '"',
204 0x01ff => '@',
205 0x0080 => '\'',
206 0x00a2 => '(',
207 0x008a => ')',
208 0x0010 => '*',
209 0x00ba => '+',
210 0x0012 | 0x0024 | 0x0009 => ',',
211 0x0038 => '-',
212 0x0002 | 0x0004 | 0x0001 => '.',
213 0x0054 | 0x00a0 | 0x0056 | 0x00d2 | 0x0052 => '/',
214 0x0197 => '1',
215 0x0082 => ':',
216 0x01c7 => '=',
217 0x01d7 => 'I',
218 0x0127 => 'L',
219 0x01d2 => 'T',
220 0x016a => 'V',
221 0x0155 => 'X',
222 0x0152 => 'Y',
223 0x01a6 => '[',
224 0x0088 | 0x0111 | 0x0192 | 0x0113 | 0x0112 => '\\',
225 0x00cb => ']',
226 0x00a8 => '^',
227 0x0007 => '_',
228 0x0100 => '`',
229 0x0193 => 'l',
230 0x003c => 'r',
231 0x002a => 'v',
232 0x00f3 => '{',
233 0x0092 | 0x0124 | 0x0049 => '|',
234 0x019e => '}',
235 _ => ' ',
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::AsciiCanvas;
242 use crate::canvas::{Canvas, draw_reference_canvas_scene, render_canvas_show};
243 use crate::color::CanvasColor;
244 use crate::test_util::assert_fixture_eq;
245
246 fn fixture_canvas() -> AsciiCanvas {
247 let mut canvas = AsciiCanvas::new(40, 10, 0.0, 0.0, 1.0, 1.0);
248 draw_reference_canvas_scene(&mut canvas);
249 canvas
250 }
251
252 #[test]
253 fn print_row_matches_fixture() {
254 let canvas = fixture_canvas();
255 let actual = canvas.print_row(2, true);
256
257 assert_fixture_eq(&actual, "tests/fixtures/canvas/ascii_printrow.txt");
258 }
259
260 #[test]
261 fn print_matches_fixture_with_color() {
262 let canvas = fixture_canvas();
263 let actual = canvas.print(true);
264
265 assert_fixture_eq(&actual, "tests/fixtures/canvas/ascii_print.txt");
266 }
267
268 #[test]
269 fn print_matches_fixture_without_color() {
270 let canvas = fixture_canvas();
271 let actual = canvas.print(false);
272
273 assert_fixture_eq(&actual, "tests/fixtures/canvas/ascii_print_nocolor.txt");
274 }
275
276 #[test]
277 fn show_matches_fixture_with_color() {
278 let canvas = fixture_canvas();
279 let actual = render_canvas_show(canvas, true);
280
281 assert_fixture_eq(&actual, "tests/fixtures/canvas/ascii_show.txt");
282 }
283
284 #[test]
285 fn show_matches_fixture_without_color() {
286 let canvas = fixture_canvas();
287 let actual = render_canvas_show(canvas, false);
288
289 assert_fixture_eq(&actual, "tests/fixtures/canvas/ascii_show_nocolor.txt");
290 }
291
292 #[test]
293 fn ascii_lookup_uses_expected_canonical_patterns() {
294 let mut dot = AsciiCanvas::new(1, 1, 0.0, 0.0, 1.0, 1.0);
295 dot.pixel(2, 2, CanvasColor::GREEN);
296 assert_eq!(dot.glyph_at(0, 0), '.');
297
298 let mut star = AsciiCanvas::new(1, 1, 0.0, 0.0, 1.0, 1.0);
299 star.pixel(1, 1, CanvasColor::GREEN);
300 assert_eq!(star.glyph_at(0, 0), '*');
301
302 let mut apostrophe = AsciiCanvas::new(1, 1, 0.0, 0.0, 1.0, 1.0);
303 apostrophe.pixel(1, 0, CanvasColor::GREEN);
304 assert_eq!(apostrophe.glyph_at(0, 0), '\'');
305 }
306
307 #[test]
308 fn ascii_pixel_ors_color_bits_on_overlaps() {
309 let mut canvas = AsciiCanvas::new(1, 1, 0.0, 0.0, 1.0, 1.0);
310 canvas.pixel(1, 1, CanvasColor::BLUE);
311 canvas.pixel(1, 1, CanvasColor::RED);
312
313 assert_eq!(canvas.color_at(0, 0), CanvasColor::MAGENTA);
314 }
315
316 #[test]
317 fn ascii_pixel_clamps_upper_bounds_and_rejects_out_of_bounds() {
318 let mut canvas = AsciiCanvas::new(1, 1, 0.0, 0.0, 1.0, 1.0);
319 canvas.pixel(3, 3, CanvasColor::YELLOW);
320 assert_eq!(canvas.glyph_at(0, 0), '.');
321
322 canvas.pixel(4, 0, CanvasColor::WHITE);
323 assert_eq!(canvas.glyph_at(0, 0), '.');
324 }
325}