use crate::style::Style;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Cell {
pub ch: char,
pub style: Style,
pub width: u8,
}
impl Default for Cell {
fn default() -> Self {
Cell {
ch: ' ',
style: Style::default(),
width: 1,
}
}
}
impl Cell {
pub const fn new(ch: char, style: Style) -> Self {
Cell {
ch,
style,
width: 1,
}
}
pub const fn wide(ch: char, style: Style) -> Self {
Cell {
ch,
style,
width: 2,
}
}
pub const fn continuation(style: Style) -> Self {
Cell {
ch: ' ',
style,
width: 0,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Screen {
rows: usize,
cols: usize,
cells: Vec<Cell>,
pub cursor: (usize, usize),
}
impl Screen {
pub fn new(rows: usize, cols: usize) -> Self {
Screen {
rows,
cols,
cells: vec![Cell::default(); rows * cols],
cursor: (0, 0),
}
}
pub fn rows(&self) -> usize {
self.rows
}
pub fn cols(&self) -> usize {
self.cols
}
pub fn clear(&mut self) {
self.cells.fill(Cell::default());
self.cursor = (0, 0);
}
pub fn cell(&self, row: usize, col: usize) -> Cell {
if row < self.rows && col < self.cols {
self.cells[row * self.cols + col]
} else {
Cell::default()
}
}
pub fn set(&mut self, row: usize, col: usize, cell: Cell) {
if row < self.rows && col < self.cols {
self.cells[row * self.cols + col] = cell;
}
}
pub fn write_str(&mut self, row: usize, col: usize, text: &str, style: Style) {
for (i, ch) in text.chars().enumerate() {
self.set(row, col + i, Cell::new(ch, style));
}
}
pub fn copy_cells(&mut self, row: usize, col: usize, cells: &[Cell]) {
if row >= self.rows || col >= self.cols {
return;
}
let n = cells.len().min(self.cols - col);
let base = row * self.cols + col;
self.cells[base..base + n].copy_from_slice(&cells[..n]);
}
pub fn diff(&self, next: &Screen) -> Vec<u8> {
if self.rows != next.rows || self.cols != next.cols {
return next.render_full();
}
if self == next {
return Vec::new();
}
let mut out = String::new();
emit_changes(Some(self), next, &mut out);
out.into_bytes()
}
pub fn render_full(&self) -> Vec<u8> {
let mut out = String::from("\x1b[2J");
emit_changes(None, self, &mut out);
out.into_bytes()
}
}
fn emit_changes(prev: Option<&Screen>, next: &Screen, out: &mut String) {
use std::fmt::Write;
let mut style = Style::default();
let mut at: Option<(usize, usize)> = None;
for row in 0..next.rows {
let last_content = (0..next.cols)
.rev()
.find(|&c| next.cell(row, c) != Cell::default())
.map_or(0, |c| c + 1);
for col in 0..last_content {
let target = next.cell(row, col);
if target.width == 0 {
continue;
}
let same = match prev {
Some(p) => p.cell(row, col) == target,
None => target == Cell::default(),
};
if same {
continue;
}
if at != Some((row, col)) {
write!(out, "\x1b[{};{}H", row + 1, col + 1).unwrap();
}
out.push_str(&style.transition_to(&target.style));
style = target.style;
out.push(target.ch);
let advance = target.width.max(1) as usize;
at = if col + advance < next.cols {
Some((row, col + advance))
} else {
None
};
}
if last_content < next.cols {
let needs_erase = match prev {
Some(p) => (last_content..next.cols).any(|c| p.cell(row, c) != Cell::default()),
None => false,
};
if needs_erase {
out.push_str(&style.transition_to(&Style::default()));
style = Style::default();
if at != Some((row, last_content)) {
write!(out, "\x1b[{};{}H", row + 1, last_content + 1).unwrap();
}
out.push_str("\x1b[K");
at = None;
}
}
}
out.push_str(&style.transition_to(&Style::default()));
let (r, c) = next.cursor;
write!(out, "\x1b[{};{}H", r + 1, c + 1).unwrap();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::Style;
fn filled_row(rows: usize, cols: usize, row: usize, ch: char) -> Screen {
let mut s = Screen::new(rows, cols);
for c in 0..cols {
s.set(row, c, Cell::new(ch, Style::default()));
}
s
}
fn contains_el(bytes: &[u8]) -> bool {
let s = std::str::from_utf8(bytes).unwrap_or("");
s.contains("\x1b[K")
}
#[test]
fn clear_resets_all_cells_and_cursor_without_realloc() {
let mut s = Screen::new(4, 6);
let dirty_style = Style {
bold: true,
..Style::default()
};
for r in 0..4 {
for c in 0..6 {
s.set(r, c, Cell::new('X', dirty_style));
}
}
s.cursor = (3, 5);
s.clear();
assert_eq!(s.rows(), 4);
assert_eq!(s.cols(), 6);
assert_eq!(s.cursor, (0, 0));
let blank = Cell::default();
for r in 0..4 {
for c in 0..6 {
assert_eq!(
s.cell(r, c),
blank,
"cell ({r},{c}) was not reset by clear()"
);
}
}
}
#[test]
fn diff_emits_el_when_row_tail_clears_to_blank() {
let prev = filled_row(2, 8, 0, 'X');
let mut next = Screen::new(2, 8);
for c in 0..3 {
next.set(0, c, Cell::new('X', Style::default()));
}
let diff = prev.diff(&next);
assert!(
contains_el(&diff),
"expected \\x1b[K in diff when tail blanks out"
);
}
#[test]
fn diff_no_el_when_tail_was_already_blank_in_prev() {
let mut prev = Screen::new(2, 8);
let mut next = Screen::new(2, 8);
for c in 0..3 {
prev.set(0, c, Cell::new('A', Style::default()));
next.set(0, c, Cell::new('B', Style::default()));
}
let diff = prev.diff(&next);
assert!(
!contains_el(&diff),
"unexpected \\x1b[K when tail was already blank"
);
}
#[test]
fn render_full_never_emits_el() {
let s = filled_row(2, 8, 0, 'X');
let bytes = s.render_full();
assert!(!contains_el(&bytes), "render_full must not emit \\x1b[K");
}
#[test]
fn diff_el_visual_correctness_via_round_trip() {
let prev = filled_row(1, 6, 0, 'Z');
let mut next = Screen::new(1, 6);
next.set(0, 0, Cell::new('A', Style::default()));
assert!(contains_el(&prev.diff(&next)));
let empty = next.diff(&next);
assert!(empty.is_empty(), "diff of identical screens must be empty");
}
#[test]
fn diff_entire_row_blank_in_next_uses_el() {
let prev = filled_row(3, 5, 1, 'Y');
let next = Screen::new(3, 5);
let diff = prev.diff(&next);
assert!(
contains_el(&diff),
"entire-row blank transition should use EL"
);
}
#[test]
fn cell_constructors_set_width() {
let st = Style::default();
assert_eq!(Cell::default().width, 1);
assert_eq!(Cell::new('a', st).width, 1);
assert_eq!(Cell::wide('世', st).width, 2);
let cont = Cell::continuation(st);
assert_eq!(cont.width, 0);
assert_eq!(cont.ch, ' ');
}
#[test]
fn wide_lead_emits_glyph_once_no_continuation_char() {
let prev = Screen::new(1, 4);
let mut next = Screen::new(1, 4);
next.set(0, 0, Cell::wide('世', Style::default()));
next.set(0, 1, Cell::continuation(Style::default()));
let diff = prev.diff(&next);
let s = std::str::from_utf8(&diff).unwrap();
assert_eq!(s.matches('世').count(), 1, "glyph must appear exactly once");
assert!(
!s.contains(' '),
"continuation column must emit no space: {s:?}"
);
assert_eq!(diff, "\x1b[1;1H世\x1b[1;1H".as_bytes());
}
#[test]
fn wide_lead_advances_cursor_by_two() {
let prev = Screen::new(1, 6);
let mut next = Screen::new(1, 6);
next.set(0, 0, Cell::wide('世', Style::default()));
next.set(0, 1, Cell::continuation(Style::default()));
next.set(0, 2, Cell::new('X', Style::default()));
let diff = prev.diff(&next);
assert_eq!(diff, "\x1b[1;1H世X\x1b[1;1H".as_bytes());
}
#[test]
fn ascii_diff_is_byte_identical_regression() {
let mut old = Screen::new(2, 10);
old.write_str(0, 0, "hello", Style::default());
let mut new = old.clone();
new.set(0, 1, Cell::new('a', Style::default()));
assert_eq!(old.diff(&new), b"\x1b[1;2Ha\x1b[1;1H");
}
#[test]
fn copy_cells_matches_repeated_set() {
let st = Style::default();
let run = [
Cell::new('a', st),
Cell::wide('世', st),
Cell::continuation(st),
Cell::new('b', st),
];
let mut via_copy = Screen::new(2, 8);
via_copy.copy_cells(1, 2, &run);
let mut via_set = Screen::new(2, 8);
for (i, cell) in run.iter().enumerate() {
via_set.set(1, 2 + i, *cell);
}
assert_eq!(via_copy, via_set);
}
#[test]
fn copy_cells_clips_at_edge_and_ignores_out_of_range() {
let st = Style::default();
let run = [Cell::new('x', st); 5];
let mut s = Screen::new(2, 4);
s.copy_cells(0, 2, &run);
assert_eq!(s.cell(0, 2).ch, 'x');
assert_eq!(s.cell(0, 3).ch, 'x');
assert_eq!(s.cell(0, 1), Cell::default());
s.copy_cells(9, 0, &run);
s.copy_cells(0, 9, &run);
}
}