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            && (x >= clip.right() || y >= clip.bottom())
379        {
380            return;
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            && (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        // Apply scroll crop info if any frame is active
403        if let Some(info) = self.current_kitty_clip() {
404            let top_clip_rows = info.top_clip_rows;
405            let original_height = info.original_height;
406            if original_height > 0 && (top_clip_rows > 0 || p.rows < original_height) {
407                let ratio = p.src_height as f64 / original_height as f64;
408                p.crop_y = (top_clip_rows as f64 * ratio) as u32;
409                let bottom_clip = original_height.saturating_sub(top_clip_rows + p.rows);
410                let bottom_pixels = (bottom_clip as f64 * ratio) as u32;
411                p.crop_h = p.src_height.saturating_sub(p.crop_y + bottom_pixels);
412            }
413        }
414
415        self.kitty_placements.push(p);
416    }
417
418    /// Store a non-Kitty pixel-graphic placement (Sixel or iTerm2 OSC 1337)
419    /// with its per-cell damage footprint.
420    ///
421    /// Respects the clip stack the same way [`Buffer::kitty_place`] does:
422    /// placements wholly outside the active clip are dropped. The footprint
423    /// `cells` are recorded as-supplied; the flush layer flips covered cells to
424    /// [`SprixelCell::Annihilated`] when a text write overwrites graphic ink so
425    /// only dirtied graphics are re-emitted (issue #265).
426    ///
427    /// Callers (`sixel_image` / `iterm_image*`) are `crossterm`-gated, so this
428    /// is unused under `--no-default-features`; the lint is suppressed only on
429    /// that build so a genuine dead-code signal still fires by default.
430    #[cfg_attr(not(feature = "crossterm"), allow(dead_code))]
431    pub(crate) fn sprixel_place(&mut self, p: SprixelPlacement) {
432        if let Some(clip) = self.effective_clip()
433            && (p.x >= clip.right()
434                || p.y >= clip.bottom()
435                || p.x + p.cols <= clip.x
436                || p.y + p.rows <= clip.y)
437        {
438            return;
439        }
440        self.sprixels.push(p);
441    }
442
443    /// Push a clipping rectangle onto the clip stack.
444    ///
445    /// Subsequent writes are restricted to the intersection of all active clip
446    /// regions. Nested calls intersect with the current clip, so the effective
447    /// clip can only shrink, never grow.
448    pub fn push_clip(&mut self, rect: Rect) {
449        let effective = if let Some(current) = self.clip_stack.last() {
450            intersect_rects(*current, rect)
451        } else {
452            rect
453        };
454        self.clip_stack.push(effective);
455    }
456
457    /// Pop the most recently pushed clipping rectangle.
458    ///
459    /// After this call, writes are clipped to the previous region (or
460    /// unclipped if the stack is now empty).
461    pub fn pop_clip(&mut self) {
462        self.clip_stack.pop();
463    }
464
465    fn effective_clip(&self) -> Option<&Rect> {
466        self.clip_stack.last()
467    }
468
469    #[inline]
470    fn index_of(&self, x: u32, y: u32) -> usize {
471        ((y - self.area.y) * self.area.width + (x - self.area.x)) as usize
472    }
473
474    /// Returns `true` if `(x, y)` is within the buffer's area.
475    #[inline]
476    pub fn in_bounds(&self, x: u32, y: u32) -> bool {
477        x >= self.area.x && x < self.area.right() && y >= self.area.y && y < self.area.bottom()
478    }
479
480    /// Return a reference to the cell at `(x, y)`.
481    ///
482    /// Panics if `(x, y)` is out of bounds. Use [`Buffer::try_get`] when the
483    /// coordinates may come from untrusted input.
484    #[inline]
485    pub fn get(&self, x: u32, y: u32) -> &Cell {
486        assert!(
487            self.in_bounds(x, y),
488            "Buffer::get({x}, {y}) out of bounds for area {:?}",
489            self.area
490        );
491        &self.content[self.index_of(x, y)]
492    }
493
494    /// Return a mutable reference to the cell at `(x, y)`.
495    ///
496    /// Panics if `(x, y)` is out of bounds. Use [`Buffer::try_get_mut`] when
497    /// the coordinates may come from untrusted input.
498    #[inline]
499    pub fn get_mut(&mut self, x: u32, y: u32) -> &mut Cell {
500        assert!(
501            self.in_bounds(x, y),
502            "Buffer::get_mut({x}, {y}) out of bounds for area {:?}",
503            self.area
504        );
505        let idx = self.index_of(x, y);
506        &mut self.content[idx]
507    }
508
509    /// Return a reference to the cell at `(x, y)`, or `None` if out of bounds.
510    ///
511    /// Non-panicking counterpart to [`Buffer::get`]. Prefer this inside
512    /// `draw()` closures when coordinates are computed from mouse input,
513    /// scroll offsets, or other sources that could land outside the buffer.
514    #[inline]
515    pub fn try_get(&self, x: u32, y: u32) -> Option<&Cell> {
516        if self.in_bounds(x, y) {
517            Some(&self.content[self.index_of(x, y)])
518        } else {
519            None
520        }
521    }
522
523    /// Return a mutable reference to the cell at `(x, y)`, or `None` if out
524    /// of bounds.
525    ///
526    /// Non-panicking counterpart to [`Buffer::get_mut`].
527    #[inline]
528    pub fn try_get_mut(&mut self, x: u32, y: u32) -> Option<&mut Cell> {
529        if self.in_bounds(x, y) {
530            let idx = self.index_of(x, y);
531            Some(&mut self.content[idx])
532        } else {
533            None
534        }
535    }
536
537    /// Write a string into the buffer starting at `(x, y)`.
538    ///
539    /// Respects cell boundaries and Unicode character widths. Wide characters
540    /// (e.g., CJK) occupy two columns; the trailing cell is blanked. Writes
541    /// that fall outside the current clip region are skipped but still advance
542    /// the cursor position.
543    pub fn set_string(&mut self, x: u32, y: u32, s: &str, style: Style) {
544        self.set_string_inner(x, y, s, style, None);
545    }
546
547    /// Write a hyperlinked string into the buffer starting at `(x, y)`.
548    ///
549    /// Like [`Buffer::set_string`] but attaches an OSC 8 hyperlink URL to each
550    /// cell. The terminal renders these cells as clickable links.
551    pub fn set_string_linked(&mut self, x: u32, y: u32, s: &str, style: Style, url: &str) {
552        let link = sanitize_osc8_url(url).map(compact_str::CompactString::new);
553        self.set_string_inner(x, y, s, style, link.as_ref());
554    }
555
556    /// Shared implementation for [`Self::set_string`] and
557    /// [`Self::set_string_linked`].
558    ///
559    /// `link` is `Some` only for the OSC 8 path; both paths share clip,
560    /// wide-char, and zero-width grapheme handling. Keeping a single
561    /// implementation prevents the two call sites from drifting on edge cases
562    /// (e.g., `MAX_CELL_SYMBOL_BYTES` checks, wide-char blanking).
563    fn set_string_inner(
564        &mut self,
565        mut x: u32,
566        y: u32,
567        s: &str,
568        style: Style,
569        link: Option<&compact_str::CompactString>,
570    ) {
571        if y >= self.area.bottom() {
572            return;
573        }
574        // Issue #171: mark this row dirty so the next flush refreshes its
575        // hash. Marking unconditionally here keeps the write paths cheap;
576        // false positives only cost one redundant hash recompute, never a
577        // correctness issue.
578        self.mark_row_dirty(y);
579        // Bidi (UAX #9) reorder: convert this logical-order line into visual
580        // (display) order before the positional cell-write loop below. The
581        // loop is purely left-to-right by column, so RTL runs must be
582        // reordered *here* or they render mirrored. `needs_bidi_reorder`
583        // gates the work so pure-LTR input neither allocates nor calls into
584        // `unicode-bidi` — its output is byte-identical to skipping this
585        // block entirely. Width/clip/zero-width/hyperlink handling below is
586        // order-independent and applies unchanged to the reordered glyphs.
587        #[cfg(feature = "bidi")]
588        let reordered;
589        #[cfg(feature = "bidi")]
590        let s: &str = if needs_bidi_reorder(s) {
591            reordered = reorder_line_visual(s);
592            &reordered
593        } else {
594            s
595        };
596        let clip = self.effective_clip().copied();
597        for ch in s.chars() {
598            if x >= self.area.right() {
599                break;
600            }
601            let ch = sanitize_cell_char(ch);
602            let char_width = UnicodeWidthChar::width(ch).unwrap_or(0) as u32;
603            if char_width == 0 {
604                // Append zero-width char (combining mark, ZWJ, variation selector)
605                // to the previous cell so grapheme clusters stay intact.
606                if x > self.area.x {
607                    let prev_in_clip = clip.is_none_or(|clip| {
608                        (x - 1) >= clip.x
609                            && (x - 1) < clip.right()
610                            && y >= clip.y
611                            && y < clip.bottom()
612                    });
613                    if prev_in_clip {
614                        let prev = self.get_mut(x - 1, y);
615                        if prev.symbol.len() + ch.len_utf8() <= MAX_CELL_SYMBOL_BYTES {
616                            prev.symbol.push(ch);
617                        }
618                    }
619                }
620                continue;
621            }
622
623            let in_clip = clip.is_none_or(|clip| {
624                x >= clip.x && x < clip.right() && y >= clip.y && y < clip.bottom()
625            });
626
627            if !in_clip {
628                x = x.saturating_add(char_width);
629                continue;
630            }
631
632            let cell = self.get_mut(x, y);
633            cell.set_char(ch);
634            cell.set_style(style);
635            cell.hyperlink = link.cloned();
636
637            // Wide characters occupy two cells; blank the trailing cell.
638            if char_width > 1 {
639                let next_x = x + 1;
640                if next_x < self.area.right() {
641                    let next = self.get_mut(next_x, y);
642                    next.symbol.clear();
643                    next.style = style;
644                    next.hyperlink = link.cloned();
645                }
646            }
647
648            x = x.saturating_add(char_width);
649        }
650    }
651
652    /// Write a single character at `(x, y)` with the given style.
653    ///
654    /// No-ops if `(x, y)` is out of bounds or outside the current clip region.
655    pub fn set_char(&mut self, x: u32, y: u32, ch: char, style: Style) {
656        let in_clip = self
657            .effective_clip()
658            .is_none_or(|clip| x >= clip.x && x < clip.right() && y >= clip.y && y < clip.bottom());
659        if !self.in_bounds(x, y) || !in_clip {
660            return;
661        }
662        // Issue #171: mark this row dirty so the next flush refreshes its
663        // hash before deciding whether to skip the per-cell scan.
664        self.mark_row_dirty(y);
665        let cell = self.get_mut(x, y);
666        cell.set_char(ch);
667        cell.set_style(style);
668    }
669
670    /// Mark row `y` as dirty so the next flush recomputes its line hash.
671    ///
672    /// `y` is in the buffer's coordinate space (i.e. `area.y..area.bottom()`).
673    /// Out-of-range values are ignored so callers don't need to bounds-check
674    /// before invoking this on every cell write.
675    #[inline]
676    pub(crate) fn mark_row_dirty(&mut self, y: u32) {
677        if y < self.area.y {
678            return;
679        }
680        let idx = (y - self.area.y) as usize;
681        if let Some(slot) = self.line_dirty.get_mut(idx) {
682            *slot = true;
683        }
684    }
685
686    /// Recompute the per-row digest for every row currently flagged dirty.
687    ///
688    /// This is the only call site that updates [`Self::line_hashes`]; once
689    /// a row's hash is refreshed its `line_dirty` entry is cleared. Hashes
690    /// derive from each cell's `(symbol, style, hyperlink)` tuple via the
691    /// non-cryptographic [`Fnv1a`] hasher — sufficient for equality detection,
692    /// faster than SipHash in the per-frame loop, and with no extra dependency.
693    ///
694    /// Called by `flush_buffer_diff` once per frame, before the per-row
695    /// skip check (issue #171).
696    ///
697    /// Gated on `crossterm` (the only flush call site) and `test`. Without
698    /// the gate it shows as `dead_code` under `--no-default-features`.
699    #[cfg(any(feature = "crossterm", test))]
700    pub(crate) fn recompute_line_hashes(&mut self) {
701        let height = self.area.height;
702        if height == 0 {
703            return;
704        }
705        // `line_hashes` / `line_dirty` are sized at construction / resize;
706        // an interior mutation (e.g. resize before reset) could leave them
707        // out of step with `area.height`. Repair lazily here so callers
708        // never observe a stale length.
709        let expected_len = height as usize;
710        if self.line_hashes.len() != expected_len {
711            self.line_hashes.resize(expected_len, 0);
712        }
713        if self.line_dirty.len() != expected_len {
714            self.line_dirty.resize(expected_len, true);
715        }
716
717        let width = self.area.width as usize;
718        for (idx, dirty) in self.line_dirty.iter_mut().enumerate() {
719            if !*dirty {
720                continue;
721            }
722            let row_start = idx * width;
723            let row_end = row_start + width;
724            let mut hasher = Fnv1a::default();
725            for cell in &self.content[row_start..row_end] {
726                cell.symbol.as_str().hash(&mut hasher);
727                cell.style.hash(&mut hasher);
728                cell.hyperlink.as_deref().hash(&mut hasher);
729            }
730            self.line_hashes[idx] = hasher.finish();
731            *dirty = false;
732        }
733    }
734
735    /// Returns `true` if row `y` (buffer-space) was not touched since the
736    /// last [`Self::recompute_line_hashes`] call.
737    ///
738    /// Gated on `crossterm` (consumed by `flush_buffer_diff`) and `test`.
739    ///
740    /// Used by `flush_buffer_diff` to short-circuit the per-cell scan when
741    /// combined with a hash match against the previous frame (issue #171).
742    /// Out-of-range rows report as dirty so callers fall back to the
743    /// existing per-cell path on edge inputs.
744    #[inline]
745    #[cfg(any(feature = "crossterm", test))]
746    pub(crate) fn row_clean(&self, y: u32) -> bool {
747        if y < self.area.y {
748            return false;
749        }
750        let idx = (y - self.area.y) as usize;
751        self.line_dirty
752            .get(idx)
753            .copied()
754            .map(|d| !d)
755            .unwrap_or(false)
756    }
757
758    /// Read row `y`'s cached digest, or `None` if out of range.
759    ///
760    /// Pairs with [`Self::row_clean`] inside `flush_buffer_diff`: only the
761    /// hash for clean rows is used as a short-circuit signal, so callers
762    /// must check `row_clean` first.
763    #[inline]
764    #[cfg(any(feature = "crossterm", test))]
765    pub(crate) fn row_hash(&self, y: u32) -> Option<u64> {
766        if y < self.area.y {
767            return None;
768        }
769        let idx = (y - self.area.y) as usize;
770        self.line_hashes.get(idx).copied()
771    }
772
773    /// Compute the diff between `self` (current) and `other` (previous).
774    ///
775    /// Returns `(x, y, cell)` tuples for every cell that changed. Useful for
776    /// custom backends or tests that need to inspect changed cells directly.
777    ///
778    /// # Allocation
779    ///
780    /// Allocates a new [`Vec`] on every call. For high-frequency use
781    /// (per-frame diffing in a render loop), prefer the internal
782    /// `flush_buffer_diff` path used by [`crate::run`], which streams updates
783    /// directly to the backend without an intermediate `Vec`. Calling
784    /// `diff()` on every frame in a 60 fps loop adds one heap allocation
785    /// (sized to the changed-cell count) per frame.
786    ///
787    /// # Benchmarks
788    ///
789    /// `benches/benchmarks.rs` exercises this path in `bench_buffer_diff`.
790    pub fn diff<'a>(&'a self, other: &'a Buffer) -> Vec<(u32, u32, &'a Cell)> {
791        let mut updates = Vec::new();
792        for y in self.area.y..self.area.bottom() {
793            for x in self.area.x..self.area.right() {
794                let cur = self.get(x, y);
795                let prev = other.get(x, y);
796                if cur != prev {
797                    updates.push((x, y, cur));
798                }
799            }
800        }
801        updates
802    }
803
804    /// Reset every cell to a blank space with default style, and clear the clip stack.
805    pub fn reset(&mut self) {
806        for cell in &mut self.content {
807            cell.reset();
808        }
809        self.clip_stack.clear();
810        self.raw_sequences.clear();
811        self.sprixels.clear();
812        self.kitty_placements.clear();
813        self.cursor_pos = None;
814        self.kitty_clip_info_stack.clear();
815        // Issue #171: every row is now blank — flag them all dirty so the
816        // next flush refreshes the digest before any skip check.
817        for d in &mut self.line_dirty {
818            *d = true;
819        }
820    }
821
822    /// Reset every cell and apply a background color to all cells.
823    pub fn reset_with_bg(&mut self, bg: crate::style::Color) {
824        for cell in &mut self.content {
825            cell.reset();
826            cell.style.bg = Some(bg);
827        }
828        self.clip_stack.clear();
829        self.raw_sequences.clear();
830        self.sprixels.clear();
831        self.kitty_placements.clear();
832        self.cursor_pos = None;
833        self.kitty_clip_info_stack.clear();
834        // Issue #171: every cell was just rewritten — mark all rows dirty.
835        for d in &mut self.line_dirty {
836            *d = true;
837        }
838    }
839
840    /// Resize the buffer to fit a new area, resetting all cells.
841    ///
842    /// If the new area is larger, new cells are initialized to blank. All
843    /// existing content is discarded.
844    pub fn resize(&mut self, area: Rect) {
845        self.area = area;
846        let size = area.area() as usize;
847        self.content.resize(size, Cell::default());
848        // Issue #171: keep the per-row tracking arrays sized to the new
849        // height. `reset()` re-marks every row dirty so initial values
850        // here don't affect correctness.
851        let height = area.height as usize;
852        self.line_hashes.resize(height, 0);
853        self.line_dirty.resize(height, true);
854        self.reset();
855    }
856
857    /// Serialize the buffer into a stable, styled-snapshot format suitable for
858    /// snapshot testing (e.g. with `insta::assert_snapshot!`).
859    ///
860    /// # Format
861    ///
862    /// One line per buffer row, joined with `\n`. Within a row, runs of cells
863    /// that share an identical [`Style`] are grouped. The default style (no
864    /// foreground, no background, no modifiers) emits **unannotated** text —
865    /// no `[...]` markers. Any non-default run is wrapped:
866    ///
867    /// ```text
868    /// [fg=...,bg=...,mods]"text"[/]
869    /// ```
870    ///
871    /// Trailing whitespace per row is preserved in the styled segment but
872    /// trailing default-style spaces at the end of a row are emitted verbatim
873    /// (they are visually invisible in diffs). Empty cells render as a single
874    /// space. The terminating `[/]` marker only appears when a styled run is
875    /// in effect at the end of a row.
876    ///
877    /// # Color formatting
878    ///
879    /// Named palette colors use short lowercase codes:
880    /// `reset`, `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`,
881    /// `white`, `dark_gray`, `light_red`, `light_green`, `light_yellow`,
882    /// `light_blue`, `light_magenta`, `light_cyan`, `light_white`. RGB colors
883    /// emit `#rrggbb`. Indexed palette colors emit `idx<N>` (decimal).
884    ///
885    /// # Modifier formatting
886    ///
887    /// Modifiers are emitted as comma-separated lowercase tokens in a fixed
888    /// canonical order: `bold`, `dim`, `italic`, `underline`, `reversed`,
889    /// `strikethrough`. Order is independent of the bit pattern, so two
890    /// equivalent `Modifiers` values always serialize identically.
891    ///
892    /// # Stability
893    ///
894    /// The output format is stable across patch and minor versions of SLT.
895    /// Names use a hand-rolled formatter (not `Debug`) so derives changing
896    /// upstream cannot accidentally break locked snapshots. A breaking change
897    /// to the format would be reserved for a major version bump.
898    ///
899    /// # Determinism
900    ///
901    /// Identical input buffers always produce byte-equal output. This is a
902    /// hard requirement — snapshot tests rely on it.
903    ///
904    /// # Example
905    ///
906    /// ```
907    /// use slt::{Buffer, Color, Rect, Style};
908    ///
909    /// let mut buf = Buffer::empty(Rect::new(0, 0, 5, 1));
910    /// buf.set_string(0, 0, "ab", Style::new().fg(Color::Red).bold());
911    /// buf.set_string(2, 0, "cd", Style::new());
912    /// let snap = buf.snapshot_format();
913    /// assert!(snap.starts_with("[fg=red,bold]\"ab\"[/]cd"));
914    /// ```
915    pub fn snapshot_format(&self) -> String {
916        let mut out = String::new();
917        let width = self.area.width;
918        let height = self.area.height;
919        if width == 0 || height == 0 {
920            return out;
921        }
922
923        for y in self.area.y..self.area.bottom() {
924            if y > self.area.y {
925                out.push('\n');
926            }
927
928            // Walk the row, grouping consecutive cells by Style.
929            let mut current_style: Option<Style> = None;
930            let mut run_text = String::new();
931
932            for x in self.area.x..self.area.right() {
933                let cell = self.get(x, y);
934                let style = cell.style;
935                // Empty cell symbol → single space (e.g. trailing wide-char cell).
936                let sym: &str = if cell.symbol.is_empty() {
937                    " "
938                } else {
939                    cell.symbol.as_str()
940                };
941
942                match current_style {
943                    Some(s) if s == style => {
944                        run_text.push_str(sym);
945                    }
946                    _ => {
947                        if let Some(s) = current_style.take() {
948                            flush_run(&mut out, s, &run_text);
949                            run_text.clear();
950                        }
951                        current_style = Some(style);
952                        run_text.push_str(sym);
953                    }
954                }
955            }
956
957            if let Some(s) = current_style {
958                flush_run(&mut out, s, &run_text);
959            }
960        }
961
962        out
963    }
964}
965
966/// Flush a single style-run into the snapshot output.
967///
968/// Default style → unannotated raw text (no markers, escape only embedded `"`).
969/// Non-default style → `[fg=...,bg=...,mods]"text"[/]` form. Embedded `"` and
970/// `\` characters in cell symbols are escaped so the snapshot remains
971/// unambiguous.
972fn flush_run(out: &mut String, style: Style, text: &str) {
973    if style == Style::default() {
974        out.push_str(text);
975        return;
976    }
977    out.push('[');
978    let mut first = true;
979    if let Some(fg) = style.fg {
980        out.push_str("fg=");
981        write_color(out, fg);
982        first = false;
983    }
984    if let Some(bg) = style.bg {
985        if !first {
986            out.push(',');
987        }
988        out.push_str("bg=");
989        write_color(out, bg);
990        first = false;
991    }
992    let mods = style.modifiers;
993    // Canonical order: bold, dim, italic, underline, reversed, strikethrough.
994    let pairs: [(crate::style::Modifiers, &str); 6] = [
995        (crate::style::Modifiers::BOLD, "bold"),
996        (crate::style::Modifiers::DIM, "dim"),
997        (crate::style::Modifiers::ITALIC, "italic"),
998        (crate::style::Modifiers::UNDERLINE, "underline"),
999        (crate::style::Modifiers::REVERSED, "reversed"),
1000        (crate::style::Modifiers::STRIKETHROUGH, "strikethrough"),
1001    ];
1002    for (bit, name) in pairs {
1003        if mods.contains(bit) {
1004            if !first {
1005                out.push(',');
1006            }
1007            out.push_str(name);
1008            first = false;
1009        }
1010    }
1011    out.push(']');
1012    out.push('"');
1013    for ch in text.chars() {
1014        match ch {
1015            '"' => out.push_str("\\\""),
1016            '\\' => out.push_str("\\\\"),
1017            other => out.push(other),
1018        }
1019    }
1020    out.push('"');
1021    out.push_str("[/]");
1022}
1023
1024/// Format a [`crate::style::Color`] using the stable snapshot vocabulary.
1025///
1026/// Hand-rolled instead of `Debug` so upstream derive changes can't silently
1027/// break snapshot stability.
1028fn write_color(out: &mut String, color: crate::style::Color) {
1029    use crate::style::Color;
1030    match color {
1031        Color::Reset => out.push_str("reset"),
1032        Color::Black => out.push_str("black"),
1033        Color::Red => out.push_str("red"),
1034        Color::Green => out.push_str("green"),
1035        Color::Yellow => out.push_str("yellow"),
1036        Color::Blue => out.push_str("blue"),
1037        Color::Magenta => out.push_str("magenta"),
1038        Color::Cyan => out.push_str("cyan"),
1039        Color::White => out.push_str("white"),
1040        Color::DarkGray => out.push_str("dark_gray"),
1041        Color::LightRed => out.push_str("light_red"),
1042        Color::LightGreen => out.push_str("light_green"),
1043        Color::LightYellow => out.push_str("light_yellow"),
1044        Color::LightBlue => out.push_str("light_blue"),
1045        Color::LightMagenta => out.push_str("light_magenta"),
1046        Color::LightCyan => out.push_str("light_cyan"),
1047        Color::LightWhite => out.push_str("light_white"),
1048        Color::Rgb(r, g, b) => {
1049            use std::fmt::Write;
1050            let _ = write!(out, "#{:02x}{:02x}{:02x}", r, g, b);
1051        }
1052        Color::Indexed(idx) => {
1053            use std::fmt::Write;
1054            let _ = write!(out, "idx{}", idx);
1055        }
1056    }
1057}
1058
1059/// Maximum byte length for OSC 8 hyperlink URLs.
1060///
1061/// Longer than any legitimate URL and enough to prevent DoS via
1062/// balloon-sized hyperlinks. Shared by [`is_valid_osc8_url`] and
1063/// [`sanitize_osc8_url`] so both gates agree on acceptance.
1064const MAX_OSC8_URL_BYTES: usize = 2048;
1065
1066/// Returns `true` if `url` is safe to emit as an OSC 8 hyperlink payload.
1067///
1068/// Equivalent to `sanitize_osc8_url(url).is_some()` but avoids the `String`
1069/// allocation when callers only need a boolean validity check (e.g.,
1070/// defense-in-depth validation of a public `Cell::hyperlink` field on the
1071/// flush path).
1072#[inline]
1073pub(crate) fn is_valid_osc8_url(url: &str) -> bool {
1074    if url.is_empty() || url.len() > MAX_OSC8_URL_BYTES {
1075        return false;
1076    }
1077    // Reject all C0 controls (incl. BEL 0x07, ESC 0x1b), DEL 0x7f, and
1078    // anything below 0x20. ESC enables the ST (ESC \) terminator trick;
1079    // BEL is the legacy OSC terminator. Either would let an
1080    // attacker-controlled URL prematurely close the OSC 8 sequence and
1081    // inject arbitrary follow-up commands (e.g., OSC 52 clipboard writes).
1082    url.bytes().all(|b| b >= 0x20 && b != 0x7f)
1083}
1084
1085/// Validate an OSC 8 hyperlink URL, returning `Some(url)` if safe to emit.
1086///
1087/// Rejects URLs containing control bytes, the BEL terminator, or an
1088/// embedded ST (`ESC \`). Those would let an attacker-controlled URL
1089/// prematurely close the OSC 8 sequence and inject arbitrary follow-up
1090/// commands (e.g., OSC 52 clipboard writes). Also caps length at
1091/// [`MAX_OSC8_URL_BYTES`] (2048).
1092///
1093/// For boolean validation (no allocation), use [`is_valid_osc8_url`].
1094pub(crate) fn sanitize_osc8_url(url: &str) -> Option<String> {
1095    if is_valid_osc8_url(url) {
1096        Some(url.to_string())
1097    } else {
1098        None
1099    }
1100}
1101
1102fn intersect_rects(a: Rect, b: Rect) -> Rect {
1103    let x = a.x.max(b.x);
1104    let y = a.y.max(b.y);
1105    let right = a.right().min(b.right());
1106    let bottom = a.bottom().min(b.bottom());
1107    let width = right.saturating_sub(x);
1108    let height = bottom.saturating_sub(y);
1109    Rect::new(x, y, width, height)
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114    use super::*;
1115
1116    #[test]
1117    fn clip_stack_intersects_nested_regions() {
1118        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 5));
1119        buf.push_clip(Rect::new(1, 1, 6, 3));
1120        buf.push_clip(Rect::new(4, 0, 6, 4));
1121
1122        buf.set_char(3, 2, 'x', Style::new());
1123        buf.set_char(4, 2, 'y', Style::new());
1124
1125        assert_eq!(buf.get(3, 2).symbol, " ");
1126        assert_eq!(buf.get(4, 2).symbol, "y");
1127    }
1128
1129    #[test]
1130    fn set_string_advances_even_when_clipped() {
1131        let mut buf = Buffer::empty(Rect::new(0, 0, 8, 1));
1132        buf.push_clip(Rect::new(2, 0, 6, 1));
1133
1134        buf.set_string(0, 0, "abcd", Style::new());
1135
1136        assert_eq!(buf.get(2, 0).symbol, "c");
1137        assert_eq!(buf.get(3, 0).symbol, "d");
1138    }
1139
1140    #[test]
1141    fn pop_clip_restores_previous_clip() {
1142        let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
1143        buf.push_clip(Rect::new(0, 0, 2, 1));
1144        buf.push_clip(Rect::new(4, 0, 2, 1));
1145
1146        buf.set_char(1, 0, 'a', Style::new());
1147        buf.pop_clip();
1148        buf.set_char(1, 0, 'b', Style::new());
1149
1150        assert_eq!(buf.get(1, 0).symbol, "b");
1151    }
1152
1153    #[test]
1154    fn reset_clears_clip_stack() {
1155        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
1156        buf.push_clip(Rect::new(0, 0, 0, 0));
1157        buf.reset();
1158        buf.set_char(0, 0, 'z', Style::new());
1159
1160        assert_eq!(buf.get(0, 0).symbol, "z");
1161    }
1162
1163    #[test]
1164    fn set_string_replaces_control_chars_with_replacement() {
1165        let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
1166        // ESC must never land in a cell — a flushed ESC would let the
1167        // string escape its cell and execute as a real terminal command.
1168        buf.set_string(0, 0, "a\x1bbc", Style::new());
1169        assert_eq!(buf.get(0, 0).symbol, "a");
1170        assert_eq!(buf.get(1, 0).symbol, "\u{FFFD}");
1171        assert_eq!(buf.get(2, 0).symbol, "b");
1172        assert_eq!(buf.get(3, 0).symbol, "c");
1173    }
1174
1175    #[test]
1176    fn zero_width_combining_does_not_append_control_bytes() {
1177        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
1178        buf.set_char(0, 0, 'a', Style::new());
1179        // BEL is zero-width per unicode_width; the pre-fix code would have
1180        // pushed it onto cell(0,0).symbol. After sanitize_cell_char it is
1181        // replaced with U+FFFD and then appended (width 1, still fits).
1182        buf.set_string(1, 0, "\x07", Style::new());
1183        let symbol = buf.get(1, 0).symbol.as_str();
1184        assert!(!symbol.contains('\x07'), "BEL leaked into cell symbol");
1185    }
1186
1187    #[test]
1188    fn set_string_caps_combining_overflow() {
1189        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
1190        buf.set_char(0, 0, 'a', Style::new());
1191        // 200 copies of an ASCII-printable zero-width-ish char would bypass
1192        // the byte cap. Use a legitimate zero-width combining character —
1193        // U+0301 (combining acute accent) — and confirm the cap kicks in.
1194        let combining: String = "\u{0301}".repeat(200);
1195        buf.set_string(1, 0, &combining, Style::new());
1196        assert!(
1197            buf.get(0, 0).symbol.len() <= MAX_CELL_SYMBOL_BYTES,
1198            "cell symbol exceeded MAX_CELL_SYMBOL_BYTES cap"
1199        );
1200    }
1201
1202    #[test]
1203    fn sanitize_osc8_url_rejects_control_chars_and_esc() {
1204        assert!(sanitize_osc8_url("https://example.com").is_some());
1205        assert!(sanitize_osc8_url("https://example.com?q=1&r=2").is_some());
1206        // BEL — terminates OSC, would let follow-up text be interpreted.
1207        assert!(sanitize_osc8_url("https://example.com\x07attack").is_none());
1208        // ESC — can open ST (ESC \) or another OSC.
1209        assert!(sanitize_osc8_url("https://example.com\x1b]52;c;hi\x1b\\").is_none());
1210        // Empty / oversize.
1211        assert!(sanitize_osc8_url("").is_none());
1212        assert!(sanitize_osc8_url(&"a".repeat(2049)).is_none());
1213    }
1214
1215    #[test]
1216    fn is_valid_osc8_url_matches_sanitize() {
1217        // is_valid_osc8_url must agree with sanitize_osc8_url on every input.
1218        // If the two ever drift, the OSC 8 flush path either rejects
1219        // legitimate URLs (silent) or admits dangerous ones (security).
1220        let oversize = "x".repeat(2049);
1221        let cases: &[&str] = &[
1222            "https://example.com",
1223            "http://localhost:8080/path?q=1#frag",
1224            "ftp://[::1]/file",
1225            "",
1226            &oversize,
1227            "https://evil.com\x1b]52;c;inject\x1b\\",
1228            "https://evil.com\x07bel",
1229            "https://example.com\x7f",
1230            "https://example.com\x00",
1231        ];
1232        for url in cases {
1233            assert_eq!(
1234                is_valid_osc8_url(url),
1235                sanitize_osc8_url(url).is_some(),
1236                "is_valid_osc8_url and sanitize_osc8_url disagree on {url:?}"
1237            );
1238        }
1239    }
1240
1241    #[test]
1242    fn set_string_inner_parity_no_link() {
1243        // set_string and set_string_linked with an invalid URL must produce
1244        // identical buffer state (link rejected → None).
1245        let area = Rect::new(0, 0, 20, 1);
1246        let mut buf_a = Buffer::empty(area);
1247        let mut buf_b = Buffer::empty(area);
1248        let style = Style::new();
1249
1250        buf_a.set_string(0, 0, "Hello wide世界", style);
1251        buf_b.set_string_linked(0, 0, "Hello wide世界", style, "");
1252
1253        for x in 0..20 {
1254            let ca = buf_a.get(x, 0);
1255            let cb = buf_b.get(x, 0);
1256            assert_eq!(ca.symbol, cb.symbol, "symbol mismatch at x={x}");
1257            assert_eq!(ca.style, cb.style, "style mismatch at x={x}");
1258            assert_eq!(
1259                cb.hyperlink, None,
1260                "invalid URL must produce None hyperlink at x={x}"
1261            );
1262        }
1263    }
1264
1265    #[test]
1266    fn set_string_linked_attaches_hyperlink_to_wide_char_pair() {
1267        // Wide chars span two cells; both must carry the same hyperlink.
1268        let area = Rect::new(0, 0, 4, 1);
1269        let mut buf = Buffer::empty(area);
1270        buf.set_string_linked(0, 0, "世", Style::new(), "https://example.com");
1271        let leading = buf.get(0, 0);
1272        let trailing = buf.get(1, 0);
1273        assert_eq!(leading.symbol, "世");
1274        assert!(trailing.symbol.is_empty(), "wide-char trailing must blank");
1275        assert!(leading.hyperlink.is_some());
1276        assert_eq!(leading.hyperlink, trailing.hyperlink);
1277    }
1278
1279    #[test]
1280    fn try_get_out_of_bounds_returns_none() {
1281        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
1282        assert!(buf.try_get(0, 0).is_some());
1283        assert!(buf.try_get(2, 0).is_none());
1284        assert!(buf.try_get(0, 2).is_none());
1285        assert!(buf.try_get_mut(5, 5).is_none());
1286    }
1287
1288    #[test]
1289    fn kitty_clip_stack_restores_outer_on_pop() {
1290        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 4));
1291        assert!(buf.current_kitty_clip().is_none());
1292
1293        let outer = KittyClipInfo {
1294            top_clip_rows: 2,
1295            original_height: 10,
1296        };
1297        let inner = KittyClipInfo {
1298            top_clip_rows: 5,
1299            original_height: 20,
1300        };
1301
1302        buf.push_kitty_clip(outer);
1303        assert_eq!(buf.current_kitty_clip(), Some(&outer));
1304
1305        // Nested region pushes its own frame.
1306        buf.push_kitty_clip(inner);
1307        assert_eq!(buf.current_kitty_clip(), Some(&inner));
1308
1309        // After inner pops, outer MUST still be active — the bug this
1310        // refactor fixes is exactly that the outer was previously clobbered.
1311        let popped_inner = buf.pop_kitty_clip();
1312        assert_eq!(popped_inner, Some(inner));
1313        assert_eq!(buf.current_kitty_clip(), Some(&outer));
1314
1315        let popped_outer = buf.pop_kitty_clip();
1316        assert_eq!(popped_outer, Some(outer));
1317        assert!(buf.current_kitty_clip().is_none());
1318    }
1319
1320    #[test]
1321    fn kitty_clip_stack_cleared_on_reset() {
1322        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
1323        buf.push_kitty_clip(KittyClipInfo {
1324            top_clip_rows: 1,
1325            original_height: 2,
1326        });
1327        buf.push_kitty_clip(KittyClipInfo {
1328            top_clip_rows: 3,
1329            original_height: 4,
1330        });
1331        buf.reset();
1332        assert!(buf.kitty_clip_info_stack.is_empty());
1333        assert!(buf.current_kitty_clip().is_none());
1334    }
1335
1336    #[test]
1337    fn kitty_clip_pop_on_empty_stack_is_none() {
1338        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
1339        assert!(buf.pop_kitty_clip().is_none());
1340    }
1341
1342    // ---- snapshot_format tests (#231) -------------------------------------
1343
1344    #[test]
1345    fn snapshot_format_default_style_unannotated() {
1346        let mut buf = Buffer::empty(Rect::new(0, 0, 5, 1));
1347        buf.set_string(0, 0, "abc", Style::new());
1348        // Two trailing default cells render as raw spaces.
1349        assert_eq!(buf.snapshot_format(), "abc  ");
1350    }
1351
1352    #[test]
1353    fn snapshot_format_color_runs_grouped() {
1354        use crate::style::Color;
1355        let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
1356        buf.set_string(0, 0, "abc", Style::new().fg(Color::Red));
1357        buf.set_string(3, 0, "def", Style::new().fg(Color::Blue));
1358        let snap = buf.snapshot_format();
1359        assert_eq!(snap, "[fg=red]\"abc\"[/][fg=blue]\"def\"[/]");
1360    }
1361
1362    #[test]
1363    fn snapshot_format_modifier_transitions() {
1364        let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
1365        buf.set_string(0, 0, "ab", Style::new().bold());
1366        // gap with default style
1367        buf.set_string(2, 0, "cd", Style::new());
1368        buf.set_string(4, 0, "ef", Style::new().bold());
1369        let snap = buf.snapshot_format();
1370        assert_eq!(snap, "[bold]\"ab\"[/]cd[bold]\"ef\"[/]");
1371    }
1372
1373    #[test]
1374    fn snapshot_format_deterministic() {
1375        use crate::style::Color;
1376        let mut buf = Buffer::empty(Rect::new(0, 0, 8, 2));
1377        buf.set_string(0, 0, "hello", Style::new().fg(Color::Cyan).bold());
1378        buf.set_string(0, 1, "world", Style::new().bg(Color::Rgb(10, 20, 30)));
1379        let a = buf.snapshot_format();
1380        let b = buf.snapshot_format();
1381        assert_eq!(a, b, "snapshot_format must be deterministic");
1382        // Verify byte length equality as a stronger anti-flake guarantee.
1383        assert_eq!(a.len(), b.len());
1384    }
1385
1386    #[test]
1387    fn snapshot_format_empty_buffer_is_spaces() {
1388        let buf = Buffer::empty(Rect::new(0, 0, 4, 2));
1389        // 4 default-style spaces per row, joined by '\n'.
1390        assert_eq!(buf.snapshot_format(), "    \n    ");
1391    }
1392
1393    #[test]
1394    fn snapshot_format_zero_dim_returns_empty() {
1395        let buf_a = Buffer::empty(Rect::new(0, 0, 0, 4));
1396        let buf_b = Buffer::empty(Rect::new(0, 0, 4, 0));
1397        assert_eq!(buf_a.snapshot_format(), "");
1398        assert_eq!(buf_b.snapshot_format(), "");
1399    }
1400
1401    #[test]
1402    fn snapshot_format_rgb_uses_hex_codes() {
1403        use crate::style::Color;
1404        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
1405        buf.set_string(0, 0, "x", Style::new().fg(Color::Rgb(0xff, 0x00, 0xab)));
1406        let snap = buf.snapshot_format();
1407        assert!(
1408            snap.contains("fg=#ff00ab"),
1409            "expected hex RGB code, got {snap:?}"
1410        );
1411    }
1412
1413    #[test]
1414    fn snapshot_format_indexed_color() {
1415        use crate::style::Color;
1416        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
1417        buf.set_string(0, 0, "x", Style::new().fg(Color::Indexed(42)));
1418        assert!(buf.snapshot_format().contains("fg=idx42"));
1419    }
1420
1421    #[test]
1422    fn snapshot_format_modifiers_canonical_order() {
1423        // Insert in reverse order; output must still be canonical.
1424        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
1425        let style = Style::new().strikethrough().italic().bold();
1426        buf.set_string(0, 0, "x", style);
1427        let snap = buf.snapshot_format();
1428        // Order in output: bold, italic, strikethrough.
1429        let bold_idx = snap.find("bold").expect("bold present");
1430        let italic_idx = snap.find("italic").expect("italic present");
1431        let strike_idx = snap.find("strikethrough").expect("strikethrough present");
1432        assert!(bold_idx < italic_idx);
1433        assert!(italic_idx < strike_idx);
1434    }
1435
1436    #[test]
1437    fn snapshot_format_escapes_quote_and_backslash() {
1438        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
1439        buf.set_string(0, 0, "a\"b\\", Style::new().bold());
1440        let snap = buf.snapshot_format();
1441        // Embedded quote → \" and backslash → \\
1442        assert!(
1443            snap.contains("\"a\\\"b\\\\\""),
1444            "expected escapes, got {snap:?}"
1445        );
1446    }
1447
1448    #[test]
1449    fn snapshot_format_multi_row_uses_newlines() {
1450        let mut buf = Buffer::empty(Rect::new(0, 0, 3, 3));
1451        buf.set_string(0, 0, "aaa", Style::new());
1452        buf.set_string(0, 1, "bbb", Style::new());
1453        buf.set_string(0, 2, "ccc", Style::new());
1454        assert_eq!(buf.snapshot_format(), "aaa\nbbb\nccc");
1455    }
1456
1457    // ---- per-row hash skip (#171) -----------------------------------------
1458
1459    #[test]
1460    fn line_dirty_initial_state_is_all_dirty() {
1461        // Fresh buffer must start with every row dirty so the first flush
1462        // refreshes hashes before the per-row skip ever fires.
1463        let buf = Buffer::empty(Rect::new(0, 0, 4, 3));
1464        assert_eq!(buf.line_dirty.len(), 3);
1465        assert!(buf.line_dirty.iter().all(|d| *d));
1466    }
1467
1468    #[test]
1469    fn set_string_marks_row_dirty() {
1470        // After a recompute every row is clean. A subsequent write must
1471        // re-mark the touched row as dirty so its hash gets refreshed.
1472        let mut buf = Buffer::empty(Rect::new(0, 0, 8, 4));
1473        buf.recompute_line_hashes();
1474        assert!(buf.line_dirty.iter().all(|d| !*d));
1475
1476        buf.set_string(0, 1, "hello", Style::new());
1477        assert!(!buf.line_dirty[0]);
1478        assert!(buf.line_dirty[1]);
1479        assert!(!buf.line_dirty[2]);
1480        assert!(!buf.line_dirty[3]);
1481    }
1482
1483    #[test]
1484    fn set_char_marks_row_dirty() {
1485        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 3));
1486        buf.recompute_line_hashes();
1487        buf.set_char(2, 2, 'X', Style::new());
1488        assert!(!buf.line_dirty[0]);
1489        assert!(!buf.line_dirty[1]);
1490        assert!(buf.line_dirty[2]);
1491    }
1492
1493    #[test]
1494    fn recompute_line_hashes_clears_dirty_and_caches_hashes() {
1495        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 2));
1496        buf.set_string(0, 0, "abcd", Style::new());
1497        buf.set_string(0, 1, "wxyz", Style::new());
1498        buf.recompute_line_hashes();
1499
1500        assert!(buf.line_dirty.iter().all(|d| !*d));
1501        // Different content → different hashes.
1502        assert_ne!(buf.line_hashes[0], buf.line_hashes[1]);
1503        assert!(buf.row_clean(0));
1504        assert!(buf.row_clean(1));
1505    }
1506
1507    #[test]
1508    fn row_clean_returns_false_for_unrecomputed_or_dirty_row() {
1509        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 2));
1510        // Initial state — every row dirty until recompute.
1511        assert!(!buf.row_clean(0));
1512        buf.recompute_line_hashes();
1513        assert!(buf.row_clean(0));
1514        // Touching the row re-marks it dirty.
1515        buf.set_string(0, 0, "z", Style::new());
1516        assert!(!buf.row_clean(0));
1517    }
1518
1519    #[test]
1520    fn identical_buffers_share_line_hashes_after_recompute() {
1521        // Foundation of the flush short-circuit: two buffers with the same
1522        // cells must produce equal per-row digests.
1523        let area = Rect::new(0, 0, 5, 3);
1524        let mut a = Buffer::empty(area);
1525        let mut b = Buffer::empty(area);
1526        a.set_string(0, 0, "hello", Style::new());
1527        b.set_string(0, 0, "hello", Style::new());
1528        a.set_string(0, 1, "world", Style::new());
1529        b.set_string(0, 1, "world", Style::new());
1530        a.recompute_line_hashes();
1531        b.recompute_line_hashes();
1532
1533        assert_eq!(a.row_hash(0), b.row_hash(0));
1534        assert_eq!(a.row_hash(1), b.row_hash(1));
1535        // Untouched row 2 — both buffers have it as default-cell row.
1536        assert_eq!(a.row_hash(2), b.row_hash(2));
1537    }
1538
1539    #[test]
1540    fn different_styles_yield_different_line_hashes() {
1541        // Identical glyph but different style must still hash distinctly —
1542        // the flush would otherwise emit the wrong style if it skipped a
1543        // "matching" row.
1544        use crate::style::Color;
1545        let area = Rect::new(0, 0, 3, 1);
1546        let mut a = Buffer::empty(area);
1547        let mut b = Buffer::empty(area);
1548        a.set_string(0, 0, "abc", Style::new().fg(Color::Red));
1549        b.set_string(0, 0, "abc", Style::new().fg(Color::Blue));
1550        a.recompute_line_hashes();
1551        b.recompute_line_hashes();
1552
1553        assert_ne!(a.row_hash(0), b.row_hash(0));
1554    }
1555
1556    #[test]
1557    fn resize_keeps_line_arrays_in_sync() {
1558        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 3));
1559        buf.recompute_line_hashes();
1560        // Grow → all rows dirty + arrays sized to new height.
1561        buf.resize(Rect::new(0, 0, 4, 5));
1562        assert_eq!(buf.line_dirty.len(), 5);
1563        assert_eq!(buf.line_hashes.len(), 5);
1564        assert!(buf.line_dirty.iter().all(|d| *d));
1565        // Shrink — same invariants.
1566        buf.resize(Rect::new(0, 0, 4, 2));
1567        assert_eq!(buf.line_dirty.len(), 2);
1568        assert_eq!(buf.line_hashes.len(), 2);
1569        assert!(buf.line_dirty.iter().all(|d| *d));
1570    }
1571
1572    #[test]
1573    fn fnv1a_distinct_rows_distinct_identical_rows_collide() {
1574        // After swapping SipHash for FNV-1a, the dirty-row digest must keep its
1575        // two contract guarantees: distinct content → distinct digest, and
1576        // identical content → identical digest (deterministic within a run).
1577        let area = Rect::new(0, 0, 5, 3);
1578        let mut buf = Buffer::empty(area);
1579        buf.set_string(0, 0, "alpha", Style::new());
1580        buf.set_string(0, 1, "alpha", Style::new()); // identical to row 0
1581        buf.set_string(0, 2, "omega", Style::new()); // distinct
1582        buf.recompute_line_hashes();
1583
1584        assert_eq!(
1585            buf.row_hash(0),
1586            buf.row_hash(1),
1587            "identical rows must collide"
1588        );
1589        assert_ne!(
1590            buf.row_hash(0),
1591            buf.row_hash(2),
1592            "distinct rows must not collide"
1593        );
1594    }
1595
1596    #[test]
1597    fn fnv1a_hash_rgba_is_deterministic_and_content_sensitive() {
1598        // `hash_rgba` (now FNV-1a) underpins Kitty image dedup: equal pixels
1599        // must dedup (equal hash), differing pixels must not.
1600        let a = [1u8, 2, 3, 4];
1601        let b = [1u8, 2, 3, 4];
1602        let c = [1u8, 2, 3, 5];
1603        assert_eq!(hash_rgba(&a), hash_rgba(&b));
1604        assert_ne!(hash_rgba(&a), hash_rgba(&c));
1605        // Determinism within the run.
1606        assert_eq!(hash_rgba(&a), hash_rgba(&a));
1607    }
1608
1609    // ── Bidi (UAX #9) reordering ────────────────────────────────────────
1610    //
1611    // `line_visual` reads a buffer row left-to-right by column and trims
1612    // trailing blanks — exactly the visual order a reader sees, which is the
1613    // correct oracle for asserting reorder output.
1614    #[cfg(feature = "bidi")]
1615    fn line_visual(buf: &Buffer, y: u32) -> String {
1616        let mut s = String::new();
1617        for x in buf.area.x..buf.area.right() {
1618            let sym = buf.get(x, y).symbol.as_str();
1619            if sym.is_empty() {
1620                continue; // wide-char trailing cell
1621            }
1622            s.push_str(sym);
1623        }
1624        s.trim_end().to_string()
1625    }
1626
1627    #[cfg(feature = "bidi")]
1628    #[test]
1629    fn needs_bidi_reorder_false_for_pure_ltr() {
1630        // Pure-LTR strings take the zero-allocation fast path.
1631        assert!(!needs_bidi_reorder("Hello, world 123"));
1632        assert!(!needs_bidi_reorder(""));
1633        assert!(!needs_bidi_reorder("café résumé"));
1634        assert!(!needs_bidi_reorder("世界 CJK wide"));
1635    }
1636
1637    #[cfg(feature = "bidi")]
1638    #[test]
1639    fn needs_bidi_reorder_true_for_rtl_and_controls() {
1640        assert!(needs_bidi_reorder("שלום")); // Hebrew
1641        assert!(needs_bidi_reorder("شكرا")); // Arabic
1642        assert!(needs_bidi_reorder("abc אבג def")); // mixed
1643        assert!(needs_bidi_reorder("a\u{202E}bc")); // RLO control
1644        assert!(needs_bidi_reorder("\u{200F}")); // RLM
1645    }
1646
1647    #[cfg(feature = "bidi")]
1648    #[test]
1649    fn set_string_ltr_unchanged_by_reorder_path() {
1650        // Regression guard: LTR text must NOT be reordered.
1651        let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
1652        buf.set_string(0, 0, "abcde", Style::new());
1653        assert_eq!(buf.get(0, 0).symbol, "a");
1654        assert_eq!(buf.get(1, 0).symbol, "b");
1655        assert_eq!(buf.get(2, 0).symbol, "c");
1656        assert_eq!(buf.get(3, 0).symbol, "d");
1657        assert_eq!(buf.get(4, 0).symbol, "e");
1658    }
1659
1660    #[cfg(feature = "bidi")]
1661    #[test]
1662    fn set_string_pure_rtl_reverses_to_visual_order() {
1663        // Hebrew "שלום" is logical ש,ל,ו,ם. In visual order the first
1664        // logical char (ש) lands on the rightmost column and the last (ם)
1665        // on the leftmost — i.e. the row reads "םולש" left-to-right.
1666        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
1667        buf.set_string(0, 0, "\u{05E9}\u{05DC}\u{05D5}\u{05DD}", Style::new());
1668        // column 0 == last logical char, last column == first logical char
1669        assert_eq!(buf.get(0, 0).symbol, "\u{05DD}"); // ם
1670        assert_eq!(buf.get(3, 0).symbol, "\u{05E9}"); // ש
1671        assert_eq!(line_visual(&buf, 0), "\u{05DD}\u{05D5}\u{05DC}\u{05E9}");
1672    }
1673
1674    #[cfg(feature = "bidi")]
1675    #[test]
1676    fn set_string_mixed_ltr_rtl_run() {
1677        // Per UAX #9 (unicode-bidi reference vectors): "abc אבג" → "abc גבא".
1678        // The Latin segment keeps LTR order; the Hebrew segment reverses.
1679        let mut buf = Buffer::empty(Rect::new(0, 0, 8, 1));
1680        buf.set_string(0, 0, "abc \u{05D0}\u{05D1}\u{05D2}", Style::new());
1681        assert_eq!(line_visual(&buf, 0), "abc \u{05D2}\u{05D1}\u{05D0}");
1682    }
1683
1684    #[cfg(feature = "bidi")]
1685    #[test]
1686    fn set_string_numbers_inside_rtl_stay_ltr() {
1687        // "123 אבג" → "גבא 123": European numbers are weak LTR and cannot
1688        // reorder a strong RTL run, so the digits stay "123" left-to-right
1689        // while the Hebrew reverses (unicode-bidi reference vector).
1690        let mut buf = Buffer::empty(Rect::new(0, 0, 8, 1));
1691        buf.set_string(0, 0, "123 \u{05D0}\u{05D1}\u{05D2}", Style::new());
1692        assert_eq!(line_visual(&buf, 0), "\u{05D2}\u{05D1}\u{05D0} 123");
1693    }
1694
1695    #[cfg(feature = "bidi")]
1696    #[test]
1697    fn set_string_wide_char_with_rtl_blanks_trailing_cell() {
1698        // A CJK wide glyph mixed with Hebrew: after reorder the wide char's
1699        // trailing cell must still be blanked at the correct visual column.
1700        // Logical "世 אב" → the wide 世 stays leftmost (LTR base), Hebrew
1701        // reverses to "בא". Visual: 世 (cols 0-1), space (col 2), ב (3) א (4).
1702        let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
1703        buf.set_string(0, 0, "\u{4E16} \u{05D0}\u{05D1}", Style::new());
1704        assert_eq!(buf.get(0, 0).symbol, "\u{4E16}"); // 世 leading
1705        assert!(buf.get(1, 0).symbol.is_empty(), "wide trailing must blank");
1706        assert_eq!(buf.get(3, 0).symbol, "\u{05D1}"); // ב
1707        assert_eq!(buf.get(4, 0).symbol, "\u{05D0}"); // א
1708    }
1709
1710    #[cfg(feature = "bidi")]
1711    #[test]
1712    fn set_string_linked_hyperlink_survives_reorder() {
1713        // Every non-blank emitted cell of an RTL link must carry the URL,
1714        // regardless of its new visual column.
1715        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
1716        buf.set_string_linked(
1717            0,
1718            0,
1719            "\u{05E9}\u{05DC}\u{05D5}\u{05DD}",
1720            Style::new(),
1721            "https://example.com",
1722        );
1723        for x in 0..4 {
1724            let cell = buf.get(x, 0);
1725            assert!(
1726                cell.hyperlink.is_some(),
1727                "hyperlink missing at visual column {x}"
1728            );
1729        }
1730    }
1731
1732    #[cfg(feature = "bidi")]
1733    #[test]
1734    fn set_string_control_chars_filtered_in_rtl() {
1735        // An ESC embedded in an RTL string must still be replaced with
1736        // U+FFFD — the reorder path must not bypass sanitize_cell_char.
1737        let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
1738        buf.set_string(0, 0, "\u{05D0}\x1b\u{05D1}", Style::new());
1739        let mut found_replacement = false;
1740        for x in 0..6 {
1741            let sym = buf.get(x, 0).symbol.as_str();
1742            assert!(!sym.contains('\x1b'), "ESC leaked into a cell");
1743            if sym.contains('\u{FFFD}') {
1744                found_replacement = true;
1745            }
1746        }
1747        assert!(found_replacement, "ESC was not replaced with U+FFFD");
1748    }
1749
1750    #[cfg(feature = "bidi")]
1751    #[test]
1752    fn reorder_line_visual_empty_is_noop() {
1753        assert_eq!(reorder_line_visual(""), "");
1754    }
1755
1756    #[cfg(feature = "bidi")]
1757    mod bidi_proptest {
1758        use super::{needs_bidi_reorder, reorder_line_visual};
1759        use proptest::prelude::*;
1760
1761        proptest! {
1762            #![proptest_config(ProptestConfig::with_cases(256))]
1763
1764            /// Fast-path no-op: arbitrary ASCII strings never need reordering.
1765            #[test]
1766            fn ascii_takes_fast_path_and_reorder_is_identity(s in "[ -~]{0,64}") {
1767                prop_assert!(!needs_bidi_reorder(&s));
1768                // Even if forced through the reorder, ASCII is a no-op permutation.
1769                prop_assert_eq!(reorder_line_visual(&s), s);
1770            }
1771
1772            /// Reorder is a pure permutation of scalar values: it never adds,
1773            /// drops, or mutates a codepoint.
1774            ///
1775            /// Note: total *display width* is deliberately NOT asserted —
1776            /// `unicode-width` 0.2 is contextual (e.g. Arabic lam+alef forms a
1777            /// single-cell ligature while alef+lam does not), so reordering can
1778            /// legitimately change the rendered cell count. The invariant that
1779            /// actually holds is multiset equality of `char`s.
1780            #[test]
1781            fn reorder_is_codepoint_permutation(
1782                s in "[a-z\\x{05D0}-\\x{05EA}\\x{0627}-\\x{064A}0-9 ]{0,48}"
1783            ) {
1784                let mut before: Vec<char> = s.chars().collect();
1785                let mut after: Vec<char> = reorder_line_visual(&s).chars().collect();
1786                before.sort_unstable();
1787                after.sort_unstable();
1788                prop_assert_eq!(before, after);
1789            }
1790        }
1791    }
1792}