Skip to main content

ratatui_core/buffer/
diff.rs

1use crate::buffer::{Buffer, Cell, CellDiffOption, CellWidth};
2use crate::layout::Rect;
3use crate::style::{Color, Modifier};
4
5/// A zero-allocation iterator over the differences between two buffers of the same width.
6///
7/// Yields `(x, y, &Cell)` tuples for each cell in `next` that differs from the corresponding cell
8/// in `prev`. Handles multi-width characters (including VS16 emoji trailing cells) and
9/// [`CellDiffOption`] directives.
10#[derive(Debug)]
11pub struct BufferDiff<'prev, 'next> {
12    /// The next (current) buffer's cells.
13    next: &'next [Cell],
14    /// The previous buffer's cells.
15    prev: &'prev [Cell],
16    /// Buffer width (for `pos_of` calculation).
17    area: Rect,
18    /// Current position in the flat cell array.
19    pos: usize,
20    /// Tracks trailing cells that must be yielded after a wide character is processed.
21    ///
22    /// Set when a wide char was replaced by narrower content (force=true) or when a VS16 emoji
23    /// needs its trailing column checked (force=false).
24    trailing: Option<TrailingState>,
25}
26
27/// Tracks pending trailing-cell yields when a wide character is followed by narrower content.
28#[derive(Debug)]
29struct TrailingState {
30    next_index: usize,
31    end: usize,
32    /// When `true`, all cells in the trailing range are emitted unconditionally: the previous
33    /// wide character's style was visible on blank cells, so the terminal may show stale style
34    /// there and every trailing cell must be refreshed.
35    ///
36    /// When `false` (VS16 path), only cells whose symbol changed are emitted, because the emoji
37    /// visually covers its trailing column and style differences there are invisible.
38    force: bool,
39}
40
41/// Modifiers that are visually apparent on a blank (space) cell.
42const VISIBLE_ON_BLANK: Modifier = Modifier::REVERSED
43    .union(Modifier::UNDERLINED)
44    .union(Modifier::SLOW_BLINK)
45    .union(Modifier::RAPID_BLINK)
46    .union(Modifier::CROSSED_OUT);
47
48impl<'prev, 'next> BufferDiff<'prev, 'next> {
49    /// Creates a new iterator over the differences between `prev` and `next` terminal cells.
50    ///
51    /// Heights may differ; the iterator uses the minimum of the two.
52    ///
53    /// # Panics
54    ///
55    /// Panics if the buffers have different `x`, `y`, or `width` values.
56    pub(crate) fn new(prev: &'prev Buffer, next: &'next Buffer) -> Self {
57        assert!(
58            prev.area.x == next.area.x
59                && prev.area.y == next.area.y
60                && prev.area.width == next.area.width,
61            "buffer areas must have the same x, y, and width: prev={:?}, next={:?}",
62            prev.area,
63            next.area,
64        );
65
66        let mut area = prev.area;
67        area.height = area.height.min(next.area.height);
68
69        Self {
70            next: &next.content,
71            prev: &prev.content,
72            area,
73            pos: 0,
74            trailing: None,
75        }
76    }
77
78    /// Converts a flat index to (x, y) coordinates.
79    const fn pos_of(&self, index: usize) -> (u16, u16) {
80        let w = self.area.width as usize;
81
82        let x = index % w + self.area.x as usize;
83        let y = index / w + self.area.y as usize;
84
85        (x as u16, y as u16)
86    }
87}
88
89impl<'next> Iterator for BufferDiff<'_, 'next> {
90    type Item = (u16, u16, &'next Cell);
91
92    fn next(&mut self) -> Option<Self::Item> {
93        let len = self.next.len().min(self.prev.len());
94
95        // First, yield any pending trailing cells.
96        if let Some(TrailingState {
97            next_index,
98            end,
99            force,
100        }) = &mut self.trailing
101        {
102            while *next_index < *end {
103                let j = *next_index;
104                // Advance past this cell; if it is wide, also skip its own trailing column
105                // so the main loop does not emit a spurious EMPTY write over it.
106                let cell_width = self.next[j].cell_width().max(1) as usize;
107                *next_index += cell_width;
108                *end = (*end).max(*next_index).min(len);
109
110                if !is_skip(&self.next[j])
111                    && (*force || self.prev[j].symbol() != self.next[j].symbol())
112                {
113                    let (tx, ty) = self.pos_of(j);
114                    return Some((tx, ty, &self.next[j]));
115                }
116            }
117
118            // Done with trailing cells; resume main loop past the wide character.
119            self.pos = *end;
120            self.trailing = None;
121        }
122        while self.pos < len {
123            let i = self.pos;
124            self.pos += 1;
125
126            let current = &self.next[i];
127            let previous = &self.prev[i];
128
129            match current.diff_option {
130                CellDiffOption::Skip => {}
131                _ if is_skip(current) => {}
132
133                CellDiffOption::ForcedWidth(width) => {
134                    self.pos = self
135                        .pos
136                        .saturating_add(width.get().saturating_sub(1) as usize);
137                    if current != previous {
138                        let (x, y) = self.pos_of(i);
139                        return Some((x, y, &self.next[i]));
140                    }
141                }
142                CellDiffOption::None | CellDiffOption::AlwaysUpdate => {
143                    // If the current cell is multi-width, ensure the trailing cells are
144                    // explicitly cleared when they previously contained non-blank content.
145                    // Some terminals do not reliably clear the trailing cell(s) when printing
146                    // a wide grapheme, which can result in visual artifacts (e.g., leftover
147                    // characters). Emitting an explicit update for the trailing cells avoids
148                    // this.
149                    let cell_width = current.cell_width() as usize;
150                    if matches!(current.diff_option, CellDiffOption::None) && current == previous {
151                        // Equal cells still need to account for multi-width skip.
152                        self.pos += cell_width.saturating_sub(1);
153                        continue;
154                    }
155
156                    let previous_width = previous.cell_width() as usize;
157
158                    // Work around terminals that fail to clear the trailing cell of certain
159                    // emoji presentation sequences (those containing VS16 / U+FE0F).
160                    // Only emit explicit clears for such sequences to avoid bloating diffs
161                    // for standard wide characters (e.g., CJK), which terminals handle well.
162                    let contains_vs16 =
163                        cell_width > 1 && current.symbol().chars().any(|c| c == '\u{FE0F}');
164
165                    if contains_vs16 {
166                        let trailing_end = (i + cell_width).min(len);
167                        self.trailing = Some(TrailingState {
168                            next_index: i + 1,
169                            end: trailing_end,
170                            force: false,
171                        });
172                    } else if cell_width > 1 {
173                        self.pos += cell_width.saturating_sub(1);
174                    } else if previous_width > cell_width
175                        && (previous.bg != Color::Reset
176                            || previous.modifier.intersects(VISIBLE_ON_BLANK))
177                    {
178                        // The previous wide character's style is visible on blank cells, so the
179                        // terminal may still show it on the trailing columns even after the
180                        // character is replaced. Force-emit every cell in the trailing range to
181                        // refresh the terminal regardless of whether the buffer content changed.
182                        self.trailing = Some(TrailingState {
183                            next_index: i + 1,
184                            end: i + previous_width,
185                            force: true,
186                        });
187                    } else {
188                        // single-width character, no position adjustment needed
189                    }
190
191                    let (x, y) = self.pos_of(i);
192                    return Some((x, y, &self.next[i]));
193                }
194            }
195        }
196
197        None
198    }
199}
200
201/// Returns `true` if this cell should be skipped during diffing.
202#[allow(deprecated)]
203const fn is_skip(cell: &Cell) -> bool {
204    matches!(cell.diff_option, CellDiffOption::Skip)
205        || (cell.skip && matches!(cell.diff_option, CellDiffOption::None))
206}
207
208#[cfg(test)]
209mod tests {
210    use alloc::vec::Vec;
211    use core::num::NonZeroU16;
212
213    use compact_str::CompactString;
214
215    use super::*;
216    use crate::buffer::Buffer;
217    use crate::layout::Rect;
218    use crate::style::Color;
219
220    #[test]
221    fn empty_buffers_yield_no_diffs() {
222        let rect = Rect::new(0, 0, 5, 1);
223        let buf = Buffer::empty(rect);
224        let diff: Vec<_> = BufferDiff::new(&buf, &buf).collect();
225        assert!(diff.is_empty());
226    }
227
228    #[test]
229    fn identical_buffers_yield_no_diffs() {
230        let buf = Buffer::with_lines(["hello"]);
231        let diff: Vec<_> = BufferDiff::new(&buf, &buf).collect();
232        assert!(diff.is_empty());
233    }
234
235    #[test]
236    fn single_cell_change() {
237        let prev = Buffer::with_lines(["hello"]);
238        let next = Buffer::with_lines(["hallo"]);
239        let diff: Vec<_> = BufferDiff::new(&prev, &next).collect();
240        assert_eq!(diff.len(), 1);
241        assert_eq!(diff[0].0, 1); // x
242        assert_eq!(diff[0].1, 0); // y
243        assert_eq!(diff[0].2.symbol(), "a");
244    }
245
246    #[test]
247    fn all_cells_changed() {
248        let prev = Buffer::with_lines(["aaa"]);
249        let next = Buffer::with_lines(["bbb"]);
250        let diff: Vec<_> = BufferDiff::new(&prev, &next).collect();
251        assert_eq!(diff.len(), 3);
252    }
253
254    #[test]
255    fn skip_cells_are_skipped() {
256        let prev = Buffer::with_lines(["abc"]);
257        let mut next = Buffer::with_lines(["xyz"]);
258        next.content[1].diff_option = CellDiffOption::Skip;
259
260        let diff: Vec<_> = BufferDiff::new(&prev, &next).collect();
261        assert_eq!(diff.len(), 2);
262        assert_eq!(diff[0].2.symbol(), "x");
263        assert_eq!(diff[1].2.symbol(), "z");
264    }
265
266    #[test]
267    fn always_update_cells_are_emitted_even_when_identical() {
268        let mut prev = Buffer::with_lines(["abc"]);
269        prev.content[1].diff_option = CellDiffOption::AlwaysUpdate;
270
271        let mut next = Buffer::with_lines(["abc"]);
272        next.content[1].diff_option = CellDiffOption::AlwaysUpdate;
273
274        let diff: Vec<_> = BufferDiff::new(&prev, &next).collect();
275        assert_eq!(diff.len(), 1);
276        assert_eq!(diff[0].0, 1);
277        assert_eq!(diff[0].1, 0);
278        assert_eq!(diff[0].2.symbol(), "b");
279    }
280
281    #[test]
282    fn forced_width_skips_trailing() {
283        let prev = Buffer::with_lines(["abcd"]);
284        let mut next = Buffer::with_lines(["xbcd"]);
285        next.content[0].diff_option = CellDiffOption::ForcedWidth(NonZeroU16::new(2).unwrap());
286
287        let diff: Vec<_> = BufferDiff::new(&prev, &next).collect();
288        assert_eq!(diff.len(), 1);
289        assert_eq!(diff[0].2.symbol(), "x");
290    }
291
292    #[test]
293    fn vs16_trailing_cell_unchanged() {
294        use crate::style::{Color, Style};
295
296        let rect = Rect::new(0, 0, 4, 1);
297        let mut prev = Buffer::empty(rect);
298        prev.set_string(0, 0, "⌨️", Style::new());
299        prev.set_string(2, 0, "ab", Style::new());
300
301        let mut next = Buffer::empty(rect);
302        next.set_string(0, 0, "⌨️", Style::new().fg(Color::Red));
303        next.set_string(2, 0, "ab", Style::new());
304
305        // Only the main emoji cell (0,0) differs (different style);
306        // the trailing cell (1,0) is identical in both buffers.
307        let diff: Vec<_> = BufferDiff::new(&prev, &next).collect();
308        assert_eq!(diff.len(), 1);
309        assert_eq!(diff[0].0, 0);
310        assert_eq!(diff[0].1, 0);
311    }
312
313    #[test]
314    #[allow(deprecated)]
315    fn deprecated_skip_field_is_respected() {
316        let prev = Buffer::with_lines(["abc"]);
317        let mut next = Buffer::with_lines(["xyz"]);
318        next.content[1].skip = true;
319
320        let diff: CompactString = BufferDiff::new(&prev, &next)
321            .map(|(_, _, cell)| cell.symbol())
322            .collect();
323
324        assert_eq!(diff, "xz");
325    }
326
327    #[test]
328    #[allow(deprecated)]
329    fn forced_width_takes_precedence_over_deprecated_skip() {
330        let prev = Buffer::with_lines(["abcd"]);
331        let mut next = Buffer::with_lines(["xbcd"]);
332        next.content[0].skip = true;
333        next.content[0].diff_option = CellDiffOption::ForcedWidth(NonZeroU16::new(2).unwrap());
334
335        // ForcedWidth wins over skip=true, so the cell is diffed with forced width
336        let diff: CompactString = BufferDiff::new(&prev, &next)
337            .map(|(_, _, cell)| cell.symbol())
338            .collect();
339
340        assert_eq!(diff, "x");
341    }
342
343    /// Regression test for the "uncovered trailing cell" bug.
344    ///
345    /// When a wide char with a non-default style (e.g. bg=Blue) is replaced by a narrow char,
346    /// its trailing cell was `reset()` in the buffer and therefore has default style in both
347    /// `prev` and `next`. The diff would skip it because symbol and style are identical.
348    ///
349    /// But the *terminal* rendered the trailing cell with the wide char's style (that's how
350    /// terminals work: printing "," with bg=Blue paints both columns blue). So an explicit
351    /// update must be emitted even though the buffer cell looks unchanged.
352    ///
353    /// With "你好,世界!" (Blue) replaced by "Hello" (default), "Hello" covers columns 0–4.
354    /// Columns 5, 7, 9, 11 are trailing cells of ",", "世", "界", "!" respectively — all
355    /// EMPTY in both buffers, but rendered blue on the terminal.
356    #[test]
357    fn uncovered_trailing_cells_emitted_when_wide_char_style_changes() {
358        use crate::style::{Color, Style};
359
360        // Width 12 fits exactly "你好,世界!" (6 wide chars × 2 cols each).
361        let rect = Rect::new(0, 0, 12, 1);
362        let mut prev = Buffer::empty(rect);
363        prev.set_string(0, 0, "你好,世界!", Style::new().bg(Color::Blue));
364
365        let mut next = Buffer::empty(rect);
366        next.set_string(0, 0, "Hello", Style::default());
367
368        let diff: Vec<_> = BufferDiff::new(&prev, &next).collect();
369
370        // Columns 5, 7, 9, 11 are the trailing cells of ",", "世", "界", "!".
371        // Both prev and next hold EMPTY there (same symbol, same default style), but the
372        // previous cell did have a different style, so the terminal painted them blue.
373        // Without an explicit update the blue background is never cleared, producing
374        // "Hello ░ ░ ░ ░".
375        for x in [5u16, 7, 9, 11] {
376            assert!(
377                diff.iter().any(|(dx, dy, _)| *dx == x && *dy == 0),
378                "expected update for trailing cell at x={x} (blue bg not cleared otherwise)"
379            );
380        }
381    }
382
383    /// Regression for a multi-step scenario that produces "Hello  ░" artifacts.
384    ///
385    /// "你好,世界!"(Blue) → "喵呜www"(Blue): "世"(cols 6-7) is replaced by "w"(Blue). Both
386    /// have the same background, so a guard like `previous.bg != current.bg` would not fire —
387    /// but the terminal painted col 7 (the trailing cell of "世") blue when it rendered the
388    /// wide char. Col 7 is EMPTY in both buffers, so without an explicit update the blue
389    /// lingers. A subsequent "Hello" draw then shows "Hello  ░" because "Hello" covers cols
390    /// 0-4, cols 5-6 are cleared (they held "ww"), but col 7 is never cleared.
391    #[test]
392    fn uncovered_trailing_cells_emitted_when_wide_chars_partially_replaced() {
393        use crate::style::{Color, Style};
394
395        let rect = Rect::new(0, 0, 12, 1);
396        let mut prev = Buffer::empty(rect);
397        prev.set_string(0, 0, "你好,世界!", Style::new().bg(Color::Blue));
398
399        let mut next = Buffer::empty(rect);
400        next.set_string(0, 0, "喵呜www", Style::new().bg(Color::Blue));
401
402        let diff: Vec<_> = BufferDiff::new(&prev, &next).collect();
403
404        // Col 7 is the trailing cell of "世"(6-7). "世" and its replacement "w" both have
405        // Blue bg, so a `previous.bg != current.bg` guard would not trigger — but the
406        // terminal painted it blue and it must be cleared.
407        assert!(
408            diff.iter().any(|(dx, dy, _)| *dx == 7 && *dy == 0),
409            "expected update for trailing cell at x=7 (blue bg not cleared otherwise)"
410        );
411    }
412
413    #[test]
414    fn no_force_update_when_wide_char_has_default_style() {
415        use crate::style::Style;
416
417        let rect = Rect::new(0, 0, 12, 1);
418        let mut prev = Buffer::empty(rect);
419        prev.set_string(0, 0, "你好,世界!", Style::default());
420
421        let mut next = Buffer::empty(rect);
422        next.set_string(0, 0, "Hello", Style::default());
423
424        let diff: Vec<_> = BufferDiff::new(&prev, &next).collect();
425
426        // Columns 5..=11 include the trailing cells of ",", "世", "界", "!".
427        // Both prev and next hold EMPTY there, and the previous wide chars had default style
428        // (bg=Reset, no visible modifier), so `force=true` is never set and trailing cells
429        // are not emitted.
430        for x in [5u16, 7, 9, 11] {
431            assert!(
432                !diff.iter().any(|(dx, dy, _)| *dx == x && *dy == 0),
433                "expected no update for trailing cell at x={x}"
434            );
435        }
436    }
437
438    #[test]
439    fn shrinking_wide_glyph_clears_trailing_cell() {
440        // Regression for https://github.com/ratatui/ratatui/issues/2585 (introduced by #1605):
441        // the cell-diff-options refactor dropped the classic `invalidated` counter, so when a
442        // multi-width glyph (here the full-width plus +, U+FF0B) is replaced by narrower content,
443        // the trailing cell it physically painted over was left un-cleared. Because that trailing
444        // cell is blank in both buffers (`current == previous`), only the previous glyph's width
445        // can force the redraw — exactly what `invalidated` tracks.
446
447        let rect = Rect::new(0, 0, 2, 1);
448
449        let mut prev = Buffer::empty(rect);
450        prev.set_string(0, 0, "+", crate::style::Style::new());
451        // Force some style that would leave artifacts (anything except foreground color).
452        prev.cell_mut((0, 0)).unwrap().set_bg(Color::Red);
453
454        assert_eq!(prev.content[0].symbol(), "+");
455
456        // The trailing cell is reset to a blank space when the wide glyph is written.
457        assert_eq!(prev.content[1].symbol(), " ");
458
459        // Next frame clears the glyph: both cells are blank.
460        let next = Buffer::empty(rect);
461        assert_eq!(next.content[1], prev.content[1]); // trailing cell is unchanged
462
463        let diff: Vec<_> = BufferDiff::new(&prev, &next).collect();
464
465        assert_eq!(diff.len(), 2, "both columns must be redrawn, got {diff:?}");
466        assert_eq!((diff[0].0, diff[0].1), (0, 0));
467        assert_eq!(diff[0].2.symbol(), " ");
468
469        // The trailing cell (1,0) must be emitted even though it is identical in both buffers,
470        // otherwise the right half of the old glyph (and its background) lingers on screen.
471        assert_eq!((diff[1].0, diff[1].1), (1, 0));
472        assert_eq!(diff[1].2.symbol(), " ");
473    }
474
475    #[test]
476    fn shrinking_wide_glyph_clears_trailing_background() {
477        use crate::style::{Color, Style};
478
479        // Same regression, framed as the reported symptom: a styled background painted across a
480        // wide glyph must be cleared from the trailing cell when the glyph shrinks away.
481
482        let rect = Rect::new(0, 0, 2, 1);
483
484        let mut prev = Buffer::empty(rect);
485        prev.set_string(0, 0, "+", Style::new().bg(Color::Blue));
486
487        let next = Buffer::empty(rect); // blank, default background
488
489        let diff: Vec<_> = BufferDiff::new(&prev, &next).collect();
490
491        assert!(
492            diff.iter().any(|(x, y, _)| *x == 1 && *y == 0),
493            "trailing cell (1,0) must be redrawn to clear the leftover background, got {diff:?}"
494        );
495    }
496
497    /// Regression: a non-blank cell inside the trailing range whose style changes between frames
498    /// must still be emitted.
499    ///
500    /// The trailing loop replaces the main loop for indices in `[i+1, i+prev_width)`. When
501    /// `force=true` every non-skip cell in the range is emitted unconditionally — not because the
502    /// style change is specifically detected, but because `force=true` short-circuits the check
503    /// entirely. This ensures style-only changes on trailing cells are never silently dropped.
504    ///
505    /// Scenario: widget A draws "你"(Blue) at col 0; widget B then writes "X"(Red) at col 1
506    /// (overriding the trailing reset). Next frame: widget A writes "a"(default), widget B writes
507    /// "X"(Green). Symbol at col 1 is unchanged, but fg changed Red→Green.
508    /// `force=true` fires because prev "你" had bg=Blue (visible on blank), so col 1 is emitted
509    /// regardless.
510    #[test]
511    fn trailing_range_cell_style_change_is_emitted() {
512        use crate::style::{Color, Style};
513
514        let rect = Rect::new(0, 0, 3, 1);
515        let mut prev = Buffer::empty(rect);
516        prev.set_string(0, 0, "你", Style::new().bg(Color::Blue));
517        // Widget B overrides the trailing reset at col 1.
518        prev.content[1]
519            .set_symbol("X")
520            .set_fg(Color::Red)
521            .set_bg(Color::Reset);
522
523        let mut next = Buffer::empty(rect);
524        next.set_string(0, 0, "a", Style::default());
525        // Same symbol, different fg — style-only change.
526        next.content[1]
527            .set_symbol("X")
528            .set_fg(Color::Green)
529            .set_bg(Color::Reset);
530
531        let diff: Vec<_> = BufferDiff::new(&prev, &next).collect();
532
533        assert!(
534            diff.iter().any(|(x, y, _)| *x == 1 && *y == 0),
535            "col 1 style change (Red→Green) must be emitted even though symbol is unchanged; got {diff:?}"
536        );
537    }
538
539    /// Regression: when the trailing loop emits a wide char, its own trailing column must be
540    /// skipped so the main loop does not write EMPTY there and corrupt the display.
541    ///
542    /// Scenario: prev="你好"(Blue) → next="a好"(Blue) where 'a' replaces '你'.
543    /// `force=true` is set (Blue bg), trailing range = [1, 2).
544    /// At j=1 the trailing loop finds '好' (width 2) in next and emits it.
545    /// Without the fix `next_index` advances to 2 = end; `self.pos` is set to 2.
546    /// The main loop then sees next[2]=EMPTY vs prev[2]='好'(Blue) and emits
547    /// EMPTY at col 2 — writing a space over the right half of the just-drawn '好'.
548    #[test]
549    fn trailing_range_wide_char_trailing_not_corrupted() {
550        use crate::style::{Color, Style};
551
552        let rect = Rect::new(0, 0, 4, 1);
553        let mut prev = Buffer::empty(rect);
554        prev.set_string(0, 0, "你好", Style::new().bg(Color::Blue));
555        // prev: [你(Blue)][EMPTY][好(Blue)][EMPTY]
556
557        let mut next = Buffer::empty(rect);
558        next.set_string(0, 0, "a", Style::new().bg(Color::Blue));
559        next.set_string(1, 0, "好", Style::new().bg(Color::Blue));
560        // next: [a(Blue)][好(Blue)][EMPTY][EMPTY]
561
562        let diff: Vec<_> = BufferDiff::new(&prev, &next).collect();
563
564        // col 2 is the trailing cell of '好' in next; writing EMPTY there would
565        // overwrite the right half of '好' and corrupt the display.
566        assert!(
567            !diff.iter().any(|(x, y, _)| *x == 2 && *y == 0),
568            "trailing cell of '好' must not receive a spurious EMPTY write; got {diff:?}"
569        );
570        // '好' itself must be emitted at col 1.
571        assert!(
572            diff.iter()
573                .any(|(x, y, cell)| *x == 1 && *y == 0 && cell.symbol() == "好"),
574            "'好' at col 1 must be emitted; got {diff:?}"
575        );
576    }
577
578    #[test]
579    #[should_panic(expected = "buffer areas must have the same x, y, and width")]
580    fn mismatched_widths_panics() {
581        let prev = Buffer::empty(Rect::new(0, 0, 5, 1));
582        let next = Buffer::empty(Rect::new(0, 0, 10, 1));
583        BufferDiff::new(&prev, &next);
584    }
585}