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}