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