Skip to main content

slt/
buffer.rs

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