use crate::style::Style;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CellWidth {
Continuation,
#[default]
Single,
Wide,
}
impl CellWidth {
pub const fn columns(self) -> usize {
match self {
CellWidth::Continuation => 0,
CellWidth::Single => 1,
CellWidth::Wide => 2,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Cell {
pub ch: char,
pub style: Style,
pub width: CellWidth,
}
impl Default for Cell {
fn default() -> Self {
Cell {
ch: ' ',
style: Style::default(),
width: CellWidth::Single,
}
}
}
impl Cell {
pub const fn new(ch: char, style: Style) -> Self {
Cell {
ch,
style,
width: CellWidth::Single,
}
}
pub const fn wide(ch: char, style: Style) -> Self {
Cell {
ch,
style,
width: CellWidth::Wide,
}
}
pub const fn continuation(style: Style) -> Self {
Cell {
ch: ' ',
style,
width: CellWidth::Continuation,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Cursor {
pub row: usize,
pub col: usize,
}
impl Cursor {
pub const fn new(row: usize, col: usize) -> Self {
Cursor { row, col }
}
}
#[derive(Debug, Clone)]
pub struct Screen {
rows: usize,
cols: usize,
cells: Vec<Cell>,
origin: usize,
cursor: Cursor,
}
impl PartialEq for Screen {
fn eq(&self, other: &Self) -> bool {
if self.rows != other.rows || self.cols != other.cols || self.cursor != other.cursor {
return false;
}
if self.origin == other.origin {
return self.cells == other.cells;
}
(0..self.rows).all(|r| {
let (a, b) = (self.row_slice(r), other.row_slice(r));
a == b
})
}
}
impl Eq for Screen {}
impl Screen {
pub fn new(rows: usize, cols: usize) -> Self {
Screen {
rows,
cols,
cells: vec![Cell::default(); rows * cols],
origin: 0,
cursor: Cursor::default(),
}
}
pub fn rows(&self) -> usize {
self.rows
}
pub fn cols(&self) -> usize {
self.cols
}
#[inline]
pub fn cursor(&self) -> Cursor {
self.cursor
}
#[inline]
pub fn set_cursor(&mut self, cursor: Cursor) {
self.cursor = Cursor {
row: cursor.row.min(self.rows.saturating_sub(1)),
col: cursor.col.min(self.cols.saturating_sub(1)),
};
}
pub fn clear(&mut self) {
self.cells.fill(Cell::default());
self.origin = 0;
self.cursor = Cursor::default();
}
#[inline]
fn index(&self, row: usize, col: usize) -> usize {
debug_assert_eq!(self.cells.len(), self.rows * self.cols);
debug_assert!(row < self.rows && col < self.cols);
self.physical(row) * self.cols + col
}
#[inline]
fn physical(&self, row: usize) -> usize {
debug_assert!(self.rows > 0);
let r = self.origin + row;
if r >= self.rows {
r - self.rows
} else {
r
}
}
#[inline]
fn row_slice(&self, row: usize) -> &[Cell] {
let start = self.physical(row) * self.cols;
&self.cells[start..start + self.cols]
}
pub fn cell(&self, row: usize, col: usize) -> Cell {
if row < self.rows && col < self.cols {
self.cells[self.index(row, col)]
} else {
Cell::default()
}
}
pub fn set(&mut self, row: usize, col: usize, cell: Cell) {
if row < self.rows && col < self.cols {
let i = self.index(row, col);
self.cells[i] = 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 = self.index(row, col);
self.cells[base..base + n].copy_from_slice(&cells[..n]);
}
pub fn scroll_rows_up(&mut self, top: usize, bottom: usize, n: usize, fill: Cell) {
if top > bottom || bottom >= self.rows || n == 0 {
return;
}
let n = n.min(bottom - top + 1);
if top == 0 && bottom == self.rows - 1 {
self.origin = self.physical(n);
for r in self.rows - n..self.rows {
self.fill_row(r, fill);
}
return;
}
if n <= bottom - top {
for r in top..=bottom - n {
self.copy_row(r + n, r);
}
}
for r in bottom + 1 - n..=bottom {
self.fill_row(r, fill);
}
}
#[inline]
fn copy_row(&mut self, from: usize, to: usize) {
let (src, dst) = (self.index(from, 0), self.index(to, 0));
self.cells.copy_within(src..src + self.cols, dst);
}
#[inline]
fn fill_row(&mut self, row: usize, fill: Cell) {
let start = self.index(row, 0);
self.cells[start..start + self.cols].fill(fill);
}
pub fn scroll_rows_down(&mut self, top: usize, bottom: usize, n: usize, fill: Cell) {
if top > bottom || bottom >= self.rows || n == 0 {
return;
}
let n = n.min(bottom - top + 1);
if top == 0 && bottom == self.rows - 1 {
self.origin = self.physical(self.rows - n);
for r in 0..n {
self.fill_row(r, fill);
}
return;
}
for r in (top + n..=bottom).rev() {
self.copy_row(r - n, r);
}
for r in top..top + n {
self.fill_row(r, fill);
}
}
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 == CellWidth::Continuation {
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.columns();
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 Cursor { row, col } = next.cursor;
write!(out, "\x1b[{};{}H", row + 1, col + 1).unwrap();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::Style;
fn numbered(rows: usize, cols: usize) -> Screen {
let mut s = Screen::new(rows, cols);
for r in 0..rows {
for c in 0..cols {
let ch = char::from_u32(0x4E00 + (r * 64 + c) as u32).unwrap();
s.set(r, c, Cell::new(ch, Style::default()));
}
}
s
}
fn reference_up(s: &mut Screen, top: usize, bottom: usize, n: usize, fill: Cell) {
let n = n.min(bottom - top + 1);
for r in top..=bottom {
for c in 0..s.cols() {
let cell = if r + n <= bottom {
s.cell(r + n, c)
} else {
fill
};
s.set(r, c, cell);
}
}
}
fn reference_down(s: &mut Screen, top: usize, bottom: usize, n: usize, fill: Cell) {
let n = n.min(bottom - top + 1);
for r in (top..=bottom).rev() {
for c in 0..s.cols() {
let cell = if r >= top + n { s.cell(r - n, c) } else { fill };
s.set(r, c, cell);
}
}
}
#[test]
fn region_scrolls_match_the_per_cell_reference_everywhere() {
let fill = Cell::new(' ', Style::default());
for (rows, cols) in [(1, 1), (5, 3), (8, 7)] {
for top in 0..rows {
for bottom in top..rows {
for n in 0..=(bottom - top + 2) {
let (mut fast, mut slow) = (numbered(rows, cols), numbered(rows, cols));
fast.scroll_rows_up(top, bottom, n, fill);
if n > 0 {
reference_up(&mut slow, top, bottom, n, fill);
}
assert_eq!(fast, slow, "up {rows}x{cols} {top}..={bottom} by {n}");
let (mut fast, mut slow) = (numbered(rows, cols), numbered(rows, cols));
fast.scroll_rows_down(top, bottom, n, fill);
if n > 0 {
reference_down(&mut slow, top, bottom, n, fill);
}
assert_eq!(fast, slow, "down {rows}x{cols} {top}..={bottom} by {n}");
}
}
}
}
}
#[test]
fn the_row_ring_is_invisible_to_readers() {
let fill = Cell::new(' ', Style::default());
let mut s = numbered(4, 3);
for _ in 0..7 {
s.scroll_rows_up(0, 3, 1, fill);
}
let blank = Screen::new(4, 3);
assert_eq!(s, blank, "everything scrolled off: a blank screen");
let mut s = numbered(4, 3);
s.scroll_rows_up(0, 3, 2, fill);
let mut want = Screen::new(4, 3);
for r in 0..2 {
for c in 0..3 {
want.set(r, c, numbered(4, 3).cell(r + 2, c));
}
}
assert_eq!(s, want, "two rows up, two blank rows in");
assert_eq!(s.render_full(), want.render_full(), "same bytes");
s.set(0, 0, Cell::new('A', Style::default()));
assert_eq!(s.cell(0, 0).ch, 'A');
s.clear();
assert_eq!(s, Screen::new(4, 3));
}
#[test]
fn an_out_of_range_region_scroll_changes_nothing() {
let fill = Cell::new('x', Style::default());
let mut s = numbered(4, 4);
let before = s.clone();
s.scroll_rows_up(3, 1, 1, fill);
s.scroll_rows_up(0, 4, 1, fill);
s.scroll_rows_down(2, 9, 1, fill);
assert_eq!(s, before);
}
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.set_cursor(Cursor::new(3, 5));
s.clear();
assert_eq!(s.rows(), 4);
assert_eq!(s.cols(), 6);
assert_eq!(s.cursor(), Cursor::new(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, CellWidth::Single);
assert_eq!(Cell::new('a', st).width, CellWidth::Single);
assert_eq!(Cell::wide('世', st).width, CellWidth::Wide);
let cont = Cell::continuation(st);
assert_eq!(cont.width, CellWidth::Continuation);
let cols: Vec<usize> = [CellWidth::Continuation, CellWidth::Single, CellWidth::Wide]
.iter()
.map(|w| w.columns())
.collect();
assert_eq!(cols, [0, 1, 2]);
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 set_cursor_clamps_onto_the_grid() {
let mut s = Screen::new(4, 6);
s.set_cursor(Cursor::new(2, 3));
assert_eq!(s.cursor(), Cursor::new(2, 3));
s.set_cursor(Cursor::new(99, 99));
assert_eq!(s.cursor(), Cursor::new(3, 5));
let mut empty = Screen::new(0, 0);
empty.set_cursor(Cursor::new(5, 5));
assert_eq!(empty.cursor(), Cursor::new(0, 0));
}
#[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);
}
}