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