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::history::History;
6use crate::style::Style;
7
8/// How many terminal columns a [`Cell`] covers, modeling East Asian
9/// double-width glyphs and emoji, which occupy two columns.
10///
11/// This crate does not *compute* width — that needs the Unicode database, which
12/// a zero-dependency crate cannot own. Callers pick the width (e.g. from the
13/// `uwidth` crate); the renderers here honor whatever they are given.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum CellWidth {
16    /// The right half of the double-width glyph to its left: it carries no
17    /// character of its own and is never emitted, because the terminal advances
18    /// the cursor by two columns when it draws the lead glyph.
19    Continuation,
20    /// A normal single-width cell (the common case, and the default).
21    #[default]
22    Single,
23    /// The left half (lead) of a double-width glyph; the cell holds the
24    /// character, and the cell immediately to its right should be a
25    /// [`CellWidth::Continuation`].
26    Wide,
27}
28
29impl CellWidth {
30    /// The columns this cell advances the terminal cursor when drawn: `0`, `1`
31    /// or `2`.
32    pub const fn columns(self) -> usize {
33        match self {
34            CellWidth::Continuation => 0,
35            CellWidth::Single => 1,
36            CellWidth::Wide => 2,
37        }
38    }
39}
40
41/// One character cell: a `char`, its [`Style`], and its [`CellWidth`]. The
42/// default cell is a single-width space in the default style.
43///
44/// A double-width glyph is a [`Cell::wide`] lead followed by a
45/// [`Cell::continuation`]; build cells with the constructors to keep that
46/// pairing. A grid can still hold an unpaired half (a compositor clipping a
47/// row through a glyph produces one), so readers of arbitrary cells should
48/// expect it.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct Cell {
51    pub ch: char,
52    pub style: Style,
53    pub width: CellWidth,
54}
55
56impl Default for Cell {
57    fn default() -> Self {
58        Cell {
59            ch: ' ',
60            style: Style::default(),
61            width: CellWidth::Single,
62        }
63    }
64}
65
66impl Cell {
67    /// A normal single-width cell.
68    pub const fn new(ch: char, style: Style) -> Self {
69        Cell {
70            ch,
71            style,
72            width: CellWidth::Single,
73        }
74    }
75
76    /// The lead (left half) of a double-width glyph. Its right neighbor should
77    /// be a [`Cell::continuation`].
78    pub const fn wide(ch: char, style: Style) -> Self {
79        Cell {
80            ch,
81            style,
82            width: CellWidth::Wide,
83        }
84    }
85
86    /// A continuation (right half) of a double-width glyph: no character of its
87    /// own, but carrying `style` so the covered column keeps the right
88    /// background. Never emitted — the lead glyph fills both columns.
89    pub const fn continuation(style: Style) -> Self {
90        Cell {
91            ch: ' ',
92            style,
93            width: CellWidth::Continuation,
94        }
95    }
96}
97
98/// A 0-based cursor position on a [`Screen`]. Named fields, so a row and a
99/// column cannot be transposed the way a bare `(usize, usize)` can.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
101pub struct Cursor {
102    pub row: usize,
103    pub col: usize,
104}
105
106impl Cursor {
107    pub const fn new(row: usize, col: usize) -> Self {
108        Cursor { row, col }
109    }
110}
111
112/// What a physical row's written cells are, so that storing it in the
113/// history need not look at each cell.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115enum Plain {
116    /// Nothing written.
117    Empty,
118    /// Every written cell is a single-width character below U+0100 in this
119    /// style, one byte each in `Screen::text`.
120    Bytes(Style),
121    /// Anything else.
122    Mixed,
123}
124
125/// A `rows x cols` grid of [`Cell`]s plus a [`Cursor`], both 0-based.
126#[derive(Debug, Clone)]
127pub struct Screen {
128    rows: usize,
129    cols: usize,
130    /// Row-major, and exactly `rows * cols` long: sized by
131    /// [`Screen::new`] / [`Screen::with_history`] and never grown or shrunk
132    /// afterwards (`clear` refills in place, `set` and
133    /// `copy_cells` overwrite in bounds). The indexers check it in debug builds.
134    ///
135    /// Rows are reached through a **row map**: logical row `r` lives at
136    /// physical row `map[r]`, so scrolling any region rotates a few row
137    /// indices instead of moving cells. Nothing outside [`Screen::index`]
138    /// knows this.
139    ///
140    /// Rows are also **cleared lazily**: physical row `p` holds real cells
141    /// only in its first `written[p]` columns; every column past that reads
142    /// as `blank[p]`. Scrolling a row in or clearing it just resets those two
143    /// (O(1)), where filling every cell cost ~half of all emulation time on a
144    /// 177-column pane of short agent lines. Writes fill any gap first.
145    cells: Vec<Cell>,
146    /// Logical row to physical row.
147    map: Vec<usize>,
148    /// **Scrollback, stored compactly.** When the whole screen scrolls up,
149    /// the row leaving the top is encoded (its characters in UTF-8 plus
150    /// runs of style) and appended here, and its physical row comes back in
151    /// at the bottom, lazily blank. (It was a ring of full rows: 3.5 KB a
152    /// line on a 177-column screen, 35 MB for 10,000 lines.)
153    hist: History,
154    /// Only with history: per physical row, whether its written cells are
155    /// plain [`Plain::Bytes`], whose characters `text` (`rows * cols`)
156    /// mirrors. Such a row, the usual one, goes into the history as a copy
157    /// of its bytes rather than a scan of its cells (~110 ns a line of
158    /// 48 cells for the scan).
159    plain: Vec<Plain>,
160    text: Vec<u8>,
161    /// Per physical row: how many leading cells are real.
162    written: Vec<usize>,
163    /// Per physical row: what every cell past `written` reads as.
164    blank: Vec<Cell>,
165    /// Per physical row: it wrapped onto the next row (the emulator's
166    /// autowrap) rather than ending in a line break of its own, so copying
167    /// joins the two. Cleared when the row is cleared or scrolled in.
168    wrapped: Vec<bool>,
169    /// Where the cursor should rest after rendering. Always on the grid (see
170    /// [`Screen::set_cursor`]).
171    cursor: Cursor,
172}
173
174/// Two screens are equal when they *show* the same thing: same size, same
175/// cursor, same cell at every logical position. Where each row physically sits
176/// in the ring is an implementation detail, so a scrolled screen and a freshly
177/// built one with the same content compare equal.
178impl PartialEq for Screen {
179    fn eq(&self, other: &Self) -> bool {
180        if self.rows != other.rows || self.cols != other.cols || self.cursor != other.cursor {
181            return false;
182        }
183        (0..self.rows).all(|r| {
184            self.row_wrapped(r) == other.row_wrapped(r)
185                && (0..self.cols).all(|c| self.cell(r, c) == other.cell(r, c))
186        })
187    }
188}
189
190impl Eq for Screen {}
191
192impl Screen {
193    /// A blank screen (all default cells, cursor at the origin).
194    pub fn new(rows: usize, cols: usize) -> Self {
195        Screen::with_history(rows, cols, 0)
196    }
197
198    /// A blank screen that keeps up to `history` lines scrolled off its top
199    /// (see [`Screen::history_len`]). Only a scroll of the whole screen feeds
200    /// the history, as in other terminals; a scroll region does not.
201    pub fn with_history(rows: usize, cols: usize, history: usize) -> Self {
202        Screen {
203            rows,
204            cols,
205            cells: vec![Cell::default(); rows * cols],
206            map: (0..rows).collect(),
207            hist: History::new(history),
208            plain: vec![Plain::Empty; if history > 0 { rows } else { 0 }],
209            text: vec![0; if history > 0 { rows * cols } else { 0 }],
210            written: vec![0; rows],
211            blank: vec![Cell::default(); rows],
212            wrapped: vec![false; rows],
213            cursor: Cursor::default(),
214        }
215    }
216
217    /// The same visible rows and cursor, without the history: what a
218    /// renderer or a synchronized-output snapshot needs, at the cost of the
219    /// visible cells only.
220    pub fn visible(&self) -> Screen {
221        let mut s = Screen::new(self.rows, self.cols);
222        for r in 0..self.rows {
223            let p = self.map[r];
224            let w = self.written[p];
225            s.blank[r] = self.blank[p];
226            s.written[r] = w;
227            s.wrapped[r] = self.wrapped[p];
228            let (src, dst) = (p * self.cols, r * self.cols);
229            s.cells[dst..dst + w].copy_from_slice(&self.cells[src..src + w]);
230        }
231        s.cursor = self.cursor;
232        s
233    }
234
235    /// Lines of history held (at most the capacity given to
236    /// [`Screen::with_history`]).
237    pub fn history_len(&self) -> usize {
238        self.hist.len()
239    }
240
241    /// The history capacity, in lines.
242    pub fn history_capacity(&self) -> usize {
243        self.hist.capacity()
244    }
245
246    /// Hold at most `max` bytes of memory in history (0: no cap), dropping
247    /// the oldest lines past it: a bound however wide or colourful lines
248    /// are, on top of the line capacity. It may run over by up to one
249    /// 96 KiB chunk.
250    pub fn set_history_bytes(&mut self, max: usize) {
251        self.hist.set_max_bytes(max);
252    }
253
254    /// Memory the history holds: its buffers' capacity.
255    pub fn history_bytes(&self) -> usize {
256        self.hist.bytes()
257    }
258
259    /// Lines that have ever entered the history, including ones since
260    /// dropped: a monotonic count to anchor a scrolled-back view on.
261    pub fn scrolled_lines(&self) -> u64 {
262        self.hist.pushed()
263    }
264
265    /// A cell of the history: `age` 0 is the line that most recently left
266    /// the top of the screen. Out of range returns a default cell. Each call
267    /// decodes into the stored line; for whole rows use
268    /// [`Screen::history_row`].
269    pub fn history_cell(&self, age: usize, col: usize) -> Cell {
270        if col >= self.cols {
271            return Cell::default();
272        }
273        self.hist.cell(age, col).unwrap_or_default()
274    }
275
276    /// History line `age` (0 the newest) as this screen's `cols` cells, into
277    /// `out`, replacing its contents. Returns false, leaving `out` empty,
278    /// when the line is not held.
279    pub fn history_row(&self, age: usize, out: &mut Vec<Cell>) -> bool {
280        self.hist.row(age, self.cols, out)
281    }
282
283    /// Whether history line `age` (0 the newest) wrapped onto the next line;
284    /// false when not held.
285    pub fn history_wrapped(&self, age: usize) -> bool {
286        self.hist.wrapped(age)
287    }
288
289    /// Whether `row` wrapped onto the row below (autowrap), so the two are
290    /// one line of output. Out of range is false.
291    pub fn row_wrapped(&self, row: usize) -> bool {
292        row < self.rows && self.wrapped[self.physical(row)]
293    }
294
295    /// Mark `row` as wrapped onto the row below, or not; the emulator sets
296    /// it when a line autowraps. Out of range is ignored.
297    pub fn set_row_wrapped(&mut self, row: usize, wrapped: bool) {
298        if row < self.rows {
299            let p = self.physical(row);
300            self.wrapped[p] = wrapped;
301        }
302    }
303
304    /// Forget the history (as `ESC [3J` asks).
305    pub fn clear_history(&mut self) {
306        self.hist.clear();
307    }
308
309    /// Copy `other`'s history (oldest first) into this screen's, each line
310    /// cut or padded to this width: used to keep scrollback across a resize.
311    pub fn copy_history_from(&mut self, other: &Screen) {
312        let cols = self.cols.min(other.cols);
313        let mut row = Vec::with_capacity(other.cols);
314        for age in (0..other.history_len()).rev() {
315            other.history_row(age, &mut row);
316            row.truncate(cols);
317            self.hist
318                .push(&row, Cell::default(), other.history_wrapped(age));
319        }
320    }
321
322    pub fn rows(&self) -> usize {
323        self.rows
324    }
325
326    pub fn cols(&self) -> usize {
327        self.cols
328    }
329
330    /// Where the cursor rests after rendering.
331    #[inline]
332    pub fn cursor(&self) -> Cursor {
333        self.cursor
334    }
335
336    /// Move the cursor, clamped onto the grid: a row or column past the edge
337    /// lands on the last row or column (the origin on an empty screen). The
338    /// renderers park the terminal cursor here.
339    #[inline]
340    pub fn set_cursor(&mut self, cursor: Cursor) {
341        self.cursor = Cursor {
342            row: cursor.row.min(self.rows.saturating_sub(1)),
343            col: cursor.col.min(self.cols.saturating_sub(1)),
344        };
345    }
346
347    /// Reset all cells to default and the cursor to the origin, reusing the
348    /// existing allocation. The caller must ensure the screen is already the
349    /// right size; use [`Screen::new`] when the size changes.
350    pub fn clear(&mut self) {
351        // Visible rows only; any history stays (see `clear_history`).
352        for r in 0..self.rows {
353            self.fill_row(r, Cell::default());
354        }
355        self.cursor = Cursor::default();
356    }
357
358    /// The flat index of `(row, col)`, which the caller has bounds-checked.
359    /// Logical row `row` sits at physical row `map[row]`.
360    #[inline]
361    fn index(&self, row: usize, col: usize) -> usize {
362        debug_assert_eq!(self.cells.len(), self.rows * self.cols);
363        debug_assert!(row < self.rows && col < self.cols);
364        self.physical(row) * self.cols + col
365    }
366
367    /// The physical row holding logical `row`.
368    #[inline]
369    fn physical(&self, row: usize) -> usize {
370        self.map[row]
371    }
372
373    /// Make the first `upto` cells of physical row `p` real, filling the
374    /// gap past `written[p]` with the row's blank.
375    #[inline]
376    fn materialize(&mut self, p: usize, upto: usize) {
377        let w = self.written[p];
378        if w < upto {
379            let base = p * self.cols;
380            let blank = self.blank[p];
381            self.cells[base + w..base + upto].fill(blank);
382            self.written[p] = upto;
383            if self.track(p, blank) {
384                self.text[base + w..base + upto].fill(blank.ch as u8);
385            }
386        }
387    }
388
389    /// With history, fold a write of cells like `cell` into physical row
390    /// `p`'s [`Plain`] state; true if the row stays plain, and the caller
391    /// then mirrors the characters into `text`.
392    #[inline]
393    fn track(&mut self, p: usize, cell: Cell) -> bool {
394        let Some(state) = self.plain.get_mut(p) else {
395            return false;
396        };
397        let byte = cell.width == CellWidth::Single && (cell.ch as u32) < 0x100;
398        *state = match *state {
399            _ if !byte => Plain::Mixed,
400            Plain::Empty => Plain::Bytes(cell.style),
401            Plain::Bytes(s) if s == cell.style => Plain::Bytes(s),
402            _ => Plain::Mixed,
403        };
404        *state != Plain::Mixed
405    }
406
407    /// The cell at `(row, col)`; out of bounds returns a default cell.
408    pub fn cell(&self, row: usize, col: usize) -> Cell {
409        if row < self.rows && col < self.cols {
410            let p = self.physical(row);
411            if col < self.written[p] {
412                self.cells[self.index(row, col)]
413            } else {
414                self.blank[p]
415            }
416        } else {
417            Cell::default()
418        }
419    }
420
421    /// Row `row` without a copy: the cells written so far, and the cell every
422    /// column past them reads as (the row's blank). The two together are
423    /// what [`Screen::cell`] returns column by column, so a compositor or a
424    /// diff walks a row as one slice and one fill instead of `cols` lookups.
425    /// Out of range: an empty slice and a default cell.
426    pub fn row_cells(&self, row: usize) -> (&[Cell], Cell) {
427        if row >= self.rows {
428            return (&[], Cell::default());
429        }
430        let p = self.physical(row);
431        let base = p * self.cols;
432        (&self.cells[base..base + self.written[p]], self.blank[p])
433    }
434
435    /// Set one cell. Out-of-bounds writes are ignored.
436    pub fn set(&mut self, row: usize, col: usize, cell: Cell) {
437        if row < self.rows && col < self.cols {
438            let p = self.physical(row);
439            self.materialize(p, col + 1);
440            let i = self.index(row, col);
441            self.cells[i] = cell;
442            if self.track(p, cell) {
443                self.text[i] = cell.ch as u8;
444            }
445        }
446    }
447
448    /// Write a string across a row starting at `(row, col)`, truncating at
449    /// the right edge. Each `char` takes one cell (single-width); callers that
450    /// need double-width layout use [`Cell::wide`] with [`Screen::set`] or
451    /// [`Screen::copy_cells`].
452    pub fn write_str(&mut self, row: usize, col: usize, text: &str, style: Style) {
453        for (i, ch) in text.chars().enumerate() {
454            self.set(row, col + i, Cell::new(ch, style));
455        }
456    }
457
458    /// Copy a contiguous run of cells into `row` starting at column `col`,
459    /// clipping the run at the right edge (and ignoring an out-of-range row or
460    /// start column). Equivalent to calling [`Screen::set`] for each cell in
461    /// turn, but with a single bounds check for the whole run — the fast path a
462    /// compositor uses to blit a row slice.
463    pub fn copy_cells(&mut self, row: usize, col: usize, cells: &[Cell]) {
464        if row >= self.rows || col >= self.cols {
465            return;
466        }
467        let n = cells.len().min(self.cols - col);
468        let p = self.physical(row);
469        self.materialize(p, col);
470        let base = self.index(row, col);
471        self.cells[base..base + n].copy_from_slice(&cells[..n]);
472        self.written[p] = self.written[p].max(col + n);
473        if let Some(state) = self.plain.get_mut(p) {
474            *state = Plain::Mixed;
475        }
476    }
477
478    /// Write ASCII `text` into `row` from column `col` in one pass, each byte
479    /// a single-width cell in `style`, clipped at the right edge (an
480    /// out-of-range row or column writes nothing). Returns the cells written.
481    /// The same as [`Screen::copy_cells`] of `Cell::new(b as char, style)`
482    /// for each byte, without building the cells first — the hot path of a
483    /// terminal printing plain text. Bytes are not checked: callers pass
484    /// printable ASCII.
485    pub fn write_ascii(&mut self, row: usize, col: usize, text: &[u8], style: Style) -> usize {
486        if row >= self.rows || col >= self.cols {
487            return 0;
488        }
489        let n = text.len().min(self.cols - col);
490        let p = self.physical(row);
491        if col == 0 && n >= self.written[p] {
492            // Everything written is replaced.
493            if let Some(state) = self.plain.get_mut(p) {
494                *state = Plain::Empty;
495            }
496        }
497        self.materialize(p, col);
498        let base = self.index(row, col);
499        for (cell, &b) in self.cells[base..base + n].iter_mut().zip(text) {
500            *cell = Cell::new(b as char, style);
501        }
502        self.written[p] = self.written[p].max(col + n);
503        if n > 0 && self.track(p, Cell::new(' ', style)) {
504            self.text[base..base + n].copy_from_slice(&text[..n]);
505        }
506        n
507    }
508
509    /// Scroll rows `top..=bottom` up by `n`. Rows move up, the top `n` rows of
510    /// the region are lost, and `n` rows of `fill` enter at the bottom. Rows
511    /// outside the region are untouched. An empty or out-of-range region does
512    /// nothing, and `n` is clamped to the region's height.
513    ///
514    /// Any region, the whole screen included, scrolls by rotating its entries
515    /// in the row map and clearing the rows that come in: `region` index moves
516    /// and O(1) per incoming row, never `region x cols`.
517    ///
518    /// A terminal scrolls on every output line. Copying the region cell by cell
519    /// fed a 16 MB log at 11 MB/s on a 40x160 grid; moving whole rows still
520    /// held a 177x47 pane with a one-row status line (`DECSTBM`) to 21 MiB/s,
521    /// against 182 MiB/s scrolling the full screen.
522    pub fn scroll_rows_up(&mut self, top: usize, bottom: usize, n: usize, fill: Cell) {
523        if top > bottom || bottom >= self.rows || n == 0 {
524            return;
525        }
526        let n = n.min(bottom - top + 1);
527        if self.hist.capacity() > 0 && top == 0 && bottom == self.rows - 1 {
528            // The whole screen: each departing row is stored in the history
529            // before its physical row comes back in at the bottom.
530            for r in 0..n {
531                let p = self.map[r];
532                let (from, to) = (p * self.cols, p * self.cols + self.written[p]);
533                let wrapped = self.wrapped[p];
534                match self.plain[p] {
535                    Plain::Bytes(style) => {
536                        self.hist
537                            .push_ascii(&self.text[from..to], style, self.blank[p], wrapped)
538                    }
539                    Plain::Empty => {
540                        self.hist
541                            .push_ascii(&[], Style::default(), self.blank[p], wrapped)
542                    }
543                    Plain::Mixed => self
544                        .hist
545                        .push(&self.cells[from..to], self.blank[p], wrapped),
546                }
547            }
548        }
549        self.map[top..=bottom].rotate_left(n);
550        for r in bottom + 1 - n..=bottom {
551            self.fill_row(r, fill);
552        }
553    }
554
555    /// Fill one logical row with `fill`: O(1), by making the whole row read
556    /// as `fill` (see `written`).
557    #[inline]
558    fn fill_row(&mut self, row: usize, fill: Cell) {
559        let p = self.physical(row);
560        self.written[p] = 0;
561        self.blank[p] = fill;
562        self.wrapped[p] = false;
563        if let Some(state) = self.plain.get_mut(p) {
564            *state = Plain::Empty;
565        }
566    }
567
568    /// Scroll rows `top..=bottom` down by `n`: the mirror of
569    /// [`Screen::scroll_rows_up`]. The bottom `n` rows of the region are lost,
570    /// and `n` rows of `fill` enter at the top.
571    pub fn scroll_rows_down(&mut self, top: usize, bottom: usize, n: usize, fill: Cell) {
572        if top > bottom || bottom >= self.rows || n == 0 {
573            return;
574        }
575        let n = n.min(bottom - top + 1);
576        self.map[top..=bottom].rotate_right(n);
577        for r in top..top + n {
578            self.fill_row(r, fill);
579        }
580    }
581
582    /// The bytes that transform a terminal currently showing `self` into one
583    /// showing `next`: cursor moves (CUP), minimal SGR transitions, and the
584    /// changed characters, ending with a style reset and the cursor parked at
585    /// `next`'s cursor. Equal screens produce no bytes at all.
586    ///
587    /// Both screens must have the same dimensions; if they differ (a resize),
588    /// the diff falls back to a full repaint of `next`.
589    pub fn diff(&self, next: &Screen) -> Vec<u8> {
590        if self.rows != next.rows || self.cols != next.cols {
591            return next.render_full();
592        }
593        if self == next {
594            return Vec::new();
595        }
596        let mut out = String::new();
597        emit_changes(Some(self), next, &mut out);
598        out.into_bytes()
599    }
600
601    /// A from-scratch repaint: clear the screen, then paint every non-default
602    /// cell, ending with a style reset and the cursor parked at
603    /// this screen's cursor.
604    pub fn render_full(&self) -> Vec<u8> {
605        let mut out = String::from("\x1b[2J");
606        emit_changes(None, self, &mut out);
607        out.into_bytes()
608    }
609}
610
611/// Emit the cell changes from `prev` to `next` (`None` means "cleared
612/// screen": every non-default cell of `next` is painted).
613///
614/// Row suffix optimisation: when the trailing cells of a row are all default
615/// in `next` but were not in `prev`, a single `\x1b[K]` (Erase to EOL)
616/// replaces one write-per-blank-cell. Style is reset before the erase so the
617/// terminal fills with the default background.
618fn emit_changes(prev: Option<&Screen>, next: &Screen, out: &mut String) {
619    use std::fmt::Write;
620    let mut style = Style::default();
621    // Where the terminal cursor is right now, when known. Printing advances
622    // it; at the right edge the position becomes ambiguous (pending wrap),
623    // so it is dropped to force an explicit move next time.
624    let mut at: Option<(usize, usize)> = None;
625    for row in 0..next.rows {
626        // Find the exclusive right bound of the non-default suffix in `next`.
627        // Columns in [last_content, cols) are all Cell::default() in `next`.
628        let last_content = (0..next.cols)
629            .rev()
630            .find(|&c| next.cell(row, c) != Cell::default())
631            .map_or(0, |c| c + 1);
632
633        // Emit changed cells up to (but not including) the trailing blank region.
634        for col in 0..last_content {
635            let target = next.cell(row, col);
636            // A width-0 cell is the right half (continuation) of a double-width
637            // glyph: it carries no character of its own and emits nothing. The
638            // lead cell to its left drew the glyph and the terminal advanced the
639            // cursor across both columns.
640            if target.width == CellWidth::Continuation {
641                continue;
642            }
643            let same = match prev {
644                Some(p) => p.cell(row, col) == target,
645                None => target == Cell::default(),
646            };
647            if same {
648                continue;
649            }
650            if at != Some((row, col)) {
651                write!(out, "\x1b[{};{}H", row + 1, col + 1).unwrap();
652            }
653            out.push_str(&style.transition_to(&target.style));
654            style = target.style;
655            out.push(target.ch);
656            // The terminal advances the cursor by the glyph's column width, so a
657            // width-2 lead lands `at` two columns on and its continuation column
658            // is then skipped. (A single-width cell advances by one, exactly as
659            // before — ASCII output is byte-for-byte unchanged.) At or past the
660            // right edge the position is ambiguous (pending wrap), so drop it to
661            // force an explicit move next time.
662            let advance = target.width.columns();
663            at = if col + advance < next.cols {
664                Some((row, col + advance))
665            } else {
666                None
667            };
668        }
669
670        // If prev had non-default content in the trailing blank region of next,
671        // erase it with a single EL rather than writing each blank cell.
672        // Not used in render_full (prev == None) because `\x1b[2J` already
673        // cleared the screen before emit_changes is called.
674        if last_content < next.cols {
675            let needs_erase = match prev {
676                Some(p) => (last_content..next.cols).any(|c| p.cell(row, c) != Cell::default()),
677                None => false,
678            };
679            if needs_erase {
680                // Reset SGR before EL so the terminal erases with the default
681                // background, not whatever the last cell's style was.
682                out.push_str(&style.transition_to(&Style::default()));
683                style = Style::default();
684                if at != Some((row, last_content)) {
685                    write!(out, "\x1b[{};{}H", row + 1, last_content + 1).unwrap();
686                }
687                out.push_str("\x1b[K");
688                // Cursor stays at (row, last_content); next row needs an explicit
689                // move regardless, so mark position unknown.
690                at = None;
691            }
692        }
693    }
694    out.push_str(&style.transition_to(&Style::default()));
695    let Cursor { row, col } = next.cursor;
696    write!(out, "\x1b[{};{}H", row + 1, col + 1).unwrap();
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702
703    #[test]
704    fn row_cells_agrees_with_cell_before_and_after_scrolling() {
705        use crate::Color;
706        let mut s = Screen::with_history(4, 10, 8);
707        let red = Style {
708            fg: Color::Indexed(1),
709            ..Style::default()
710        };
711        s.write_str(0, 0, "abc", Style::default());
712        s.write_str(1, 2, "de", red);
713        s.copy_cells(
714            3,
715            0,
716            &[Cell::wide('\u{4e2d}', red), Cell::continuation(red)],
717        );
718        let check = |s: &Screen| {
719            for r in 0..s.rows() {
720                let (written, fill) = s.row_cells(r);
721                assert!(written.len() <= s.cols());
722                for c in 0..s.cols() {
723                    let want = s.cell(r, c);
724                    let got = written.get(c).copied().unwrap_or(fill);
725                    assert_eq!(got, want, "row {r} col {c}");
726                }
727            }
728        };
729        check(&s);
730        // Rows written in full, then scrolled: the row map moves, the
731        // slices follow.
732        s.write_str(2, 0, "0123456789", red);
733        check(&s);
734        s.scroll_rows_up(0, 3, 1, Cell::new('~', red));
735        check(&s);
736        let (written, fill) = s.row_cells(3);
737        assert!(written.is_empty(), "a scrolled-in row is lazily blank");
738        assert_eq!(fill, Cell::new('~', red));
739        assert_eq!(s.row_cells(4), (&[][..], Cell::default()));
740    }
741    use crate::style::Style;
742
743    /// A screen whose every cell names its own position, so any move is visible.
744    fn numbered(rows: usize, cols: usize) -> Screen {
745        let mut s = Screen::new(rows, cols);
746        for r in 0..rows {
747            for c in 0..cols {
748                let ch = char::from_u32(0x4E00 + (r * 64 + c) as u32).unwrap();
749                s.set(r, c, Cell::new(ch, Style::default()));
750            }
751        }
752        s
753    }
754
755    /// The per-cell scroll this replaces, as the reference.
756    fn reference_up(s: &mut Screen, top: usize, bottom: usize, n: usize, fill: Cell) {
757        let n = n.min(bottom - top + 1);
758        for r in top..=bottom {
759            for c in 0..s.cols() {
760                let cell = if r + n <= bottom {
761                    s.cell(r + n, c)
762                } else {
763                    fill
764                };
765                s.set(r, c, cell);
766            }
767        }
768    }
769
770    fn reference_down(s: &mut Screen, top: usize, bottom: usize, n: usize, fill: Cell) {
771        let n = n.min(bottom - top + 1);
772        for r in (top..=bottom).rev() {
773            for c in 0..s.cols() {
774                let cell = if r >= top + n { s.cell(r - n, c) } else { fill };
775                s.set(r, c, cell);
776            }
777        }
778    }
779
780    #[test]
781    fn region_scrolls_match_the_per_cell_reference_everywhere() {
782        let fill = Cell::new(' ', Style::default());
783        for (rows, cols) in [(1, 1), (5, 3), (8, 7)] {
784            for top in 0..rows {
785                for bottom in top..rows {
786                    for n in 0..=(bottom - top + 2) {
787                        let (mut fast, mut slow) = (numbered(rows, cols), numbered(rows, cols));
788                        fast.scroll_rows_up(top, bottom, n, fill);
789                        if n > 0 {
790                            reference_up(&mut slow, top, bottom, n, fill);
791                        }
792                        assert_eq!(fast, slow, "up {rows}x{cols} {top}..={bottom} by {n}");
793
794                        let (mut fast, mut slow) = (numbered(rows, cols), numbered(rows, cols));
795                        fast.scroll_rows_down(top, bottom, n, fill);
796                        if n > 0 {
797                            reference_down(&mut slow, top, bottom, n, fill);
798                        }
799                        assert_eq!(fast, slow, "down {rows}x{cols} {top}..={bottom} by {n}");
800                    }
801                }
802            }
803        }
804    }
805
806    /// The row map is invisible: a screen scrolled into a state equals a
807    /// screen built that way, its rows read back in order, and a clear resets it.
808    #[test]
809    fn a_fully_scrolled_screen_equals_one_built_that_way() {
810        let fill = Cell::new(' ', Style::default());
811        let mut s = numbered(4, 3);
812        // Scroll a whole screen's worth and then some, so origin wraps.
813        for _ in 0..7 {
814            s.scroll_rows_up(0, 3, 1, fill);
815        }
816        let blank = Screen::new(4, 3);
817        assert_eq!(s, blank, "everything scrolled off: a blank screen");
818
819        let mut s = numbered(4, 3);
820        s.scroll_rows_up(0, 3, 2, fill);
821        let mut want = Screen::new(4, 3);
822        for r in 0..2 {
823            for c in 0..3 {
824                want.set(r, c, numbered(4, 3).cell(r + 2, c));
825            }
826        }
827        assert_eq!(s, want, "two rows up, two blank rows in");
828        assert_eq!(s.render_full(), want.render_full(), "same bytes");
829
830        // A wrapped ring still writes, reads and clears by logical position.
831        s.set(0, 0, Cell::new('A', Style::default()));
832        assert_eq!(s.cell(0, 0).ch, 'A');
833        s.clear();
834        assert_eq!(s, Screen::new(4, 3));
835    }
836
837    /// Long random mixes of full-screen scrolls, region scrolls (up and down)
838    /// and writes agree with the per-cell reference at every step, so the row
839    /// map stays a faithful permutation however it has been rotated.
840    #[test]
841    fn mixed_scrolls_and_writes_match_the_reference_over_many_steps() {
842        let mut seed = 0x9E37_79B9_7F4A_7C15u64;
843        let mut next = |n: usize| {
844            seed ^= seed << 13;
845            seed ^= seed >> 7;
846            seed ^= seed << 17;
847            (seed % n as u64) as usize
848        };
849        for (rows, cols) in [(1, 3), (6, 5), (24, 9)] {
850            let (mut fast, mut slow) = (numbered(rows, cols), numbered(rows, cols));
851            for step in 0..2_000 {
852                let fill = Cell::new(char::from(b'a' + (step % 26) as u8), Style::default());
853                let (top, bottom) = if next(3) == 0 {
854                    (0, rows - 1)
855                } else {
856                    let a = next(rows);
857                    (a, a + next(rows - a))
858                };
859                let n = 1 + next(3);
860                match next(4) {
861                    0 => {
862                        fast.scroll_rows_up(top, bottom, n, fill);
863                        reference_up(&mut slow, top, bottom, n, fill);
864                    }
865                    1 => {
866                        fast.scroll_rows_down(top, bottom, n, fill);
867                        reference_down(&mut slow, top, bottom, n, fill);
868                    }
869                    _ => {
870                        let (r, c) = (next(rows), next(cols));
871                        fast.set(r, c, fill);
872                        slow.set(r, c, fill);
873                    }
874                }
875                assert_eq!(fast, slow, "{rows}x{cols}, step {step}");
876            }
877        }
878    }
879
880    #[test]
881    fn write_ascii_matches_copy_cells_and_clips() {
882        let style = Style {
883            bold: true,
884            ..Style::default()
885        };
886        let (mut a, mut b) = (numbered(3, 8), numbered(3, 8));
887        // Scroll first so the row is lazily blank with a styled fill.
888        let fill = Cell::new(' ', style);
889        a.scroll_rows_up(0, 2, 1, fill);
890        b.scroll_rows_up(0, 2, 1, fill);
891        let text = b"hello, world";
892        assert_eq!(a.write_ascii(2, 3, text, style), 5, "clipped at the edge");
893        let cells: Vec<Cell> = text.iter().map(|&c| Cell::new(c as char, style)).collect();
894        b.copy_cells(2, 3, &cells);
895        assert_eq!(a, b);
896        assert_eq!(a.cell(2, 0), fill, "the gap before the text keeps the fill");
897        assert_eq!(a.write_ascii(3, 0, b"x", style), 0);
898        assert_eq!(a.write_ascii(0, 8, b"x", style), 0);
899    }
900
901    /// A row's wrap flag leaves with it into the history and is clear on the
902    /// row that takes its place; setting it out of range is ignored.
903    #[test]
904    fn a_wrap_flag_follows_its_row_into_history_and_clears_on_reuse() {
905        let fill = Cell::default();
906        let mut s = Screen::with_history(3, 5, 10);
907        s.write_str(0, 0, "hello", Style::default());
908        s.set_row_wrapped(0, true);
909        assert!(s.row_wrapped(0) && !s.row_wrapped(1));
910        assert!(s.visible().row_wrapped(0), "a snapshot keeps it");
911        let mut other = Screen::with_history(3, 5, 10);
912        other.write_str(0, 0, "hello", Style::default());
913        assert_ne!(s, other, "the flag is part of what a screen holds");
914        // Scrolled off the top: the history keeps it; the row that comes back
915        // in at the bottom starts unwrapped.
916        s.scroll_rows_up(0, 2, 1, fill);
917        assert!(s.history_wrapped(0));
918        assert!(!s.row_wrapped(2), "reused row cleared");
919        let mut copy = Screen::with_history(3, 5, 10);
920        copy.copy_history_from(&s);
921        assert!(copy.history_wrapped(0), "a resize's history copy keeps it");
922        s.set_row_wrapped(1, true);
923        s.clear();
924        assert!(!s.row_wrapped(1));
925        s.set_row_wrapped(9, true); // out of range: ignored
926        assert!(!s.row_wrapped(9));
927    }
928
929    /// A screen with history scrolls exactly like one without, and every line
930    /// that left the top is in the history, newest first, until the ring is
931    /// full and drops the oldest.
932    #[test]
933    fn history_keeps_what_scrolls_off_the_top() {
934        let fill = Cell::new(' ', Style::default());
935        let line = |k: usize| -> Vec<Cell> {
936            format!("line {k:03}")
937                .chars()
938                .map(|c| Cell::new(c, Style::default()))
939                .collect()
940        };
941        let (mut plain, mut hist) = (Screen::new(4, 10), Screen::with_history(4, 10, 5));
942        for k in 0..12 {
943            for s in [&mut plain, &mut hist] {
944                s.scroll_rows_up(0, 3, 1, fill);
945                s.copy_cells(3, 0, &line(k));
946            }
947            assert_eq!(
948                hist, plain,
949                "the visible screen is unchanged by history, step {k}"
950            );
951        }
952        // 12 scrolls: the 4 initial blank rows, then lines 0..=7, left the
953        // top (lines 8..=11 are on screen); the ring keeps the newest 5.
954        assert_eq!(hist.history_len(), 5);
955        assert_eq!(hist.scrolled_lines(), 12);
956        let text = |age| -> String { (0..8).map(|c| hist.history_cell(age, c).ch).collect() };
957        assert_eq!(text(0), "line 007");
958        assert_eq!(text(4), "line 003");
959        assert_eq!(hist.history_cell(5, 0), Cell::default(), "past the ring");
960        // A scroll region does not feed the history.
961        hist.scroll_rows_up(1, 3, 1, fill);
962        assert_eq!(hist.history_len(), 5);
963        assert_eq!(hist.scrolled_lines(), 12);
964        // visible() drops the history, keeps what shows.
965        let v = hist.visible();
966        assert_eq!(v, hist);
967        assert_eq!(v.history_len(), 0);
968        // History survives a copy into another size, cut to its width.
969        let mut wide = Screen::with_history(3, 6, 10);
970        wide.copy_history_from(&hist);
971        assert_eq!(wide.history_len(), 5);
972        let t: String = (0..6).map(|c| wide.history_cell(0, c).ch).collect();
973        assert_eq!(t, "line 0");
974        hist.clear_history();
975        assert_eq!(hist.history_len(), 0);
976        // After clearing, scrolling reuses the freed rows.
977        for k in 0..3 {
978            hist.scroll_rows_up(0, 3, 1, fill);
979            hist.copy_cells(3, 0, &line(100 + k));
980        }
981        assert_eq!(hist.history_len(), 3);
982    }
983
984    /// Every row that scrolls off comes back from the history cell for cell:
985    /// styles, wide and continuation halves, other characters, and the lazy
986    /// blank past what was written, over many random rows and scroll counts.
987    #[test]
988    fn history_rows_come_back_exactly_as_they_left() {
989        let (rows, cols, keep) = (5, 12, 400);
990        let mut seed = 0x2545_f491_4f6c_dd1du64;
991        let mut next = move |n: u64| {
992            seed ^= seed << 13;
993            seed ^= seed >> 7;
994            seed ^= seed << 17;
995            seed % n
996        };
997        let styles = [
998            Style::default(),
999            Style {
1000                fg: crate::style::Color::Indexed(9),
1001                bold: true,
1002                ..Style::default()
1003            },
1004            Style {
1005                bg: crate::style::Color::Rgb(10, 20, 30),
1006                italic: true,
1007                strike: true,
1008                ..Style::default()
1009            },
1010        ];
1011        let chars = ['a', 'Z', ' ', 'é', '─', '😀'];
1012        let mut s = Screen::with_history(rows, cols, keep);
1013        let mut left: Vec<Vec<Cell>> = Vec::new();
1014        for step in 0..3000 {
1015            let row = next(rows as u64) as usize;
1016            let col = next(cols as u64) as usize;
1017            // Mostly one style and plain text, so rows often stay plain.
1018            let style = styles[if next(4) == 0 { next(3) as usize } else { 0 }];
1019            match next(10) {
1020                0 => s.set(row, col, Cell::wide('漢', style)),
1021                1 => s.set(row, col, Cell::continuation(style)),
1022                2..=4 => s.set(row, col, Cell::new(chars[next(6) as usize], style)),
1023                _ => {
1024                    let text = &b"ls -la \xe9x"[..next(10) as usize];
1025                    s.write_ascii(row, if next(2) == 0 { 0 } else { col }, text, style);
1026                }
1027            }
1028            if next(3) == 0 {
1029                let n = 1 + next(3) as usize;
1030                for r in 0..n.min(rows) {
1031                    left.push((0..cols).map(|c| s.cell(r, c)).collect());
1032                }
1033                s.scroll_rows_up(0, rows - 1, n, Cell::new(' ', styles[step % 3]));
1034            }
1035        }
1036        assert_eq!(s.scrolled_lines(), left.len() as u64);
1037        assert_eq!(s.history_len(), keep.min(left.len()));
1038        let mut out = Vec::new();
1039        for age in 0..s.history_len() {
1040            assert!(s.history_row(age, &mut out));
1041            let want = &left[left.len() - 1 - age];
1042            assert_eq!(&out, want, "age {age}");
1043            for (c, cell) in want.iter().enumerate() {
1044                assert_eq!(s.history_cell(age, c), *cell, "age {age} col {c}");
1045            }
1046        }
1047        assert!(!s.history_row(s.history_len(), &mut out));
1048    }
1049
1050    #[test]
1051    fn an_out_of_range_region_scroll_changes_nothing() {
1052        let fill = Cell::new('x', Style::default());
1053        let mut s = numbered(4, 4);
1054        let before = s.clone();
1055        s.scroll_rows_up(3, 1, 1, fill);
1056        s.scroll_rows_up(0, 4, 1, fill);
1057        s.scroll_rows_down(2, 9, 1, fill);
1058        assert_eq!(s, before);
1059    }
1060
1061    // ── helpers ──────────────────────────────────────────────────────────────
1062
1063    fn filled_row(rows: usize, cols: usize, row: usize, ch: char) -> Screen {
1064        let mut s = Screen::new(rows, cols);
1065        for c in 0..cols {
1066            s.set(row, c, Cell::new(ch, Style::default()));
1067        }
1068        s
1069    }
1070
1071    fn contains_el(bytes: &[u8]) -> bool {
1072        let s = std::str::from_utf8(bytes).unwrap_or("");
1073        s.contains("\x1b[K")
1074    }
1075
1076    // ── Screen::clear() ──────────────────────────────────────────────────────
1077
1078    #[test]
1079    fn clear_resets_all_cells_and_cursor_without_realloc() {
1080        let mut s = Screen::new(4, 6);
1081        // Dirty every cell with a non-default character and style.
1082        let dirty_style = Style {
1083            bold: true,
1084            ..Style::default()
1085        };
1086        for r in 0..4 {
1087            for c in 0..6 {
1088                s.set(r, c, Cell::new('X', dirty_style));
1089            }
1090        }
1091        // Move the cursor off the origin.
1092        s.set_cursor(Cursor::new(3, 5));
1093
1094        s.clear();
1095
1096        // Dimensions must be unchanged.
1097        assert_eq!(s.rows(), 4);
1098        assert_eq!(s.cols(), 6);
1099        // Cursor must be at the origin.
1100        assert_eq!(s.cursor(), Cursor::new(0, 0));
1101        // Every cell must equal the default.
1102        let blank = Cell::default();
1103        for r in 0..4 {
1104            for c in 0..6 {
1105                assert_eq!(
1106                    s.cell(r, c),
1107                    blank,
1108                    "cell ({r},{c}) was not reset by clear()"
1109                );
1110            }
1111        }
1112    }
1113
1114    // ── emit_changes / diff: erase-to-EOL optimisation ───────────────────────
1115
1116    #[test]
1117    fn diff_emits_el_when_row_tail_clears_to_blank() {
1118        // prev: row 0 fully filled with 'X'; next: only first 3 cols are 'X'.
1119        // The tail cols [3..8) revert to blank → diff should contain EL.
1120        let prev = filled_row(2, 8, 0, 'X');
1121        let mut next = Screen::new(2, 8);
1122        for c in 0..3 {
1123            next.set(0, c, Cell::new('X', Style::default()));
1124        }
1125        let diff = prev.diff(&next);
1126        assert!(
1127            contains_el(&diff),
1128            "expected \\x1b[K in diff when tail blanks out"
1129        );
1130    }
1131
1132    #[test]
1133    fn diff_no_el_when_tail_was_already_blank_in_prev() {
1134        // Both screens have content only in cols [0, 3); tail was already blank.
1135        // No cells changed in the tail, so no EL should be emitted.
1136        let mut prev = Screen::new(2, 8);
1137        let mut next = Screen::new(2, 8);
1138        for c in 0..3 {
1139            prev.set(0, c, Cell::new('A', Style::default()));
1140            next.set(0, c, Cell::new('B', Style::default()));
1141        }
1142        let diff = prev.diff(&next);
1143        assert!(
1144            !contains_el(&diff),
1145            "unexpected \\x1b[K when tail was already blank"
1146        );
1147    }
1148
1149    #[test]
1150    fn render_full_never_emits_el() {
1151        // render_full follows \x1b[2J with individual cell writes; it must NOT
1152        // use EL (the screen is already clear, so EL would be redundant, and
1153        // the contract is that render_full never relies on prior state).
1154        let s = filled_row(2, 8, 0, 'X');
1155        let bytes = s.render_full();
1156        assert!(!contains_el(&bytes), "render_full must not emit \\x1b[K");
1157    }
1158
1159    #[test]
1160    fn diff_el_visual_correctness_via_round_trip() {
1161        // Prove that a diff containing EL produces the correct visual result:
1162        // simulate applying the diff to a "terminal" modelled as a Screen copy.
1163        // prev → diff(next) → the result must equal next.
1164        let prev = filled_row(1, 6, 0, 'Z');
1165        let mut next = Screen::new(1, 6);
1166        next.set(0, 0, Cell::new('A', Style::default()));
1167        // next has 'A' at (0,0) and blanks at (0,1..5).
1168        assert!(contains_el(&prev.diff(&next)));
1169        // The diff-then-apply path is tested implicitly: if diff produces EL,
1170        // but the NEXT diff of (next, next) is empty, then prev+diff == next.
1171        let empty = next.diff(&next);
1172        assert!(empty.is_empty(), "diff of identical screens must be empty");
1173    }
1174
1175    #[test]
1176    fn diff_entire_row_blank_in_next_uses_el() {
1177        // prev: entire row 1 filled; next: row 1 is all blank.
1178        let prev = filled_row(3, 5, 1, 'Y');
1179        let next = Screen::new(3, 5);
1180        let diff = prev.diff(&next);
1181        assert!(
1182            contains_el(&diff),
1183            "entire-row blank transition should use EL"
1184        );
1185    }
1186
1187    // ── double-width cells: Cell.width, continuation, width-aware emit ────────
1188
1189    #[test]
1190    fn cell_constructors_set_width() {
1191        let st = Style::default();
1192        assert_eq!(Cell::default().width, CellWidth::Single);
1193        assert_eq!(Cell::new('a', st).width, CellWidth::Single);
1194        assert_eq!(Cell::wide('世', st).width, CellWidth::Wide);
1195        let cont = Cell::continuation(st);
1196        assert_eq!(cont.width, CellWidth::Continuation);
1197        let cols: Vec<usize> = [CellWidth::Continuation, CellWidth::Single, CellWidth::Wide]
1198            .iter()
1199            .map(|w| w.columns())
1200            .collect();
1201        assert_eq!(cols, [0, 1, 2]);
1202        assert_eq!(cont.ch, ' ');
1203    }
1204
1205    #[test]
1206    fn wide_lead_emits_glyph_once_no_continuation_char() {
1207        // A width-2 lead followed by a width-0 continuation must emit the glyph
1208        // exactly once and NOTHING for the continuation column (the terminal
1209        // advances the cursor by two when it draws the wide glyph).
1210        let prev = Screen::new(1, 4);
1211        let mut next = Screen::new(1, 4);
1212        next.set(0, 0, Cell::wide('世', Style::default()));
1213        next.set(0, 1, Cell::continuation(Style::default()));
1214        let diff = prev.diff(&next);
1215        let s = std::str::from_utf8(&diff).unwrap();
1216        assert_eq!(s.matches('世').count(), 1, "glyph must appear exactly once");
1217        assert!(
1218            !s.contains(' '),
1219            "continuation column must emit no space: {s:?}"
1220        );
1221        // Exact bytes: CUP to (1,1), the glyph, then park cursor at (1,1).
1222        assert_eq!(diff, "\x1b[1;1H世\x1b[1;1H".as_bytes());
1223    }
1224
1225    #[test]
1226    fn wide_lead_advances_cursor_by_two() {
1227        // After a width-2 lead, a cell two columns on needs no fresh CUP — the
1228        // emitter tracks that the terminal advanced the cursor across both
1229        // halves of the wide glyph. If accounting were off by one, a CUP escape
1230        // would appear between the glyph and 'X'.
1231        let prev = Screen::new(1, 6);
1232        let mut next = Screen::new(1, 6);
1233        next.set(0, 0, Cell::wide('世', Style::default()));
1234        next.set(0, 1, Cell::continuation(Style::default()));
1235        next.set(0, 2, Cell::new('X', Style::default()));
1236        let diff = prev.diff(&next);
1237        assert_eq!(diff, "\x1b[1;1H世X\x1b[1;1H".as_bytes());
1238    }
1239
1240    #[test]
1241    fn ascii_diff_is_byte_identical_regression() {
1242        // Guard: the width model must not change single-width output at all.
1243        let mut old = Screen::new(2, 10);
1244        old.write_str(0, 0, "hello", Style::default());
1245        let mut new = old.clone();
1246        new.set(0, 1, Cell::new('a', Style::default()));
1247        assert_eq!(old.diff(&new), b"\x1b[1;2Ha\x1b[1;1H");
1248    }
1249
1250    #[test]
1251    fn set_cursor_clamps_onto_the_grid() {
1252        let mut s = Screen::new(4, 6);
1253        s.set_cursor(Cursor::new(2, 3));
1254        assert_eq!(s.cursor(), Cursor::new(2, 3));
1255        s.set_cursor(Cursor::new(99, 99));
1256        assert_eq!(s.cursor(), Cursor::new(3, 5));
1257        // An empty screen has no cell to stand on: the origin.
1258        let mut empty = Screen::new(0, 0);
1259        empty.set_cursor(Cursor::new(5, 5));
1260        assert_eq!(empty.cursor(), Cursor::new(0, 0));
1261    }
1262
1263    #[test]
1264    fn copy_cells_matches_repeated_set() {
1265        let st = Style::default();
1266        let run = [
1267            Cell::new('a', st),
1268            Cell::wide('世', st),
1269            Cell::continuation(st),
1270            Cell::new('b', st),
1271        ];
1272        let mut via_copy = Screen::new(2, 8);
1273        via_copy.copy_cells(1, 2, &run);
1274        let mut via_set = Screen::new(2, 8);
1275        for (i, cell) in run.iter().enumerate() {
1276            via_set.set(1, 2 + i, *cell);
1277        }
1278        assert_eq!(via_copy, via_set);
1279    }
1280
1281    #[test]
1282    fn copy_cells_clips_at_edge_and_ignores_out_of_range() {
1283        let st = Style::default();
1284        let run = [Cell::new('x', st); 5];
1285        let mut s = Screen::new(2, 4);
1286        // 5-cell run starting at col 2: only cols 2 and 3 are written.
1287        s.copy_cells(0, 2, &run);
1288        assert_eq!(s.cell(0, 2).ch, 'x');
1289        assert_eq!(s.cell(0, 3).ch, 'x');
1290        assert_eq!(s.cell(0, 1), Cell::default());
1291        // Out-of-range row or start column are silent no-ops (must not panic).
1292        s.copy_cells(9, 0, &run);
1293        s.copy_cells(0, 9, &run);
1294    }
1295}