Skip to main content

slt/
buffer.rs

1//! Double-buffer grid of [`Cell`]s with clip-stack support.
2//!
3//! Two buffers are maintained per frame (current and previous). Only the diff
4//! is flushed to the terminal, giving immediate-mode ergonomics with
5//! retained-mode efficiency.
6
7use std::hash::{Hash, Hasher};
8use std::sync::Arc;
9
10use crate::cell::Cell;
11use crate::rect::Rect;
12use crate::style::Style;
13use unicode_width::UnicodeWidthChar;
14
15/// Maximum bytes allowed in a single cell's `symbol` field.
16///
17/// A grapheme cluster rarely exceeds ~16 bytes in the wild; anything
18/// longer is typically an attempt to weaponize zero-width combining chars.
19/// This cap bounds the worst case flush cost per cell.
20const MAX_CELL_SYMBOL_BYTES: usize = 32;
21
22/// Hard cap on pixel count processed by image decode/encode paths.
23///
24/// 16_777_216 ≈ 4096×4096 — well above any sane terminal image payload,
25/// but guards 32-bit targets (WASM) from overflow and prevents a
26/// hostile `width`/`height` pair from triggering multi-GiB allocations.
27pub(crate) const MAX_IMAGE_PIXELS: u64 = 16_777_216;
28
29/// Replace terminal-dangerous control characters with `U+FFFD`.
30///
31/// Unfiltered C0 (0x00–0x1F), DEL (0x7F), or C1 (0x80–0x9F) bytes can
32/// break out of cell rendering and inject arbitrary escape sequences
33/// (cursor moves, OSC 52 clipboard, title spoof, etc.) when flushed.
34/// Replacing with the replacement character keeps byte counts sane and
35/// makes the tampering visible.
36#[inline]
37fn sanitize_cell_char(ch: char) -> char {
38    let c = ch as u32;
39    if c < 0x20 || c == 0x7f || (0x80..=0x9f).contains(&c) {
40        '\u{FFFD}'
41    } else {
42        ch
43    }
44}
45
46/// Structured Kitty graphics protocol image placement.
47///
48/// Stored separately from raw escape sequences so the terminal can manage
49/// image IDs, compression, and placement lifecycle. Images are deduplicated
50/// by `content_hash` — identical pixel data is uploaded only once.
51#[derive(Clone, Debug)]
52#[allow(dead_code)]
53pub(crate) struct KittyPlacement {
54    /// Hash of the RGBA pixel data for dedup (avoids re-uploading).
55    pub content_hash: u64,
56    /// Reference-counted raw RGBA pixel data (shared across frames).
57    pub rgba: Arc<Vec<u8>>,
58    /// Source image width in pixels.
59    pub src_width: u32,
60    /// Source image height in pixels.
61    pub src_height: u32,
62    /// Screen cell position.
63    pub x: u32,
64    pub y: u32,
65    /// Cell columns/rows to display.
66    pub cols: u32,
67    pub rows: u32,
68    /// Source crop Y offset in pixels (for scroll clipping).
69    pub crop_y: u32,
70    /// Source crop height in pixels (0 = full height from crop_y).
71    pub crop_h: u32,
72}
73
74/// Compute a content hash for RGBA pixel data.
75pub(crate) fn hash_rgba(data: &[u8]) -> u64 {
76    let mut hasher = std::collections::hash_map::DefaultHasher::new();
77    data.hash(&mut hasher);
78    hasher.finish()
79}
80
81impl PartialEq for KittyPlacement {
82    fn eq(&self, other: &Self) -> bool {
83        self.content_hash == other.content_hash
84            && self.x == other.x
85            && self.y == other.y
86            && self.cols == other.cols
87            && self.rows == other.rows
88            && self.crop_y == other.crop_y
89            && self.crop_h == other.crop_h
90    }
91}
92
93/// Scroll clip information applied to Kitty image placements emitted inside a
94/// raw-draw callback.
95///
96/// Stored on a stack so that nested raw-draw regions restore the outer clip
97/// info on pop, rather than silently clobbering it.
98#[derive(Clone, Copy, Debug, PartialEq, Eq)]
99pub(crate) struct KittyClipInfo {
100    /// Rows of the source region already scrolled off the top.
101    pub top_clip_rows: u32,
102    /// Original total row count of the scrollable content.
103    pub original_height: u32,
104}
105
106/// A 2D grid of [`Cell`]s backing the terminal display.
107///
108/// Two buffers are kept (current + previous); only the diff is flushed to the
109/// terminal, giving immediate-mode ergonomics with retained-mode efficiency.
110///
111/// The buffer also maintains a clip stack. Push a [`Rect`] with
112/// [`Buffer::push_clip`] to restrict writes to that region, and pop it with
113/// [`Buffer::pop_clip`] when done.
114pub struct Buffer {
115    /// The area this buffer covers, in terminal coordinates.
116    pub area: Rect,
117    /// Flat row-major storage of all cells. Length equals `area.width * area.height`.
118    pub content: Vec<Cell>,
119    pub(crate) clip_stack: Vec<Rect>,
120    pub(crate) raw_sequences: Vec<(u32, u32, String)>,
121    pub(crate) kitty_placements: Vec<KittyPlacement>,
122    pub(crate) cursor_pos: Option<(u32, u32)>,
123    /// Stack of scroll clip infos set by the run loop before invoking draw
124    /// closures. The top entry is the active clip; nested raw-draw regions
125    /// push and pop without losing the outer clip.
126    pub(crate) kitty_clip_info_stack: Vec<KittyClipInfo>,
127    /// Per-row digest of every cell on row `y`, used by `flush_buffer_diff`
128    /// to skip the per-cell scan when both the dirty flag and the hash
129    /// match the previous frame (issue #171).
130    ///
131    /// Length equals `area.height`. Stale until
132    /// [`Buffer::recompute_line_hashes`] is called — `flush_buffer_diff` is
133    /// the only call site that relies on these being up to date.
134    pub(crate) line_hashes: Vec<u64>,
135    /// Per-row dirty flag. Set by every cell-write path
136    /// ([`Buffer::set_string`], [`Buffer::set_string_linked`],
137    /// [`Buffer::set_char`], [`Buffer::reset`], [`Buffer::reset_with_bg`]).
138    /// Cleared by [`Buffer::recompute_line_hashes`] after the row hash is
139    /// refreshed.
140    ///
141    /// A `false` entry means the row has not been touched since the last
142    /// hash refresh, so `flush_buffer_diff` can short-circuit the cell
143    /// scan when its hash also matches `previous.line_hashes[y]`.
144    pub(crate) line_dirty: Vec<bool>,
145}
146
147impl Buffer {
148    /// Create a buffer filled with blank cells covering `area`.
149    pub fn empty(area: Rect) -> Self {
150        let size = area.area() as usize;
151        let height = area.height as usize;
152        Self {
153            area,
154            content: vec![Cell::default(); size],
155            clip_stack: Vec::new(),
156            raw_sequences: Vec::new(),
157            kitty_placements: Vec::new(),
158            cursor_pos: None,
159            kitty_clip_info_stack: Vec::new(),
160            // Empty buffers start with default cells on every row; their
161            // hashes are equal across two empty buffers, so initialise to
162            // 0 with `line_dirty=true` so the first flush still recomputes.
163            line_hashes: vec![0; height],
164            line_dirty: vec![true; height],
165        }
166    }
167
168    /// Push a scroll clip info frame. Paired with [`Buffer::pop_kitty_clip`].
169    pub(crate) fn push_kitty_clip(&mut self, info: KittyClipInfo) {
170        self.kitty_clip_info_stack.push(info);
171    }
172
173    /// Pop the most recently pushed scroll clip info frame.
174    pub(crate) fn pop_kitty_clip(&mut self) -> Option<KittyClipInfo> {
175        self.kitty_clip_info_stack.pop()
176    }
177
178    /// Peek the currently active scroll clip info, if any.
179    pub(crate) fn current_kitty_clip(&self) -> Option<&KittyClipInfo> {
180        self.kitty_clip_info_stack.last()
181    }
182
183    pub(crate) fn set_cursor_pos(&mut self, x: u32, y: u32) {
184        self.cursor_pos = Some((x, y));
185    }
186
187    #[cfg(feature = "crossterm")]
188    pub(crate) fn cursor_pos(&self) -> Option<(u32, u32)> {
189        self.cursor_pos
190    }
191
192    /// Store a raw escape sequence to be written at position `(x, y)` during flush.
193    ///
194    /// Used for Sixel images and other passthrough sequences.
195    /// Respects the clip stack: sequences fully outside the current clip are skipped.
196    pub fn raw_sequence(&mut self, x: u32, y: u32, seq: String) {
197        if let Some(clip) = self.effective_clip() {
198            if x >= clip.right() || y >= clip.bottom() {
199                return;
200            }
201        }
202        self.raw_sequences.push((x, y, seq));
203    }
204
205    /// Store a structured Kitty graphics protocol placement.
206    ///
207    /// Unlike `raw_sequence`, Kitty placements are managed with image IDs,
208    /// compression, and placement lifecycle by the terminal flush code.
209    /// Scroll crop info is automatically applied from the top of the
210    /// `kitty_clip_info_stack` (set via [`Buffer::push_kitty_clip`]).
211    pub(crate) fn kitty_place(&mut self, mut p: KittyPlacement) {
212        // Apply clip check
213        if let Some(clip) = self.effective_clip() {
214            if p.x >= clip.right()
215                || p.y >= clip.bottom()
216                || p.x + p.cols <= clip.x
217                || p.y + p.rows <= clip.y
218            {
219                return;
220            }
221        }
222
223        // Apply scroll crop info if any frame is active
224        if let Some(info) = self.current_kitty_clip() {
225            let top_clip_rows = info.top_clip_rows;
226            let original_height = info.original_height;
227            if original_height > 0 && (top_clip_rows > 0 || p.rows < original_height) {
228                let ratio = p.src_height as f64 / original_height as f64;
229                p.crop_y = (top_clip_rows as f64 * ratio) as u32;
230                let bottom_clip = original_height.saturating_sub(top_clip_rows + p.rows);
231                let bottom_pixels = (bottom_clip as f64 * ratio) as u32;
232                p.crop_h = p.src_height.saturating_sub(p.crop_y + bottom_pixels);
233            }
234        }
235
236        self.kitty_placements.push(p);
237    }
238
239    /// Push a clipping rectangle onto the clip stack.
240    ///
241    /// Subsequent writes are restricted to the intersection of all active clip
242    /// regions. Nested calls intersect with the current clip, so the effective
243    /// clip can only shrink, never grow.
244    pub fn push_clip(&mut self, rect: Rect) {
245        let effective = if let Some(current) = self.clip_stack.last() {
246            intersect_rects(*current, rect)
247        } else {
248            rect
249        };
250        self.clip_stack.push(effective);
251    }
252
253    /// Pop the most recently pushed clipping rectangle.
254    ///
255    /// After this call, writes are clipped to the previous region (or
256    /// unclipped if the stack is now empty).
257    pub fn pop_clip(&mut self) {
258        self.clip_stack.pop();
259    }
260
261    fn effective_clip(&self) -> Option<&Rect> {
262        self.clip_stack.last()
263    }
264
265    #[inline]
266    fn index_of(&self, x: u32, y: u32) -> usize {
267        ((y - self.area.y) * self.area.width + (x - self.area.x)) as usize
268    }
269
270    /// Returns `true` if `(x, y)` is within the buffer's area.
271    #[inline]
272    pub fn in_bounds(&self, x: u32, y: u32) -> bool {
273        x >= self.area.x && x < self.area.right() && y >= self.area.y && y < self.area.bottom()
274    }
275
276    /// Return a reference to the cell at `(x, y)`.
277    ///
278    /// Panics if `(x, y)` is out of bounds. Use [`Buffer::try_get`] when the
279    /// coordinates may come from untrusted input.
280    #[inline]
281    pub fn get(&self, x: u32, y: u32) -> &Cell {
282        assert!(
283            self.in_bounds(x, y),
284            "Buffer::get({x}, {y}) out of bounds for area {:?}",
285            self.area
286        );
287        &self.content[self.index_of(x, y)]
288    }
289
290    /// Return a mutable reference to the cell at `(x, y)`.
291    ///
292    /// Panics if `(x, y)` is out of bounds. Use [`Buffer::try_get_mut`] when
293    /// the coordinates may come from untrusted input.
294    #[inline]
295    pub fn get_mut(&mut self, x: u32, y: u32) -> &mut Cell {
296        assert!(
297            self.in_bounds(x, y),
298            "Buffer::get_mut({x}, {y}) out of bounds for area {:?}",
299            self.area
300        );
301        let idx = self.index_of(x, y);
302        &mut self.content[idx]
303    }
304
305    /// Return a reference to the cell at `(x, y)`, or `None` if out of bounds.
306    ///
307    /// Non-panicking counterpart to [`Buffer::get`]. Prefer this inside
308    /// `draw()` closures when coordinates are computed from mouse input,
309    /// scroll offsets, or other sources that could land outside the buffer.
310    #[inline]
311    pub fn try_get(&self, x: u32, y: u32) -> Option<&Cell> {
312        if self.in_bounds(x, y) {
313            Some(&self.content[self.index_of(x, y)])
314        } else {
315            None
316        }
317    }
318
319    /// Return a mutable reference to the cell at `(x, y)`, or `None` if out
320    /// of bounds.
321    ///
322    /// Non-panicking counterpart to [`Buffer::get_mut`].
323    #[inline]
324    pub fn try_get_mut(&mut self, x: u32, y: u32) -> Option<&mut Cell> {
325        if self.in_bounds(x, y) {
326            let idx = self.index_of(x, y);
327            Some(&mut self.content[idx])
328        } else {
329            None
330        }
331    }
332
333    /// Write a string into the buffer starting at `(x, y)`.
334    ///
335    /// Respects cell boundaries and Unicode character widths. Wide characters
336    /// (e.g., CJK) occupy two columns; the trailing cell is blanked. Writes
337    /// that fall outside the current clip region are skipped but still advance
338    /// the cursor position.
339    pub fn set_string(&mut self, x: u32, y: u32, s: &str, style: Style) {
340        self.set_string_inner(x, y, s, style, None);
341    }
342
343    /// Write a hyperlinked string into the buffer starting at `(x, y)`.
344    ///
345    /// Like [`Buffer::set_string`] but attaches an OSC 8 hyperlink URL to each
346    /// cell. The terminal renders these cells as clickable links.
347    pub fn set_string_linked(&mut self, x: u32, y: u32, s: &str, style: Style, url: &str) {
348        let link = sanitize_osc8_url(url).map(compact_str::CompactString::new);
349        self.set_string_inner(x, y, s, style, link.as_ref());
350    }
351
352    /// Shared implementation for [`Self::set_string`] and
353    /// [`Self::set_string_linked`].
354    ///
355    /// `link` is `Some` only for the OSC 8 path; both paths share clip,
356    /// wide-char, and zero-width grapheme handling. Keeping a single
357    /// implementation prevents the two call sites from drifting on edge cases
358    /// (e.g., `MAX_CELL_SYMBOL_BYTES` checks, wide-char blanking).
359    fn set_string_inner(
360        &mut self,
361        mut x: u32,
362        y: u32,
363        s: &str,
364        style: Style,
365        link: Option<&compact_str::CompactString>,
366    ) {
367        if y >= self.area.bottom() {
368            return;
369        }
370        // Issue #171: mark this row dirty so the next flush refreshes its
371        // hash. Marking unconditionally here keeps the write paths cheap;
372        // false positives only cost one redundant hash recompute, never a
373        // correctness issue.
374        self.mark_row_dirty(y);
375        let clip = self.effective_clip().copied();
376        for ch in s.chars() {
377            if x >= self.area.right() {
378                break;
379            }
380            let ch = sanitize_cell_char(ch);
381            let char_width = UnicodeWidthChar::width(ch).unwrap_or(0) as u32;
382            if char_width == 0 {
383                // Append zero-width char (combining mark, ZWJ, variation selector)
384                // to the previous cell so grapheme clusters stay intact.
385                if x > self.area.x {
386                    let prev_in_clip = clip.map_or(true, |clip| {
387                        (x - 1) >= clip.x
388                            && (x - 1) < clip.right()
389                            && y >= clip.y
390                            && y < clip.bottom()
391                    });
392                    if prev_in_clip {
393                        let prev = self.get_mut(x - 1, y);
394                        if prev.symbol.len() + ch.len_utf8() <= MAX_CELL_SYMBOL_BYTES {
395                            prev.symbol.push(ch);
396                        }
397                    }
398                }
399                continue;
400            }
401
402            let in_clip = clip.map_or(true, |clip| {
403                x >= clip.x && x < clip.right() && y >= clip.y && y < clip.bottom()
404            });
405
406            if !in_clip {
407                x = x.saturating_add(char_width);
408                continue;
409            }
410
411            let cell = self.get_mut(x, y);
412            cell.set_char(ch);
413            cell.set_style(style);
414            cell.hyperlink = link.cloned();
415
416            // Wide characters occupy two cells; blank the trailing cell.
417            if char_width > 1 {
418                let next_x = x + 1;
419                if next_x < self.area.right() {
420                    let next = self.get_mut(next_x, y);
421                    next.symbol.clear();
422                    next.style = style;
423                    next.hyperlink = link.cloned();
424                }
425            }
426
427            x = x.saturating_add(char_width);
428        }
429    }
430
431    /// Write a single character at `(x, y)` with the given style.
432    ///
433    /// No-ops if `(x, y)` is out of bounds or outside the current clip region.
434    pub fn set_char(&mut self, x: u32, y: u32, ch: char, style: Style) {
435        let in_clip = self.effective_clip().map_or(true, |clip| {
436            x >= clip.x && x < clip.right() && y >= clip.y && y < clip.bottom()
437        });
438        if !self.in_bounds(x, y) || !in_clip {
439            return;
440        }
441        // Issue #171: mark this row dirty so the next flush refreshes its
442        // hash before deciding whether to skip the per-cell scan.
443        self.mark_row_dirty(y);
444        let cell = self.get_mut(x, y);
445        cell.set_char(ch);
446        cell.set_style(style);
447    }
448
449    /// Mark row `y` as dirty so the next flush recomputes its line hash.
450    ///
451    /// `y` is in the buffer's coordinate space (i.e. `area.y..area.bottom()`).
452    /// Out-of-range values are ignored so callers don't need to bounds-check
453    /// before invoking this on every cell write.
454    #[inline]
455    pub(crate) fn mark_row_dirty(&mut self, y: u32) {
456        if y < self.area.y {
457            return;
458        }
459        let idx = (y - self.area.y) as usize;
460        if let Some(slot) = self.line_dirty.get_mut(idx) {
461            *slot = true;
462        }
463    }
464
465    /// Recompute the per-row digest for every row currently flagged dirty.
466    ///
467    /// This is the only call site that updates [`Self::line_hashes`]; once
468    /// a row's hash is refreshed its `line_dirty` entry is cleared. Hashes
469    /// derive from each cell's `(symbol, style, hyperlink)` tuple via
470    /// [`std::collections::hash_map::DefaultHasher`] — sufficient for
471    /// equality detection with no extra dependency.
472    ///
473    /// Called by `flush_buffer_diff` once per frame, before the per-row
474    /// skip check (issue #171).
475    ///
476    /// Gated on `crossterm` (the only flush call site) and `test`. Without
477    /// the gate it shows as `dead_code` under `--no-default-features`.
478    #[cfg(any(feature = "crossterm", test))]
479    pub(crate) fn recompute_line_hashes(&mut self) {
480        let height = self.area.height;
481        if height == 0 {
482            return;
483        }
484        // `line_hashes` / `line_dirty` are sized at construction / resize;
485        // an interior mutation (e.g. resize before reset) could leave them
486        // out of step with `area.height`. Repair lazily here so callers
487        // never observe a stale length.
488        let expected_len = height as usize;
489        if self.line_hashes.len() != expected_len {
490            self.line_hashes.resize(expected_len, 0);
491        }
492        if self.line_dirty.len() != expected_len {
493            self.line_dirty.resize(expected_len, true);
494        }
495
496        let width = self.area.width as usize;
497        for (idx, dirty) in self.line_dirty.iter_mut().enumerate() {
498            if !*dirty {
499                continue;
500            }
501            let row_start = idx * width;
502            let row_end = row_start + width;
503            let mut hasher = std::collections::hash_map::DefaultHasher::new();
504            for cell in &self.content[row_start..row_end] {
505                cell.symbol.as_str().hash(&mut hasher);
506                cell.style.hash(&mut hasher);
507                cell.hyperlink.as_deref().hash(&mut hasher);
508            }
509            self.line_hashes[idx] = hasher.finish();
510            *dirty = false;
511        }
512    }
513
514    /// Returns `true` if row `y` (buffer-space) was not touched since the
515    /// last [`Self::recompute_line_hashes`] call.
516    ///
517    /// Gated on `crossterm` (consumed by `flush_buffer_diff`) and `test`.
518    ///
519    /// Used by `flush_buffer_diff` to short-circuit the per-cell scan when
520    /// combined with a hash match against the previous frame (issue #171).
521    /// Out-of-range rows report as dirty so callers fall back to the
522    /// existing per-cell path on edge inputs.
523    #[inline]
524    #[cfg(any(feature = "crossterm", test))]
525    pub(crate) fn row_clean(&self, y: u32) -> bool {
526        if y < self.area.y {
527            return false;
528        }
529        let idx = (y - self.area.y) as usize;
530        self.line_dirty
531            .get(idx)
532            .copied()
533            .map(|d| !d)
534            .unwrap_or(false)
535    }
536
537    /// Read row `y`'s cached digest, or `None` if out of range.
538    ///
539    /// Pairs with [`Self::row_clean`] inside `flush_buffer_diff`: only the
540    /// hash for clean rows is used as a short-circuit signal, so callers
541    /// must check `row_clean` first.
542    #[inline]
543    #[cfg(any(feature = "crossterm", test))]
544    pub(crate) fn row_hash(&self, y: u32) -> Option<u64> {
545        if y < self.area.y {
546            return None;
547        }
548        let idx = (y - self.area.y) as usize;
549        self.line_hashes.get(idx).copied()
550    }
551
552    /// Compute the diff between `self` (current) and `other` (previous).
553    ///
554    /// Returns `(x, y, cell)` tuples for every cell that changed. Useful for
555    /// custom backends or tests that need to inspect changed cells directly.
556    ///
557    /// # Allocation
558    ///
559    /// Allocates a new [`Vec`] on every call. For high-frequency use
560    /// (per-frame diffing in a render loop), prefer the internal
561    /// `flush_buffer_diff` path used by [`crate::run`], which streams updates
562    /// directly to the backend without an intermediate `Vec`. Calling
563    /// `diff()` on every frame in a 60 fps loop adds one heap allocation
564    /// (sized to the changed-cell count) per frame.
565    ///
566    /// # Benchmarks
567    ///
568    /// `benches/benchmarks.rs` exercises this path in `bench_buffer_diff`.
569    pub fn diff<'a>(&'a self, other: &'a Buffer) -> Vec<(u32, u32, &'a Cell)> {
570        let mut updates = Vec::new();
571        for y in self.area.y..self.area.bottom() {
572            for x in self.area.x..self.area.right() {
573                let cur = self.get(x, y);
574                let prev = other.get(x, y);
575                if cur != prev {
576                    updates.push((x, y, cur));
577                }
578            }
579        }
580        updates
581    }
582
583    /// Reset every cell to a blank space with default style, and clear the clip stack.
584    pub fn reset(&mut self) {
585        for cell in &mut self.content {
586            cell.reset();
587        }
588        self.clip_stack.clear();
589        self.raw_sequences.clear();
590        self.kitty_placements.clear();
591        self.cursor_pos = None;
592        self.kitty_clip_info_stack.clear();
593        // Issue #171: every row is now blank — flag them all dirty so the
594        // next flush refreshes the digest before any skip check.
595        for d in &mut self.line_dirty {
596            *d = true;
597        }
598    }
599
600    /// Reset every cell and apply a background color to all cells.
601    pub fn reset_with_bg(&mut self, bg: crate::style::Color) {
602        for cell in &mut self.content {
603            cell.reset();
604            cell.style.bg = Some(bg);
605        }
606        self.clip_stack.clear();
607        self.raw_sequences.clear();
608        self.kitty_placements.clear();
609        self.cursor_pos = None;
610        self.kitty_clip_info_stack.clear();
611        // Issue #171: every cell was just rewritten — mark all rows dirty.
612        for d in &mut self.line_dirty {
613            *d = true;
614        }
615    }
616
617    /// Resize the buffer to fit a new area, resetting all cells.
618    ///
619    /// If the new area is larger, new cells are initialized to blank. All
620    /// existing content is discarded.
621    pub fn resize(&mut self, area: Rect) {
622        self.area = area;
623        let size = area.area() as usize;
624        self.content.resize(size, Cell::default());
625        // Issue #171: keep the per-row tracking arrays sized to the new
626        // height. `reset()` re-marks every row dirty so initial values
627        // here don't affect correctness.
628        let height = area.height as usize;
629        self.line_hashes.resize(height, 0);
630        self.line_dirty.resize(height, true);
631        self.reset();
632    }
633
634    /// Serialize the buffer into a stable, styled-snapshot format suitable for
635    /// snapshot testing (e.g. with `insta::assert_snapshot!`).
636    ///
637    /// # Format
638    ///
639    /// One line per buffer row, joined with `\n`. Within a row, runs of cells
640    /// that share an identical [`Style`] are grouped. The default style (no
641    /// foreground, no background, no modifiers) emits **unannotated** text —
642    /// no `[...]` markers. Any non-default run is wrapped:
643    ///
644    /// ```text
645    /// [fg=...,bg=...,mods]"text"[/]
646    /// ```
647    ///
648    /// Trailing whitespace per row is preserved in the styled segment but
649    /// trailing default-style spaces at the end of a row are emitted verbatim
650    /// (they are visually invisible in diffs). Empty cells render as a single
651    /// space. The terminating `[/]` marker only appears when a styled run is
652    /// in effect at the end of a row.
653    ///
654    /// # Color formatting
655    ///
656    /// Named palette colors use short lowercase codes:
657    /// `reset`, `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`,
658    /// `white`, `dark_gray`, `light_red`, `light_green`, `light_yellow`,
659    /// `light_blue`, `light_magenta`, `light_cyan`, `light_white`. RGB colors
660    /// emit `#rrggbb`. Indexed palette colors emit `idx<N>` (decimal).
661    ///
662    /// # Modifier formatting
663    ///
664    /// Modifiers are emitted as comma-separated lowercase tokens in a fixed
665    /// canonical order: `bold`, `dim`, `italic`, `underline`, `reversed`,
666    /// `strikethrough`. Order is independent of the bit pattern, so two
667    /// equivalent `Modifiers` values always serialize identically.
668    ///
669    /// # Stability
670    ///
671    /// The output format is stable across patch and minor versions of SLT.
672    /// Names use a hand-rolled formatter (not `Debug`) so derives changing
673    /// upstream cannot accidentally break locked snapshots. A breaking change
674    /// to the format would be reserved for a major version bump.
675    ///
676    /// # Determinism
677    ///
678    /// Identical input buffers always produce byte-equal output. This is a
679    /// hard requirement — snapshot tests rely on it.
680    ///
681    /// # Example
682    ///
683    /// ```
684    /// use slt::{Buffer, Color, Rect, Style};
685    ///
686    /// let mut buf = Buffer::empty(Rect::new(0, 0, 5, 1));
687    /// buf.set_string(0, 0, "ab", Style::new().fg(Color::Red).bold());
688    /// buf.set_string(2, 0, "cd", Style::new());
689    /// let snap = buf.snapshot_format();
690    /// assert!(snap.starts_with("[fg=red,bold]\"ab\"[/]cd"));
691    /// ```
692    pub fn snapshot_format(&self) -> String {
693        let mut out = String::new();
694        let width = self.area.width;
695        let height = self.area.height;
696        if width == 0 || height == 0 {
697            return out;
698        }
699
700        for y in self.area.y..self.area.bottom() {
701            if y > self.area.y {
702                out.push('\n');
703            }
704
705            // Walk the row, grouping consecutive cells by Style.
706            let mut current_style: Option<Style> = None;
707            let mut run_text = String::new();
708
709            for x in self.area.x..self.area.right() {
710                let cell = self.get(x, y);
711                let style = cell.style;
712                // Empty cell symbol → single space (e.g. trailing wide-char cell).
713                let sym: &str = if cell.symbol.is_empty() {
714                    " "
715                } else {
716                    cell.symbol.as_str()
717                };
718
719                match current_style {
720                    Some(s) if s == style => {
721                        run_text.push_str(sym);
722                    }
723                    _ => {
724                        if let Some(s) = current_style.take() {
725                            flush_run(&mut out, s, &run_text);
726                            run_text.clear();
727                        }
728                        current_style = Some(style);
729                        run_text.push_str(sym);
730                    }
731                }
732            }
733
734            if let Some(s) = current_style {
735                flush_run(&mut out, s, &run_text);
736            }
737        }
738
739        out
740    }
741}
742
743/// Flush a single style-run into the snapshot output.
744///
745/// Default style → unannotated raw text (no markers, escape only embedded `"`).
746/// Non-default style → `[fg=...,bg=...,mods]"text"[/]` form. Embedded `"` and
747/// `\` characters in cell symbols are escaped so the snapshot remains
748/// unambiguous.
749fn flush_run(out: &mut String, style: Style, text: &str) {
750    if style == Style::default() {
751        out.push_str(text);
752        return;
753    }
754    out.push('[');
755    let mut first = true;
756    if let Some(fg) = style.fg {
757        out.push_str("fg=");
758        write_color(out, fg);
759        first = false;
760    }
761    if let Some(bg) = style.bg {
762        if !first {
763            out.push(',');
764        }
765        out.push_str("bg=");
766        write_color(out, bg);
767        first = false;
768    }
769    let mods = style.modifiers;
770    // Canonical order: bold, dim, italic, underline, reversed, strikethrough.
771    let pairs: [(crate::style::Modifiers, &str); 6] = [
772        (crate::style::Modifiers::BOLD, "bold"),
773        (crate::style::Modifiers::DIM, "dim"),
774        (crate::style::Modifiers::ITALIC, "italic"),
775        (crate::style::Modifiers::UNDERLINE, "underline"),
776        (crate::style::Modifiers::REVERSED, "reversed"),
777        (crate::style::Modifiers::STRIKETHROUGH, "strikethrough"),
778    ];
779    for (bit, name) in pairs {
780        if mods.contains(bit) {
781            if !first {
782                out.push(',');
783            }
784            out.push_str(name);
785            first = false;
786        }
787    }
788    out.push(']');
789    out.push('"');
790    for ch in text.chars() {
791        match ch {
792            '"' => out.push_str("\\\""),
793            '\\' => out.push_str("\\\\"),
794            other => out.push(other),
795        }
796    }
797    out.push('"');
798    out.push_str("[/]");
799}
800
801/// Format a [`crate::style::Color`] using the stable snapshot vocabulary.
802///
803/// Hand-rolled instead of `Debug` so upstream derive changes can't silently
804/// break snapshot stability.
805fn write_color(out: &mut String, color: crate::style::Color) {
806    use crate::style::Color;
807    match color {
808        Color::Reset => out.push_str("reset"),
809        Color::Black => out.push_str("black"),
810        Color::Red => out.push_str("red"),
811        Color::Green => out.push_str("green"),
812        Color::Yellow => out.push_str("yellow"),
813        Color::Blue => out.push_str("blue"),
814        Color::Magenta => out.push_str("magenta"),
815        Color::Cyan => out.push_str("cyan"),
816        Color::White => out.push_str("white"),
817        Color::DarkGray => out.push_str("dark_gray"),
818        Color::LightRed => out.push_str("light_red"),
819        Color::LightGreen => out.push_str("light_green"),
820        Color::LightYellow => out.push_str("light_yellow"),
821        Color::LightBlue => out.push_str("light_blue"),
822        Color::LightMagenta => out.push_str("light_magenta"),
823        Color::LightCyan => out.push_str("light_cyan"),
824        Color::LightWhite => out.push_str("light_white"),
825        Color::Rgb(r, g, b) => {
826            use std::fmt::Write;
827            let _ = write!(out, "#{:02x}{:02x}{:02x}", r, g, b);
828        }
829        Color::Indexed(idx) => {
830            use std::fmt::Write;
831            let _ = write!(out, "idx{}", idx);
832        }
833    }
834}
835
836/// Maximum byte length for OSC 8 hyperlink URLs.
837///
838/// Longer than any legitimate URL and enough to prevent DoS via
839/// balloon-sized hyperlinks. Shared by [`is_valid_osc8_url`] and
840/// [`sanitize_osc8_url`] so both gates agree on acceptance.
841const MAX_OSC8_URL_BYTES: usize = 2048;
842
843/// Returns `true` if `url` is safe to emit as an OSC 8 hyperlink payload.
844///
845/// Equivalent to `sanitize_osc8_url(url).is_some()` but avoids the `String`
846/// allocation when callers only need a boolean validity check (e.g.,
847/// defense-in-depth validation of a public `Cell::hyperlink` field on the
848/// flush path).
849#[inline]
850pub(crate) fn is_valid_osc8_url(url: &str) -> bool {
851    if url.is_empty() || url.len() > MAX_OSC8_URL_BYTES {
852        return false;
853    }
854    // Reject all C0 controls (incl. BEL 0x07, ESC 0x1b), DEL 0x7f, and
855    // anything below 0x20. ESC enables the ST (ESC \) terminator trick;
856    // BEL is the legacy OSC terminator. Either would let an
857    // attacker-controlled URL prematurely close the OSC 8 sequence and
858    // inject arbitrary follow-up commands (e.g., OSC 52 clipboard writes).
859    url.bytes().all(|b| b >= 0x20 && b != 0x7f)
860}
861
862/// Validate an OSC 8 hyperlink URL, returning `Some(url)` if safe to emit.
863///
864/// Rejects URLs containing control bytes, the BEL terminator, or an
865/// embedded ST (`ESC \`). Those would let an attacker-controlled URL
866/// prematurely close the OSC 8 sequence and inject arbitrary follow-up
867/// commands (e.g., OSC 52 clipboard writes). Also caps length at
868/// [`MAX_OSC8_URL_BYTES`] (2048).
869///
870/// For boolean validation (no allocation), use [`is_valid_osc8_url`].
871pub(crate) fn sanitize_osc8_url(url: &str) -> Option<String> {
872    if is_valid_osc8_url(url) {
873        Some(url.to_string())
874    } else {
875        None
876    }
877}
878
879fn intersect_rects(a: Rect, b: Rect) -> Rect {
880    let x = a.x.max(b.x);
881    let y = a.y.max(b.y);
882    let right = a.right().min(b.right());
883    let bottom = a.bottom().min(b.bottom());
884    let width = right.saturating_sub(x);
885    let height = bottom.saturating_sub(y);
886    Rect::new(x, y, width, height)
887}
888
889#[cfg(test)]
890mod tests {
891    use super::*;
892
893    #[test]
894    fn clip_stack_intersects_nested_regions() {
895        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 5));
896        buf.push_clip(Rect::new(1, 1, 6, 3));
897        buf.push_clip(Rect::new(4, 0, 6, 4));
898
899        buf.set_char(3, 2, 'x', Style::new());
900        buf.set_char(4, 2, 'y', Style::new());
901
902        assert_eq!(buf.get(3, 2).symbol, " ");
903        assert_eq!(buf.get(4, 2).symbol, "y");
904    }
905
906    #[test]
907    fn set_string_advances_even_when_clipped() {
908        let mut buf = Buffer::empty(Rect::new(0, 0, 8, 1));
909        buf.push_clip(Rect::new(2, 0, 6, 1));
910
911        buf.set_string(0, 0, "abcd", Style::new());
912
913        assert_eq!(buf.get(2, 0).symbol, "c");
914        assert_eq!(buf.get(3, 0).symbol, "d");
915    }
916
917    #[test]
918    fn pop_clip_restores_previous_clip() {
919        let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
920        buf.push_clip(Rect::new(0, 0, 2, 1));
921        buf.push_clip(Rect::new(4, 0, 2, 1));
922
923        buf.set_char(1, 0, 'a', Style::new());
924        buf.pop_clip();
925        buf.set_char(1, 0, 'b', Style::new());
926
927        assert_eq!(buf.get(1, 0).symbol, "b");
928    }
929
930    #[test]
931    fn reset_clears_clip_stack() {
932        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
933        buf.push_clip(Rect::new(0, 0, 0, 0));
934        buf.reset();
935        buf.set_char(0, 0, 'z', Style::new());
936
937        assert_eq!(buf.get(0, 0).symbol, "z");
938    }
939
940    #[test]
941    fn set_string_replaces_control_chars_with_replacement() {
942        let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
943        // ESC must never land in a cell — a flushed ESC would let the
944        // string escape its cell and execute as a real terminal command.
945        buf.set_string(0, 0, "a\x1bbc", Style::new());
946        assert_eq!(buf.get(0, 0).symbol, "a");
947        assert_eq!(buf.get(1, 0).symbol, "\u{FFFD}");
948        assert_eq!(buf.get(2, 0).symbol, "b");
949        assert_eq!(buf.get(3, 0).symbol, "c");
950    }
951
952    #[test]
953    fn zero_width_combining_does_not_append_control_bytes() {
954        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
955        buf.set_char(0, 0, 'a', Style::new());
956        // BEL is zero-width per unicode_width; the pre-fix code would have
957        // pushed it onto cell(0,0).symbol. After sanitize_cell_char it is
958        // replaced with U+FFFD and then appended (width 1, still fits).
959        buf.set_string(1, 0, "\x07", Style::new());
960        let symbol = buf.get(1, 0).symbol.as_str();
961        assert!(!symbol.contains('\x07'), "BEL leaked into cell symbol");
962    }
963
964    #[test]
965    fn set_string_caps_combining_overflow() {
966        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
967        buf.set_char(0, 0, 'a', Style::new());
968        // 200 copies of an ASCII-printable zero-width-ish char would bypass
969        // the byte cap. Use a legitimate zero-width combining character —
970        // U+0301 (combining acute accent) — and confirm the cap kicks in.
971        let combining: String = "\u{0301}".repeat(200);
972        buf.set_string(1, 0, &combining, Style::new());
973        assert!(
974            buf.get(0, 0).symbol.len() <= MAX_CELL_SYMBOL_BYTES,
975            "cell symbol exceeded MAX_CELL_SYMBOL_BYTES cap"
976        );
977    }
978
979    #[test]
980    fn sanitize_osc8_url_rejects_control_chars_and_esc() {
981        assert!(sanitize_osc8_url("https://example.com").is_some());
982        assert!(sanitize_osc8_url("https://example.com?q=1&r=2").is_some());
983        // BEL — terminates OSC, would let follow-up text be interpreted.
984        assert!(sanitize_osc8_url("https://example.com\x07attack").is_none());
985        // ESC — can open ST (ESC \) or another OSC.
986        assert!(sanitize_osc8_url("https://example.com\x1b]52;c;hi\x1b\\").is_none());
987        // Empty / oversize.
988        assert!(sanitize_osc8_url("").is_none());
989        assert!(sanitize_osc8_url(&"a".repeat(2049)).is_none());
990    }
991
992    #[test]
993    fn is_valid_osc8_url_matches_sanitize() {
994        // is_valid_osc8_url must agree with sanitize_osc8_url on every input.
995        // If the two ever drift, the OSC 8 flush path either rejects
996        // legitimate URLs (silent) or admits dangerous ones (security).
997        let oversize = "x".repeat(2049);
998        let cases: &[&str] = &[
999            "https://example.com",
1000            "http://localhost:8080/path?q=1#frag",
1001            "ftp://[::1]/file",
1002            "",
1003            &oversize,
1004            "https://evil.com\x1b]52;c;inject\x1b\\",
1005            "https://evil.com\x07bel",
1006            "https://example.com\x7f",
1007            "https://example.com\x00",
1008        ];
1009        for url in cases {
1010            assert_eq!(
1011                is_valid_osc8_url(url),
1012                sanitize_osc8_url(url).is_some(),
1013                "is_valid_osc8_url and sanitize_osc8_url disagree on {url:?}"
1014            );
1015        }
1016    }
1017
1018    #[test]
1019    fn set_string_inner_parity_no_link() {
1020        // set_string and set_string_linked with an invalid URL must produce
1021        // identical buffer state (link rejected → None).
1022        let area = Rect::new(0, 0, 20, 1);
1023        let mut buf_a = Buffer::empty(area);
1024        let mut buf_b = Buffer::empty(area);
1025        let style = Style::new();
1026
1027        buf_a.set_string(0, 0, "Hello wide世界", style);
1028        buf_b.set_string_linked(0, 0, "Hello wide世界", style, "");
1029
1030        for x in 0..20 {
1031            let ca = buf_a.get(x, 0);
1032            let cb = buf_b.get(x, 0);
1033            assert_eq!(ca.symbol, cb.symbol, "symbol mismatch at x={x}");
1034            assert_eq!(ca.style, cb.style, "style mismatch at x={x}");
1035            assert_eq!(
1036                cb.hyperlink, None,
1037                "invalid URL must produce None hyperlink at x={x}"
1038            );
1039        }
1040    }
1041
1042    #[test]
1043    fn set_string_linked_attaches_hyperlink_to_wide_char_pair() {
1044        // Wide chars span two cells; both must carry the same hyperlink.
1045        let area = Rect::new(0, 0, 4, 1);
1046        let mut buf = Buffer::empty(area);
1047        buf.set_string_linked(0, 0, "世", Style::new(), "https://example.com");
1048        let leading = buf.get(0, 0);
1049        let trailing = buf.get(1, 0);
1050        assert_eq!(leading.symbol, "世");
1051        assert!(trailing.symbol.is_empty(), "wide-char trailing must blank");
1052        assert!(leading.hyperlink.is_some());
1053        assert_eq!(leading.hyperlink, trailing.hyperlink);
1054    }
1055
1056    #[test]
1057    fn try_get_out_of_bounds_returns_none() {
1058        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
1059        assert!(buf.try_get(0, 0).is_some());
1060        assert!(buf.try_get(2, 0).is_none());
1061        assert!(buf.try_get(0, 2).is_none());
1062        assert!(buf.try_get_mut(5, 5).is_none());
1063    }
1064
1065    #[test]
1066    fn kitty_clip_stack_restores_outer_on_pop() {
1067        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 4));
1068        assert!(buf.current_kitty_clip().is_none());
1069
1070        let outer = KittyClipInfo {
1071            top_clip_rows: 2,
1072            original_height: 10,
1073        };
1074        let inner = KittyClipInfo {
1075            top_clip_rows: 5,
1076            original_height: 20,
1077        };
1078
1079        buf.push_kitty_clip(outer);
1080        assert_eq!(buf.current_kitty_clip(), Some(&outer));
1081
1082        // Nested region pushes its own frame.
1083        buf.push_kitty_clip(inner);
1084        assert_eq!(buf.current_kitty_clip(), Some(&inner));
1085
1086        // After inner pops, outer MUST still be active — the bug this
1087        // refactor fixes is exactly that the outer was previously clobbered.
1088        let popped_inner = buf.pop_kitty_clip();
1089        assert_eq!(popped_inner, Some(inner));
1090        assert_eq!(buf.current_kitty_clip(), Some(&outer));
1091
1092        let popped_outer = buf.pop_kitty_clip();
1093        assert_eq!(popped_outer, Some(outer));
1094        assert!(buf.current_kitty_clip().is_none());
1095    }
1096
1097    #[test]
1098    fn kitty_clip_stack_cleared_on_reset() {
1099        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
1100        buf.push_kitty_clip(KittyClipInfo {
1101            top_clip_rows: 1,
1102            original_height: 2,
1103        });
1104        buf.push_kitty_clip(KittyClipInfo {
1105            top_clip_rows: 3,
1106            original_height: 4,
1107        });
1108        buf.reset();
1109        assert!(buf.kitty_clip_info_stack.is_empty());
1110        assert!(buf.current_kitty_clip().is_none());
1111    }
1112
1113    #[test]
1114    fn kitty_clip_pop_on_empty_stack_is_none() {
1115        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
1116        assert!(buf.pop_kitty_clip().is_none());
1117    }
1118
1119    // ---- snapshot_format tests (#231) -------------------------------------
1120
1121    #[test]
1122    fn snapshot_format_default_style_unannotated() {
1123        let mut buf = Buffer::empty(Rect::new(0, 0, 5, 1));
1124        buf.set_string(0, 0, "abc", Style::new());
1125        // Two trailing default cells render as raw spaces.
1126        assert_eq!(buf.snapshot_format(), "abc  ");
1127    }
1128
1129    #[test]
1130    fn snapshot_format_color_runs_grouped() {
1131        use crate::style::Color;
1132        let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
1133        buf.set_string(0, 0, "abc", Style::new().fg(Color::Red));
1134        buf.set_string(3, 0, "def", Style::new().fg(Color::Blue));
1135        let snap = buf.snapshot_format();
1136        assert_eq!(snap, "[fg=red]\"abc\"[/][fg=blue]\"def\"[/]");
1137    }
1138
1139    #[test]
1140    fn snapshot_format_modifier_transitions() {
1141        let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
1142        buf.set_string(0, 0, "ab", Style::new().bold());
1143        // gap with default style
1144        buf.set_string(2, 0, "cd", Style::new());
1145        buf.set_string(4, 0, "ef", Style::new().bold());
1146        let snap = buf.snapshot_format();
1147        assert_eq!(snap, "[bold]\"ab\"[/]cd[bold]\"ef\"[/]");
1148    }
1149
1150    #[test]
1151    fn snapshot_format_deterministic() {
1152        use crate::style::Color;
1153        let mut buf = Buffer::empty(Rect::new(0, 0, 8, 2));
1154        buf.set_string(0, 0, "hello", Style::new().fg(Color::Cyan).bold());
1155        buf.set_string(0, 1, "world", Style::new().bg(Color::Rgb(10, 20, 30)));
1156        let a = buf.snapshot_format();
1157        let b = buf.snapshot_format();
1158        assert_eq!(a, b, "snapshot_format must be deterministic");
1159        // Verify byte length equality as a stronger anti-flake guarantee.
1160        assert_eq!(a.len(), b.len());
1161    }
1162
1163    #[test]
1164    fn snapshot_format_empty_buffer_is_spaces() {
1165        let buf = Buffer::empty(Rect::new(0, 0, 4, 2));
1166        // 4 default-style spaces per row, joined by '\n'.
1167        assert_eq!(buf.snapshot_format(), "    \n    ");
1168    }
1169
1170    #[test]
1171    fn snapshot_format_zero_dim_returns_empty() {
1172        let buf_a = Buffer::empty(Rect::new(0, 0, 0, 4));
1173        let buf_b = Buffer::empty(Rect::new(0, 0, 4, 0));
1174        assert_eq!(buf_a.snapshot_format(), "");
1175        assert_eq!(buf_b.snapshot_format(), "");
1176    }
1177
1178    #[test]
1179    fn snapshot_format_rgb_uses_hex_codes() {
1180        use crate::style::Color;
1181        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
1182        buf.set_string(0, 0, "x", Style::new().fg(Color::Rgb(0xff, 0x00, 0xab)));
1183        let snap = buf.snapshot_format();
1184        assert!(
1185            snap.contains("fg=#ff00ab"),
1186            "expected hex RGB code, got {snap:?}"
1187        );
1188    }
1189
1190    #[test]
1191    fn snapshot_format_indexed_color() {
1192        use crate::style::Color;
1193        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
1194        buf.set_string(0, 0, "x", Style::new().fg(Color::Indexed(42)));
1195        assert!(buf.snapshot_format().contains("fg=idx42"));
1196    }
1197
1198    #[test]
1199    fn snapshot_format_modifiers_canonical_order() {
1200        // Insert in reverse order; output must still be canonical.
1201        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
1202        let style = Style::new().strikethrough().italic().bold();
1203        buf.set_string(0, 0, "x", style);
1204        let snap = buf.snapshot_format();
1205        // Order in output: bold, italic, strikethrough.
1206        let bold_idx = snap.find("bold").expect("bold present");
1207        let italic_idx = snap.find("italic").expect("italic present");
1208        let strike_idx = snap.find("strikethrough").expect("strikethrough present");
1209        assert!(bold_idx < italic_idx);
1210        assert!(italic_idx < strike_idx);
1211    }
1212
1213    #[test]
1214    fn snapshot_format_escapes_quote_and_backslash() {
1215        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
1216        buf.set_string(0, 0, "a\"b\\", Style::new().bold());
1217        let snap = buf.snapshot_format();
1218        // Embedded quote → \" and backslash → \\
1219        assert!(
1220            snap.contains("\"a\\\"b\\\\\""),
1221            "expected escapes, got {snap:?}"
1222        );
1223    }
1224
1225    #[test]
1226    fn snapshot_format_multi_row_uses_newlines() {
1227        let mut buf = Buffer::empty(Rect::new(0, 0, 3, 3));
1228        buf.set_string(0, 0, "aaa", Style::new());
1229        buf.set_string(0, 1, "bbb", Style::new());
1230        buf.set_string(0, 2, "ccc", Style::new());
1231        assert_eq!(buf.snapshot_format(), "aaa\nbbb\nccc");
1232    }
1233
1234    // ---- per-row hash skip (#171) -----------------------------------------
1235
1236    #[test]
1237    fn line_dirty_initial_state_is_all_dirty() {
1238        // Fresh buffer must start with every row dirty so the first flush
1239        // refreshes hashes before the per-row skip ever fires.
1240        let buf = Buffer::empty(Rect::new(0, 0, 4, 3));
1241        assert_eq!(buf.line_dirty.len(), 3);
1242        assert!(buf.line_dirty.iter().all(|d| *d));
1243    }
1244
1245    #[test]
1246    fn set_string_marks_row_dirty() {
1247        // After a recompute every row is clean. A subsequent write must
1248        // re-mark the touched row as dirty so its hash gets refreshed.
1249        let mut buf = Buffer::empty(Rect::new(0, 0, 8, 4));
1250        buf.recompute_line_hashes();
1251        assert!(buf.line_dirty.iter().all(|d| !*d));
1252
1253        buf.set_string(0, 1, "hello", Style::new());
1254        assert!(!buf.line_dirty[0]);
1255        assert!(buf.line_dirty[1]);
1256        assert!(!buf.line_dirty[2]);
1257        assert!(!buf.line_dirty[3]);
1258    }
1259
1260    #[test]
1261    fn set_char_marks_row_dirty() {
1262        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 3));
1263        buf.recompute_line_hashes();
1264        buf.set_char(2, 2, 'X', Style::new());
1265        assert!(!buf.line_dirty[0]);
1266        assert!(!buf.line_dirty[1]);
1267        assert!(buf.line_dirty[2]);
1268    }
1269
1270    #[test]
1271    fn recompute_line_hashes_clears_dirty_and_caches_hashes() {
1272        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 2));
1273        buf.set_string(0, 0, "abcd", Style::new());
1274        buf.set_string(0, 1, "wxyz", Style::new());
1275        buf.recompute_line_hashes();
1276
1277        assert!(buf.line_dirty.iter().all(|d| !*d));
1278        // Different content → different hashes.
1279        assert_ne!(buf.line_hashes[0], buf.line_hashes[1]);
1280        assert!(buf.row_clean(0));
1281        assert!(buf.row_clean(1));
1282    }
1283
1284    #[test]
1285    fn row_clean_returns_false_for_unrecomputed_or_dirty_row() {
1286        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 2));
1287        // Initial state — every row dirty until recompute.
1288        assert!(!buf.row_clean(0));
1289        buf.recompute_line_hashes();
1290        assert!(buf.row_clean(0));
1291        // Touching the row re-marks it dirty.
1292        buf.set_string(0, 0, "z", Style::new());
1293        assert!(!buf.row_clean(0));
1294    }
1295
1296    #[test]
1297    fn identical_buffers_share_line_hashes_after_recompute() {
1298        // Foundation of the flush short-circuit: two buffers with the same
1299        // cells must produce equal per-row digests.
1300        let area = Rect::new(0, 0, 5, 3);
1301        let mut a = Buffer::empty(area);
1302        let mut b = Buffer::empty(area);
1303        a.set_string(0, 0, "hello", Style::new());
1304        b.set_string(0, 0, "hello", Style::new());
1305        a.set_string(0, 1, "world", Style::new());
1306        b.set_string(0, 1, "world", Style::new());
1307        a.recompute_line_hashes();
1308        b.recompute_line_hashes();
1309
1310        assert_eq!(a.row_hash(0), b.row_hash(0));
1311        assert_eq!(a.row_hash(1), b.row_hash(1));
1312        // Untouched row 2 — both buffers have it as default-cell row.
1313        assert_eq!(a.row_hash(2), b.row_hash(2));
1314    }
1315
1316    #[test]
1317    fn different_styles_yield_different_line_hashes() {
1318        // Identical glyph but different style must still hash distinctly —
1319        // the flush would otherwise emit the wrong style if it skipped a
1320        // "matching" row.
1321        use crate::style::Color;
1322        let area = Rect::new(0, 0, 3, 1);
1323        let mut a = Buffer::empty(area);
1324        let mut b = Buffer::empty(area);
1325        a.set_string(0, 0, "abc", Style::new().fg(Color::Red));
1326        b.set_string(0, 0, "abc", Style::new().fg(Color::Blue));
1327        a.recompute_line_hashes();
1328        b.recompute_line_hashes();
1329
1330        assert_ne!(a.row_hash(0), b.row_hash(0));
1331    }
1332
1333    #[test]
1334    fn resize_keeps_line_arrays_in_sync() {
1335        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 3));
1336        buf.recompute_line_hashes();
1337        // Grow → all rows dirty + arrays sized to new height.
1338        buf.resize(Rect::new(0, 0, 4, 5));
1339        assert_eq!(buf.line_dirty.len(), 5);
1340        assert_eq!(buf.line_hashes.len(), 5);
1341        assert!(buf.line_dirty.iter().all(|d| *d));
1342        // Shrink — same invariants.
1343        buf.resize(Rect::new(0, 0, 4, 2));
1344        assert_eq!(buf.line_dirty.len(), 2);
1345        assert_eq!(buf.line_hashes.len(), 2);
1346        assert!(buf.line_dirty.iter().all(|d| *d));
1347    }
1348}