use std::sync::OnceLock;
use crate::canvas::{AxisTransform, Canvas, CanvasCore, Scale, Transform2D};
use crate::color::CanvasColor;
const PIXELS_PER_CHAR: usize = 3;
const ASCII_BITS: [[u16; PIXELS_PER_CHAR]; PIXELS_PER_CHAR] = [
[0b100_000_000, 0b000_100_000, 0b000_000_100],
[0b010_000_000, 0b000_010_000, 0b000_000_010],
[0b001_000_000, 0b000_001_000, 0b000_000_001],
];
const ASCII_LOOKUP_KEY_ORDER: [u16; 46] = [
0x0002, 0x00d2, 0x0113, 0x00a0, 0x0088, 0x002a, 0x0100, 0x0197, 0x0012, 0x0193, 0x0092, 0x0082,
0x008a, 0x0054, 0x0004, 0x01d2, 0x01ff, 0x0124, 0x00a8, 0x0056, 0x0001, 0x01c7, 0x0052, 0x0080,
0x0009, 0x00cb, 0x0007, 0x003c, 0x0111, 0x0140, 0x0024, 0x0127, 0x0192, 0x0010, 0x019e, 0x01a6,
0x01d7, 0x0155, 0x00a2, 0x00ba, 0x0112, 0x0049, 0x00f3, 0x0152, 0x0038, 0x016a,
];
#[derive(Debug, Clone, PartialEq)]
pub struct AsciiCanvas {
core: CanvasCore<u16>,
}
impl AsciiCanvas {
#[must_use]
pub fn new(
char_width: usize,
char_height: usize,
origin_x: f64,
origin_y: f64,
plot_width: f64,
plot_height: f64,
) -> Self {
let pixel_width = char_width.saturating_mul(PIXELS_PER_CHAR);
let pixel_height = char_height.saturating_mul(PIXELS_PER_CHAR);
let x = AxisTransform::new(origin_x, plot_width, pixel_width, Scale::Identity, false)
.expect("AsciiCanvas::new requires finite x-origin, positive span, and non-zero width");
let y = AxisTransform::new(origin_y, plot_height, pixel_height, Scale::Identity, true)
.expect(
"AsciiCanvas::new requires finite y-origin, positive span, and non-zero height",
);
Self {
core: CanvasCore::new(
char_width,
char_height,
pixel_width,
pixel_height,
Transform2D::new(x, y),
),
}
}
#[must_use]
#[cfg(test)]
pub(crate) fn print_row(&self, row: usize, color: bool) -> String {
if row >= self.char_height() {
return String::new();
}
let mut out = String::new();
for col in 0..self.char_width() {
super::write_colored_cell(
&mut out,
self.glyph_at(col, row),
self.color_at(col, row),
color,
);
}
out
}
#[must_use]
#[cfg(test)]
pub(crate) fn print(&self, color: bool) -> String {
let mut out = String::new();
for row in 0..self.char_height() {
for col in 0..self.char_width() {
super::write_colored_cell(
&mut out,
self.glyph_at(col, row),
self.color_at(col, row),
color,
);
}
if row + 1 < self.char_height() {
out.push('\n');
}
}
out
}
fn cell_index(&self, col: usize, row: usize) -> Option<usize> {
if col >= self.core.char_width || row >= self.core.char_height {
return None;
}
Some(row * self.core.char_width + col)
}
}
impl Canvas for AsciiCanvas {
fn pixel(&mut self, x: usize, y: usize, color: CanvasColor) {
if x > self.pixel_width() || y > self.pixel_height() {
return;
}
let clamped_x = if x < self.pixel_width() {
x
} else {
x.saturating_sub(1)
};
let clamped_y = if y < self.pixel_height() {
y
} else {
y.saturating_sub(1)
};
let col = clamped_x / PIXELS_PER_CHAR;
let row = clamped_y / PIXELS_PER_CHAR;
let x_off = clamped_x % PIXELS_PER_CHAR;
let y_off = clamped_y % PIXELS_PER_CHAR;
let Some(index) = self.cell_index(col, row) else {
return;
};
self.core.grid[index] |= ASCII_BITS[x_off][y_off];
self.core.colors[index] |= color.as_u8();
}
fn glyph_at(&self, col: usize, row: usize) -> char {
self.cell_index(col, row).map_or(' ', |index| {
ascii_decode()[usize::from(self.core.grid[index])]
})
}
fn color_at(&self, col: usize, row: usize) -> CanvasColor {
self.cell_index(col, row)
.and_then(|index| CanvasColor::new(self.core.colors[index]))
.unwrap_or(CanvasColor::NORMAL)
}
fn char_width(&self) -> usize {
self.core.char_width
}
fn char_height(&self) -> usize {
self.core.char_height
}
fn pixel_width(&self) -> usize {
self.core.pixel_width
}
fn pixel_height(&self) -> usize {
self.core.pixel_height
}
fn transform(&self) -> &Transform2D {
&self.core.transform
}
fn transform_mut(&mut self) -> &mut Transform2D {
&mut self.core.transform
}
}
fn ascii_decode() -> &'static [char; 512] {
static TABLE: OnceLock<[char; 512]> = OnceLock::new();
TABLE.get_or_init(|| {
let mut decode = [' '; 512];
for (value, slot) in decode.iter_mut().enumerate().skip(1) {
let code = u16::try_from(value).unwrap_or(0);
let mut best_key = ASCII_LOOKUP_KEY_ORDER[0];
let mut best_distance = (code ^ best_key).count_ones();
for key in &ASCII_LOOKUP_KEY_ORDER[1..] {
let distance = (code ^ *key).count_ones();
if distance < best_distance {
best_distance = distance;
best_key = *key;
}
}
*slot = ascii_lookup(best_key);
}
decode
})
}
fn ascii_lookup(key: u16) -> char {
match key {
0x0140 => '"',
0x01ff => '@',
0x0080 => '\'',
0x00a2 => '(',
0x008a => ')',
0x0010 => '*',
0x00ba => '+',
0x0012 | 0x0024 | 0x0009 => ',',
0x0038 => '-',
0x0002 | 0x0004 | 0x0001 => '.',
0x0054 | 0x00a0 | 0x0056 | 0x00d2 | 0x0052 => '/',
0x0197 => '1',
0x0082 => ':',
0x01c7 => '=',
0x01d7 => 'I',
0x0127 => 'L',
0x01d2 => 'T',
0x016a => 'V',
0x0155 => 'X',
0x0152 => 'Y',
0x01a6 => '[',
0x0088 | 0x0111 | 0x0192 | 0x0113 | 0x0112 => '\\',
0x00cb => ']',
0x00a8 => '^',
0x0007 => '_',
0x0100 => '`',
0x0193 => 'l',
0x003c => 'r',
0x002a => 'v',
0x00f3 => '{',
0x0092 | 0x0124 | 0x0049 => '|',
0x019e => '}',
_ => ' ',
}
}
#[cfg(test)]
mod tests {
use super::AsciiCanvas;
use crate::canvas::{Canvas, draw_reference_canvas_scene, render_canvas_show};
use crate::color::CanvasColor;
use crate::test_util::assert_fixture_eq;
fn fixture_canvas() -> AsciiCanvas {
let mut canvas = AsciiCanvas::new(40, 10, 0.0, 0.0, 1.0, 1.0);
draw_reference_canvas_scene(&mut canvas);
canvas
}
#[test]
fn print_row_matches_fixture() {
let canvas = fixture_canvas();
let actual = canvas.print_row(2, true);
assert_fixture_eq(&actual, "tests/fixtures/canvas/ascii_printrow.txt");
}
#[test]
fn print_matches_fixture_with_color() {
let canvas = fixture_canvas();
let actual = canvas.print(true);
assert_fixture_eq(&actual, "tests/fixtures/canvas/ascii_print.txt");
}
#[test]
fn print_matches_fixture_without_color() {
let canvas = fixture_canvas();
let actual = canvas.print(false);
assert_fixture_eq(&actual, "tests/fixtures/canvas/ascii_print_nocolor.txt");
}
#[test]
fn show_matches_fixture_with_color() {
let canvas = fixture_canvas();
let actual = render_canvas_show(canvas, true);
assert_fixture_eq(&actual, "tests/fixtures/canvas/ascii_show.txt");
}
#[test]
fn show_matches_fixture_without_color() {
let canvas = fixture_canvas();
let actual = render_canvas_show(canvas, false);
assert_fixture_eq(&actual, "tests/fixtures/canvas/ascii_show_nocolor.txt");
}
#[test]
fn ascii_lookup_uses_expected_canonical_patterns() {
let mut dot = AsciiCanvas::new(1, 1, 0.0, 0.0, 1.0, 1.0);
dot.pixel(2, 2, CanvasColor::GREEN);
assert_eq!(dot.glyph_at(0, 0), '.');
let mut star = AsciiCanvas::new(1, 1, 0.0, 0.0, 1.0, 1.0);
star.pixel(1, 1, CanvasColor::GREEN);
assert_eq!(star.glyph_at(0, 0), '*');
let mut apostrophe = AsciiCanvas::new(1, 1, 0.0, 0.0, 1.0, 1.0);
apostrophe.pixel(1, 0, CanvasColor::GREEN);
assert_eq!(apostrophe.glyph_at(0, 0), '\'');
}
#[test]
fn ascii_pixel_ors_color_bits_on_overlaps() {
let mut canvas = AsciiCanvas::new(1, 1, 0.0, 0.0, 1.0, 1.0);
canvas.pixel(1, 1, CanvasColor::BLUE);
canvas.pixel(1, 1, CanvasColor::RED);
assert_eq!(canvas.color_at(0, 0), CanvasColor::MAGENTA);
}
#[test]
fn ascii_pixel_clamps_upper_bounds_and_rejects_out_of_bounds() {
let mut canvas = AsciiCanvas::new(1, 1, 0.0, 0.0, 1.0, 1.0);
canvas.pixel(3, 3, CanvasColor::YELLOW);
assert_eq!(canvas.glyph_at(0, 0), '.');
canvas.pixel(4, 0, CanvasColor::WHITE);
assert_eq!(canvas.glyph_at(0, 0), '.');
}
}