#[cfg(feature = "colorize")]
use crate::colorize::{animation_color, PixelAnimation};
use crate::rgb::Rgb;
use std::fmt::Write as _;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Cell {
pub ch: char,
pub color: Option<Rgb>,
}
impl Cell {
pub const fn new(ch: char, color: Option<Rgb>) -> Self {
Self { ch, color }
}
pub const fn blank() -> Self {
Self {
ch: ' ',
color: None,
}
}
}
#[derive(Debug, Clone)]
pub struct Canvas {
width: u32,
height: u32,
cells: Vec<Cell>,
row_dirty: Vec<bool>,
render_cache: Option<Vec<Vec<u8>>>,
rendered: Vec<Cell>,
last_recomputed: usize,
}
impl Canvas {
pub fn new(width: u32, height: u32) -> Self {
Self {
width,
height,
cells: vec![Cell::blank(); width as usize * height as usize],
row_dirty: vec![true; height as usize],
render_cache: None,
rendered: vec![Cell::blank(); width as usize * height as usize],
last_recomputed: 0,
}
}
pub fn width(&self) -> u32 {
self.width
}
pub fn height(&self) -> u32 {
self.height
}
#[inline]
fn index(&self, x: i32, y: i32) -> Option<usize> {
if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 {
return None;
}
Some(y as usize * self.width as usize + x as usize)
}
#[inline]
pub fn mark_row_dirty(&mut self, y: i32) {
if y >= 0 && (y as usize) < self.row_dirty.len() {
self.row_dirty[y as usize] = true;
}
}
pub fn dirty_rows(&self) -> usize {
self.row_dirty.iter().filter(|&&d| d).count()
}
pub fn last_recomputed_rows(&self) -> usize {
self.last_recomputed
}
pub fn dirty_fraction(&self) -> f32 {
if self.row_dirty.is_empty() {
return 0.0;
}
self.dirty_rows() as f32 / self.row_dirty.len() as f32
}
pub fn reset_render_cache(&mut self) {
self.render_cache = None;
self.row_dirty.iter_mut().for_each(|d| *d = true);
}
pub fn get(&self, x: i32, y: i32) -> Option<Cell> {
self.index(x, y).map(|i| self.cells[i])
}
pub fn put(&mut self, x: i32, y: i32, ch: char, color: Option<Rgb>) {
if let Some(i) = self.index(x, y) {
self.cells[i] = Cell::new(ch, color);
self.row_dirty[y as usize] = true;
}
}
pub fn clear(&mut self) {
self.cells.fill(Cell::blank());
self.row_dirty.iter_mut().for_each(|d| *d = true);
}
pub fn fill(&mut self, ch: char, color: Option<Rgb>) {
self.cells.fill(Cell::new(ch, color));
self.row_dirty.iter_mut().for_each(|d| *d = true);
}
pub fn text(&mut self, x: i32, y: i32, s: &str, color: Option<Rgb>) {
for (i, ch) in s.chars().enumerate() {
self.put(x + i as i32, y, ch, color);
}
}
pub fn text_centered(&mut self, y: i32, s: &str, color: Option<Rgb>) {
let len = s.chars().count() as i32;
self.text((self.width as i32 - len) / 2, y, s, color);
}
pub fn line(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, ch: char, color: Option<Rgb>) {
let (mut x, mut y) = (x0, y0);
let dx = (x1 - x0).abs();
let dy = -(y1 - y0).abs();
let sx = if x0 < x1 { 1 } else { -1 };
let sy = if y0 < y1 { 1 } else { -1 };
loop {
self.put(x, y, ch, color);
if x == x1 && y == y1 {
break;
}
let e2 = 2 * (dx + dy);
if e2 >= dy {
x += sx;
}
if e2 <= dx {
y += sy;
}
}
}
pub fn rect_fill(&mut self, x: i32, y: i32, w: u32, h: u32, ch: char, color: Option<Rgb>) {
for dy in 0..h as i32 {
for dx in 0..w as i32 {
self.put(x + dx, y + dy, ch, color);
}
}
}
pub fn rect_outline(&mut self, x: i32, y: i32, w: u32, h: u32, color: Option<Rgb>) {
if w == 0 || h == 0 {
return;
}
let (w, h) = (w as i32, h as i32);
for dx in 0..w {
let (top, bottom) = if dx == 0 || dx == w - 1 {
('+', '+')
} else {
('-', '-')
};
self.put(x + dx, y, top, color);
if h > 1 {
self.put(x + dx, y + h - 1, bottom, color);
}
}
for dy in 1..h - 1 {
self.put(x, y + dy, '|', color);
if w > 1 {
self.put(x + w - 1, y + dy, '|', color);
}
}
}
pub fn panel(
&mut self,
x: i32,
y: i32,
w: u32,
h: u32,
title: Option<&str>,
color: Option<Rgb>,
) {
self.rect_outline(x, y, w, h, color);
if let Some(title) = title {
if w as usize > title.chars().count() + 2 {
self.text(x + 2, y, title, color);
}
}
}
#[allow(clippy::too_many_arguments)] pub fn bar(
&mut self,
x: i32,
y: i32,
w: u32,
frac: f32,
full_ch: char,
empty_ch: char,
color: Option<Rgb>,
) {
let frac = frac.clamp(0.0, 1.0);
let filled = (w as f32 * frac).round() as u32;
for dx in 0..w {
let ch = if dx < filled { full_ch } else { empty_ch };
self.put(x + dx as i32, y, ch, color);
}
}
pub fn circle(&mut self, cx: i32, cy: i32, r: u32, ch: char, color: Option<Rgb>, fill: bool) {
let r = r as i32;
for dy in -r..=r {
for dx in -r..=r {
let d = ((dx * dx + dy * dy) as f32).sqrt();
let on = if fill {
d <= r as f32 + 0.5
} else {
(d - r as f32).abs() < 0.5
};
if on {
self.put(cx + dx, cy + dy, ch, color);
}
}
}
}
pub fn blit<S: AsRef<str>>(
&mut self,
frame: &[S],
x: i32,
y: i32,
color: Option<Rgb>,
transparent: char,
) {
for (dy, row) in frame.iter().enumerate() {
for (dx, ch) in row.as_ref().chars().enumerate() {
if ch == transparent {
continue;
}
self.put(x + dx as i32, y + dy as i32, ch, color);
}
}
}
pub fn blit_sprite(
&mut self,
sprite: &Sprite,
tick: u64,
x: i32,
y: i32,
color: Option<Rgb>,
transparent: char,
) {
self.blit_cells(sprite.frame(tick), x, y, color, transparent);
}
pub fn blit_cells(
&mut self,
rows: &[Vec<Cell>],
x: i32,
y: i32,
color: Option<Rgb>,
transparent: char,
) {
for (dy, row) in rows.iter().enumerate() {
for (dx, cell) in row.iter().enumerate() {
if cell.ch == transparent {
continue;
}
self.put(x + dx as i32, y + dy as i32, cell.ch, color.or(cell.color));
}
}
}
#[allow(clippy::too_many_arguments)] pub fn blit_cells_scaled(
&mut self,
rows: &[Vec<Cell>],
x: i32,
y: i32,
w: u32,
h: u32,
color: Option<Rgb>,
transparent: char,
) {
let (sw, sh) = (sprite_cells_width(rows) as f32, rows.len() as f32);
if sw == 0.0 || sh == 0.0 || w == 0 || h == 0 {
return;
}
for dy in 0..h as i32 {
for dx in 0..w as i32 {
let sx = ((dx as f32 / w as f32) * sw) as usize;
let sy = ((dy as f32 / h as f32) * sh) as usize;
let Some(row) = rows.get(sy) else {
continue;
};
let Some(cell) = row.get(sx) else {
continue;
};
if cell.ch == transparent {
continue;
}
self.put(x + dx, y + dy, cell.ch, color.or(cell.color));
}
}
}
#[allow(clippy::too_many_arguments)] pub fn blit_sprite_scaled(
&mut self,
sprite: &Sprite,
tick: u64,
x: i32,
y: i32,
w: u32,
h: u32,
color: Option<Rgb>,
transparent: char,
) {
self.blit_cells_scaled(sprite.frame(tick), x, y, w, h, color, transparent);
}
pub fn blit_ansi(&mut self, frame: &str, x: i32, y: i32, transparent: char) {
for (dy, row) in parse_ansi_rows(frame).iter().enumerate() {
for (dx, cell) in row.iter().enumerate() {
if cell.ch == transparent {
continue;
}
self.put(x + dx as i32, y + dy as i32, cell.ch, cell.color);
}
}
}
#[cfg(feature = "colorize")]
pub fn apply_animation(&mut self, animation: &PixelAnimation, tick: u64) {
let (w, h) = (self.width as usize, self.height as usize);
if w == 0 {
return;
}
for y in 0..h {
let start = y * w;
let mut changed = false;
for (x, cell) in self.cells[start..start + w].iter_mut().enumerate() {
if cell.ch == ' ' {
continue;
}
let color = Some(animation_color(animation, x, y, tick, cell.ch, w, h));
if cell.color != color {
cell.color = color;
changed = true;
}
}
if changed {
self.row_dirty[y] = true;
}
}
}
fn render_row(row: &[Cell], buf: &mut Vec<u8>) {
let mut run: Option<Rgb> = None;
for cell in row {
match cell.color {
None => {
if run.take().is_some() {
buf.extend_from_slice(b"\x1b[0m");
}
crate::fast_ansi::push_char(buf, cell.ch);
}
Some(c) => {
if run != Some(c) {
if run.is_some() {
buf.extend_from_slice(b"\x1b[0m");
}
crate::fast_ansi::push_truecolor_escape(buf, c.0, c.1, c.2);
run = Some(c);
}
crate::fast_ansi::push_char(buf, cell.ch);
}
}
}
if run.is_some() {
buf.extend_from_slice(b"\x1b[0m");
}
}
pub fn write_into(&self, out: &mut String) {
let (w, h) = (self.width as usize, self.height as usize);
if self.cells.is_empty() {
return;
}
out.reserve(h * (w * 8 + 5));
let buf = unsafe { out.as_mut_vec() };
for y in 0..h {
Self::render_row(&self.cells[y * w..y * w + w], buf);
buf.push(b'\n');
}
}
pub fn write_dirty_into(&mut self, out: &mut String) {
let (w, h) = (self.width as usize, self.height as usize);
if self.cells.is_empty() {
self.render_cache = None;
self.row_dirty.iter_mut().for_each(|d| *d = true);
self.last_recomputed = 0;
return;
}
let mut cache = self
.render_cache
.take()
.unwrap_or_else(|| vec![Vec::new(); h]);
out.reserve(h * (w * 8 + 5));
let mut recomputed = 0usize;
for (y, cached) in cache.iter_mut().enumerate() {
let start = y * w;
let row = &self.cells[start..start + w];
let changed = self.row_dirty[y] && self.rendered[start..start + w] != *row;
if changed || cached.is_empty() {
cached.clear();
Self::render_row(row, cached);
self.rendered[start..start + w].copy_from_slice(row);
recomputed += 1;
}
out.push_str(unsafe { std::str::from_utf8_unchecked(cached) });
out.push('\n');
}
self.render_cache = Some(cache);
self.row_dirty.iter_mut().for_each(|d| *d = false);
self.last_recomputed = recomputed;
}
pub fn present_dirty_into(&mut self, out: &mut String) -> bool {
let (w, h) = (self.width as usize, self.height as usize);
if self.cells.is_empty() {
self.render_cache = None;
self.row_dirty.iter_mut().for_each(|d| *d = true);
self.last_recomputed = 0;
return false;
}
let first_paint = self.render_cache.is_none();
let mut cache = self
.render_cache
.take()
.unwrap_or_else(|| vec![Vec::new(); h]);
let mut prev_cells: Vec<crate::fast_ansi::ParsedCell> = Vec::new();
let mut next_cells: Vec<crate::fast_ansi::ParsedCell> = Vec::new();
let mut recomputed = 0usize;
let mut emitted = false;
for (y, cached) in cache.iter_mut().enumerate() {
let start = y * w;
let row = &self.cells[start..start + w];
let changed = self.row_dirty[y] && self.rendered[start..start + w] != *row;
if !(changed || cached.is_empty()) {
continue;
}
cached.clear();
Self::render_row(row, cached);
recomputed += 1;
if first_paint {
let _ = write!(out, "\x1b[{};1H", y + 1);
out.push_str(unsafe { std::str::from_utf8_unchecked(cached.as_slice()) });
emitted = true;
} else {
prev_cells.clear();
next_cells.clear();
for (old, new) in self.rendered[start..start + w].iter().zip(row) {
prev_cells.push((old.ch, old.color));
next_cells.push((new.ch, new.color));
}
let before = out.len();
crate::fast_ansi::diff_cells_into(out, y + 1, &prev_cells, &next_cells);
emitted |= out.len() != before;
}
self.rendered[start..start + w].copy_from_slice(row);
}
self.render_cache = Some(cache);
self.row_dirty.iter_mut().for_each(|d| *d = false);
self.last_recomputed = recomputed;
emitted
}
#[allow(clippy::inherent_to_string)] pub fn to_string(&self) -> String {
let mut out = String::new();
self.write_into(&mut out);
out
}
pub fn to_string_dirty(&mut self) -> String {
let mut out = String::new();
self.write_dirty_into(&mut out);
out
}
pub fn to_plain(&self) -> String {
let (w, h) = (self.width as usize, self.height as usize);
if self.cells.is_empty() {
return String::new();
}
let mut out = String::with_capacity(h * (w + 1));
for (i, cell) in self.cells.iter().enumerate() {
if i > 0 && i % w == 0 {
out.push('\n');
}
out.push(cell.ch);
}
out.push('\n');
out
}
}
fn sprite_cells_width(rows: &[Vec<Cell>]) -> usize {
rows.iter().map(|r| r.len()).max().unwrap_or(0)
}
fn parse_ansi_rows(frame: &str) -> Vec<Vec<Cell>> {
let mut rows = Vec::new();
let mut cells: Vec<crate::fast_ansi::ParsedCell> = Vec::new();
for line in frame.split('\n') {
crate::fast_ansi::parse_sgr_cells(line, &mut cells);
rows.push(
cells
.iter()
.map(|&(ch, color)| Cell::new(ch, color))
.collect(),
);
}
if rows.last().is_some_and(|r: &Vec<Cell>| r.is_empty()) {
rows.pop();
}
rows
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Sprite {
frames: Vec<Vec<Vec<Cell>>>,
}
impl Sprite {
pub fn from_lines<S: AsRef<str>>(lines: &[S]) -> Self {
Self {
frames: vec![lines
.iter()
.map(|l| l.as_ref().chars().map(|ch| Cell::new(ch, None)).collect())
.collect()],
}
}
pub fn from_grid(grid: &iascii::grid::Grid) -> Self {
let w = grid.width() as usize;
if w == 0 || grid.height() == 0 {
return Self {
frames: vec![Vec::new()],
};
}
let mut rows = Vec::with_capacity(grid.height() as usize);
for row in grid.as_slice().chunks_exact(w) {
rows.push(
row.iter()
.map(|c| Cell::new(c.ch, Some(Rgb::new(c.color.r, c.color.g, c.color.b))))
.collect(),
);
}
Self { frames: vec![rows] }
}
pub fn from_frames(frames: Vec<Vec<Vec<Cell>>>) -> Self {
Self { frames }
}
pub fn add_frame<S: AsRef<str>>(&mut self, lines: &[S]) {
self.frames.push(
lines
.iter()
.map(|l| l.as_ref().chars().map(|ch| Cell::new(ch, None)).collect())
.collect(),
);
}
pub fn add_frame_grid(&mut self, grid: &iascii::grid::Grid) {
let mut other = Sprite::from_grid(grid);
self.frames.append(&mut other.frames);
}
pub fn len(&self) -> usize {
self.frames.len()
}
pub fn is_empty(&self) -> bool {
self.frames.is_empty()
}
pub fn frame(&self, tick: u64) -> &[Vec<Cell>] {
if self.frames.is_empty() {
return &[];
}
&self.frames[tick as usize % self.frames.len()]
}
pub fn frame_text(&self, tick: u64) -> Vec<String> {
self.frame(tick)
.iter()
.map(|row| row.iter().map(|c| c.ch).collect())
.collect()
}
pub fn width(&self, tick: u64) -> usize {
sprite_cells_width(self.frame(tick))
}
pub fn height(&self, tick: u64) -> usize {
self.frame(tick).len()
}
pub fn flip_h(&self) -> Self {
Self {
frames: self
.frames
.iter()
.map(|f| {
f.iter()
.map(|r| r.iter().rev().copied().collect())
.collect()
})
.collect(),
}
}
}
pub mod screen {
pub const ENTER_ALT: &str = "\x1b[?1049h";
pub const LEAVE_ALT: &str = "\x1b[?1049l";
pub const HIDE_CURSOR: &str = "\x1b[?25l";
pub const SHOW_CURSOR: &str = "\x1b[?25h";
pub const CLEAR: &str = "\x1b[H\x1b[2J";
pub const HOME: &str = "\x1b[H";
}
pub fn present_diff(prev: &mut String, next: &str) -> String {
let mut out = String::new();
if !prev.is_empty() {
crate::render::diff_frame_into(prev, next, crate::render::DiffGranularity::Cell, &mut out);
} else {
out.push_str(next);
}
prev.clear();
prev.push_str(next);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "colorize")]
fn plain_canvas() -> Canvas {
let mut c = Canvas::new(10, 5);
c.text(0, 0, "hello", None);
c
}
#[test]
fn put_and_get_round_trip_with_clipping() {
let mut c = Canvas::new(4, 3);
c.put(1, 1, '@', Some(Rgb::RED));
assert_eq!(c.get(1, 1), Some(Cell::new('@', Some(Rgb::RED))));
assert_eq!(c.get(-1, 0), None);
assert_eq!(c.get(4, 0), None);
assert_eq!(c.get(0, 3), None);
c.put(-5, -5, 'X', None);
c.put(99, 99, 'X', None);
assert_eq!(c.get(0, 0), Some(Cell::blank()));
}
#[test]
fn text_clips_at_edges() {
let mut c = Canvas::new(5, 2);
c.text(-2, 0, "abcdef", None);
assert_eq!(c.get(0, 0).unwrap().ch, 'c');
assert_eq!(c.get(3, 0).unwrap().ch, 'f');
assert_eq!(c.get(4, 0).unwrap().ch, ' ');
c.text(0, 7, "offscreen", None); let plain = c.to_plain();
assert_eq!(plain.lines().count(), 2);
}
#[test]
fn text_centered_lands_in_the_middle() {
let mut c = Canvas::new(10, 1);
c.text_centered(0, "ab", None);
assert_eq!(c.get(4, 0).unwrap().ch, 'a');
assert_eq!(c.get(5, 0).unwrap().ch, 'b');
}
#[test]
fn line_hits_both_endpoints() {
let mut c = Canvas::new(8, 8);
c.line(0, 0, 7, 7, '#', None);
assert_eq!(c.get(0, 0).unwrap().ch, '#');
assert_eq!(c.get(7, 7).unwrap().ch, '#');
assert_eq!(c.get(3, 3).unwrap().ch, '#');
let mut c2 = Canvas::new(3, 3);
c2.line(1, 1, 1, 1, 'X', None);
assert_eq!(c2.get(1, 1).unwrap().ch, 'X');
}
#[test]
fn rect_fill_and_outline_counts() {
let mut c = Canvas::new(6, 6);
c.rect_fill(1, 1, 2, 2, '.', None);
let filled = c.cells.iter().filter(|cell| cell.ch == '.').count();
assert_eq!(filled, 4);
let mut o = Canvas::new(6, 6);
o.rect_outline(0, 0, 4, 3, None);
let drawn = o.cells.iter().filter(|cell| cell.ch != ' ').count();
assert_eq!(drawn, 10);
assert_eq!(o.get(0, 0).unwrap().ch, '+');
}
#[test]
fn panel_draws_frame_and_title() {
let mut c = Canvas::new(12, 5);
c.panel(0, 0, 12, 5, Some("menu"), None);
let plain = c.to_plain();
assert!(plain.contains("menu"));
assert!(plain.contains('+'));
}
#[test]
fn bar_scales_with_fraction() {
let mut c = Canvas::new(10, 1);
c.bar(0, 0, 10, 0.5, '#', '-', None);
let row: String = (0..10).map(|x| c.get(x, 0).unwrap().ch).collect();
assert_eq!(row, "#####-----");
c.bar(0, 0, 10, 2.0, '#', '-', None); let row: String = (0..10).map(|x| c.get(x, 0).unwrap().ch).collect();
assert_eq!(row, "##########");
}
#[test]
fn circle_outline_and_fill() {
let mut outline = Canvas::new(11, 11);
outline.circle(5, 5, 3, 'o', None, false);
assert_eq!(outline.get(5, 2).unwrap().ch, 'o'); assert_eq!(outline.get(5, 5).unwrap().ch, ' '); let mut filled = Canvas::new(11, 11);
filled.circle(5, 5, 3, 'o', None, true);
assert_eq!(filled.get(5, 5).unwrap().ch, 'o');
}
#[test]
fn blit_skips_transparent_and_clips() {
let mut c = Canvas::new(4, 2);
c.fill('.', None);
c.blit(&["a b".to_owned()], -1, 0, Some(Rgb::RED), ' ');
assert_eq!(c.get(0, 0).unwrap().ch, '.');
assert_eq!(c.get(1, 0).unwrap().ch, 'b');
assert_eq!(c.get(1, 0).unwrap().color, Some(Rgb::RED));
}
#[test]
fn sprite_cycles_frames_and_flips() {
let s = Sprite::from_lines(&["->", " "]);
assert_eq!(s.frame_text(0), vec!["->".to_owned(), " ".to_owned()]);
assert_eq!((s.width(0), s.height(0)), (2, 2));
assert!(s.frame(0).iter().flatten().all(|c| c.color.is_none()));
let two = Sprite::from_frames(vec![
vec![vec![Cell::new('a', Some(Rgb::RED))]],
vec![vec![Cell::new('b', None)]],
]);
assert_eq!(two.frame_text(0), vec!["a".to_owned()]);
assert_eq!(two.frame_text(1), vec!["b".to_owned()]);
assert_eq!(two.frame_text(2), vec!["a".to_owned()]);
assert_eq!(two.frame_text(1_000_001), vec!["b".to_owned()]);
assert_eq!(two.frame(0)[0][0].color, Some(Rgb::RED));
let flipped = Sprite::from_lines(&["ab>"]).flip_h();
assert_eq!(flipped.frame_text(0), vec![">ba".to_owned()]);
assert!(Sprite::from_frames(vec![]).frame(99).is_empty());
}
#[test]
fn sprite_from_grid_keeps_glyphs_and_colors() {
let grid = test_grid();
let sprite = Sprite::from_grid(&grid);
assert_eq!((sprite.width(0), sprite.height(0)), (6, 4));
assert!(sprite.frame(0).iter().flatten().all(|c| c.color.is_some()));
assert_eq!(sprite.frame_text(0).len(), 4);
}
fn test_grid() -> iascii::grid::Grid {
let cfg = iascii::config::ConfigBuilder::new()
.output_sizing(iascii::config::OutputSizing::Explicit {
width: 6,
height: 4,
})
.build()
.unwrap();
let mut pix = Vec::with_capacity(6 * 4 * 3);
for y in 0..4u32 {
for x in 0..6u32 {
pix.push((x * 51) as u8);
pix.push((y * 85) as u8);
pix.push(((x + y) * 36) as u8);
}
}
iascii::convert::convert_image(6, 4, &pix, &cfg).unwrap()
}
#[test]
fn blit_sprite_tint_overrides_while_none_keeps_cell_colors() {
let mut c = Canvas::new(4, 1);
let sprite = Sprite::from_grid(&test_grid());
c.blit_sprite(&sprite, 0, 0, 0, Some(Rgb::WHITE), ' ');
assert!(c
.cells
.iter()
.filter(|cell| cell.ch != ' ')
.all(|cell| cell.color == Some(Rgb::WHITE)));
let mut d = Canvas::new(6, 4);
d.blit_sprite(&sprite, 0, 0, 0, None, ' ');
for (i, cell) in sprite.frame(0).iter().flatten().copied().enumerate() {
if cell.ch == ' ' {
assert_eq!(
d.cells[i],
Cell::blank(),
"transparent cell must be skipped"
);
} else {
assert_eq!(d.cells[i], cell, "inked cell must keep its own color");
}
}
}
#[test]
fn blit_ansi_parses_escapes_and_positions_cells() {
let mut c = Canvas::new(5, 2);
c.fill('.', None);
c.blit_ansi("\x1b[38;2;10;20;30mAB\x1b[0mC\x1b[38;5;196mD", 1, 0, ' ');
assert_eq!(
c.get(1, 0).unwrap(),
Cell::new('A', Some(Rgb::new(10, 20, 30)))
);
assert_eq!(
c.get(2, 0).unwrap(),
Cell::new('B', Some(Rgb::new(10, 20, 30)))
);
assert_eq!(c.get(3, 0).unwrap(), Cell::new('C', None));
assert_eq!(c.get(4, 0).unwrap().color, Some(Rgb::new(255, 0, 0)));
let mut d = Canvas::new(2, 1);
d.blit_ansi("\x1b[91mX\x1b[37mY", 0, 0, ' ');
assert_eq!(d.get(0, 0).unwrap().color, Some(Rgb::new(255, 0, 0)));
assert_eq!(d.get(1, 0).unwrap().color, Some(Rgb::new(192, 192, 192)));
let mut e = Canvas::new(2, 1);
e.fill('.', None);
e.blit_ansi("\x1b[38;2;1;2;3m ", 0, 0, ' ');
assert_eq!(e.get(0, 0).unwrap().ch, '.');
}
#[test]
fn blit_scaled_grows_and_shrinks() {
let sprite = Sprite::from_lines(&["AB", "CD"]);
let mut big = Canvas::new(4, 4);
big.blit_sprite_scaled(&sprite, 0, 0, 0, 4, 4, None, ' ');
assert_eq!(big.get(0, 0).unwrap().ch, 'A');
assert_eq!(big.get(1, 0).unwrap().ch, 'A');
assert_eq!(big.get(2, 0).unwrap().ch, 'B');
assert_eq!(big.get(0, 2).unwrap().ch, 'C');
assert_eq!(big.get(3, 3).unwrap().ch, 'D');
let mut small = Canvas::new(4, 4);
small.blit_sprite_scaled(&sprite, 0, 0, 0, 1, 1, None, ' ');
assert_eq!(small.get(0, 0).unwrap().ch, 'A');
assert_eq!(small.get(1, 0).unwrap().ch, ' ');
}
#[test]
fn to_string_dirty_is_byte_identical_to_full_render() {
let mut c = Canvas::new(6, 3);
c.fill('.', None);
c.text(1, 1, "hi", Some(Rgb::RED));
assert_eq!(c.to_string_dirty(), c.to_string());
assert_eq!(c.last_recomputed_rows(), 3, "cold cache renders every row");
assert_eq!(c.dirty_rows(), 0, "render consumes the dirty flags");
c.put(2, 1, 'X', Some(Rgb::BLUE));
assert_eq!(c.dirty_rows(), 1);
assert_eq!(c.to_string_dirty(), c.to_string());
assert_eq!(c.last_recomputed_rows(), 1);
assert_eq!(c.dirty_rows(), 0);
assert_eq!(c.to_string_dirty(), c.to_string());
assert_eq!(
c.last_recomputed_rows(),
0,
"clean frame costs zero renders"
);
}
#[test]
fn to_string_dirty_sees_clear_and_painted_rows() {
let mut c = Canvas::new(4, 2);
c.fill('#', Some(Rgb::GREEN));
let _ = c.to_string_dirty();
c.clear();
assert_eq!(c.dirty_rows(), 2);
let out = c.to_string_dirty();
assert_eq!(out, c.to_string());
assert!(!out.contains('#'));
assert_eq!(c.last_recomputed_rows(), 2);
}
#[test]
fn to_string_dirty_reuses_rows_after_identical_clear_redraw() {
let mut c = Canvas::new(4, 2);
c.text(0, 0, "abcd", Some(Rgb::RED));
c.text(0, 1, "efgh", Some(Rgb::BLUE));
let first = c.to_string_dirty();
assert_eq!(c.last_recomputed_rows(), 2);
c.clear();
c.text(0, 0, "abcd", Some(Rgb::RED));
c.text(0, 1, "efgh", Some(Rgb::BLUE));
assert_eq!(c.dirty_rows(), 2);
assert_eq!(c.to_string_dirty(), first);
assert_eq!(
c.last_recomputed_rows(),
0,
"identical clear+redraw must render nothing"
);
}
#[test]
fn to_string_dirty_tracks_movement_across_rows() {
let mut c = Canvas::new(8, 3);
let _ = c.to_string_dirty();
c.put(1, 1, '@', Some(Rgb::WHITE));
c.put(2, 1, ' ', None);
assert_eq!(c.dirty_rows(), 1);
let out = c.to_string_dirty();
assert_eq!(out, c.to_string());
assert_eq!(
c.last_recomputed_rows(),
1,
"one row moved, one row rendered"
);
}
#[test]
fn reset_render_cache_forces_a_full_render() {
let mut c = Canvas::new(3, 2);
let _ = c.to_string_dirty();
assert_eq!(c.last_recomputed_rows(), 2);
let _ = c.to_string_dirty();
assert_eq!(c.last_recomputed_rows(), 0);
c.reset_render_cache();
assert_eq!(c.dirty_rows(), 2, "everything dirty after a reset");
let _ = c.to_string_dirty();
assert_eq!(c.last_recomputed_rows(), 2, "reset forces full re-render");
}
#[test]
fn write_into_matches_to_string_and_reuses_the_buffer() {
let mut c = Canvas::new(5, 3);
c.fill('.', Some(Rgb::BLUE));
c.text(1, 1, "hi", Some(Rgb::RED));
let mut buf = String::new();
c.write_into(&mut buf);
assert_eq!(buf, c.to_string());
c.write_into(&mut buf);
assert_eq!(buf, format!("{}{}", c.to_string(), c.to_string()));
}
#[test]
fn write_dirty_into_matches_the_string_variant_every_frame() {
let mut c = Canvas::new(6, 4);
c.text(0, 0, "score", Some(Rgb::RED));
let mut buf = String::new();
for step in 0..4i32 {
c.clear();
c.text(0, 0, "score", Some(Rgb::RED));
c.put(2, step, '@', Some(Rgb::WHITE));
buf.clear();
c.write_dirty_into(&mut buf);
assert_eq!(buf, c.to_string(), "step {step} must match full render");
}
}
#[test]
fn present_dirty_into_paints_full_then_only_patches() {
let mut c = Canvas::new(8, 3);
c.fill('.', None);
c.text(0, 0, "hi", Some(Rgb::RED));
let mut frame = String::new();
assert!(c.present_dirty_into(&mut frame));
assert!(frame.contains("\x1b[1;1H"));
assert!(frame.contains("\x1b[3;1H"));
assert!(!frame.contains('\n'), "present uses absolute addressing");
assert_eq!(c.last_recomputed_rows(), 3);
frame.clear();
assert!(!c.present_dirty_into(&mut frame));
assert_eq!(frame, "");
c.put(3, 1, 'X', Some(Rgb::GREEN));
frame.clear();
assert!(c.present_dirty_into(&mut frame));
assert_eq!(c.last_recomputed_rows(), 1, "only the dirty row is visited");
assert!(
frame.contains("\x1b[2;4H"),
"patched the exact cell: {frame:?}"
);
assert!(frame.contains('X'));
assert!(!frame.contains("..."), "static rows must not be re-sent");
}
#[test]
fn present_dirty_patch_stream_replays_to_the_frame() {
let (w, h) = (10usize, 3usize);
let mut c = Canvas::new(w as u32, h as u32);
c.fill('.', None);
c.text(1, 1, "hello", Some(Rgb::RED));
let mut screen = vec![vec![' '; w]; h];
let mut frame = String::new();
for step in 0..4i32 {
c.put(step, 0, '@', Some(Rgb::WHITE));
c.put(5, 2, (b'0' + step as u8) as char, Some(Rgb::GREEN));
frame.clear();
if c.present_dirty_into(&mut frame) {
apply_patch(&mut screen, &frame, w, h);
}
assert_eq!(glyphs(&screen), c.to_plain(), "step {step}");
}
}
fn apply_patch(screen: &mut [Vec<char>], patch: &str, cols: usize, rows: usize) {
let (mut r, mut c) = (0usize, 0usize);
let mut rest = patch;
while let Some(i) = rest.find('\x1b') {
for ch in rest[..i].chars() {
if r < rows && c < cols {
screen[r][c] = ch;
}
c += 1;
}
rest = &rest[i..];
let Some(end) = rest.find(|ch: char| ch.is_ascii_alphabetic()) else {
return;
};
match rest.as_bytes()[end] {
b'H' => {
let mut it = rest[2..end].split(';');
r = it.next().and_then(|s| s.parse().ok()).unwrap_or(1) - 1;
c = it.next().and_then(|s| s.parse().ok()).unwrap_or(1) - 1;
}
b'K' if r < rows => {
if let Some(tail) = screen[r].get_mut(c..cols) {
tail.fill(' ');
}
}
_ => {} }
rest = &rest[end + 1..];
}
for ch in rest.chars() {
if r < rows && c < cols {
screen[r][c] = ch;
}
c += 1;
}
}
fn glyphs(screen: &[Vec<char>]) -> String {
let mut out: String = screen
.iter()
.map(|row| row.iter().collect::<String>())
.collect::<Vec<_>>()
.join("\n");
out.push('\n');
out
}
#[test]
fn present_dirty_into_reset_forces_a_full_repaint() {
let mut c = Canvas::new(4, 2);
let mut frame = String::new();
assert!(c.present_dirty_into(&mut frame));
frame.clear();
assert!(!c.present_dirty_into(&mut frame));
c.reset_render_cache();
frame.clear();
assert!(c.present_dirty_into(&mut frame));
assert!(frame.contains("\x1b[1;1H") && frame.contains("\x1b[2;1H"));
}
#[cfg(feature = "colorize")]
#[test]
fn apply_animation_colors_every_inked_cell() {
let mut c = plain_canvas();
c.apply_animation(&PixelAnimation::Rainbow { speed: 5.0 }, 0);
assert!(c
.cells
.iter()
.all(|cell| cell.color.is_some() || cell.ch == ' '));
assert!(c.to_string().contains("\x1b[38;2;"));
}
#[test]
fn canvas_to_string_merges_runs_and_resets() {
let mut c = Canvas::new(4, 1);
c.text(0, 0, "ab", Some(Rgb::RED));
c.text(2, 0, "cd", Some(Rgb::RED));
let out = c.to_string();
assert_eq!(out.matches("\x1b[38;2;255;0;0m").count(), 1);
assert!(out.ends_with("\x1b[0m\n"));
}
#[test]
fn present_diff_first_paint_is_full_then_patches() {
let mut prev = String::new();
let full = present_diff(&mut prev, "ab\n");
assert_eq!(full, "ab\n");
let patch = present_diff(&mut prev, "ab\n");
assert_eq!(patch, "", "identical frame patches to nothing");
let patch2 = present_diff(&mut prev, "ac\n");
assert!(patch2.contains('c'));
assert!(!patch2.contains("\x1b[2J"), "no full clear on patch");
}
#[test]
fn present_diff_is_safe_when_only_colors_change() {
let mut prev = String::new();
let _ = present_diff(&mut prev, "\x1b[38;2;255;0;0mab\x1b[0m\n");
let patch = present_diff(&mut prev, "\x1b[38;2;0;255;0mab\x1b[0m\n");
assert!(
patch.contains("\x1b[38;2;0;255;0m"),
"whole escape: {patch:?}"
);
let residue = patch.replace("\x1b[38;2;0;255;0m", "");
assert!(
!residue.contains('8'),
"no escape fragment leaked: {patch:?}"
);
}
#[test]
fn screen_constants_are_sane_escapes() {
assert!(screen::ENTER_ALT.starts_with('\x1b'));
assert!(screen::LEAVE_ALT.starts_with('\x1b'));
assert_eq!(screen::CLEAR, "\x1b[H\x1b[2J");
}
#[test]
fn zero_sized_canvas_is_harmless() {
let mut c = Canvas::new(0, 0);
assert_eq!(c.to_string(), "");
assert_eq!(c.to_string_dirty(), "");
let mut out = String::new();
assert!(!c.present_dirty_into(&mut out));
assert_eq!(out, "");
}
}