Skip to main content

ratatui_core/buffer/
diff.rs

1use crate::buffer::{Buffer, Cell, CellDiffOption, CellWidth};
2use crate::layout::Rect;
3
4/// A zero-allocation iterator over the differences between two buffers of the same width.
5///
6/// Yields `(x, y, &Cell)` tuples for each cell in `next` that differs from the corresponding cell
7/// in `prev`. Handles multi-width characters (including VS16 emoji trailing cells) and
8/// [`CellDiffOption`] directives.
9#[derive(Debug)]
10pub struct BufferDiff<'prev, 'next> {
11    /// The next (current) buffer's cells.
12    next: &'next [Cell],
13    /// The previous buffer's cells.
14    prev: &'prev [Cell],
15    /// Buffer width (for `pos_of` calculation).
16    area: Rect,
17    /// Current position in the flat cell array.
18    pos: usize,
19    /// When processing VS16 trailing cells, tracks the range of trailing indices still to yield.
20    trailing: Option<TrailingState>,
21}
22
23/// Tracks pending trailing-cell yields for VS16 wide characters.
24#[derive(Debug)]
25struct TrailingState {
26    next_index: usize,
27    end: usize,
28}
29
30impl<'prev, 'next> BufferDiff<'prev, 'next> {
31    /// Creates a new iterator over the differences between `prev` and `next` terminal cells.
32    ///
33    /// Heights may differ; the iterator uses the minimum of the two.
34    ///
35    /// # Panics
36    ///
37    /// Panics if the buffers have different `x`, `y`, or `width` values.
38    pub(crate) fn new(prev: &'prev Buffer, next: &'next Buffer) -> Self {
39        assert!(
40            prev.area.x == next.area.x
41                && prev.area.y == next.area.y
42                && prev.area.width == next.area.width,
43            "buffer areas must have the same x, y, and width: prev={:?}, next={:?}",
44            prev.area,
45            next.area,
46        );
47
48        let mut area = prev.area;
49        area.height = area.height.min(next.area.height);
50
51        Self {
52            next: &next.content,
53            prev: &prev.content,
54            area,
55            pos: 0,
56            trailing: None,
57        }
58    }
59
60    /// Converts a flat index to (x, y) coordinates.
61    const fn pos_of(&self, index: usize) -> (u16, u16) {
62        let w = self.area.width as usize;
63
64        let x = index % w + self.area.x as usize;
65        let y = index / w + self.area.y as usize;
66
67        (x as u16, y as u16)
68    }
69}
70
71impl<'next> Iterator for BufferDiff<'_, 'next> {
72    type Item = (u16, u16, &'next Cell);
73
74    fn next(&mut self) -> Option<Self::Item> {
75        // First, yield any pending VS16 trailing cells.
76        if let Some(TrailingState {
77            ref mut next_index,
78            end,
79        }) = self.trailing
80        {
81            while *next_index < end {
82                let j = *next_index;
83                *next_index += 1;
84
85                // Only emit update if the symbol has changed.
86                // The style of hidden trailing cells is not visible, so style
87                // differences alone should not trigger updates that can cause
88                // cursor positioning issues on some terminals.
89                if !is_skip(&self.next[j]) && self.prev[j].symbol() != self.next[j].symbol() {
90                    let (tx, ty) = self.pos_of(j);
91                    return Some((tx, ty, &self.next[j]));
92                }
93            }
94
95            // Done with trailing cells; resume main loop past the wide character.
96            self.pos = end;
97            self.trailing = None;
98        }
99
100        let len = self.next.len().min(self.prev.len());
101        while self.pos < len {
102            let i = self.pos;
103            self.pos += 1;
104
105            let current = &self.next[i];
106            let previous = &self.prev[i];
107
108            match current.diff_option {
109                CellDiffOption::Skip => {}
110                _ if is_skip(current) => {}
111
112                CellDiffOption::ForcedWidth(width) => {
113                    self.pos = self
114                        .pos
115                        .saturating_add(width.get().saturating_sub(1) as usize);
116                    if current != previous {
117                        let (x, y) = self.pos_of(i);
118                        return Some((x, y, &self.next[i]));
119                    }
120                }
121                CellDiffOption::None | CellDiffOption::AlwaysUpdate => {
122                    // If the current cell is multi-width, ensure the trailing cells are
123                    // explicitly cleared when they previously contained non-blank content.
124                    // Some terminals do not reliably clear the trailing cell(s) when printing
125                    // a wide grapheme, which can result in visual artifacts (e.g., leftover
126                    // characters). Emitting an explicit update for the trailing cells avoids
127                    // this.
128                    let cell_width = current.cell_width() as usize;
129                    if matches!(current.diff_option, CellDiffOption::None) && current == previous {
130                        // Equal cells still need to account for multi-width skip.
131                        self.pos += cell_width.saturating_sub(1);
132                        continue;
133                    }
134
135                    // Work around terminals that fail to clear the trailing cell of certain
136                    // emoji presentation sequences (those containing VS16 / U+FE0F).
137                    // Only emit explicit clears for such sequences to avoid bloating diffs
138                    // for standard wide characters (e.g., CJK), which terminals handle well.
139                    let contains_vs16 =
140                        cell_width > 1 && current.symbol().chars().any(|c| c == '\u{FE0F}');
141
142                    if contains_vs16 {
143                        let trailing_end = (i + cell_width).min(len);
144                        self.trailing = Some(TrailingState {
145                            next_index: i + 1,
146                            end: trailing_end,
147                        });
148                    } else if cell_width > 1 {
149                        self.pos += cell_width.saturating_sub(1);
150                    } else {
151                        // single-width character, no position adjustment needed
152                    }
153
154                    let (x, y) = self.pos_of(i);
155                    return Some((x, y, &self.next[i]));
156                }
157            }
158        }
159
160        None
161    }
162}
163
164/// Returns `true` if this cell should be skipped during diffing.
165#[allow(deprecated)]
166const fn is_skip(cell: &Cell) -> bool {
167    matches!(cell.diff_option, CellDiffOption::Skip)
168        || (cell.skip && matches!(cell.diff_option, CellDiffOption::None))
169}
170
171#[cfg(test)]
172mod tests {
173    use alloc::vec::Vec;
174    use core::num::NonZeroU16;
175
176    use compact_str::CompactString;
177
178    use super::*;
179    use crate::buffer::Buffer;
180    use crate::layout::Rect;
181
182    #[test]
183    fn empty_buffers_yield_no_diffs() {
184        let rect = Rect::new(0, 0, 5, 1);
185        let buf = Buffer::empty(rect);
186        let diff: Vec<_> = BufferDiff::new(&buf, &buf).collect();
187        assert!(diff.is_empty());
188    }
189
190    #[test]
191    fn identical_buffers_yield_no_diffs() {
192        let buf = Buffer::with_lines(["hello"]);
193        let diff: Vec<_> = BufferDiff::new(&buf, &buf).collect();
194        assert!(diff.is_empty());
195    }
196
197    #[test]
198    fn single_cell_change() {
199        let prev = Buffer::with_lines(["hello"]);
200        let next = Buffer::with_lines(["hallo"]);
201        let diff: Vec<_> = BufferDiff::new(&prev, &next).collect();
202        assert_eq!(diff.len(), 1);
203        assert_eq!(diff[0].0, 1); // x
204        assert_eq!(diff[0].1, 0); // y
205        assert_eq!(diff[0].2.symbol(), "a");
206    }
207
208    #[test]
209    fn all_cells_changed() {
210        let prev = Buffer::with_lines(["aaa"]);
211        let next = Buffer::with_lines(["bbb"]);
212        let diff: Vec<_> = BufferDiff::new(&prev, &next).collect();
213        assert_eq!(diff.len(), 3);
214    }
215
216    #[test]
217    fn skip_cells_are_skipped() {
218        let prev = Buffer::with_lines(["abc"]);
219        let mut next = Buffer::with_lines(["xyz"]);
220        next.content[1].diff_option = CellDiffOption::Skip;
221
222        let diff: Vec<_> = BufferDiff::new(&prev, &next).collect();
223        assert_eq!(diff.len(), 2);
224        assert_eq!(diff[0].2.symbol(), "x");
225        assert_eq!(diff[1].2.symbol(), "z");
226    }
227
228    #[test]
229    fn always_update_cells_are_emitted_even_when_identical() {
230        let mut prev = Buffer::with_lines(["abc"]);
231        prev.content[1].diff_option = CellDiffOption::AlwaysUpdate;
232
233        let mut next = Buffer::with_lines(["abc"]);
234        next.content[1].diff_option = CellDiffOption::AlwaysUpdate;
235
236        let diff: Vec<_> = BufferDiff::new(&prev, &next).collect();
237        assert_eq!(diff.len(), 1);
238        assert_eq!(diff[0].0, 1);
239        assert_eq!(diff[0].1, 0);
240        assert_eq!(diff[0].2.symbol(), "b");
241    }
242
243    #[test]
244    fn forced_width_skips_trailing() {
245        let prev = Buffer::with_lines(["abcd"]);
246        let mut next = Buffer::with_lines(["xbcd"]);
247        next.content[0].diff_option = CellDiffOption::ForcedWidth(NonZeroU16::new(2).unwrap());
248
249        let diff: Vec<_> = BufferDiff::new(&prev, &next).collect();
250        assert_eq!(diff.len(), 1);
251        assert_eq!(diff[0].2.symbol(), "x");
252    }
253
254    #[test]
255    fn vs16_trailing_cell_unchanged() {
256        use crate::style::{Color, Style};
257
258        let rect = Rect::new(0, 0, 4, 1);
259        let mut prev = Buffer::empty(rect);
260        prev.set_string(0, 0, "⌨️", Style::new());
261        prev.set_string(2, 0, "ab", Style::new());
262
263        let mut next = Buffer::empty(rect);
264        next.set_string(0, 0, "⌨️", Style::new().fg(Color::Red));
265        next.set_string(2, 0, "ab", Style::new());
266
267        // Only the main emoji cell (0,0) differs (different style);
268        // the trailing cell (1,0) is identical in both buffers.
269        let diff: Vec<_> = BufferDiff::new(&prev, &next).collect();
270        assert_eq!(diff.len(), 1);
271        assert_eq!(diff[0].0, 0);
272        assert_eq!(diff[0].1, 0);
273    }
274
275    #[test]
276    #[allow(deprecated)]
277    fn deprecated_skip_field_is_respected() {
278        let prev = Buffer::with_lines(["abc"]);
279        let mut next = Buffer::with_lines(["xyz"]);
280        next.content[1].skip = true;
281
282        let diff: CompactString = BufferDiff::new(&prev, &next)
283            .map(|(_, _, cell)| cell.symbol())
284            .collect();
285
286        assert_eq!(diff, "xz");
287    }
288
289    #[test]
290    #[allow(deprecated)]
291    fn forced_width_takes_precedence_over_deprecated_skip() {
292        let prev = Buffer::with_lines(["abcd"]);
293        let mut next = Buffer::with_lines(["xbcd"]);
294        next.content[0].skip = true;
295        next.content[0].diff_option = CellDiffOption::ForcedWidth(NonZeroU16::new(2).unwrap());
296
297        // ForcedWidth wins over skip=true, so the cell is diffed with forced width
298        let diff: CompactString = BufferDiff::new(&prev, &next)
299            .map(|(_, _, cell)| cell.symbol())
300            .collect();
301
302        assert_eq!(diff, "x");
303    }
304
305    #[test]
306    #[should_panic(expected = "buffer areas must have the same x, y, and width")]
307    fn mismatched_widths_panics() {
308        let prev = Buffer::empty(Rect::new(0, 0, 5, 1));
309        let next = Buffer::empty(Rect::new(0, 0, 10, 1));
310        BufferDiff::new(&prev, &next);
311    }
312}