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::fmt;
8use std::hash::{Hash, Hasher};
9use std::sync::Arc;
10
11use crate::cell::{Cell, normalize_cell_symbol};
12use crate::rect::Rect;
13use crate::style::Style;
14use unicode_segmentation::UnicodeSegmentation;
15use unicode_width::UnicodeWidthStr;
16
17/// Maximum cells allocated by one [`Buffer`].
18///
19/// At the current 64-byte `Cell` budget this limits one grid to 64 MiB and a
20/// terminal's current/previous pair to 128 MiB, before small row metadata.
21pub const MAX_BUFFER_CELLS: usize = 1_048_576;
22/// Maximum row metadata entries allocated by one [`Buffer`].
23pub const MAX_BUFFER_ROWS: usize = MAX_BUFFER_CELLS;
24
25/// Error returned by checked buffer construction and resize.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum BufferError {
28    /// The rectangle's exclusive right or bottom edge is not representable.
29    InvalidEdges,
30    /// The rectangle exceeds [`MAX_BUFFER_CELLS`].
31    CellBudgetExceeded {
32        /// Number of cells requested by the rectangle.
33        requested: u64,
34        /// Maximum cells allowed in one buffer.
35        maximum: usize,
36    },
37    /// The height exceeds [`MAX_BUFFER_ROWS`] even when cell area is zero.
38    RowBudgetExceeded {
39        /// Number of rows requested by the rectangle.
40        requested: u32,
41        /// Maximum rows allowed in one buffer.
42        maximum: usize,
43    },
44    /// The allocator rejected a bounded allocation request.
45    AllocationFailed,
46}
47
48impl fmt::Display for BufferError {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        match self {
51            Self::InvalidEdges => write!(f, "buffer rectangle edges overflow u32 coordinates"),
52            Self::CellBudgetExceeded { requested, maximum } => write!(
53                f,
54                "buffer requires {requested} cells, exceeding the {maximum}-cell budget"
55            ),
56            Self::RowBudgetExceeded { requested, maximum } => write!(
57                f,
58                "buffer requires {requested} rows, exceeding the {maximum}-row budget"
59            ),
60            Self::AllocationFailed => write!(f, "buffer allocation failed within the cell budget"),
61        }
62    }
63}
64
65impl std::error::Error for BufferError {}
66
67/// Hard cap on pixel count processed by image decode/encode paths.
68///
69/// 16_777_216 ≈ 4096×4096 — well above any sane terminal image payload,
70/// but guards 32-bit targets (WASM) from overflow and prevents a
71/// hostile `width`/`height` pair from triggering multi-GiB allocations.
72pub(crate) const MAX_IMAGE_PIXELS: u64 = 16_777_216;
73
74/// Returns `true` if `s` contains any codepoint that can trigger
75/// right-to-left or explicit bidirectional reordering under the Unicode
76/// Bidirectional Algorithm (UAX #9).
77///
78/// Pure-LTR strings (ASCII, Latin, CJK, …) return `false` and take the
79/// zero-allocation fast path in [`Buffer::set_string`]: no `String` is
80/// allocated and `unicode-bidi` is never invoked. Only strings that carry
81/// Hebrew, Arabic, Syriac, Thaana, Arabic presentation forms, or the
82/// explicit bidi control characters (RLM/LRM, RLE/LRE, RLO/LRO, PDF,
83/// RLI/LRI/FSI/PDI) need the full reorder pass.
84///
85/// This is intentionally a cheap, conservative character-class scan rather
86/// than a full UAX #9 resolution: a `true` here only *gates* the (possibly
87/// no-op) reorder, so over-inclusion costs at worst one extra reorder call,
88/// never incorrect output. Under-inclusion would silently mirror RTL text,
89/// so the ranges err toward inclusion.
90#[cfg(feature = "bidi")]
91#[inline]
92pub(crate) fn needs_bidi_reorder(s: &str) -> bool {
93    use unicode_bidi::BidiClass::{AL, FSI, LRE, LRI, LRO, PDF, PDI, R, RLE, RLI, RLO};
94
95    s.chars().any(|ch| {
96        matches!(
97            unicode_bidi::bidi_class(ch),
98            R | AL | RLE | RLO | RLI | LRE | LRO | LRI | FSI | PDI | PDF
99        )
100    })
101}
102
103/// Reorder one logical-order line into visual (display) order per UAX #9.
104///
105/// The input is treated as a single paragraph (callers already split on
106/// `\n` upstream — see [`Buffer::set_string`]). The base paragraph
107/// direction is resolved from the first strong character (no override),
108/// matching default UAX #9 behavior. Returns the visually-ordered string.
109///
110/// Only ever called after [`needs_bidi_reorder`] returns `true`, so the
111/// `String` allocation here is incurred solely on the RTL path; pure-LTR
112/// input never reaches this function.
113#[cfg(feature = "bidi")]
114fn reorder_line_visual(s: &str) -> String {
115    use unicode_bidi::BidiInfo;
116    // No paragraph override: let the first strong char set base direction.
117    let info = BidiInfo::new(s, None);
118    let Some(para) = info.paragraphs.first() else {
119        return s.to_string();
120    };
121
122    // Reorder display atoms rather than scalar values. `unicode-bidi` exposes
123    // byte-indexed levels; sampling the resolved level at each grapheme start
124    // applies UAX #9 L2 while keeping combining and ZWJ sequences atomic.
125    let resolved = info.reordered_levels(para, para.range.clone());
126    let graphemes: Vec<(usize, &str)> = s.grapheme_indices(true).collect();
127    let levels: Vec<_> = graphemes.iter().map(|(byte, _)| resolved[*byte]).collect();
128    let visual_to_logical = BidiInfo::reorder_visual(&levels);
129    let mut reordered = String::with_capacity(s.len());
130    for logical in visual_to_logical {
131        reordered.push_str(graphemes[logical].1);
132    }
133    reordered
134}
135
136/// Structured Kitty graphics protocol image placement.
137///
138/// Stored separately from raw escape sequences so the terminal can manage
139/// image IDs, compression, and placement lifecycle. Images are deduplicated
140/// by `content_hash` — identical pixel data is uploaded only once.
141#[derive(Clone, Debug)]
142#[allow(dead_code)]
143pub(crate) struct KittyPlacement {
144    /// Hash of the RGBA pixel data for dedup (avoids re-uploading).
145    pub content_hash: u64,
146    /// Reference-counted raw RGBA pixel data (shared across frames).
147    pub rgba: Arc<Vec<u8>>,
148    /// Source image width in pixels.
149    pub src_width: u32,
150    /// Source image height in pixels.
151    pub src_height: u32,
152    /// Screen cell position.
153    pub x: u32,
154    pub y: u32,
155    /// Cell columns/rows to display.
156    pub cols: u32,
157    pub rows: u32,
158    /// Source crop Y offset in pixels (for scroll clipping).
159    pub crop_y: u32,
160    /// Source crop height in pixels (0 = full height from crop_y).
161    pub crop_h: u32,
162}
163
164/// Per-cell coverage state of a [`SprixelPlacement`]'s footprint.
165///
166/// Borrowed from notcurses' sprixel damage model. Each owned cell records how a
167/// pixel graphic relates to the text cell beneath it, so the flush layer can
168/// decide whether a text write forces a re-blit of the whole graphic (issue
169/// #265). Sixel and iTerm2 (OSC 1337) graphics own a footprint of these cells;
170/// Kitty keeps its separate `KittyImageManager` lifecycle.
171///
172/// All four variants form the spec'd damage vocabulary (issue #265): the image
173/// entry points currently emit fully-`Opaque` footprints, while `Mixed` /
174/// `Transparent` are reserved for partial-coverage callers and `Annihilated`
175/// for the flush-time damage flip. The full set is exercised by the flush tests
176/// and is part of the matrix contract, so the unused-construction lint is
177/// suppressed (mirrors [`KittyPlacement`]).
178#[derive(Clone, Copy, Debug, PartialEq, Eq)]
179#[allow(dead_code)]
180pub(crate) enum SprixelCell {
181    /// Graphic fully covers the cell; a text write here forces a re-blit.
182    Opaque,
183    /// Graphic partially covers the cell; a text write here forces a re-blit.
184    Mixed,
185    /// No graphic ink in this cell; text is free and triggers no re-blit.
186    Transparent,
187    /// Text overwrote graphic ink in this cell this frame, so the owning
188    /// graphic is dirty and must be re-emitted.
189    Annihilated,
190}
191
192/// A non-Kitty pixel-graphic placement (Sixel or iTerm2 OSC 1337) tracked with
193/// a per-cell damage footprint.
194///
195/// Unlike a flat [`Buffer::raw_sequence`] entry, a sprixel records the cell
196/// footprint it covers so the flush layer can re-emit a graphic **only** when a
197/// text cell annihilates its ink or its `(x, y, content_hash)` changed, rather
198/// than re-blitting every stored sequence on any delta (issue #265).
199///
200/// `seq` / `cells` are read only by the `crossterm` flush layer
201/// (`flush_sprixels`), so the unused-field lint is suppressed for
202/// `--no-default-features` builds where that consumer is gated out (mirrors
203/// [`KittyPlacement`]).
204#[derive(Clone, Debug)]
205#[allow(dead_code)]
206pub(crate) struct SprixelPlacement {
207    /// Hash of the source bytes for change detection across frames.
208    pub content_hash: u64,
209    /// Encoded passthrough payload (Sixel `DCS` or iTerm2 OSC 1337).
210    pub seq: String,
211    /// Screen cell position of the top-left corner.
212    pub x: u32,
213    pub y: u32,
214    /// Cell columns/rows the graphic footprint covers.
215    pub cols: u32,
216    pub rows: u32,
217    /// Row-major per-cell coverage state; `cells.len() == (cols * rows)`.
218    pub cells: Vec<SprixelCell>,
219}
220
221impl PartialEq for SprixelPlacement {
222    fn eq(&self, other: &Self) -> bool {
223        // Equality drives the "did this placement change?" flush check. A
224        // re-blit is needed when position or content shifts; the per-cell
225        // damage matrix (`cells`) is recomputed each frame from the text diff
226        // and is deliberately excluded so two structurally identical
227        // placements compare equal regardless of transient annihilation state.
228        self.content_hash == other.content_hash
229            && self.x == other.x
230            && self.y == other.y
231            && self.cols == other.cols
232            && self.rows == other.rows
233    }
234}
235
236/// FNV-1a 64-bit offset basis (the standard seed for the algorithm).
237const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
238/// FNV-1a 64-bit prime multiplier.
239const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
240
241/// A tiny, allocation-free [`Hasher`] implementing the FNV-1a algorithm.
242///
243/// Used for internal dirty-row digests ([`Buffer::recompute_line_hashes`]) and
244/// RGBA content hashing ([`hash_rgba`]). Row/image equality is **not** a
245/// security boundary, so the crypto-strength SipHash that
246/// [`std::collections::hash_map::DefaultHasher`] uses is unnecessary tax in the
247/// per-frame flush loop. FNV-1a is a non-cryptographic hash with no DoS
248/// resistance, which is exactly the right trade-off here: it is faster, has no
249/// extra dependency, and is deterministic within (and across) process runs.
250/// The digest is never persisted, so cross-run stability is incidental, not
251/// relied upon.
252pub(crate) struct Fnv1a(u64);
253
254impl Default for Fnv1a {
255    #[inline]
256    fn default() -> Self {
257        Self(FNV_OFFSET_BASIS)
258    }
259}
260
261impl Hasher for Fnv1a {
262    #[inline]
263    fn finish(&self) -> u64 {
264        self.0
265    }
266
267    #[inline]
268    fn write(&mut self, bytes: &[u8]) {
269        let mut hash = self.0;
270        for &byte in bytes {
271            hash ^= byte as u64;
272            hash = hash.wrapping_mul(FNV_PRIME);
273        }
274        self.0 = hash;
275    }
276}
277
278/// Compute a content hash for RGBA pixel data.
279///
280/// Uses a non-cryptographic FNV-1a digest ([`Fnv1a`]) — image dedup is not a
281/// security boundary and the digest is never persisted.
282pub(crate) fn hash_rgba(data: &[u8]) -> u64 {
283    let mut hasher = Fnv1a::default();
284    data.hash(&mut hasher);
285    hasher.finish()
286}
287
288fn crop_kitty_horizontal(placement: &mut KittyPlacement, info: KittyHorizontalClipInfo) -> bool {
289    if info.original_width == 0 || placement.src_width == 0 || placement.src_height == 0 {
290        return false;
291    }
292    let visible_start = info.left_clip_cols.min(info.original_width);
293    let visible_end = visible_start
294        .saturating_add(placement.cols)
295        .min(info.original_width);
296    if visible_start >= visible_end {
297        return false;
298    }
299
300    let source_width = u64::from(placement.src_width);
301    let original_width = u64::from(info.original_width);
302    let start_pixel = source_width.saturating_mul(u64::from(visible_start)) / original_width;
303    let scaled_end = source_width.saturating_mul(u64::from(visible_end));
304    let end_pixel = scaled_end
305        .saturating_add(original_width.saturating_sub(1))
306        .checked_div(original_width)
307        .unwrap_or(0)
308        .min(source_width);
309    let crop_width = end_pixel.saturating_sub(start_pixel);
310    if crop_width == 0 {
311        return false;
312    }
313    if start_pixel == 0 && crop_width == source_width {
314        return true;
315    }
316
317    let Some(source_stride) = usize::try_from(source_width)
318        .ok()
319        .and_then(|width| width.checked_mul(4))
320    else {
321        return false;
322    };
323    let Some(crop_stride) = usize::try_from(crop_width)
324        .ok()
325        .and_then(|width| width.checked_mul(4))
326    else {
327        return false;
328    };
329    let Some(expected_source) = source_stride.checked_mul(placement.src_height as usize) else {
330        return false;
331    };
332    if placement.rgba.len() < expected_source {
333        return false;
334    }
335    let Some(cropped_len) = crop_stride.checked_mul(placement.src_height as usize) else {
336        return false;
337    };
338    let mut cropped = Vec::new();
339    if cropped.try_reserve_exact(cropped_len).is_err() {
340        return false;
341    }
342    let start_byte = start_pixel as usize * 4;
343    for row in 0..placement.src_height as usize {
344        let row_start = row * source_stride + start_byte;
345        cropped.extend_from_slice(&placement.rgba[row_start..row_start + crop_stride]);
346    }
347
348    placement.src_width = crop_width as u32;
349    placement.content_hash = hash_rgba(&cropped);
350    placement.rgba = Arc::new(cropped);
351    true
352}
353
354impl PartialEq for KittyPlacement {
355    fn eq(&self, other: &Self) -> bool {
356        self.content_hash == other.content_hash
357            && self.x == other.x
358            && self.y == other.y
359            && self.cols == other.cols
360            && self.rows == other.rows
361            && self.crop_y == other.crop_y
362            && self.crop_h == other.crop_h
363    }
364}
365
366/// Scroll clip information applied to Kitty image placements emitted inside a
367/// raw-draw callback.
368///
369/// Stored on a stack so that nested raw-draw regions restore the outer clip
370/// info on pop, rather than silently clobbering it.
371#[derive(Clone, Copy, Debug, PartialEq, Eq)]
372pub(crate) struct KittyClipInfo {
373    /// Rows of the source region already scrolled off the top.
374    pub top_clip_rows: u32,
375    /// Original total row count of the scrollable content.
376    pub original_height: u32,
377}
378
379/// Horizontal source crop information for Kitty placements in a raw draw.
380#[derive(Clone, Copy, Debug, PartialEq, Eq)]
381pub(crate) struct KittyHorizontalClipInfo {
382    /// Columns of the source region already clipped from the left.
383    pub left_clip_cols: u32,
384    /// Original total column count of the raw draw region.
385    pub original_width: u32,
386}
387
388/// A 2D grid of [`Cell`]s backing the terminal display.
389///
390/// Two buffers are kept (current + previous); only the diff is flushed to the
391/// terminal, giving immediate-mode ergonomics with retained-mode efficiency.
392///
393/// The buffer also maintains a clip stack. Push a [`Rect`] with
394/// [`Buffer::push_clip`] to restrict writes to that region, and pop it with
395/// [`Buffer::pop_clip`] when done.
396pub struct Buffer {
397    /// The area this buffer covers, in terminal coordinates.
398    pub area: Rect,
399    /// Flat row-major storage of all cells. Length equals `area.width * area.height`.
400    pub content: Vec<Cell>,
401    pub(crate) clip_stack: Vec<Rect>,
402    pub(crate) raw_sequences: Vec<(u32, u32, String)>,
403    /// Non-Kitty pixel-graphic placements (Sixel / iTerm2) with per-cell damage
404    /// footprints. Drives the sprixel-aware flush that re-emits a graphic only
405    /// when its ink is annihilated or its content/position changed (issue #265).
406    pub(crate) sprixels: Vec<SprixelPlacement>,
407    pub(crate) kitty_placements: Vec<KittyPlacement>,
408    pub(crate) cursor_pos: Option<(u32, u32)>,
409    /// Stack of scroll clip infos set by the run loop before invoking draw
410    /// closures. The top entry is the active clip; nested raw-draw regions
411    /// push and pop without losing the outer clip.
412    pub(crate) kitty_clip_info_stack: Vec<KittyClipInfo>,
413    /// Horizontal counterpart to `kitty_clip_info_stack`.
414    pub(crate) kitty_horizontal_clip_stack: Vec<KittyHorizontalClipInfo>,
415    /// Per-row digest of every cell on row `y`, used by `flush_buffer_diff`
416    /// to skip the per-cell scan when both the dirty flag and the hash
417    /// match the previous frame (issue #171).
418    ///
419    /// Length equals `area.height`. Stale until
420    /// [`Buffer::recompute_line_hashes`] is called — `flush_buffer_diff` is
421    /// the only call site that relies on these being up to date.
422    pub(crate) line_hashes: Vec<u64>,
423    /// Per-row dirty flag. Set by every cell-write path
424    /// ([`Buffer::set_string`], [`Buffer::set_string_linked`],
425    /// [`Buffer::set_char`], [`Buffer::reset`], [`Buffer::reset_with_bg`]).
426    /// Cleared by [`Buffer::recompute_line_hashes`] after the row hash is
427    /// refreshed.
428    ///
429    /// A `false` entry means the row has not been touched since the last
430    /// hash refresh, so `flush_buffer_diff` can short-circuit the cell
431    /// scan when its hash also matches `previous.line_hashes[y]`.
432    pub(crate) line_dirty: Vec<bool>,
433}
434
435fn checked_buffer_dimensions(area: Rect) -> Result<(usize, usize), BufferError> {
436    if !area.has_valid_edges() {
437        return Err(BufferError::InvalidEdges);
438    }
439    let requested = area.area_u64();
440    if requested > MAX_BUFFER_CELLS as u64 {
441        return Err(BufferError::CellBudgetExceeded {
442            requested,
443            maximum: MAX_BUFFER_CELLS,
444        });
445    }
446    if u64::from(area.height) > MAX_BUFFER_ROWS as u64 {
447        return Err(BufferError::RowBudgetExceeded {
448            requested: area.height,
449            maximum: MAX_BUFFER_ROWS,
450        });
451    }
452    let cells = usize::try_from(requested).map_err(|_| BufferError::CellBudgetExceeded {
453        requested,
454        maximum: MAX_BUFFER_CELLS,
455    })?;
456    let rows = usize::try_from(area.height).map_err(|_| BufferError::CellBudgetExceeded {
457        requested,
458        maximum: MAX_BUFFER_CELLS,
459    })?;
460    Ok((cells, rows))
461}
462
463fn try_repeated<T: Clone>(value: T, len: usize) -> Result<Vec<T>, BufferError> {
464    let mut values = Vec::new();
465    values
466        .try_reserve_exact(len)
467        .map_err(|_| BufferError::AllocationFailed)?;
468    values.resize(len, value);
469    Ok(values)
470}
471
472fn trim_excess_capacity<T>(values: &mut Vec<T>) {
473    const RETAIN_FACTOR: usize = 4;
474    const HEADROOM_FACTOR: usize = 2;
475
476    if values.capacity() > values.len().saturating_mul(RETAIN_FACTOR) {
477        values.shrink_to(values.len().saturating_mul(HEADROOM_FACTOR));
478    }
479}
480
481impl Buffer {
482    /// Validate rectangle edges and allocation budgets without allocating.
483    ///
484    /// Terminal backends should call this before constructing a current/previous
485    /// buffer pair so invalid geometry is rejected before either allocation.
486    pub fn validate_area(area: Rect) -> Result<(), BufferError> {
487        checked_buffer_dimensions(area).map(|_| ())
488    }
489
490    /// Create a buffer filled with blank cells covering `area`.
491    ///
492    /// # Panics
493    ///
494    /// Panics deterministically when `area` has unrepresentable edges, exceeds
495    /// [`MAX_BUFFER_CELLS`], or the bounded allocation fails. Use
496    /// [`Buffer::try_empty`] for geometry originating outside the process.
497    pub fn empty(area: Rect) -> Self {
498        Self::try_empty(area)
499            .unwrap_or_else(|error| panic!("Buffer::empty({area:?}) failed: {error}"))
500    }
501
502    /// Try to create a blank buffer without risking an oversized allocation.
503    pub fn try_empty(area: Rect) -> Result<Self, BufferError> {
504        let (size, height) = checked_buffer_dimensions(area)?;
505        Ok(Self {
506            area,
507            content: try_repeated(Cell::default(), size)?,
508            clip_stack: Vec::new(),
509            raw_sequences: Vec::new(),
510            sprixels: Vec::new(),
511            kitty_placements: Vec::new(),
512            cursor_pos: None,
513            kitty_clip_info_stack: Vec::new(),
514            kitty_horizontal_clip_stack: Vec::new(),
515            // Empty buffers start with default cells on every row; their
516            // hashes are equal across two empty buffers, so initialise to
517            // 0 with `line_dirty=true` so the first flush still recomputes.
518            line_hashes: try_repeated(0, height)?,
519            line_dirty: try_repeated(true, height)?,
520        })
521    }
522
523    /// Push a scroll clip info frame.
524    pub(crate) fn push_kitty_clip(&mut self, info: KittyClipInfo) {
525        self.kitty_clip_info_stack.push(info);
526    }
527
528    #[cfg(test)]
529    pub(crate) fn pop_kitty_clip(&mut self) -> Option<KittyClipInfo> {
530        self.kitty_clip_info_stack.pop()
531    }
532
533    /// Peek the currently active scroll clip info, if any.
534    pub(crate) fn current_kitty_clip(&self) -> Option<&KittyClipInfo> {
535        self.kitty_clip_info_stack.last()
536    }
537
538    /// Push horizontal crop metadata for a raw draw callback.
539    #[allow(dead_code)] // Called by the pending lib.rs raw-draw integration.
540    pub(crate) fn push_kitty_horizontal_clip(&mut self, info: KittyHorizontalClipInfo) {
541        self.kitty_horizontal_clip_stack.push(info);
542    }
543
544    /// Restore the previous horizontal crop metadata.
545    #[allow(dead_code)] // Called by the pending lib.rs raw-draw integration.
546    pub(crate) fn pop_kitty_horizontal_clip(&mut self) -> Option<KittyHorizontalClipInfo> {
547        self.kitty_horizontal_clip_stack.pop()
548    }
549
550    fn current_kitty_horizontal_clip(&self) -> Option<&KittyHorizontalClipInfo> {
551        self.kitty_horizontal_clip_stack.last()
552    }
553
554    pub(crate) fn set_cursor_pos(&mut self, x: u32, y: u32) {
555        self.cursor_pos = Some((x, y));
556    }
557
558    #[cfg(feature = "crossterm")]
559    pub(crate) fn cursor_pos(&self) -> Option<(u32, u32)> {
560        self.cursor_pos
561    }
562
563    /// Store a raw escape sequence to be written at position `(x, y)` during flush.
564    ///
565    /// Used for Sixel images and other passthrough sequences.
566    /// Respects the clip stack: sequences fully outside the current clip are skipped.
567    pub fn raw_sequence(&mut self, x: u32, y: u32, seq: String) {
568        if let Some(clip) = self.effective_clip()
569            && (x >= clip.right() || y >= clip.bottom())
570        {
571            return;
572        }
573        self.raw_sequences.push((x, y, seq));
574    }
575
576    /// Store a structured Kitty graphics protocol placement.
577    ///
578    /// Unlike `raw_sequence`, Kitty placements are managed with image IDs,
579    /// compression, and placement lifecycle by the terminal flush code.
580    /// Scroll crop info is automatically applied from the top of the
581    /// `kitty_clip_info_stack` (set via [`Buffer::push_kitty_clip`]).
582    pub(crate) fn kitty_place(&mut self, mut p: KittyPlacement) {
583        // Apply clip check
584        if let Some(clip) = self.effective_clip()
585            && (p.x >= clip.right()
586                || p.y >= clip.bottom()
587                || p.x.saturating_add(p.cols) <= clip.x
588                || p.y.saturating_add(p.rows) <= clip.y)
589        {
590            return;
591        }
592
593        if let Some(info) = self.current_kitty_horizontal_clip().copied()
594            && !crop_kitty_horizontal(&mut p, info)
595        {
596            return;
597        }
598
599        // Apply scroll crop info if any frame is active
600        if let Some(info) = self.current_kitty_clip() {
601            let top_clip_rows = info.top_clip_rows;
602            let original_height = info.original_height;
603            if original_height > 0 && (top_clip_rows > 0 || p.rows < original_height) {
604                let ratio = p.src_height as f64 / original_height as f64;
605                p.crop_y = (top_clip_rows as f64 * ratio) as u32;
606                let bottom_clip =
607                    original_height.saturating_sub(top_clip_rows.saturating_add(p.rows));
608                let bottom_pixels = (bottom_clip as f64 * ratio) as u32;
609                p.crop_h = p
610                    .src_height
611                    .saturating_sub(p.crop_y.saturating_add(bottom_pixels));
612            }
613        }
614
615        self.kitty_placements.push(p);
616    }
617
618    /// Store a non-Kitty pixel-graphic placement (Sixel or iTerm2 OSC 1337)
619    /// with its per-cell damage footprint.
620    ///
621    /// Respects the clip stack the same way [`Buffer::kitty_place`] does:
622    /// placements wholly outside the active clip are dropped. The footprint
623    /// `cells` are recorded as-supplied; the flush layer flips covered cells to
624    /// [`SprixelCell::Annihilated`] when a text write overwrites graphic ink so
625    /// only dirtied graphics are re-emitted (issue #265).
626    ///
627    /// Callers (`sixel_image` / `iterm_image*`) are `crossterm`-gated, so this
628    /// is unused under `--no-default-features`; the lint is suppressed only on
629    /// that build so a genuine dead-code signal still fires by default.
630    #[cfg_attr(not(feature = "crossterm"), allow(dead_code))]
631    pub(crate) fn sprixel_place(&mut self, p: SprixelPlacement) {
632        if let Some(clip) = self.effective_clip()
633            && (p.x >= clip.right()
634                || p.y >= clip.bottom()
635                || p.x.saturating_add(p.cols) <= clip.x
636                || p.y.saturating_add(p.rows) <= clip.y)
637        {
638            return;
639        }
640        self.sprixels.push(p);
641    }
642
643    /// Push a clipping rectangle onto the clip stack.
644    ///
645    /// Subsequent writes are restricted to the intersection of all active clip
646    /// regions. Nested calls intersect with the current clip, so the effective
647    /// clip can only shrink, never grow.
648    pub fn push_clip(&mut self, rect: Rect) {
649        let effective = if let Some(current) = self.clip_stack.last() {
650            intersect_rects(*current, rect)
651        } else {
652            rect
653        };
654        self.clip_stack.push(effective);
655    }
656
657    /// Pop the most recently pushed clipping rectangle.
658    ///
659    /// After this call, writes are clipped to the previous region (or
660    /// unclipped if the stack is now empty).
661    pub fn pop_clip(&mut self) {
662        self.clip_stack.pop();
663    }
664
665    fn effective_clip(&self) -> Option<&Rect> {
666        self.clip_stack.last()
667    }
668
669    #[inline]
670    fn index_of(&self, x: u32, y: u32) -> usize {
671        ((y - self.area.y) * self.area.width + (x - self.area.x)) as usize
672    }
673
674    /// Returns `true` if `(x, y)` is within the buffer's area.
675    #[inline]
676    pub fn in_bounds(&self, x: u32, y: u32) -> bool {
677        x >= self.area.x && x < self.area.right() && y >= self.area.y && y < self.area.bottom()
678    }
679
680    /// Return a reference to the cell at `(x, y)`.
681    ///
682    /// Panics if `(x, y)` is out of bounds. Use [`Buffer::try_get`] when the
683    /// coordinates may come from untrusted input.
684    #[inline]
685    pub fn get(&self, x: u32, y: u32) -> &Cell {
686        assert!(
687            self.in_bounds(x, y),
688            "Buffer::get({x}, {y}) out of bounds for area {:?}",
689            self.area
690        );
691        &self.content[self.index_of(x, y)]
692    }
693
694    /// Return a mutable reference to the cell at `(x, y)`.
695    ///
696    /// Panics if `(x, y)` is out of bounds. Use [`Buffer::try_get_mut`] when
697    /// the coordinates may come from untrusted input.
698    #[inline]
699    pub fn get_mut(&mut self, x: u32, y: u32) -> &mut Cell {
700        assert!(
701            self.in_bounds(x, y),
702            "Buffer::get_mut({x}, {y}) out of bounds for area {:?}",
703            self.area
704        );
705        let idx = self.index_of(x, y);
706        self.mark_row_dirty(y);
707        &mut self.content[idx]
708    }
709
710    /// Return a reference to the cell at `(x, y)`, or `None` if out of bounds.
711    ///
712    /// Non-panicking counterpart to [`Buffer::get`]. Prefer this inside
713    /// `draw()` closures when coordinates are computed from mouse input,
714    /// scroll offsets, or other sources that could land outside the buffer.
715    #[inline]
716    pub fn try_get(&self, x: u32, y: u32) -> Option<&Cell> {
717        if self.in_bounds(x, y) {
718            Some(&self.content[self.index_of(x, y)])
719        } else {
720            None
721        }
722    }
723
724    /// Return a mutable reference to the cell at `(x, y)`, or `None` if out
725    /// of bounds.
726    ///
727    /// Non-panicking counterpart to [`Buffer::get_mut`].
728    #[inline]
729    pub fn try_get_mut(&mut self, x: u32, y: u32) -> Option<&mut Cell> {
730        if self.in_bounds(x, y) {
731            let idx = self.index_of(x, y);
732            self.mark_row_dirty(y);
733            Some(&mut self.content[idx])
734        } else {
735            None
736        }
737    }
738
739    /// Write a string into the buffer starting at `(x, y)`.
740    ///
741    /// Respects cell boundaries and Unicode character widths. Wide characters
742    /// (e.g., CJK) occupy two columns; the trailing cell is blanked. Writes
743    /// that fall outside the current clip region are skipped but still advance
744    /// the cursor position.
745    pub fn set_string(&mut self, x: u32, y: u32, s: &str, style: Style) {
746        self.set_string_inner(x, y, s, style, None);
747    }
748
749    /// Write a hyperlinked string into the buffer starting at `(x, y)`.
750    ///
751    /// Like [`Buffer::set_string`] but attaches an OSC 8 hyperlink URL to each
752    /// cell. The terminal renders these cells as clickable links.
753    pub fn set_string_linked(&mut self, x: u32, y: u32, s: &str, style: Style, url: &str) {
754        let link = sanitize_osc8_url(url).map(compact_str::CompactString::new);
755        self.set_string_inner(x, y, s, style, link.as_ref());
756    }
757
758    /// Shared implementation for [`Self::set_string`] and
759    /// [`Self::set_string_linked`].
760    ///
761    /// `link` is `Some` only for the OSC 8 path; both paths share clip,
762    /// wide-char, and zero-width grapheme handling. Keeping a single
763    /// implementation prevents the two call sites from drifting on edge cases
764    /// (e.g., `MAX_CELL_SYMBOL_BYTES` checks, wide-char blanking).
765    fn set_string_inner(
766        &mut self,
767        mut x: u32,
768        y: u32,
769        s: &str,
770        style: Style,
771        link: Option<&compact_str::CompactString>,
772    ) {
773        if y < self.area.y || y >= self.area.bottom() {
774            return;
775        }
776        // Bidi (UAX #9) reorder: convert this logical-order line into visual
777        // (display) order before the positional cell-write loop below. The
778        // loop is purely left-to-right by column, so RTL runs must be
779        // reordered *here* or they render mirrored. `needs_bidi_reorder`
780        // gates the work so pure-LTR input neither allocates nor calls into
781        // `unicode-bidi` — its output is byte-identical to skipping this
782        // block entirely. Width/clip/zero-width/hyperlink handling below is
783        // order-independent and applies unchanged to the reordered glyphs.
784        #[cfg(feature = "bidi")]
785        let reordered;
786        #[cfg(feature = "bidi")]
787        let s: &str = if needs_bidi_reorder(s) {
788            reordered = reorder_line_visual(s);
789            &reordered
790        } else {
791            s
792        };
793        let clip = self.effective_clip().copied();
794        for grapheme in s.graphemes(true) {
795            if x >= self.area.right() {
796                break;
797            }
798            let width = self.set_grapheme_visual_inner(x, y, grapheme, style, link, clip);
799            x = x.saturating_add(width);
800        }
801    }
802
803    /// Write one already visually ordered grapheme and return its cell width.
804    ///
805    /// This is used by the styled bidi renderer after it remaps a whole line.
806    pub(crate) fn set_grapheme_visual(
807        &mut self,
808        x: u32,
809        y: u32,
810        grapheme: &str,
811        style: Style,
812        link: Option<&compact_str::CompactString>,
813    ) -> u32 {
814        let clip = self.effective_clip().copied();
815        self.set_grapheme_visual_inner(x, y, grapheme, style, link, clip)
816    }
817
818    fn set_grapheme_visual_inner(
819        &mut self,
820        x: u32,
821        y: u32,
822        grapheme: &str,
823        style: Style,
824        link: Option<&compact_str::CompactString>,
825        clip: Option<Rect>,
826    ) -> u32 {
827        let symbol = normalize_cell_symbol(grapheme);
828        let width = UnicodeWidthStr::width(symbol.as_str()) as u32;
829        if width == 0 {
830            self.append_zero_width(x, y, &symbol, clip);
831            return 0;
832        }
833
834        let Some(target_right) = x.checked_add(width) else {
835            return width;
836        };
837        if y < self.area.y
838            || y >= self.area.bottom()
839            || x < self.area.x
840            || target_right > self.area.right()
841        {
842            return width;
843        }
844
845        let (mut affected_left, mut affected_right) = (x, target_right);
846        for col in x..target_right {
847            let (old_left, old_right) = self.existing_grapheme_range(col, y);
848            affected_left = affected_left.min(old_left);
849            affected_right = affected_right.max(old_right);
850        }
851        if affected_left < self.area.x || affected_right > self.area.right() {
852            return width;
853        }
854        let fully_in_clip = clip.is_none_or(|clip| {
855            y >= clip.y
856                && y < clip.bottom()
857                && affected_left >= clip.x
858                && affected_right <= clip.right()
859        });
860        if !fully_in_clip {
861            return width;
862        }
863
864        self.mark_row_dirty(y);
865        for col in affected_left..affected_right {
866            let idx = self.index_of(col, y);
867            self.content[idx].reset();
868        }
869
870        let leading_idx = self.index_of(x, y);
871        let leading = &mut self.content[leading_idx];
872        leading.set_symbol(&symbol);
873        leading.set_style(style);
874        leading.hyperlink = link.cloned();
875        for col in x.saturating_add(1)..target_right {
876            let idx = self.index_of(col, y);
877            self.content[idx].set_continuation(style);
878            self.content[idx].hyperlink = link.cloned();
879        }
880        width
881    }
882
883    fn existing_grapheme_range(&self, x: u32, y: u32) -> (u32, u32) {
884        let mut left = x;
885        if self.content[self.index_of(x, y)].is_continuation() && x > self.area.x {
886            left = x - 1;
887        }
888        let symbol = self.content[self.index_of(left, y)].normalized_symbol();
889        let width = (UnicodeWidthStr::width(symbol.as_str()) as u32).max(1);
890        (left, left.saturating_add(width).min(self.area.right()))
891    }
892
893    fn append_zero_width(&mut self, x: u32, y: u32, suffix: &str, clip: Option<Rect>) {
894        if suffix.is_empty() || y < self.area.y || y >= self.area.bottom() || x <= self.area.x {
895            return;
896        }
897        let mut leading_x = x.saturating_sub(1).min(self.area.right().saturating_sub(1));
898        if self.content[self.index_of(leading_x, y)].is_continuation() && leading_x > self.area.x {
899            leading_x -= 1;
900        }
901        if clip.is_some_and(|clip| !clip.contains(leading_x, y)) {
902            return;
903        }
904
905        let idx = self.index_of(leading_x, y);
906        let mut combined = self.content[idx].normalized_symbol();
907        combined.push_str(suffix);
908        let normalized = normalize_cell_symbol(&combined);
909        if normalized != self.content[idx].symbol {
910            self.mark_row_dirty(y);
911            self.content[idx].symbol = normalized;
912        }
913    }
914
915    /// Write a single character at `(x, y)` with the given style.
916    ///
917    /// No-ops if `(x, y)` is out of bounds or outside the current clip region.
918    pub fn set_char(&mut self, x: u32, y: u32, ch: char, style: Style) {
919        let mut encoded = [0; 4];
920        self.set_grapheme_visual(x, y, ch.encode_utf8(&mut encoded), style, None);
921    }
922
923    /// Mark row `y` as dirty so the next flush recomputes its line hash.
924    ///
925    /// `y` is in the buffer's coordinate space (i.e. `area.y..area.bottom()`).
926    /// Out-of-range values are ignored so callers don't need to bounds-check
927    /// before invoking this on every cell write.
928    #[inline]
929    pub(crate) fn mark_row_dirty(&mut self, y: u32) {
930        if y < self.area.y {
931            return;
932        }
933        let idx = (y - self.area.y) as usize;
934        if let Some(slot) = self.line_dirty.get_mut(idx) {
935            *slot = true;
936        }
937    }
938
939    /// Recompute the per-row digest for every row currently flagged dirty.
940    ///
941    /// This is the only call site that updates [`Self::line_hashes`]; once
942    /// a row's hash is refreshed its `line_dirty` entry is cleared. Hashes
943    /// derive from each cell's `(symbol, style, hyperlink)` tuple via the
944    /// non-cryptographic [`Fnv1a`] hasher — sufficient for equality detection,
945    /// faster than SipHash in the per-frame loop, and with no extra dependency.
946    ///
947    /// Called by `flush_buffer_diff` once per frame, before the per-row
948    /// skip check (issue #171).
949    ///
950    /// Gated on `crossterm` (the only flush call site) and `test`. Without
951    /// the gate it shows as `dead_code` under `--no-default-features`.
952    #[cfg(any(feature = "crossterm", test))]
953    pub(crate) fn recompute_line_hashes(&mut self) {
954        let height = self.area.height;
955        if height == 0 {
956            return;
957        }
958        // `line_hashes` / `line_dirty` are sized at construction / resize;
959        // an interior mutation (e.g. resize before reset) could leave them
960        // out of step with `area.height`. Repair lazily here so callers
961        // never observe a stale length.
962        let expected_len = height as usize;
963        if self.line_hashes.len() != expected_len {
964            self.line_hashes.resize(expected_len, 0);
965        }
966        if self.line_dirty.len() != expected_len {
967            self.line_dirty.resize(expected_len, true);
968        }
969
970        let width = self.area.width as usize;
971        for (idx, dirty) in self.line_dirty.iter_mut().enumerate() {
972            if !*dirty {
973                continue;
974            }
975            let row_start = idx * width;
976            let row_end = row_start + width;
977            let mut hasher = Fnv1a::default();
978            for cell in &self.content[row_start..row_end] {
979                cell.symbol.as_str().hash(&mut hasher);
980                cell.style.hash(&mut hasher);
981                cell.hyperlink.as_deref().hash(&mut hasher);
982            }
983            self.line_hashes[idx] = hasher.finish();
984            *dirty = false;
985        }
986    }
987
988    /// Returns `true` if row `y` (buffer-space) was not touched since the
989    /// last [`Self::recompute_line_hashes`] call.
990    ///
991    /// Gated on `crossterm` (consumed by `flush_buffer_diff`) and `test`.
992    ///
993    /// Used by `flush_buffer_diff` to short-circuit the per-cell scan when
994    /// combined with a hash match against the previous frame (issue #171).
995    /// Out-of-range rows report as dirty so callers fall back to the
996    /// existing per-cell path on edge inputs.
997    #[inline]
998    #[cfg(any(feature = "crossterm", test))]
999    pub(crate) fn row_clean(&self, y: u32) -> bool {
1000        if y < self.area.y {
1001            return false;
1002        }
1003        let idx = (y - self.area.y) as usize;
1004        self.line_dirty
1005            .get(idx)
1006            .copied()
1007            .map(|d| !d)
1008            .unwrap_or(false)
1009    }
1010
1011    /// Read row `y`'s cached digest, or `None` if out of range.
1012    ///
1013    /// Pairs with [`Self::row_clean`] inside `flush_buffer_diff`: only the
1014    /// hash for clean rows is used as a short-circuit signal, so callers
1015    /// must check `row_clean` first.
1016    #[inline]
1017    #[cfg(any(feature = "crossterm", test))]
1018    pub(crate) fn row_hash(&self, y: u32) -> Option<u64> {
1019        if y < self.area.y {
1020            return None;
1021        }
1022        let idx = (y - self.area.y) as usize;
1023        self.line_hashes.get(idx).copied()
1024    }
1025
1026    /// Compute the diff between `self` (current) and `other` (previous).
1027    ///
1028    /// Returns `(x, y, cell)` tuples for every cell that changed. Useful for
1029    /// custom backends or tests that need to inspect changed cells directly.
1030    /// When areas, origins, or backing lengths differ, every representable cell
1031    /// in `self` is returned as a bounded full redraw; callers are responsible
1032    /// for clearing any previous-only area before applying those updates.
1033    ///
1034    /// # Allocation
1035    ///
1036    /// Allocates a new [`Vec`] on every call. For high-frequency use
1037    /// (per-frame diffing in a render loop), prefer the internal
1038    /// `flush_buffer_diff` path used by [`crate::run`], which streams updates
1039    /// directly to the backend without an intermediate `Vec`. Calling
1040    /// `diff()` on every frame in a 60 fps loop adds one heap allocation
1041    /// (sized to the changed-cell count) per frame.
1042    ///
1043    /// # Benchmarks
1044    ///
1045    /// `benches/benchmarks.rs` exercises this path in `bench_buffer_diff`.
1046    pub fn diff<'a>(&'a self, other: &'a Buffer) -> Vec<(u32, u32, &'a Cell)> {
1047        let Some(expected) = usize::try_from(self.area.area_u64()).ok() else {
1048            return Vec::new();
1049        };
1050        let len = self.content.len().min(expected);
1051        if self.area.width == 0 || len == 0 {
1052            return Vec::new();
1053        }
1054
1055        let same_geometry = self.area == other.area
1056            && self.content.len() == expected
1057            && other.content.len() == expected;
1058        let mut updates = Vec::new();
1059        for (index, cell) in self.content[..len].iter().enumerate() {
1060            let changed = !same_geometry || other.content.get(index) != Some(cell);
1061            if !changed {
1062                continue;
1063            }
1064            let row = index / self.area.width as usize;
1065            let col = index % self.area.width as usize;
1066            let x = self.area.x.saturating_add(col as u32);
1067            let y = self.area.y.saturating_add(row as u32);
1068            updates.push((x, y, cell));
1069        }
1070        updates
1071    }
1072
1073    /// Reset every cell to a blank space with default style, and clear the clip stack.
1074    pub fn reset(&mut self) {
1075        for cell in &mut self.content {
1076            cell.reset();
1077        }
1078        self.clip_stack.clear();
1079        self.raw_sequences.clear();
1080        self.sprixels.clear();
1081        self.kitty_placements.clear();
1082        self.cursor_pos = None;
1083        self.kitty_clip_info_stack.clear();
1084        self.kitty_horizontal_clip_stack.clear();
1085        // Issue #171: every row is now blank — flag them all dirty so the
1086        // next flush refreshes the digest before any skip check.
1087        self.line_dirty.fill(true);
1088    }
1089
1090    /// Reset every cell and apply a background color to all cells.
1091    pub fn reset_with_bg(&mut self, bg: crate::style::Color) {
1092        for cell in &mut self.content {
1093            cell.reset();
1094            cell.style.bg = Some(bg);
1095        }
1096        self.clip_stack.clear();
1097        self.raw_sequences.clear();
1098        self.sprixels.clear();
1099        self.kitty_placements.clear();
1100        self.cursor_pos = None;
1101        self.kitty_clip_info_stack.clear();
1102        self.kitty_horizontal_clip_stack.clear();
1103        // Issue #171: every cell was just rewritten — mark all rows dirty.
1104        self.line_dirty.fill(true);
1105    }
1106
1107    /// Resize the buffer to fit a new area, resetting all cells.
1108    ///
1109    /// If the new area is larger, new cells are initialized to blank. All
1110    /// existing content is discarded.
1111    pub fn resize(&mut self, area: Rect) {
1112        self.try_resize(area)
1113            .unwrap_or_else(|error| panic!("Buffer::resize({area:?}) failed: {error}"));
1114    }
1115
1116    /// Try to resize and reset the buffer after validating geometry and budget.
1117    /// Capacity is retained across ordinary resizes, but a downsize below one
1118    /// quarter of the high-water capacity requests a shrink to 2x headroom.
1119    pub fn try_resize(&mut self, area: Rect) -> Result<(), BufferError> {
1120        let (size, height) = checked_buffer_dimensions(area)?;
1121        self.content
1122            .try_reserve_exact(size.saturating_sub(self.content.len()))
1123            .map_err(|_| BufferError::AllocationFailed)?;
1124        self.line_hashes
1125            .try_reserve_exact(height.saturating_sub(self.line_hashes.len()))
1126            .map_err(|_| BufferError::AllocationFailed)?;
1127        self.line_dirty
1128            .try_reserve_exact(height.saturating_sub(self.line_dirty.len()))
1129            .map_err(|_| BufferError::AllocationFailed)?;
1130
1131        self.area = area;
1132        self.content.resize(size, Cell::default());
1133        // Issue #171: keep the per-row tracking arrays sized to the new
1134        // height. `reset()` re-marks every row dirty so initial values
1135        // here don't affect correctness.
1136        self.line_hashes.resize(height, 0);
1137        self.line_dirty.resize(height, true);
1138        self.reset();
1139        // A sustained downsize should not retain a pathological high-water
1140        // allocation forever. Keep up to 2x headroom once capacity exceeds 4x
1141        // the active length, avoiding churn around ordinary terminal resizes.
1142        trim_excess_capacity(&mut self.content);
1143        trim_excess_capacity(&mut self.line_hashes);
1144        trim_excess_capacity(&mut self.line_dirty);
1145        Ok(())
1146    }
1147
1148    /// Serialize the buffer into a stable, styled-snapshot format suitable for
1149    /// snapshot testing (e.g. with `insta::assert_snapshot!`).
1150    ///
1151    /// # Format
1152    ///
1153    /// One line per buffer row, joined with `\n`. Within a row, runs of cells
1154    /// that share an identical [`Style`] are grouped. The default style (no
1155    /// foreground, no background, no modifiers) emits **unannotated** text —
1156    /// no `[...]` markers. Any non-default run is wrapped:
1157    ///
1158    /// ```text
1159    /// [fg=...,bg=...,mods]"text"[/]
1160    /// ```
1161    ///
1162    /// Trailing whitespace per row is preserved in the styled segment but
1163    /// trailing default-style spaces at the end of a row are emitted verbatim
1164    /// (they are visually invisible in diffs). Empty cells render as a single
1165    /// space. The terminating `[/]` marker only appears when a styled run is
1166    /// in effect at the end of a row.
1167    ///
1168    /// # Color formatting
1169    ///
1170    /// Named palette colors use short lowercase codes:
1171    /// `reset`, `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`,
1172    /// `white`, `dark_gray`, `light_red`, `light_green`, `light_yellow`,
1173    /// `light_blue`, `light_magenta`, `light_cyan`, `light_white`. RGB colors
1174    /// emit `#rrggbb`. Indexed palette colors emit `idx<N>` (decimal).
1175    ///
1176    /// # Modifier formatting
1177    ///
1178    /// Modifiers are emitted as comma-separated lowercase tokens in a fixed
1179    /// canonical order: `bold`, `dim`, `italic`, `underline`, `reversed`,
1180    /// `strikethrough`. Order is independent of the bit pattern, so two
1181    /// equivalent `Modifiers` values always serialize identically.
1182    ///
1183    /// # Stability
1184    ///
1185    /// The output format is stable across patch and minor versions of SLT.
1186    /// Names use a hand-rolled formatter (not `Debug`) so derives changing
1187    /// upstream cannot accidentally break locked snapshots. A breaking change
1188    /// to the format would be reserved for a major version bump.
1189    ///
1190    /// # Determinism
1191    ///
1192    /// Identical input buffers always produce byte-equal output. This is a
1193    /// hard requirement — snapshot tests rely on it.
1194    ///
1195    /// # Example
1196    ///
1197    /// ```
1198    /// use slt::{Buffer, Color, Rect, Style};
1199    ///
1200    /// let mut buf = Buffer::empty(Rect::new(0, 0, 5, 1));
1201    /// buf.set_string(0, 0, "ab", Style::new().fg(Color::Red).bold());
1202    /// buf.set_string(2, 0, "cd", Style::new());
1203    /// let snap = buf.snapshot_format();
1204    /// assert!(snap.starts_with("[fg=red,bold]\"ab\"[/]cd"));
1205    /// ```
1206    pub fn snapshot_format(&self) -> String {
1207        let mut out = String::new();
1208        let width = self.area.width;
1209        let height = self.area.height;
1210        if width == 0 || height == 0 {
1211            return out;
1212        }
1213
1214        for y in self.area.y..self.area.bottom() {
1215            if y > self.area.y {
1216                out.push('\n');
1217            }
1218
1219            // Walk the row, grouping consecutive cells by Style.
1220            let mut current_style: Option<Style> = None;
1221            let mut run_text = String::new();
1222
1223            for x in self.area.x..self.area.right() {
1224                let cell = self.get(x, y);
1225                let style = cell.style;
1226                // Empty cell symbol → single space (e.g. trailing wide-char cell).
1227                let sym: &str = if cell.symbol.is_empty() {
1228                    " "
1229                } else {
1230                    cell.symbol.as_str()
1231                };
1232
1233                match current_style {
1234                    Some(s) if s == style => {
1235                        run_text.push_str(sym);
1236                    }
1237                    _ => {
1238                        if let Some(s) = current_style.take() {
1239                            flush_run(&mut out, s, &run_text);
1240                            run_text.clear();
1241                        }
1242                        current_style = Some(style);
1243                        run_text.push_str(sym);
1244                    }
1245                }
1246            }
1247
1248            if let Some(s) = current_style {
1249                flush_run(&mut out, s, &run_text);
1250            }
1251        }
1252
1253        out
1254    }
1255}
1256
1257/// Flush a single style-run into the snapshot output.
1258///
1259/// Default style → unannotated raw text (no markers, escape only embedded `"`).
1260/// Non-default style → `[fg=...,bg=...,mods]"text"[/]` form. Embedded `"` and
1261/// `\` characters in cell symbols are escaped so the snapshot remains
1262/// unambiguous.
1263fn flush_run(out: &mut String, style: Style, text: &str) {
1264    if style == Style::default() {
1265        out.push_str(text);
1266        return;
1267    }
1268    out.push('[');
1269    let mut first = true;
1270    if let Some(fg) = style.fg {
1271        out.push_str("fg=");
1272        write_color(out, fg);
1273        first = false;
1274    }
1275    if let Some(bg) = style.bg {
1276        if !first {
1277            out.push(',');
1278        }
1279        out.push_str("bg=");
1280        write_color(out, bg);
1281        first = false;
1282    }
1283    let mods = style.modifiers;
1284    // Canonical order: bold, dim, italic, underline, reversed, strikethrough.
1285    let pairs: [(crate::style::Modifiers, &str); 6] = [
1286        (crate::style::Modifiers::BOLD, "bold"),
1287        (crate::style::Modifiers::DIM, "dim"),
1288        (crate::style::Modifiers::ITALIC, "italic"),
1289        (crate::style::Modifiers::UNDERLINE, "underline"),
1290        (crate::style::Modifiers::REVERSED, "reversed"),
1291        (crate::style::Modifiers::STRIKETHROUGH, "strikethrough"),
1292    ];
1293    for (bit, name) in pairs {
1294        if mods.contains(bit) {
1295            if !first {
1296                out.push(',');
1297            }
1298            out.push_str(name);
1299            first = false;
1300        }
1301    }
1302    out.push(']');
1303    out.push('"');
1304    for ch in text.chars() {
1305        match ch {
1306            '"' => out.push_str("\\\""),
1307            '\\' => out.push_str("\\\\"),
1308            other => out.push(other),
1309        }
1310    }
1311    out.push('"');
1312    out.push_str("[/]");
1313}
1314
1315/// Format a [`crate::style::Color`] using the stable snapshot vocabulary.
1316///
1317/// Hand-rolled instead of `Debug` so upstream derive changes can't silently
1318/// break snapshot stability.
1319fn write_color(out: &mut String, color: crate::style::Color) {
1320    use crate::style::Color;
1321    match color {
1322        Color::Reset => out.push_str("reset"),
1323        Color::Black => out.push_str("black"),
1324        Color::Red => out.push_str("red"),
1325        Color::Green => out.push_str("green"),
1326        Color::Yellow => out.push_str("yellow"),
1327        Color::Blue => out.push_str("blue"),
1328        Color::Magenta => out.push_str("magenta"),
1329        Color::Cyan => out.push_str("cyan"),
1330        Color::White => out.push_str("white"),
1331        Color::DarkGray => out.push_str("dark_gray"),
1332        Color::LightRed => out.push_str("light_red"),
1333        Color::LightGreen => out.push_str("light_green"),
1334        Color::LightYellow => out.push_str("light_yellow"),
1335        Color::LightBlue => out.push_str("light_blue"),
1336        Color::LightMagenta => out.push_str("light_magenta"),
1337        Color::LightCyan => out.push_str("light_cyan"),
1338        Color::LightWhite => out.push_str("light_white"),
1339        Color::Rgb(r, g, b) => {
1340            use std::fmt::Write;
1341            let _ = write!(out, "#{r:02x}{g:02x}{b:02x}");
1342        }
1343        Color::Indexed(idx) => {
1344            use std::fmt::Write;
1345            let _ = write!(out, "idx{idx}");
1346        }
1347    }
1348}
1349
1350/// Maximum byte length for OSC 8 hyperlink URLs.
1351///
1352/// Longer than any legitimate URL and enough to prevent DoS via
1353/// balloon-sized hyperlinks. Shared by [`is_valid_osc8_url`] and
1354/// [`sanitize_osc8_url`] so both gates agree on acceptance.
1355const MAX_OSC8_URL_BYTES: usize = 2048;
1356
1357/// Returns `true` if `url` is safe to emit as an OSC 8 hyperlink payload.
1358///
1359/// Equivalent to `sanitize_osc8_url(url).is_some()` but avoids the `String`
1360/// allocation when callers only need a boolean validity check (e.g.,
1361/// defense-in-depth validation of a public `Cell::hyperlink` field on the
1362/// flush path).
1363#[inline]
1364pub(crate) fn is_valid_osc8_url(url: &str) -> bool {
1365    if url.is_empty() || url.len() > MAX_OSC8_URL_BYTES {
1366        return false;
1367    }
1368    // Reject all C0 controls (incl. BEL 0x07, ESC 0x1b), DEL 0x7f, and
1369    // anything below 0x20. ESC enables the ST (ESC \) terminator trick;
1370    // BEL is the legacy OSC terminator. Either would let an
1371    // attacker-controlled URL prematurely close the OSC 8 sequence and
1372    // inject arbitrary follow-up commands (e.g., OSC 52 clipboard writes).
1373    url.bytes().all(|b| b >= 0x20 && b != 0x7f)
1374}
1375
1376/// Validate an OSC 8 hyperlink URL, returning `Some(url)` if safe to emit.
1377///
1378/// Rejects URLs containing control bytes, the BEL terminator, or an
1379/// embedded ST (`ESC \`). Those would let an attacker-controlled URL
1380/// prematurely close the OSC 8 sequence and inject arbitrary follow-up
1381/// commands (e.g., OSC 52 clipboard writes). Also caps length at
1382/// [`MAX_OSC8_URL_BYTES`] (2048).
1383///
1384/// For boolean validation (no allocation), use [`is_valid_osc8_url`].
1385pub(crate) fn sanitize_osc8_url(url: &str) -> Option<String> {
1386    if is_valid_osc8_url(url) {
1387        Some(url.to_string())
1388    } else {
1389        None
1390    }
1391}
1392
1393fn intersect_rects(a: Rect, b: Rect) -> Rect {
1394    let x = a.x.max(b.x);
1395    let y = a.y.max(b.y);
1396    let right = a.right().min(b.right());
1397    let bottom = a.bottom().min(b.bottom());
1398    let width = right.saturating_sub(x);
1399    let height = bottom.saturating_sub(y);
1400    Rect::new(x, y, width, height)
1401}
1402
1403#[cfg(test)]
1404mod tests {
1405    use super::*;
1406    use crate::cell::MAX_CELL_SYMBOL_BYTES;
1407
1408    #[test]
1409    fn clip_stack_intersects_nested_regions() {
1410        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 5));
1411        buf.push_clip(Rect::new(1, 1, 6, 3));
1412        buf.push_clip(Rect::new(4, 0, 6, 4));
1413
1414        buf.set_char(3, 2, 'x', Style::new());
1415        buf.set_char(4, 2, 'y', Style::new());
1416
1417        assert_eq!(buf.get(3, 2).symbol, " ");
1418        assert_eq!(buf.get(4, 2).symbol, "y");
1419    }
1420
1421    #[test]
1422    fn set_string_advances_even_when_clipped() {
1423        let mut buf = Buffer::empty(Rect::new(0, 0, 8, 1));
1424        buf.push_clip(Rect::new(2, 0, 6, 1));
1425
1426        buf.set_string(0, 0, "abcd", Style::new());
1427
1428        assert_eq!(buf.get(2, 0).symbol, "c");
1429        assert_eq!(buf.get(3, 0).symbol, "d");
1430    }
1431
1432    #[test]
1433    fn pop_clip_restores_previous_clip() {
1434        let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
1435        buf.push_clip(Rect::new(0, 0, 2, 1));
1436        buf.push_clip(Rect::new(4, 0, 2, 1));
1437
1438        buf.set_char(1, 0, 'a', Style::new());
1439        buf.pop_clip();
1440        buf.set_char(1, 0, 'b', Style::new());
1441
1442        assert_eq!(buf.get(1, 0).symbol, "b");
1443    }
1444
1445    #[test]
1446    fn reset_clears_clip_stack() {
1447        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
1448        buf.push_clip(Rect::new(0, 0, 0, 0));
1449        buf.reset();
1450        buf.set_char(0, 0, 'z', Style::new());
1451
1452        assert_eq!(buf.get(0, 0).symbol, "z");
1453    }
1454
1455    #[test]
1456    fn set_string_replaces_control_chars_with_replacement() {
1457        let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
1458        // ESC must never land in a cell — a flushed ESC would let the
1459        // string escape its cell and execute as a real terminal command.
1460        buf.set_string(0, 0, "a\x1bbc", Style::new());
1461        assert_eq!(buf.get(0, 0).symbol, "a");
1462        assert_eq!(buf.get(1, 0).symbol, "\u{FFFD}");
1463        assert_eq!(buf.get(2, 0).symbol, "b");
1464        assert_eq!(buf.get(3, 0).symbol, "c");
1465    }
1466
1467    #[test]
1468    fn zero_width_combining_does_not_append_control_bytes() {
1469        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
1470        buf.set_char(0, 0, 'a', Style::new());
1471        // BEL is zero-width per unicode_width; the pre-fix code would have
1472        // pushed it onto cell(0,0).symbol. After sanitize_cell_char it is
1473        // replaced with U+FFFD and then appended (width 1, still fits).
1474        buf.set_string(1, 0, "\x07", Style::new());
1475        let symbol = buf.get(1, 0).symbol.as_str();
1476        assert!(!symbol.contains('\x07'), "BEL leaked into cell symbol");
1477    }
1478
1479    #[test]
1480    fn set_string_caps_combining_overflow() {
1481        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
1482        buf.set_char(0, 0, 'a', Style::new());
1483        // 200 copies of an ASCII-printable zero-width-ish char would bypass
1484        // the byte cap. Use a legitimate zero-width combining character —
1485        // U+0301 (combining acute accent) — and confirm the cap kicks in.
1486        let combining: String = "\u{0301}".repeat(200);
1487        buf.set_string(1, 0, &combining, Style::new());
1488        assert!(
1489            buf.get(0, 0).symbol.len() <= MAX_CELL_SYMBOL_BYTES,
1490            "cell symbol exceeded MAX_CELL_SYMBOL_BYTES cap"
1491        );
1492    }
1493
1494    #[test]
1495    fn sanitize_osc8_url_rejects_control_chars_and_esc() {
1496        assert!(sanitize_osc8_url("https://example.com").is_some());
1497        assert!(sanitize_osc8_url("https://example.com?q=1&r=2").is_some());
1498        // BEL — terminates OSC, would let follow-up text be interpreted.
1499        assert!(sanitize_osc8_url("https://example.com\x07attack").is_none());
1500        // ESC — can open ST (ESC \) or another OSC.
1501        assert!(sanitize_osc8_url("https://example.com\x1b]52;c;hi\x1b\\").is_none());
1502        // Empty / oversize.
1503        assert!(sanitize_osc8_url("").is_none());
1504        assert!(sanitize_osc8_url(&"a".repeat(2049)).is_none());
1505    }
1506
1507    #[test]
1508    fn is_valid_osc8_url_matches_sanitize() {
1509        // is_valid_osc8_url must agree with sanitize_osc8_url on every input.
1510        // If the two ever drift, the OSC 8 flush path either rejects
1511        // legitimate URLs (silent) or admits dangerous ones (security).
1512        let oversize = "x".repeat(2049);
1513        let cases: &[&str] = &[
1514            "https://example.com",
1515            "http://localhost:8080/path?q=1#frag",
1516            "ftp://[::1]/file",
1517            "",
1518            &oversize,
1519            "https://evil.com\x1b]52;c;inject\x1b\\",
1520            "https://evil.com\x07bel",
1521            "https://example.com\x7f",
1522            "https://example.com\x00",
1523        ];
1524        for url in cases {
1525            assert_eq!(
1526                is_valid_osc8_url(url),
1527                sanitize_osc8_url(url).is_some(),
1528                "is_valid_osc8_url and sanitize_osc8_url disagree on {url:?}"
1529            );
1530        }
1531    }
1532
1533    #[test]
1534    fn set_string_inner_parity_no_link() {
1535        // set_string and set_string_linked with an invalid URL must produce
1536        // identical buffer state (link rejected → None).
1537        let area = Rect::new(0, 0, 20, 1);
1538        let mut buf_a = Buffer::empty(area);
1539        let mut buf_b = Buffer::empty(area);
1540        let style = Style::new();
1541
1542        buf_a.set_string(0, 0, "Hello wide世界", style);
1543        buf_b.set_string_linked(0, 0, "Hello wide世界", style, "");
1544
1545        for x in 0..20 {
1546            let ca = buf_a.get(x, 0);
1547            let cb = buf_b.get(x, 0);
1548            assert_eq!(ca.symbol, cb.symbol, "symbol mismatch at x={x}");
1549            assert_eq!(ca.style, cb.style, "style mismatch at x={x}");
1550            assert_eq!(
1551                cb.hyperlink, None,
1552                "invalid URL must produce None hyperlink at x={x}"
1553            );
1554        }
1555    }
1556
1557    #[test]
1558    fn set_string_linked_attaches_hyperlink_to_wide_char_pair() {
1559        // Wide chars span two cells; both must carry the same hyperlink.
1560        let area = Rect::new(0, 0, 4, 1);
1561        let mut buf = Buffer::empty(area);
1562        buf.set_string_linked(0, 0, "世", Style::new(), "https://example.com");
1563        let leading = buf.get(0, 0);
1564        let trailing = buf.get(1, 0);
1565        assert_eq!(leading.symbol, "世");
1566        assert!(trailing.symbol.is_empty(), "wide-char trailing must blank");
1567        assert!(leading.hyperlink.is_some());
1568        assert_eq!(leading.hyperlink, trailing.hyperlink);
1569    }
1570
1571    #[test]
1572    fn try_get_out_of_bounds_returns_none() {
1573        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
1574        assert!(buf.try_get(0, 0).is_some());
1575        assert!(buf.try_get(2, 0).is_none());
1576        assert!(buf.try_get(0, 2).is_none());
1577        assert!(buf.try_get_mut(5, 5).is_none());
1578    }
1579
1580    #[test]
1581    fn kitty_clip_stack_restores_outer_on_pop() {
1582        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 4));
1583        assert!(buf.current_kitty_clip().is_none());
1584
1585        let outer = KittyClipInfo {
1586            top_clip_rows: 2,
1587            original_height: 10,
1588        };
1589        let inner = KittyClipInfo {
1590            top_clip_rows: 5,
1591            original_height: 20,
1592        };
1593
1594        buf.push_kitty_clip(outer);
1595        assert_eq!(buf.current_kitty_clip(), Some(&outer));
1596
1597        // Nested region pushes its own frame.
1598        buf.push_kitty_clip(inner);
1599        assert_eq!(buf.current_kitty_clip(), Some(&inner));
1600
1601        // After inner pops, outer MUST still be active — the bug this
1602        // refactor fixes is exactly that the outer was previously clobbered.
1603        let popped_inner = buf.pop_kitty_clip();
1604        assert_eq!(popped_inner, Some(inner));
1605        assert_eq!(buf.current_kitty_clip(), Some(&outer));
1606
1607        let popped_outer = buf.pop_kitty_clip();
1608        assert_eq!(popped_outer, Some(outer));
1609        assert!(buf.current_kitty_clip().is_none());
1610    }
1611
1612    #[test]
1613    fn kitty_clip_stack_cleared_on_reset() {
1614        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
1615        buf.push_kitty_clip(KittyClipInfo {
1616            top_clip_rows: 1,
1617            original_height: 2,
1618        });
1619        buf.push_kitty_clip(KittyClipInfo {
1620            top_clip_rows: 3,
1621            original_height: 4,
1622        });
1623        buf.reset();
1624        assert!(buf.kitty_clip_info_stack.is_empty());
1625        assert!(buf.current_kitty_clip().is_none());
1626    }
1627
1628    #[test]
1629    fn kitty_clip_pop_on_empty_stack_is_none() {
1630        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
1631        assert!(buf.pop_kitty_clip().is_none());
1632    }
1633
1634    #[test]
1635    fn kitty_horizontal_clip_crops_source_pixels_to_visible_columns() {
1636        let rgba = Arc::new(vec![
1637            255, 0, 0, 255, // red
1638            0, 255, 0, 255, // green
1639            0, 0, 255, 255, // blue
1640            255, 255, 255, 255, // white
1641        ]);
1642        let placement = KittyPlacement {
1643            content_hash: hash_rgba(&rgba),
1644            rgba,
1645            src_width: 4,
1646            src_height: 1,
1647            x: 0,
1648            y: 0,
1649            cols: 2,
1650            rows: 1,
1651            crop_y: 0,
1652            crop_h: 0,
1653        };
1654        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
1655        buf.push_kitty_horizontal_clip(KittyHorizontalClipInfo {
1656            left_clip_cols: 1,
1657            original_width: 4,
1658        });
1659        buf.kitty_place(placement);
1660
1661        let cropped = &buf.kitty_placements[0];
1662        assert_eq!(cropped.src_width, 2);
1663        assert_eq!(cropped.rgba.as_slice(), &[0, 255, 0, 255, 0, 0, 255, 255]);
1664        assert!(buf.pop_kitty_horizontal_clip().is_some());
1665    }
1666
1667    // ---- snapshot_format tests (#231) -------------------------------------
1668
1669    #[test]
1670    fn snapshot_format_default_style_unannotated() {
1671        let mut buf = Buffer::empty(Rect::new(0, 0, 5, 1));
1672        buf.set_string(0, 0, "abc", Style::new());
1673        // Two trailing default cells render as raw spaces.
1674        assert_eq!(buf.snapshot_format(), "abc  ");
1675    }
1676
1677    #[test]
1678    fn snapshot_format_color_runs_grouped() {
1679        use crate::style::Color;
1680        let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
1681        buf.set_string(0, 0, "abc", Style::new().fg(Color::Red));
1682        buf.set_string(3, 0, "def", Style::new().fg(Color::Blue));
1683        let snap = buf.snapshot_format();
1684        assert_eq!(snap, "[fg=red]\"abc\"[/][fg=blue]\"def\"[/]");
1685    }
1686
1687    #[test]
1688    fn snapshot_format_modifier_transitions() {
1689        let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
1690        buf.set_string(0, 0, "ab", Style::new().bold());
1691        // gap with default style
1692        buf.set_string(2, 0, "cd", Style::new());
1693        buf.set_string(4, 0, "ef", Style::new().bold());
1694        let snap = buf.snapshot_format();
1695        assert_eq!(snap, "[bold]\"ab\"[/]cd[bold]\"ef\"[/]");
1696    }
1697
1698    #[test]
1699    fn snapshot_format_deterministic() {
1700        use crate::style::Color;
1701        let mut buf = Buffer::empty(Rect::new(0, 0, 8, 2));
1702        buf.set_string(0, 0, "hello", Style::new().fg(Color::Cyan).bold());
1703        buf.set_string(0, 1, "world", Style::new().bg(Color::Rgb(10, 20, 30)));
1704        let a = buf.snapshot_format();
1705        let b = buf.snapshot_format();
1706        assert_eq!(a, b, "snapshot_format must be deterministic");
1707        // Verify byte length equality as a stronger anti-flake guarantee.
1708        assert_eq!(a.len(), b.len());
1709    }
1710
1711    #[test]
1712    fn snapshot_format_empty_buffer_is_spaces() {
1713        let buf = Buffer::empty(Rect::new(0, 0, 4, 2));
1714        // 4 default-style spaces per row, joined by '\n'.
1715        assert_eq!(buf.snapshot_format(), "    \n    ");
1716    }
1717
1718    #[test]
1719    fn snapshot_format_zero_dim_returns_empty() {
1720        let buf_a = Buffer::empty(Rect::new(0, 0, 0, 4));
1721        let buf_b = Buffer::empty(Rect::new(0, 0, 4, 0));
1722        assert_eq!(buf_a.snapshot_format(), "");
1723        assert_eq!(buf_b.snapshot_format(), "");
1724    }
1725
1726    #[test]
1727    fn snapshot_format_rgb_uses_hex_codes() {
1728        use crate::style::Color;
1729        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
1730        buf.set_string(0, 0, "x", Style::new().fg(Color::Rgb(0xff, 0x00, 0xab)));
1731        let snap = buf.snapshot_format();
1732        assert!(
1733            snap.contains("fg=#ff00ab"),
1734            "expected hex RGB code, got {snap:?}"
1735        );
1736    }
1737
1738    #[test]
1739    fn snapshot_format_indexed_color() {
1740        use crate::style::Color;
1741        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
1742        buf.set_string(0, 0, "x", Style::new().fg(Color::Indexed(42)));
1743        assert!(buf.snapshot_format().contains("fg=idx42"));
1744    }
1745
1746    #[test]
1747    fn snapshot_format_modifiers_canonical_order() {
1748        // Insert in reverse order; output must still be canonical.
1749        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
1750        let style = Style::new().strikethrough().italic().bold();
1751        buf.set_string(0, 0, "x", style);
1752        let snap = buf.snapshot_format();
1753        // Order in output: bold, italic, strikethrough.
1754        let bold_idx = snap.find("bold").expect("bold present");
1755        let italic_idx = snap.find("italic").expect("italic present");
1756        let strike_idx = snap.find("strikethrough").expect("strikethrough present");
1757        assert!(bold_idx < italic_idx);
1758        assert!(italic_idx < strike_idx);
1759    }
1760
1761    #[test]
1762    fn snapshot_format_escapes_quote_and_backslash() {
1763        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
1764        buf.set_string(0, 0, "a\"b\\", Style::new().bold());
1765        let snap = buf.snapshot_format();
1766        // Embedded quote → \" and backslash → \\
1767        assert!(
1768            snap.contains("\"a\\\"b\\\\\""),
1769            "expected escapes, got {snap:?}"
1770        );
1771    }
1772
1773    #[test]
1774    fn snapshot_format_multi_row_uses_newlines() {
1775        let mut buf = Buffer::empty(Rect::new(0, 0, 3, 3));
1776        buf.set_string(0, 0, "aaa", Style::new());
1777        buf.set_string(0, 1, "bbb", Style::new());
1778        buf.set_string(0, 2, "ccc", Style::new());
1779        assert_eq!(buf.snapshot_format(), "aaa\nbbb\nccc");
1780    }
1781
1782    // ---- per-row hash skip (#171) -----------------------------------------
1783
1784    #[test]
1785    fn line_dirty_initial_state_is_all_dirty() {
1786        // Fresh buffer must start with every row dirty so the first flush
1787        // refreshes hashes before the per-row skip ever fires.
1788        let buf = Buffer::empty(Rect::new(0, 0, 4, 3));
1789        assert_eq!(buf.line_dirty.len(), 3);
1790        assert!(buf.line_dirty.iter().all(|d| *d));
1791    }
1792
1793    #[test]
1794    fn set_string_marks_row_dirty() {
1795        // After a recompute every row is clean. A subsequent write must
1796        // re-mark the touched row as dirty so its hash gets refreshed.
1797        let mut buf = Buffer::empty(Rect::new(0, 0, 8, 4));
1798        buf.recompute_line_hashes();
1799        assert!(buf.line_dirty.iter().all(|d| !*d));
1800
1801        buf.set_string(0, 1, "hello", Style::new());
1802        assert!(!buf.line_dirty[0]);
1803        assert!(buf.line_dirty[1]);
1804        assert!(!buf.line_dirty[2]);
1805        assert!(!buf.line_dirty[3]);
1806    }
1807
1808    #[test]
1809    fn set_char_marks_row_dirty() {
1810        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 3));
1811        buf.recompute_line_hashes();
1812        buf.set_char(2, 2, 'X', Style::new());
1813        assert!(!buf.line_dirty[0]);
1814        assert!(!buf.line_dirty[1]);
1815        assert!(buf.line_dirty[2]);
1816    }
1817
1818    #[test]
1819    fn recompute_line_hashes_clears_dirty_and_caches_hashes() {
1820        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 2));
1821        buf.set_string(0, 0, "abcd", Style::new());
1822        buf.set_string(0, 1, "wxyz", Style::new());
1823        buf.recompute_line_hashes();
1824
1825        assert!(buf.line_dirty.iter().all(|d| !*d));
1826        // Different content → different hashes.
1827        assert_ne!(buf.line_hashes[0], buf.line_hashes[1]);
1828        assert!(buf.row_clean(0));
1829        assert!(buf.row_clean(1));
1830    }
1831
1832    #[test]
1833    fn row_clean_returns_false_for_unrecomputed_or_dirty_row() {
1834        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 2));
1835        // Initial state — every row dirty until recompute.
1836        assert!(!buf.row_clean(0));
1837        buf.recompute_line_hashes();
1838        assert!(buf.row_clean(0));
1839        // Touching the row re-marks it dirty.
1840        buf.set_string(0, 0, "z", Style::new());
1841        assert!(!buf.row_clean(0));
1842    }
1843
1844    #[test]
1845    fn identical_buffers_share_line_hashes_after_recompute() {
1846        // Foundation of the flush short-circuit: two buffers with the same
1847        // cells must produce equal per-row digests.
1848        let area = Rect::new(0, 0, 5, 3);
1849        let mut a = Buffer::empty(area);
1850        let mut b = Buffer::empty(area);
1851        a.set_string(0, 0, "hello", Style::new());
1852        b.set_string(0, 0, "hello", Style::new());
1853        a.set_string(0, 1, "world", Style::new());
1854        b.set_string(0, 1, "world", Style::new());
1855        a.recompute_line_hashes();
1856        b.recompute_line_hashes();
1857
1858        assert_eq!(a.row_hash(0), b.row_hash(0));
1859        assert_eq!(a.row_hash(1), b.row_hash(1));
1860        // Untouched row 2 — both buffers have it as default-cell row.
1861        assert_eq!(a.row_hash(2), b.row_hash(2));
1862    }
1863
1864    #[test]
1865    fn different_styles_yield_different_line_hashes() {
1866        // Identical glyph but different style must still hash distinctly —
1867        // the flush would otherwise emit the wrong style if it skipped a
1868        // "matching" row.
1869        use crate::style::Color;
1870        let area = Rect::new(0, 0, 3, 1);
1871        let mut a = Buffer::empty(area);
1872        let mut b = Buffer::empty(area);
1873        a.set_string(0, 0, "abc", Style::new().fg(Color::Red));
1874        b.set_string(0, 0, "abc", Style::new().fg(Color::Blue));
1875        a.recompute_line_hashes();
1876        b.recompute_line_hashes();
1877
1878        assert_ne!(a.row_hash(0), b.row_hash(0));
1879    }
1880
1881    #[test]
1882    fn resize_keeps_line_arrays_in_sync() {
1883        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 3));
1884        buf.recompute_line_hashes();
1885        // Grow → all rows dirty + arrays sized to new height.
1886        buf.resize(Rect::new(0, 0, 4, 5));
1887        assert_eq!(buf.line_dirty.len(), 5);
1888        assert_eq!(buf.line_hashes.len(), 5);
1889        assert!(buf.line_dirty.iter().all(|d| *d));
1890        // Shrink — same invariants.
1891        buf.resize(Rect::new(0, 0, 4, 2));
1892        assert_eq!(buf.line_dirty.len(), 2);
1893        assert_eq!(buf.line_hashes.len(), 2);
1894        assert!(buf.line_dirty.iter().all(|d| *d));
1895    }
1896
1897    #[test]
1898    fn checked_construction_rejects_budget_and_edge_overflow() {
1899        let oversized = Rect::new(0, 0, MAX_BUFFER_CELLS as u32 + 1, 1);
1900        assert!(matches!(
1901            Buffer::try_empty(oversized),
1902            Err(BufferError::CellBudgetExceeded {
1903                requested,
1904                maximum,
1905            }) if requested == MAX_BUFFER_CELLS as u64 + 1 && maximum == MAX_BUFFER_CELLS
1906        ));
1907        assert!(matches!(
1908            Buffer::try_empty(Rect::new(u32::MAX, 0, 1, 1)),
1909            Err(BufferError::InvalidEdges)
1910        ));
1911        assert!(matches!(
1912            Buffer::try_empty(Rect::new(0, 0, 0, u32::MAX)),
1913            Err(BufferError::RowBudgetExceeded { .. })
1914        ));
1915        assert!(Buffer::validate_area(Rect::new(12, 34, 80, 24)).is_ok());
1916    }
1917
1918    #[test]
1919    fn failed_checked_resize_preserves_existing_geometry_and_content() {
1920        let mut buf = Buffer::empty(Rect::new(7, 9, 4, 2));
1921        buf.set_string(7, 9, "safe", Style::new());
1922
1923        let result = buf.try_resize(Rect::new(0, 0, MAX_BUFFER_CELLS as u32 + 1, 1));
1924        assert!(matches!(
1925            result,
1926            Err(BufferError::CellBudgetExceeded { .. })
1927        ));
1928        assert_eq!(buf.area, Rect::new(7, 9, 4, 2));
1929        assert_eq!(buf.get(7, 9).symbol, "s");
1930    }
1931
1932    #[test]
1933    fn nonzero_origin_string_writes_clip_on_all_four_edges() {
1934        let mut buf = Buffer::empty(Rect::new(10, 20, 4, 2));
1935        buf.set_string(8, 20, "abcd", Style::new());
1936        buf.set_string(10, 19, "top", Style::new());
1937        buf.set_string(10, 22, "bottom", Style::new());
1938
1939        assert_eq!(buf.get(10, 20).symbol, "c");
1940        assert_eq!(buf.get(11, 20).symbol, "d");
1941        assert_eq!(buf.get(10, 21).symbol, " ");
1942    }
1943
1944    #[test]
1945    fn diff_with_different_origins_and_sizes_is_a_bounded_full_redraw() {
1946        let mut current = Buffer::empty(Rect::new(10, 20, 3, 2));
1947        current.set_string(10, 20, "abc", Style::new());
1948        let previous = Buffer::empty(Rect::new(0, 0, 1, 1));
1949
1950        let updates = current.diff(&previous);
1951        assert_eq!(updates.len(), current.content.len());
1952        assert_eq!((updates[0].0, updates[0].1), (10, 20));
1953        assert_eq!((updates[5].0, updates[5].1), (12, 21));
1954    }
1955
1956    #[test]
1957    fn zwj_grapheme_is_atomic_and_marks_continuation_cells() {
1958        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
1959        buf.set_string(0, 0, "👩‍💻x", Style::new());
1960
1961        assert_eq!(buf.get(0, 0).symbol, "👩‍💻");
1962        assert!(buf.get(1, 0).is_continuation());
1963        assert_eq!(buf.get(2, 0).symbol, "x");
1964    }
1965
1966    #[test]
1967    fn wide_replacement_clears_continuation_and_stale_hyperlink() {
1968        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
1969        buf.set_string_linked(1, 0, "世", Style::new(), "https://example.com");
1970        buf.set_char(1, 0, 'a', Style::new());
1971
1972        assert_eq!(buf.get(1, 0).symbol, "a");
1973        assert_eq!(buf.get(2, 0).symbol, " ");
1974        assert!(buf.get(1, 0).hyperlink.is_none());
1975        assert!(buf.get(2, 0).hyperlink.is_none());
1976    }
1977
1978    #[test]
1979    fn wide_write_never_splits_at_area_or_clip_boundary() {
1980        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
1981        buf.set_char(3, 0, 'x', Style::new());
1982        buf.set_string(3, 0, "世", Style::new());
1983        assert_eq!(buf.get(3, 0).symbol, "x");
1984
1985        buf.set_string(1, 0, "世", Style::new());
1986        buf.push_clip(Rect::new(1, 0, 1, 1));
1987        buf.set_char(1, 0, 'a', Style::new());
1988        assert_eq!(buf.get(1, 0).symbol, "世");
1989        assert!(buf.get(2, 0).is_continuation());
1990    }
1991
1992    #[test]
1993    fn fnv1a_distinct_rows_distinct_identical_rows_collide() {
1994        // After swapping SipHash for FNV-1a, the dirty-row digest must keep its
1995        // two contract guarantees: distinct content → distinct digest, and
1996        // identical content → identical digest (deterministic within a run).
1997        let area = Rect::new(0, 0, 5, 3);
1998        let mut buf = Buffer::empty(area);
1999        buf.set_string(0, 0, "alpha", Style::new());
2000        buf.set_string(0, 1, "alpha", Style::new()); // identical to row 0
2001        buf.set_string(0, 2, "omega", Style::new()); // distinct
2002        buf.recompute_line_hashes();
2003
2004        assert_eq!(
2005            buf.row_hash(0),
2006            buf.row_hash(1),
2007            "identical rows must collide"
2008        );
2009        assert_ne!(
2010            buf.row_hash(0),
2011            buf.row_hash(2),
2012            "distinct rows must not collide"
2013        );
2014    }
2015
2016    #[test]
2017    fn fnv1a_hash_rgba_is_deterministic_and_content_sensitive() {
2018        // `hash_rgba` (now FNV-1a) underpins Kitty image dedup: equal pixels
2019        // must dedup (equal hash), differing pixels must not.
2020        let a = [1u8, 2, 3, 4];
2021        let b = [1u8, 2, 3, 4];
2022        let c = [1u8, 2, 3, 5];
2023        assert_eq!(hash_rgba(&a), hash_rgba(&b));
2024        assert_ne!(hash_rgba(&a), hash_rgba(&c));
2025        // Determinism within the run.
2026        assert_eq!(hash_rgba(&a), hash_rgba(&a));
2027    }
2028
2029    // ── Bidi (UAX #9) reordering ────────────────────────────────────────
2030    //
2031    // `line_visual` reads a buffer row left-to-right by column and trims
2032    // trailing blanks — exactly the visual order a reader sees, which is the
2033    // correct oracle for asserting reorder output.
2034    #[cfg(feature = "bidi")]
2035    fn line_visual(buf: &Buffer, y: u32) -> String {
2036        let mut s = String::new();
2037        for x in buf.area.x..buf.area.right() {
2038            let sym = buf.get(x, y).symbol.as_str();
2039            if sym.is_empty() {
2040                continue; // wide-char trailing cell
2041            }
2042            s.push_str(sym);
2043        }
2044        s.trim_end().to_string()
2045    }
2046
2047    #[cfg(feature = "bidi")]
2048    #[test]
2049    fn needs_bidi_reorder_false_for_pure_ltr() {
2050        // Pure-LTR strings take the zero-allocation fast path.
2051        assert!(!needs_bidi_reorder("Hello, world 123"));
2052        assert!(!needs_bidi_reorder(""));
2053        assert!(!needs_bidi_reorder("café résumé"));
2054        assert!(!needs_bidi_reorder("世界 CJK wide"));
2055    }
2056
2057    #[cfg(feature = "bidi")]
2058    #[test]
2059    fn needs_bidi_reorder_true_for_rtl_and_controls() {
2060        assert!(needs_bidi_reorder("שלום")); // Hebrew
2061        assert!(needs_bidi_reorder("شكرا")); // Arabic
2062        assert!(needs_bidi_reorder("abc אבג def")); // mixed
2063        assert!(needs_bidi_reorder("a\u{202E}bc")); // RLO control
2064        assert!(needs_bidi_reorder("\u{200F}")); // RLM
2065    }
2066
2067    #[cfg(feature = "bidi")]
2068    #[test]
2069    fn set_string_ltr_unchanged_by_reorder_path() {
2070        // Regression guard: LTR text must NOT be reordered.
2071        let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
2072        buf.set_string(0, 0, "abcde", Style::new());
2073        assert_eq!(buf.get(0, 0).symbol, "a");
2074        assert_eq!(buf.get(1, 0).symbol, "b");
2075        assert_eq!(buf.get(2, 0).symbol, "c");
2076        assert_eq!(buf.get(3, 0).symbol, "d");
2077        assert_eq!(buf.get(4, 0).symbol, "e");
2078    }
2079
2080    #[cfg(feature = "bidi")]
2081    #[test]
2082    fn set_string_pure_rtl_reverses_to_visual_order() {
2083        // Hebrew "שלום" is logical ש,ל,ו,ם. In visual order the first
2084        // logical char (ש) lands on the rightmost column and the last (ם)
2085        // on the leftmost — i.e. the row reads "םולש" left-to-right.
2086        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
2087        buf.set_string(0, 0, "\u{05E9}\u{05DC}\u{05D5}\u{05DD}", Style::new());
2088        // column 0 == last logical char, last column == first logical char
2089        assert_eq!(buf.get(0, 0).symbol, "\u{05DD}"); // ם
2090        assert_eq!(buf.get(3, 0).symbol, "\u{05E9}"); // ש
2091        assert_eq!(line_visual(&buf, 0), "\u{05DD}\u{05D5}\u{05DC}\u{05E9}");
2092    }
2093
2094    #[cfg(feature = "bidi")]
2095    #[test]
2096    fn set_string_mixed_ltr_rtl_run() {
2097        // Per UAX #9 (unicode-bidi reference vectors): "abc אבג" → "abc גבא".
2098        // The Latin segment keeps LTR order; the Hebrew segment reverses.
2099        let mut buf = Buffer::empty(Rect::new(0, 0, 8, 1));
2100        buf.set_string(0, 0, "abc \u{05D0}\u{05D1}\u{05D2}", Style::new());
2101        assert_eq!(line_visual(&buf, 0), "abc \u{05D2}\u{05D1}\u{05D0}");
2102    }
2103
2104    #[cfg(feature = "bidi")]
2105    #[test]
2106    fn set_string_numbers_inside_rtl_stay_ltr() {
2107        // "123 אבג" → "גבא 123": European numbers are weak LTR and cannot
2108        // reorder a strong RTL run, so the digits stay "123" left-to-right
2109        // while the Hebrew reverses (unicode-bidi reference vector).
2110        let mut buf = Buffer::empty(Rect::new(0, 0, 8, 1));
2111        buf.set_string(0, 0, "123 \u{05D0}\u{05D1}\u{05D2}", Style::new());
2112        assert_eq!(line_visual(&buf, 0), "\u{05D2}\u{05D1}\u{05D0} 123");
2113    }
2114
2115    #[cfg(feature = "bidi")]
2116    #[test]
2117    fn set_string_wide_char_with_rtl_blanks_trailing_cell() {
2118        // A CJK wide glyph mixed with Hebrew: after reorder the wide char's
2119        // trailing cell must still be blanked at the correct visual column.
2120        // Logical "世 אב" → the wide 世 stays leftmost (LTR base), Hebrew
2121        // reverses to "בא". Visual: 世 (cols 0-1), space (col 2), ב (3) א (4).
2122        let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
2123        buf.set_string(0, 0, "\u{4E16} \u{05D0}\u{05D1}", Style::new());
2124        assert_eq!(buf.get(0, 0).symbol, "\u{4E16}"); // 世 leading
2125        assert!(buf.get(1, 0).symbol.is_empty(), "wide trailing must blank");
2126        assert_eq!(buf.get(3, 0).symbol, "\u{05D1}"); // ב
2127        assert_eq!(buf.get(4, 0).symbol, "\u{05D0}"); // א
2128    }
2129
2130    #[cfg(feature = "bidi")]
2131    #[test]
2132    fn set_string_linked_hyperlink_survives_reorder() {
2133        // Every non-blank emitted cell of an RTL link must carry the URL,
2134        // regardless of its new visual column.
2135        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
2136        buf.set_string_linked(
2137            0,
2138            0,
2139            "\u{05E9}\u{05DC}\u{05D5}\u{05DD}",
2140            Style::new(),
2141            "https://example.com",
2142        );
2143        for x in 0..4 {
2144            let cell = buf.get(x, 0);
2145            assert!(
2146                cell.hyperlink.is_some(),
2147                "hyperlink missing at visual column {x}"
2148            );
2149        }
2150    }
2151
2152    #[cfg(feature = "bidi")]
2153    #[test]
2154    fn set_string_control_chars_filtered_in_rtl() {
2155        // An ESC embedded in an RTL string must still be replaced with
2156        // U+FFFD — the reorder path must not bypass sanitize_cell_char.
2157        let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
2158        buf.set_string(0, 0, "\u{05D0}\x1b\u{05D1}", Style::new());
2159        let mut found_replacement = false;
2160        for x in 0..6 {
2161            let sym = buf.get(x, 0).symbol.as_str();
2162            assert!(!sym.contains('\x1b'), "ESC leaked into a cell");
2163            if sym.contains('\u{FFFD}') {
2164                found_replacement = true;
2165            }
2166        }
2167        assert!(found_replacement, "ESC was not replaced with U+FFFD");
2168    }
2169
2170    #[cfg(feature = "bidi")]
2171    #[test]
2172    fn reorder_line_visual_empty_is_noop() {
2173        assert_eq!(reorder_line_visual(""), "");
2174    }
2175
2176    mod geometry_proptest {
2177        use super::*;
2178        use proptest::prelude::*;
2179
2180        proptest! {
2181            #![proptest_config(ProptestConfig::with_cases(256))]
2182
2183            #[test]
2184            fn origin_writes_and_mismatched_diffs_never_panic(
2185                x in 0u32..200,
2186                y in 0u32..200,
2187                width in 0u32..32,
2188                height in 0u32..16,
2189                other_x in 0u32..200,
2190                other_y in 0u32..200,
2191                other_width in 0u32..32,
2192                other_height in 0u32..16,
2193                text in ".{0,48}",
2194            ) {
2195                let area = Rect::new(x, y, width, height);
2196                let other_area = Rect::new(other_x, other_y, other_width, other_height);
2197                let mut current = Buffer::try_empty(area).expect("small geometry is valid");
2198                let previous = Buffer::try_empty(other_area).expect("small geometry is valid");
2199
2200                current.set_string(x.saturating_sub(3), y.saturating_sub(3), &text, Style::new());
2201                let updates = current.diff(&previous);
2202                prop_assert!(updates.len() <= current.content.len());
2203                prop_assert!(updates.iter().all(|(cx, cy, _)| current.in_bounds(*cx, *cy)));
2204            }
2205        }
2206    }
2207
2208    #[cfg(feature = "bidi")]
2209    mod bidi_proptest {
2210        use super::{needs_bidi_reorder, reorder_line_visual};
2211        use proptest::prelude::*;
2212
2213        proptest! {
2214            #![proptest_config(ProptestConfig::with_cases(256))]
2215
2216            /// Fast-path no-op: arbitrary ASCII strings never need reordering.
2217            #[test]
2218            fn ascii_takes_fast_path_and_reorder_is_identity(s in "[ -~]{0,64}") {
2219                prop_assert!(!needs_bidi_reorder(&s));
2220                // Even if forced through the reorder, ASCII is a no-op permutation.
2221                prop_assert_eq!(reorder_line_visual(&s), s);
2222            }
2223
2224            /// Reorder is a pure permutation of scalar values: it never adds,
2225            /// drops, or mutates a codepoint.
2226            ///
2227            /// Note: total *display width* is deliberately NOT asserted —
2228            /// `unicode-width` 0.2 is contextual (e.g. Arabic lam+alef forms a
2229            /// single-cell ligature while alef+lam does not), so reordering can
2230            /// legitimately change the rendered cell count. The invariant that
2231            /// actually holds is multiset equality of `char`s.
2232            #[test]
2233            fn reorder_is_codepoint_permutation(
2234                s in "[a-z\\x{05D0}-\\x{05EA}\\x{0627}-\\x{064A}0-9 ]{0,48}"
2235            ) {
2236                let mut before: Vec<char> = s.chars().collect();
2237                let mut after: Vec<char> = reorder_line_visual(&s).chars().collect();
2238                before.sort_unstable();
2239                after.sort_unstable();
2240                prop_assert_eq!(before, after);
2241            }
2242        }
2243    }
2244}