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)]
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    ///
120    /// Rows are stored as a **ring**: logical row `r` lives at physical row
121    /// `(origin + r) % rows`, so scrolling the whole screen moves `origin`
122    /// instead of every cell. Nothing outside [`Screen::index`] knows this.
123    cells: Vec<Cell>,
124    /// The physical row holding logical row 0. See `cells`.
125    origin: usize,
126    /// Where the cursor should rest after rendering. Always on the grid (see
127    /// [`Screen::set_cursor`]).
128    cursor: Cursor,
129}
130
131/// Two screens are equal when they *show* the same thing: same size, same
132/// cursor, same cell at every logical position. Where each row physically sits
133/// in the ring is an implementation detail, so a scrolled screen and a freshly
134/// built one with the same content compare equal.
135impl PartialEq for Screen {
136    fn eq(&self, other: &Self) -> bool {
137        if self.rows != other.rows || self.cols != other.cols || self.cursor != other.cursor {
138            return false;
139        }
140        if self.origin == other.origin {
141            return self.cells == other.cells;
142        }
143        (0..self.rows).all(|r| {
144            let (a, b) = (self.row_slice(r), other.row_slice(r));
145            a == b
146        })
147    }
148}
149
150impl Eq for Screen {}
151
152impl Screen {
153    /// A blank screen (all default cells, cursor at the origin).
154    pub fn new(rows: usize, cols: usize) -> Self {
155        Screen {
156            rows,
157            cols,
158            cells: vec![Cell::default(); rows * cols],
159            origin: 0,
160            cursor: Cursor::default(),
161        }
162    }
163
164    pub fn rows(&self) -> usize {
165        self.rows
166    }
167
168    pub fn cols(&self) -> usize {
169        self.cols
170    }
171
172    /// Where the cursor rests after rendering.
173    #[inline]
174    pub fn cursor(&self) -> Cursor {
175        self.cursor
176    }
177
178    /// Move the cursor, clamped onto the grid: a row or column past the edge
179    /// lands on the last row or column (the origin on an empty screen). The
180    /// renderers park the terminal cursor here.
181    #[inline]
182    pub fn set_cursor(&mut self, cursor: Cursor) {
183        self.cursor = Cursor {
184            row: cursor.row.min(self.rows.saturating_sub(1)),
185            col: cursor.col.min(self.cols.saturating_sub(1)),
186        };
187    }
188
189    /// Reset all cells to default and the cursor to the origin, reusing the
190    /// existing allocation. The caller must ensure the screen is already the
191    /// right size; use [`Screen::new`] when the size changes.
192    pub fn clear(&mut self) {
193        self.cells.fill(Cell::default());
194        self.origin = 0;
195        self.cursor = Cursor::default();
196    }
197
198    /// The flat index of `(row, col)`, which the caller has bounds-checked.
199    /// Logical row `row` sits at physical row `(origin + row) % rows`.
200    #[inline]
201    fn index(&self, row: usize, col: usize) -> usize {
202        debug_assert_eq!(self.cells.len(), self.rows * self.cols);
203        debug_assert!(row < self.rows && col < self.cols);
204        self.physical(row) * self.cols + col
205    }
206
207    /// The physical row holding logical `row`.
208    #[inline]
209    fn physical(&self, row: usize) -> usize {
210        debug_assert!(self.rows > 0);
211        let r = self.origin + row;
212        if r >= self.rows {
213            r - self.rows
214        } else {
215            r
216        }
217    }
218
219    /// One logical row's cells, contiguous in the ring.
220    #[inline]
221    fn row_slice(&self, row: usize) -> &[Cell] {
222        let start = self.physical(row) * self.cols;
223        &self.cells[start..start + self.cols]
224    }
225
226    /// The cell at `(row, col)`; out of bounds returns a default cell.
227    pub fn cell(&self, row: usize, col: usize) -> Cell {
228        if row < self.rows && col < self.cols {
229            self.cells[self.index(row, col)]
230        } else {
231            Cell::default()
232        }
233    }
234
235    /// Set one cell. Out-of-bounds writes are ignored.
236    pub fn set(&mut self, row: usize, col: usize, cell: Cell) {
237        if row < self.rows && col < self.cols {
238            let i = self.index(row, col);
239            self.cells[i] = cell;
240        }
241    }
242
243    /// Write a string across a row starting at `(row, col)`, truncating at
244    /// the right edge. Each `char` takes one cell (single-width); callers that
245    /// need double-width layout use [`Cell::wide`] with [`Screen::set`] or
246    /// [`Screen::copy_cells`].
247    pub fn write_str(&mut self, row: usize, col: usize, text: &str, style: Style) {
248        for (i, ch) in text.chars().enumerate() {
249            self.set(row, col + i, Cell::new(ch, style));
250        }
251    }
252
253    /// Copy a contiguous run of cells into `row` starting at column `col`,
254    /// clipping the run at the right edge (and ignoring an out-of-range row or
255    /// start column). Equivalent to calling [`Screen::set`] for each cell in
256    /// turn, but with a single bounds check for the whole run — the fast path a
257    /// compositor uses to blit a row slice.
258    pub fn copy_cells(&mut self, row: usize, col: usize, cells: &[Cell]) {
259        if row >= self.rows || col >= self.cols {
260            return;
261        }
262        let n = cells.len().min(self.cols - col);
263        let base = self.index(row, col);
264        self.cells[base..base + n].copy_from_slice(&cells[..n]);
265    }
266
267    /// Scroll rows `top..=bottom` up by `n`. Rows move up, the top `n` rows of
268    /// the region are lost, and `n` rows of `fill` enter at the bottom. Rows
269    /// outside the region are untouched. An empty or out-of-range region does
270    /// nothing, and `n` is clamped to the region's height.
271    ///
272    /// Scrolling the **whole** screen (the common case: output arriving at the
273    /// bottom) only moves the ring's origin and clears the rows that come in,
274    /// which costs `n x cols`, not `rows x cols`. A smaller region moves its
275    /// rows, one block move each.
276    ///
277    /// A terminal scrolls on every output line, and copying the region cell by
278    /// cell cost `rows x cols` bounds-checked reads and writes per line: vterm
279    /// fed a 16 MB log at 11 MB/s on a 40x160 grid, and slower on bigger grids.
280    pub fn scroll_rows_up(&mut self, top: usize, bottom: usize, n: usize, fill: Cell) {
281        if top > bottom || bottom >= self.rows || n == 0 {
282            return;
283        }
284        let n = n.min(bottom - top + 1);
285        if top == 0 && bottom == self.rows - 1 {
286            self.origin = self.physical(n);
287            for r in self.rows - n..self.rows {
288                self.fill_row(r, fill);
289            }
290            return;
291        }
292        // Nothing survives when the shift covers the region; only the fill runs.
293        if n <= bottom - top {
294            for r in top..=bottom - n {
295                self.copy_row(r + n, r);
296            }
297        }
298        for r in bottom + 1 - n..=bottom {
299            self.fill_row(r, fill);
300        }
301    }
302
303    /// Copy logical row `from` over logical row `to`.
304    #[inline]
305    fn copy_row(&mut self, from: usize, to: usize) {
306        let (src, dst) = (self.index(from, 0), self.index(to, 0));
307        self.cells.copy_within(src..src + self.cols, dst);
308    }
309
310    /// Fill one logical row with `fill`.
311    #[inline]
312    fn fill_row(&mut self, row: usize, fill: Cell) {
313        let start = self.index(row, 0);
314        self.cells[start..start + self.cols].fill(fill);
315    }
316
317    /// Scroll rows `top..=bottom` down by `n`: the mirror of
318    /// [`Screen::scroll_rows_up`]. The bottom `n` rows of the region are lost,
319    /// and `n` rows of `fill` enter at the top.
320    pub fn scroll_rows_down(&mut self, top: usize, bottom: usize, n: usize, fill: Cell) {
321        if top > bottom || bottom >= self.rows || n == 0 {
322            return;
323        }
324        let n = n.min(bottom - top + 1);
325        if top == 0 && bottom == self.rows - 1 {
326            self.origin = self.physical(self.rows - n);
327            for r in 0..n {
328                self.fill_row(r, fill);
329            }
330            return;
331        }
332        for r in (top + n..=bottom).rev() {
333            self.copy_row(r - n, r);
334        }
335        for r in top..top + n {
336            self.fill_row(r, fill);
337        }
338    }
339
340    /// The bytes that transform a terminal currently showing `self` into one
341    /// showing `next`: cursor moves (CUP), minimal SGR transitions, and the
342    /// changed characters, ending with a style reset and the cursor parked at
343    /// `next`'s cursor. Equal screens produce no bytes at all.
344    ///
345    /// Both screens must have the same dimensions; if they differ (a resize),
346    /// the diff falls back to a full repaint of `next`.
347    pub fn diff(&self, next: &Screen) -> Vec<u8> {
348        if self.rows != next.rows || self.cols != next.cols {
349            return next.render_full();
350        }
351        if self == next {
352            return Vec::new();
353        }
354        let mut out = String::new();
355        emit_changes(Some(self), next, &mut out);
356        out.into_bytes()
357    }
358
359    /// A from-scratch repaint: clear the screen, then paint every non-default
360    /// cell, ending with a style reset and the cursor parked at
361    /// this screen's cursor.
362    pub fn render_full(&self) -> Vec<u8> {
363        let mut out = String::from("\x1b[2J");
364        emit_changes(None, self, &mut out);
365        out.into_bytes()
366    }
367}
368
369/// Emit the cell changes from `prev` to `next` (`None` means "cleared
370/// screen": every non-default cell of `next` is painted).
371///
372/// Row suffix optimisation: when the trailing cells of a row are all default
373/// in `next` but were not in `prev`, a single `\x1b[K]` (Erase to EOL)
374/// replaces one write-per-blank-cell. Style is reset before the erase so the
375/// terminal fills with the default background.
376fn emit_changes(prev: Option<&Screen>, next: &Screen, out: &mut String) {
377    use std::fmt::Write;
378    let mut style = Style::default();
379    // Where the terminal cursor is right now, when known. Printing advances
380    // it; at the right edge the position becomes ambiguous (pending wrap),
381    // so it is dropped to force an explicit move next time.
382    let mut at: Option<(usize, usize)> = None;
383    for row in 0..next.rows {
384        // Find the exclusive right bound of the non-default suffix in `next`.
385        // Columns in [last_content, cols) are all Cell::default() in `next`.
386        let last_content = (0..next.cols)
387            .rev()
388            .find(|&c| next.cell(row, c) != Cell::default())
389            .map_or(0, |c| c + 1);
390
391        // Emit changed cells up to (but not including) the trailing blank region.
392        for col in 0..last_content {
393            let target = next.cell(row, col);
394            // A width-0 cell is the right half (continuation) of a double-width
395            // glyph: it carries no character of its own and emits nothing. The
396            // lead cell to its left drew the glyph and the terminal advanced the
397            // cursor across both columns.
398            if target.width == CellWidth::Continuation {
399                continue;
400            }
401            let same = match prev {
402                Some(p) => p.cell(row, col) == target,
403                None => target == Cell::default(),
404            };
405            if same {
406                continue;
407            }
408            if at != Some((row, col)) {
409                write!(out, "\x1b[{};{}H", row + 1, col + 1).unwrap();
410            }
411            out.push_str(&style.transition_to(&target.style));
412            style = target.style;
413            out.push(target.ch);
414            // The terminal advances the cursor by the glyph's column width, so a
415            // width-2 lead lands `at` two columns on and its continuation column
416            // is then skipped. (A single-width cell advances by one, exactly as
417            // before — ASCII output is byte-for-byte unchanged.) At or past the
418            // right edge the position is ambiguous (pending wrap), so drop it to
419            // force an explicit move next time.
420            let advance = target.width.columns();
421            at = if col + advance < next.cols {
422                Some((row, col + advance))
423            } else {
424                None
425            };
426        }
427
428        // If prev had non-default content in the trailing blank region of next,
429        // erase it with a single EL rather than writing each blank cell.
430        // Not used in render_full (prev == None) because `\x1b[2J` already
431        // cleared the screen before emit_changes is called.
432        if last_content < next.cols {
433            let needs_erase = match prev {
434                Some(p) => (last_content..next.cols).any(|c| p.cell(row, c) != Cell::default()),
435                None => false,
436            };
437            if needs_erase {
438                // Reset SGR before EL so the terminal erases with the default
439                // background, not whatever the last cell's style was.
440                out.push_str(&style.transition_to(&Style::default()));
441                style = Style::default();
442                if at != Some((row, last_content)) {
443                    write!(out, "\x1b[{};{}H", row + 1, last_content + 1).unwrap();
444                }
445                out.push_str("\x1b[K");
446                // Cursor stays at (row, last_content); next row needs an explicit
447                // move regardless, so mark position unknown.
448                at = None;
449            }
450        }
451    }
452    out.push_str(&style.transition_to(&Style::default()));
453    let Cursor { row, col } = next.cursor;
454    write!(out, "\x1b[{};{}H", row + 1, col + 1).unwrap();
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use crate::style::Style;
461
462    /// A screen whose every cell names its own position, so any move is visible.
463    fn numbered(rows: usize, cols: usize) -> Screen {
464        let mut s = Screen::new(rows, cols);
465        for r in 0..rows {
466            for c in 0..cols {
467                let ch = char::from_u32(0x4E00 + (r * 64 + c) as u32).unwrap();
468                s.set(r, c, Cell::new(ch, Style::default()));
469            }
470        }
471        s
472    }
473
474    /// The per-cell scroll this replaces, as the reference.
475    fn reference_up(s: &mut Screen, top: usize, bottom: usize, n: usize, fill: Cell) {
476        let n = n.min(bottom - top + 1);
477        for r in top..=bottom {
478            for c in 0..s.cols() {
479                let cell = if r + n <= bottom {
480                    s.cell(r + n, c)
481                } else {
482                    fill
483                };
484                s.set(r, c, cell);
485            }
486        }
487    }
488
489    fn reference_down(s: &mut Screen, top: usize, bottom: usize, n: usize, fill: Cell) {
490        let n = n.min(bottom - top + 1);
491        for r in (top..=bottom).rev() {
492            for c in 0..s.cols() {
493                let cell = if r >= top + n { s.cell(r - n, c) } else { fill };
494                s.set(r, c, cell);
495            }
496        }
497    }
498
499    #[test]
500    fn region_scrolls_match_the_per_cell_reference_everywhere() {
501        let fill = Cell::new(' ', Style::default());
502        for (rows, cols) in [(1, 1), (5, 3), (8, 7)] {
503            for top in 0..rows {
504                for bottom in top..rows {
505                    for n in 0..=(bottom - top + 2) {
506                        let (mut fast, mut slow) = (numbered(rows, cols), numbered(rows, cols));
507                        fast.scroll_rows_up(top, bottom, n, fill);
508                        if n > 0 {
509                            reference_up(&mut slow, top, bottom, n, fill);
510                        }
511                        assert_eq!(fast, slow, "up {rows}x{cols} {top}..={bottom} by {n}");
512
513                        let (mut fast, mut slow) = (numbered(rows, cols), numbered(rows, cols));
514                        fast.scroll_rows_down(top, bottom, n, fill);
515                        if n > 0 {
516                            reference_down(&mut slow, top, bottom, n, fill);
517                        }
518                        assert_eq!(fast, slow, "down {rows}x{cols} {top}..={bottom} by {n}");
519                    }
520                }
521            }
522        }
523    }
524
525    /// The ring is invisible: a screen scrolled into a state equals a screen
526    /// built that way, its rows read back in order, and a clear resets it.
527    #[test]
528    fn the_row_ring_is_invisible_to_readers() {
529        let fill = Cell::new(' ', Style::default());
530        let mut s = numbered(4, 3);
531        // Scroll a whole screen's worth and then some, so origin wraps.
532        for _ in 0..7 {
533            s.scroll_rows_up(0, 3, 1, fill);
534        }
535        let blank = Screen::new(4, 3);
536        assert_eq!(s, blank, "everything scrolled off: a blank screen");
537
538        let mut s = numbered(4, 3);
539        s.scroll_rows_up(0, 3, 2, fill);
540        let mut want = Screen::new(4, 3);
541        for r in 0..2 {
542            for c in 0..3 {
543                want.set(r, c, numbered(4, 3).cell(r + 2, c));
544            }
545        }
546        assert_eq!(s, want, "two rows up, two blank rows in");
547        assert_eq!(s.render_full(), want.render_full(), "same bytes");
548
549        // A wrapped ring still writes, reads and clears by logical position.
550        s.set(0, 0, Cell::new('A', Style::default()));
551        assert_eq!(s.cell(0, 0).ch, 'A');
552        s.clear();
553        assert_eq!(s, Screen::new(4, 3));
554    }
555
556    #[test]
557    fn an_out_of_range_region_scroll_changes_nothing() {
558        let fill = Cell::new('x', Style::default());
559        let mut s = numbered(4, 4);
560        let before = s.clone();
561        s.scroll_rows_up(3, 1, 1, fill);
562        s.scroll_rows_up(0, 4, 1, fill);
563        s.scroll_rows_down(2, 9, 1, fill);
564        assert_eq!(s, before);
565    }
566
567    // ── helpers ──────────────────────────────────────────────────────────────
568
569    fn filled_row(rows: usize, cols: usize, row: usize, ch: char) -> Screen {
570        let mut s = Screen::new(rows, cols);
571        for c in 0..cols {
572            s.set(row, c, Cell::new(ch, Style::default()));
573        }
574        s
575    }
576
577    fn contains_el(bytes: &[u8]) -> bool {
578        let s = std::str::from_utf8(bytes).unwrap_or("");
579        s.contains("\x1b[K")
580    }
581
582    // ── Screen::clear() ──────────────────────────────────────────────────────
583
584    #[test]
585    fn clear_resets_all_cells_and_cursor_without_realloc() {
586        let mut s = Screen::new(4, 6);
587        // Dirty every cell with a non-default character and style.
588        let dirty_style = Style {
589            bold: true,
590            ..Style::default()
591        };
592        for r in 0..4 {
593            for c in 0..6 {
594                s.set(r, c, Cell::new('X', dirty_style));
595            }
596        }
597        // Move the cursor off the origin.
598        s.set_cursor(Cursor::new(3, 5));
599
600        s.clear();
601
602        // Dimensions must be unchanged.
603        assert_eq!(s.rows(), 4);
604        assert_eq!(s.cols(), 6);
605        // Cursor must be at the origin.
606        assert_eq!(s.cursor(), Cursor::new(0, 0));
607        // Every cell must equal the default.
608        let blank = Cell::default();
609        for r in 0..4 {
610            for c in 0..6 {
611                assert_eq!(
612                    s.cell(r, c),
613                    blank,
614                    "cell ({r},{c}) was not reset by clear()"
615                );
616            }
617        }
618    }
619
620    // ── emit_changes / diff: erase-to-EOL optimisation ───────────────────────
621
622    #[test]
623    fn diff_emits_el_when_row_tail_clears_to_blank() {
624        // prev: row 0 fully filled with 'X'; next: only first 3 cols are 'X'.
625        // The tail cols [3..8) revert to blank → diff should contain EL.
626        let prev = filled_row(2, 8, 0, 'X');
627        let mut next = Screen::new(2, 8);
628        for c in 0..3 {
629            next.set(0, c, Cell::new('X', Style::default()));
630        }
631        let diff = prev.diff(&next);
632        assert!(
633            contains_el(&diff),
634            "expected \\x1b[K in diff when tail blanks out"
635        );
636    }
637
638    #[test]
639    fn diff_no_el_when_tail_was_already_blank_in_prev() {
640        // Both screens have content only in cols [0, 3); tail was already blank.
641        // No cells changed in the tail, so no EL should be emitted.
642        let mut prev = Screen::new(2, 8);
643        let mut next = Screen::new(2, 8);
644        for c in 0..3 {
645            prev.set(0, c, Cell::new('A', Style::default()));
646            next.set(0, c, Cell::new('B', Style::default()));
647        }
648        let diff = prev.diff(&next);
649        assert!(
650            !contains_el(&diff),
651            "unexpected \\x1b[K when tail was already blank"
652        );
653    }
654
655    #[test]
656    fn render_full_never_emits_el() {
657        // render_full follows \x1b[2J with individual cell writes; it must NOT
658        // use EL (the screen is already clear, so EL would be redundant, and
659        // the contract is that render_full never relies on prior state).
660        let s = filled_row(2, 8, 0, 'X');
661        let bytes = s.render_full();
662        assert!(!contains_el(&bytes), "render_full must not emit \\x1b[K");
663    }
664
665    #[test]
666    fn diff_el_visual_correctness_via_round_trip() {
667        // Prove that a diff containing EL produces the correct visual result:
668        // simulate applying the diff to a "terminal" modelled as a Screen copy.
669        // prev → diff(next) → the result must equal next.
670        let prev = filled_row(1, 6, 0, 'Z');
671        let mut next = Screen::new(1, 6);
672        next.set(0, 0, Cell::new('A', Style::default()));
673        // next has 'A' at (0,0) and blanks at (0,1..5).
674        assert!(contains_el(&prev.diff(&next)));
675        // The diff-then-apply path is tested implicitly: if diff produces EL,
676        // but the NEXT diff of (next, next) is empty, then prev+diff == next.
677        let empty = next.diff(&next);
678        assert!(empty.is_empty(), "diff of identical screens must be empty");
679    }
680
681    #[test]
682    fn diff_entire_row_blank_in_next_uses_el() {
683        // prev: entire row 1 filled; next: row 1 is all blank.
684        let prev = filled_row(3, 5, 1, 'Y');
685        let next = Screen::new(3, 5);
686        let diff = prev.diff(&next);
687        assert!(
688            contains_el(&diff),
689            "entire-row blank transition should use EL"
690        );
691    }
692
693    // ── double-width cells: Cell.width, continuation, width-aware emit ────────
694
695    #[test]
696    fn cell_constructors_set_width() {
697        let st = Style::default();
698        assert_eq!(Cell::default().width, CellWidth::Single);
699        assert_eq!(Cell::new('a', st).width, CellWidth::Single);
700        assert_eq!(Cell::wide('世', st).width, CellWidth::Wide);
701        let cont = Cell::continuation(st);
702        assert_eq!(cont.width, CellWidth::Continuation);
703        let cols: Vec<usize> = [CellWidth::Continuation, CellWidth::Single, CellWidth::Wide]
704            .iter()
705            .map(|w| w.columns())
706            .collect();
707        assert_eq!(cols, [0, 1, 2]);
708        assert_eq!(cont.ch, ' ');
709    }
710
711    #[test]
712    fn wide_lead_emits_glyph_once_no_continuation_char() {
713        // A width-2 lead followed by a width-0 continuation must emit the glyph
714        // exactly once and NOTHING for the continuation column (the terminal
715        // advances the cursor by two when it draws the wide glyph).
716        let prev = Screen::new(1, 4);
717        let mut next = Screen::new(1, 4);
718        next.set(0, 0, Cell::wide('世', Style::default()));
719        next.set(0, 1, Cell::continuation(Style::default()));
720        let diff = prev.diff(&next);
721        let s = std::str::from_utf8(&diff).unwrap();
722        assert_eq!(s.matches('世').count(), 1, "glyph must appear exactly once");
723        assert!(
724            !s.contains(' '),
725            "continuation column must emit no space: {s:?}"
726        );
727        // Exact bytes: CUP to (1,1), the glyph, then park cursor at (1,1).
728        assert_eq!(diff, "\x1b[1;1H世\x1b[1;1H".as_bytes());
729    }
730
731    #[test]
732    fn wide_lead_advances_cursor_by_two() {
733        // After a width-2 lead, a cell two columns on needs no fresh CUP — the
734        // emitter tracks that the terminal advanced the cursor across both
735        // halves of the wide glyph. If accounting were off by one, a CUP escape
736        // would appear between the glyph and 'X'.
737        let prev = Screen::new(1, 6);
738        let mut next = Screen::new(1, 6);
739        next.set(0, 0, Cell::wide('世', Style::default()));
740        next.set(0, 1, Cell::continuation(Style::default()));
741        next.set(0, 2, Cell::new('X', Style::default()));
742        let diff = prev.diff(&next);
743        assert_eq!(diff, "\x1b[1;1H世X\x1b[1;1H".as_bytes());
744    }
745
746    #[test]
747    fn ascii_diff_is_byte_identical_regression() {
748        // Guard: the width model must not change single-width output at all.
749        let mut old = Screen::new(2, 10);
750        old.write_str(0, 0, "hello", Style::default());
751        let mut new = old.clone();
752        new.set(0, 1, Cell::new('a', Style::default()));
753        assert_eq!(old.diff(&new), b"\x1b[1;2Ha\x1b[1;1H");
754    }
755
756    #[test]
757    fn set_cursor_clamps_onto_the_grid() {
758        let mut s = Screen::new(4, 6);
759        s.set_cursor(Cursor::new(2, 3));
760        assert_eq!(s.cursor(), Cursor::new(2, 3));
761        s.set_cursor(Cursor::new(99, 99));
762        assert_eq!(s.cursor(), Cursor::new(3, 5));
763        // An empty screen has no cell to stand on: the origin.
764        let mut empty = Screen::new(0, 0);
765        empty.set_cursor(Cursor::new(5, 5));
766        assert_eq!(empty.cursor(), Cursor::new(0, 0));
767    }
768
769    #[test]
770    fn copy_cells_matches_repeated_set() {
771        let st = Style::default();
772        let run = [
773            Cell::new('a', st),
774            Cell::wide('世', st),
775            Cell::continuation(st),
776            Cell::new('b', st),
777        ];
778        let mut via_copy = Screen::new(2, 8);
779        via_copy.copy_cells(1, 2, &run);
780        let mut via_set = Screen::new(2, 8);
781        for (i, cell) in run.iter().enumerate() {
782            via_set.set(1, 2 + i, *cell);
783        }
784        assert_eq!(via_copy, via_set);
785    }
786
787    #[test]
788    fn copy_cells_clips_at_edge_and_ignores_out_of_range() {
789        let st = Style::default();
790        let run = [Cell::new('x', st); 5];
791        let mut s = Screen::new(2, 4);
792        // 5-cell run starting at col 2: only cols 2 and 3 are written.
793        s.copy_cells(0, 2, &run);
794        assert_eq!(s.cell(0, 2).ch, 'x');
795        assert_eq!(s.cell(0, 3).ch, 'x');
796        assert_eq!(s.cell(0, 1), Cell::default());
797        // Out-of-range row or start column are silent no-ops (must not panic).
798        s.copy_cells(9, 0, &run);
799        s.copy_cells(0, 9, &run);
800    }
801}