use std::io::Write as _;
use super::cell::{diff_cell_frames, serialize_patches, Cell};
pub struct Screen {
cells: Vec<Vec<Cell>>,
prev_cells: Vec<Vec<Cell>>,
width: u16,
height: u16,
cursor: Option<(u16, u16)>,
cursor_visible: bool,
}
impl Screen {
pub fn new(width: u16, height: u16) -> Self {
let row = vec![Cell::blank(); width as usize];
let frame = vec![row; height as usize];
Self {
cells: frame.clone(),
prev_cells: frame,
width,
height,
cursor: None,
cursor_visible: true,
}
}
pub fn width(&self) -> u16 {
self.width
}
pub fn height(&self) -> u16 {
self.height
}
pub fn clear(&mut self) {
let blank = Cell::blank();
for row in &mut self.cells {
for c in row {
*c = blank.clone();
}
}
}
pub fn draw_row(&mut self, row: usize, col: usize, cells: &[Cell]) {
if row >= self.cells.len() {
return;
}
let target = &mut self.cells[row];
for (i, cell) in cells.iter().enumerate() {
let dst_col = col + i;
if dst_col >= target.len() {
break;
}
target[dst_col] = cell.clone();
}
}
pub fn set_cursor(&mut self, row: u16, col: u16) {
self.cursor = Some((row, col));
}
pub fn set_cursor_visible(&mut self, visible: bool) {
self.cursor_visible = visible;
}
pub fn scroll_up(&mut self, bottom: usize, n: usize) {
if n == 0 || bottom == 0 {
return;
}
let n = n.min(bottom);
let blank_row = vec![Cell::blank(); self.width as usize];
self.cells[0..bottom].rotate_left(n);
for row_idx in (bottom - n)..bottom {
self.cells[row_idx] = blank_row.clone();
}
}
pub fn render_diff(&mut self) -> Vec<u8> {
let patches = diff_cell_frames(&self.prev_cells, &self.cells);
let mut out = serialize_patches(&patches);
if let Some((r, c)) = self.cursor {
let _ = write!(&mut out, "\x1b[{};{}H", r, c);
}
if self.cursor_visible {
out.extend_from_slice(b"\x1b[?25h");
} else {
out.extend_from_slice(b"\x1b[?25l");
}
std::mem::swap(&mut self.prev_cells, &mut self.cells);
self.clear();
out
}
pub fn invalidate(&mut self) {
let blank_row = vec![Cell::blank(); self.width as usize];
for row in &mut self.prev_cells {
*row = blank_row.clone();
}
}
pub fn resize(&mut self, width: u16, height: u16) {
*self = Self::new(width, height);
}
pub fn prev_cells_for_test(&self) -> &[Vec<Cell>] {
&self.prev_cells
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::cell::{push_str_cells, CellStyle};
#[test]
fn new_screen_empty_frame_produces_no_content_patches() {
let mut s = Screen::new(10, 3);
let bytes = s.render_diff();
let out = String::from_utf8(bytes).unwrap();
assert_eq!(out, "\x1b[?25h", "unexpected bytes: {:?}", out);
}
#[test]
fn draw_row_emits_content_at_1_indexed_coords() {
let mut s = Screen::new(20, 5);
let mut cells = Vec::new();
push_str_cells(&mut cells, "hello", &CellStyle::default());
s.draw_row(2, 3, &cells);
let bytes = s.render_diff();
let out = String::from_utf8_lossy(&bytes);
assert!(out.contains("hello"), "missing content: {:?}", out);
assert!(out.contains("\x1b[3;4H"), "wrong cursor target: {:?}", out);
}
#[test]
fn second_frame_with_same_content_emits_no_cells() {
let mut s = Screen::new(20, 5);
let mut cells = Vec::new();
push_str_cells(&mut cells, "x", &CellStyle::default());
s.draw_row(0, 0, &cells);
let _ = s.render_diff(); s.draw_row(0, 0, &cells);
let bytes = s.render_diff();
let out = String::from_utf8_lossy(&bytes);
assert!(
!out.contains('x'),
"identical re-draw should be a no-op diff: {:?}",
out
);
}
#[test]
fn scroll_up_shifts_rows_drops_top() {
let mut s = Screen::new(10, 5);
let mut a = Vec::new();
push_str_cells(&mut a, "AAA", &CellStyle::default());
let mut b = Vec::new();
push_str_cells(&mut b, "BBB", &CellStyle::default());
s.draw_row(0, 0, &a);
s.draw_row(1, 0, &b);
let _ = s.render_diff(); s.draw_row(0, 0, &a);
s.draw_row(1, 0, &b);
s.scroll_up(2, 1);
let bytes = s.render_diff();
let out = String::from_utf8_lossy(&bytes);
assert!(out.contains("BBB"), "row 0 should now show BBB");
}
#[test]
fn invalidate_forces_cold_start_on_next_diff() {
let mut s = Screen::new(10, 3);
let mut cells = Vec::new();
push_str_cells(&mut cells, "hi", &CellStyle::default());
s.draw_row(0, 0, &cells);
let _ = s.render_diff();
s.draw_row(0, 0, &cells);
s.invalidate();
let bytes = s.render_diff();
let out = String::from_utf8_lossy(&bytes);
assert!(
out.contains("hi"),
"invalidate must force re-emit: {:?}",
out
);
}
#[test]
fn resize_blanks_both_frames() {
let mut s = Screen::new(10, 3);
let mut cells = Vec::new();
push_str_cells(&mut cells, "stuff", &CellStyle::default());
s.draw_row(0, 0, &cells);
let _ = s.render_diff();
s.resize(20, 5);
assert_eq!(s.width(), 20);
assert_eq!(s.height(), 5);
let bytes = s.render_diff();
let out = String::from_utf8_lossy(&bytes);
assert!(
!out.contains("stuff"),
"old content must be gone after resize: {:?}",
out
);
}
#[test]
fn set_cursor_emits_final_position() {
let mut s = Screen::new(10, 3);
s.set_cursor(2, 5);
let bytes = s.render_diff();
let out = String::from_utf8_lossy(&bytes);
assert!(out.contains("\x1b[2;5H"), "cursor park missing: {:?}", out);
}
}