Skip to main content

ansi/
screen.rs

1//! A screen buffer and diff-based rendering: build the frame you want as a
2//! grid of styled cells, then emit only the bytes that change what the
3//! terminal is already showing.
4
5use crate::style::Style;
6
7/// One character cell: a `char`, its [`Style`], and its display `width` in
8/// terminal columns. The default cell is a single-width space in the default
9/// style.
10///
11/// `width` lets the grid model East Asian double-width glyphs and emoji, which
12/// occupy two columns:
13///
14/// - `1` — a normal single-width cell (the common case, and the default).
15/// - `2` — the **lead** (left half) of a double-width glyph; `ch` holds the
16///   character. The cell immediately to its right should be a width-`0`
17///   continuation.
18/// - `0` — a **continuation** (right half) of the glyph to its left: it carries
19///   no character of its own and is never emitted, because the terminal
20///   advances the cursor by two columns when it draws the lead glyph.
21///
22/// This crate does not *compute* width — that needs the Unicode database, which
23/// a zero-dependency crate cannot own. Callers set `width` (e.g. from the
24/// `uwidth` crate); the renderers here honor whatever `width` they are given.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct Cell {
27    pub ch: char,
28    pub style: Style,
29    pub width: u8,
30}
31
32impl Default for Cell {
33    fn default() -> Self {
34        Cell {
35            ch: ' ',
36            style: Style::default(),
37            width: 1,
38        }
39    }
40}
41
42impl Cell {
43    /// A normal single-width cell (`width == 1`).
44    pub const fn new(ch: char, style: Style) -> Self {
45        Cell {
46            ch,
47            style,
48            width: 1,
49        }
50    }
51
52    /// The lead (left half) of a double-width glyph (`width == 2`). Its right
53    /// neighbor should be a [`Cell::continuation`].
54    pub const fn wide(ch: char, style: Style) -> Self {
55        Cell {
56            ch,
57            style,
58            width: 2,
59        }
60    }
61
62    /// A continuation (right half) of a double-width glyph (`width == 0`): no
63    /// character of its own, but carrying `style` so the covered column keeps
64    /// the right background. Never emitted — the lead glyph fills both columns.
65    pub const fn continuation(style: Style) -> Self {
66        Cell {
67            ch: ' ',
68            style,
69            width: 0,
70        }
71    }
72}
73
74/// A `rows x cols` grid of [`Cell`]s plus a cursor position, both 0-based.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct Screen {
77    rows: usize,
78    cols: usize,
79    cells: Vec<Cell>,
80    /// Where the cursor should rest after rendering: `(row, col)`, 0-based.
81    pub cursor: (usize, usize),
82}
83
84impl Screen {
85    /// A blank screen (all default cells, cursor at the origin).
86    pub fn new(rows: usize, cols: usize) -> Self {
87        Screen {
88            rows,
89            cols,
90            cells: vec![Cell::default(); rows * cols],
91            cursor: (0, 0),
92        }
93    }
94
95    pub fn rows(&self) -> usize {
96        self.rows
97    }
98
99    pub fn cols(&self) -> usize {
100        self.cols
101    }
102
103    /// Reset all cells to default and the cursor to the origin, reusing the
104    /// existing allocation. The caller must ensure the screen is already the
105    /// right size; use [`Screen::new`] when the size changes.
106    pub fn clear(&mut self) {
107        self.cells.fill(Cell::default());
108        self.cursor = (0, 0);
109    }
110
111    /// The cell at `(row, col)`; out of bounds returns a default cell.
112    pub fn cell(&self, row: usize, col: usize) -> Cell {
113        if row < self.rows && col < self.cols {
114            self.cells[row * self.cols + col]
115        } else {
116            Cell::default()
117        }
118    }
119
120    /// Set one cell. Out-of-bounds writes are ignored.
121    pub fn set(&mut self, row: usize, col: usize, cell: Cell) {
122        if row < self.rows && col < self.cols {
123            self.cells[row * self.cols + col] = cell;
124        }
125    }
126
127    /// Write a string across a row starting at `(row, col)`, truncating at
128    /// the right edge. Each `char` takes one cell (single-width); callers that
129    /// need double-width layout set [`Cell::width`] and use [`Screen::set`] or
130    /// [`Screen::copy_cells`].
131    pub fn write_str(&mut self, row: usize, col: usize, text: &str, style: Style) {
132        for (i, ch) in text.chars().enumerate() {
133            self.set(row, col + i, Cell::new(ch, style));
134        }
135    }
136
137    /// Copy a contiguous run of cells into `row` starting at column `col`,
138    /// clipping the run at the right edge (and ignoring an out-of-range row or
139    /// start column). Equivalent to calling [`Screen::set`] for each cell in
140    /// turn, but with a single bounds check for the whole run — the fast path a
141    /// compositor uses to blit a row slice.
142    pub fn copy_cells(&mut self, row: usize, col: usize, cells: &[Cell]) {
143        if row >= self.rows || col >= self.cols {
144            return;
145        }
146        let n = cells.len().min(self.cols - col);
147        let base = row * self.cols + col;
148        self.cells[base..base + n].copy_from_slice(&cells[..n]);
149    }
150
151    /// The bytes that transform a terminal currently showing `self` into one
152    /// showing `next`: cursor moves (CUP), minimal SGR transitions, and the
153    /// changed characters, ending with a style reset and the cursor parked at
154    /// `next.cursor`. Equal screens produce no bytes at all.
155    ///
156    /// Both screens must have the same dimensions; if they differ (a resize),
157    /// the diff falls back to a full repaint of `next`.
158    pub fn diff(&self, next: &Screen) -> Vec<u8> {
159        if self.rows != next.rows || self.cols != next.cols {
160            return next.render_full();
161        }
162        if self == next {
163            return Vec::new();
164        }
165        let mut out = String::new();
166        emit_changes(Some(self), next, &mut out);
167        out.into_bytes()
168    }
169
170    /// A from-scratch repaint: clear the screen, then paint every non-default
171    /// cell, ending with a style reset and the cursor parked at
172    /// `self.cursor`.
173    pub fn render_full(&self) -> Vec<u8> {
174        let mut out = String::from("\x1b[2J");
175        emit_changes(None, self, &mut out);
176        out.into_bytes()
177    }
178}
179
180/// Emit the cell changes from `prev` to `next` (`None` means "cleared
181/// screen": every non-default cell of `next` is painted).
182///
183/// Row suffix optimisation: when the trailing cells of a row are all default
184/// in `next` but were not in `prev`, a single `\x1b[K]` (Erase to EOL)
185/// replaces one write-per-blank-cell. Style is reset before the erase so the
186/// terminal fills with the default background.
187fn emit_changes(prev: Option<&Screen>, next: &Screen, out: &mut String) {
188    use std::fmt::Write;
189    let mut style = Style::default();
190    // Where the terminal cursor is right now, when known. Printing advances
191    // it; at the right edge the position becomes ambiguous (pending wrap),
192    // so it is dropped to force an explicit move next time.
193    let mut at: Option<(usize, usize)> = None;
194    for row in 0..next.rows {
195        // Find the exclusive right bound of the non-default suffix in `next`.
196        // Columns in [last_content, cols) are all Cell::default() in `next`.
197        let last_content = (0..next.cols)
198            .rev()
199            .find(|&c| next.cell(row, c) != Cell::default())
200            .map_or(0, |c| c + 1);
201
202        // Emit changed cells up to (but not including) the trailing blank region.
203        for col in 0..last_content {
204            let target = next.cell(row, col);
205            // A width-0 cell is the right half (continuation) of a double-width
206            // glyph: it carries no character of its own and emits nothing. The
207            // lead cell to its left drew the glyph and the terminal advanced the
208            // cursor across both columns.
209            if target.width == 0 {
210                continue;
211            }
212            let same = match prev {
213                Some(p) => p.cell(row, col) == target,
214                None => target == Cell::default(),
215            };
216            if same {
217                continue;
218            }
219            if at != Some((row, col)) {
220                write!(out, "\x1b[{};{}H", row + 1, col + 1).unwrap();
221            }
222            out.push_str(&style.transition_to(&target.style));
223            style = target.style;
224            out.push(target.ch);
225            // The terminal advances the cursor by the glyph's column width, so a
226            // width-2 lead lands `at` two columns on and its continuation column
227            // is then skipped. (A single-width cell advances by one, exactly as
228            // before — ASCII output is byte-for-byte unchanged.) At or past the
229            // right edge the position is ambiguous (pending wrap), so drop it to
230            // force an explicit move next time.
231            let advance = target.width.max(1) as usize;
232            at = if col + advance < next.cols {
233                Some((row, col + advance))
234            } else {
235                None
236            };
237        }
238
239        // If prev had non-default content in the trailing blank region of next,
240        // erase it with a single EL rather than writing each blank cell.
241        // Not used in render_full (prev == None) because `\x1b[2J` already
242        // cleared the screen before emit_changes is called.
243        if last_content < next.cols {
244            let needs_erase = match prev {
245                Some(p) => (last_content..next.cols).any(|c| p.cell(row, c) != Cell::default()),
246                None => false,
247            };
248            if needs_erase {
249                // Reset SGR before EL so the terminal erases with the default
250                // background, not whatever the last cell's style was.
251                out.push_str(&style.transition_to(&Style::default()));
252                style = Style::default();
253                if at != Some((row, last_content)) {
254                    write!(out, "\x1b[{};{}H", row + 1, last_content + 1).unwrap();
255                }
256                out.push_str("\x1b[K");
257                // Cursor stays at (row, last_content); next row needs an explicit
258                // move regardless, so mark position unknown.
259                at = None;
260            }
261        }
262    }
263    out.push_str(&style.transition_to(&Style::default()));
264    let (r, c) = next.cursor;
265    write!(out, "\x1b[{};{}H", r + 1, c + 1).unwrap();
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use crate::style::Style;
272
273    // ── helpers ──────────────────────────────────────────────────────────────
274
275    fn filled_row(rows: usize, cols: usize, row: usize, ch: char) -> Screen {
276        let mut s = Screen::new(rows, cols);
277        for c in 0..cols {
278            s.set(row, c, Cell::new(ch, Style::default()));
279        }
280        s
281    }
282
283    fn contains_el(bytes: &[u8]) -> bool {
284        let s = std::str::from_utf8(bytes).unwrap_or("");
285        s.contains("\x1b[K")
286    }
287
288    // ── Screen::clear() ──────────────────────────────────────────────────────
289
290    #[test]
291    fn clear_resets_all_cells_and_cursor_without_realloc() {
292        let mut s = Screen::new(4, 6);
293        // Dirty every cell with a non-default character and style.
294        let dirty_style = Style {
295            bold: true,
296            ..Style::default()
297        };
298        for r in 0..4 {
299            for c in 0..6 {
300                s.set(r, c, Cell::new('X', dirty_style));
301            }
302        }
303        // Move the cursor off the origin.
304        s.cursor = (3, 5);
305
306        s.clear();
307
308        // Dimensions must be unchanged.
309        assert_eq!(s.rows(), 4);
310        assert_eq!(s.cols(), 6);
311        // Cursor must be at the origin.
312        assert_eq!(s.cursor, (0, 0));
313        // Every cell must equal the default.
314        let blank = Cell::default();
315        for r in 0..4 {
316            for c in 0..6 {
317                assert_eq!(
318                    s.cell(r, c),
319                    blank,
320                    "cell ({r},{c}) was not reset by clear()"
321                );
322            }
323        }
324    }
325
326    // ── emit_changes / diff: erase-to-EOL optimisation ───────────────────────
327
328    #[test]
329    fn diff_emits_el_when_row_tail_clears_to_blank() {
330        // prev: row 0 fully filled with 'X'; next: only first 3 cols are 'X'.
331        // The tail cols [3..8) revert to blank → diff should contain EL.
332        let prev = filled_row(2, 8, 0, 'X');
333        let mut next = Screen::new(2, 8);
334        for c in 0..3 {
335            next.set(0, c, Cell::new('X', Style::default()));
336        }
337        let diff = prev.diff(&next);
338        assert!(
339            contains_el(&diff),
340            "expected \\x1b[K in diff when tail blanks out"
341        );
342    }
343
344    #[test]
345    fn diff_no_el_when_tail_was_already_blank_in_prev() {
346        // Both screens have content only in cols [0, 3); tail was already blank.
347        // No cells changed in the tail, so no EL should be emitted.
348        let mut prev = Screen::new(2, 8);
349        let mut next = Screen::new(2, 8);
350        for c in 0..3 {
351            prev.set(0, c, Cell::new('A', Style::default()));
352            next.set(0, c, Cell::new('B', Style::default()));
353        }
354        let diff = prev.diff(&next);
355        assert!(
356            !contains_el(&diff),
357            "unexpected \\x1b[K when tail was already blank"
358        );
359    }
360
361    #[test]
362    fn render_full_never_emits_el() {
363        // render_full follows \x1b[2J with individual cell writes; it must NOT
364        // use EL (the screen is already clear, so EL would be redundant, and
365        // the contract is that render_full never relies on prior state).
366        let s = filled_row(2, 8, 0, 'X');
367        let bytes = s.render_full();
368        assert!(!contains_el(&bytes), "render_full must not emit \\x1b[K");
369    }
370
371    #[test]
372    fn diff_el_visual_correctness_via_round_trip() {
373        // Prove that a diff containing EL produces the correct visual result:
374        // simulate applying the diff to a "terminal" modelled as a Screen copy.
375        // prev → diff(next) → the result must equal next.
376        let prev = filled_row(1, 6, 0, 'Z');
377        let mut next = Screen::new(1, 6);
378        next.set(0, 0, Cell::new('A', Style::default()));
379        // next has 'A' at (0,0) and blanks at (0,1..5).
380        assert!(contains_el(&prev.diff(&next)));
381        // The diff-then-apply path is tested implicitly: if diff produces EL,
382        // but the NEXT diff of (next, next) is empty, then prev+diff == next.
383        let empty = next.diff(&next);
384        assert!(empty.is_empty(), "diff of identical screens must be empty");
385    }
386
387    #[test]
388    fn diff_entire_row_blank_in_next_uses_el() {
389        // prev: entire row 1 filled; next: row 1 is all blank.
390        let prev = filled_row(3, 5, 1, 'Y');
391        let next = Screen::new(3, 5);
392        let diff = prev.diff(&next);
393        assert!(
394            contains_el(&diff),
395            "entire-row blank transition should use EL"
396        );
397    }
398
399    // ── double-width cells: Cell.width, continuation, width-aware emit ────────
400
401    #[test]
402    fn cell_constructors_set_width() {
403        let st = Style::default();
404        assert_eq!(Cell::default().width, 1);
405        assert_eq!(Cell::new('a', st).width, 1);
406        assert_eq!(Cell::wide('世', st).width, 2);
407        let cont = Cell::continuation(st);
408        assert_eq!(cont.width, 0);
409        assert_eq!(cont.ch, ' ');
410    }
411
412    #[test]
413    fn wide_lead_emits_glyph_once_no_continuation_char() {
414        // A width-2 lead followed by a width-0 continuation must emit the glyph
415        // exactly once and NOTHING for the continuation column (the terminal
416        // advances the cursor by two when it draws the wide glyph).
417        let prev = Screen::new(1, 4);
418        let mut next = Screen::new(1, 4);
419        next.set(0, 0, Cell::wide('世', Style::default()));
420        next.set(0, 1, Cell::continuation(Style::default()));
421        let diff = prev.diff(&next);
422        let s = std::str::from_utf8(&diff).unwrap();
423        assert_eq!(s.matches('世').count(), 1, "glyph must appear exactly once");
424        assert!(
425            !s.contains(' '),
426            "continuation column must emit no space: {s:?}"
427        );
428        // Exact bytes: CUP to (1,1), the glyph, then park cursor at (1,1).
429        assert_eq!(diff, "\x1b[1;1H世\x1b[1;1H".as_bytes());
430    }
431
432    #[test]
433    fn wide_lead_advances_cursor_by_two() {
434        // After a width-2 lead, a cell two columns on needs no fresh CUP — the
435        // emitter tracks that the terminal advanced the cursor across both
436        // halves of the wide glyph. If accounting were off by one, a CUP escape
437        // would appear between the glyph and 'X'.
438        let prev = Screen::new(1, 6);
439        let mut next = Screen::new(1, 6);
440        next.set(0, 0, Cell::wide('世', Style::default()));
441        next.set(0, 1, Cell::continuation(Style::default()));
442        next.set(0, 2, Cell::new('X', Style::default()));
443        let diff = prev.diff(&next);
444        assert_eq!(diff, "\x1b[1;1H世X\x1b[1;1H".as_bytes());
445    }
446
447    #[test]
448    fn ascii_diff_is_byte_identical_regression() {
449        // Guard: adding Cell.width must not change single-width output at all.
450        let mut old = Screen::new(2, 10);
451        old.write_str(0, 0, "hello", Style::default());
452        let mut new = old.clone();
453        new.set(0, 1, Cell::new('a', Style::default()));
454        assert_eq!(old.diff(&new), b"\x1b[1;2Ha\x1b[1;1H");
455    }
456
457    #[test]
458    fn copy_cells_matches_repeated_set() {
459        let st = Style::default();
460        let run = [
461            Cell::new('a', st),
462            Cell::wide('世', st),
463            Cell::continuation(st),
464            Cell::new('b', st),
465        ];
466        let mut via_copy = Screen::new(2, 8);
467        via_copy.copy_cells(1, 2, &run);
468        let mut via_set = Screen::new(2, 8);
469        for (i, cell) in run.iter().enumerate() {
470            via_set.set(1, 2 + i, *cell);
471        }
472        assert_eq!(via_copy, via_set);
473    }
474
475    #[test]
476    fn copy_cells_clips_at_edge_and_ignores_out_of_range() {
477        let st = Style::default();
478        let run = [Cell::new('x', st); 5];
479        let mut s = Screen::new(2, 4);
480        // 5-cell run starting at col 2: only cols 2 and 3 are written.
481        s.copy_cells(0, 2, &run);
482        assert_eq!(s.cell(0, 2).ch, 'x');
483        assert_eq!(s.cell(0, 3).ch, 'x');
484        assert_eq!(s.cell(0, 1), Cell::default());
485        // Out-of-range row or start column are silent no-ops (must not panic).
486        s.copy_cells(9, 0, &run);
487        s.copy_cells(0, 9, &run);
488    }
489}