Skip to main content

slt/
buffer.rs

1//! Double-buffer grid of [`Cell`]s with clip-stack support.
2//!
3//! Two buffers are maintained per frame (current and previous). Only the diff
4//! is flushed to the terminal, giving immediate-mode ergonomics with
5//! retained-mode efficiency.
6
7use std::hash::{Hash, Hasher};
8use std::sync::Arc;
9
10use crate::cell::Cell;
11use crate::rect::Rect;
12use crate::style::Style;
13use unicode_width::UnicodeWidthChar;
14
15/// Maximum bytes allowed in a single cell's `symbol` field.
16///
17/// A grapheme cluster rarely exceeds ~16 bytes in the wild; anything
18/// longer is typically an attempt to weaponize zero-width combining chars.
19/// This cap bounds the worst case flush cost per cell.
20const MAX_CELL_SYMBOL_BYTES: usize = 32;
21
22/// Hard cap on pixel count processed by image decode/encode paths.
23///
24/// 16_777_216 ≈ 4096×4096 — well above any sane terminal image payload,
25/// but guards 32-bit targets (WASM) from overflow and prevents a
26/// hostile `width`/`height` pair from triggering multi-GiB allocations.
27pub(crate) const MAX_IMAGE_PIXELS: u64 = 16_777_216;
28
29/// Replace terminal-dangerous control characters with `U+FFFD`.
30///
31/// Unfiltered C0 (0x00–0x1F), DEL (0x7F), or C1 (0x80–0x9F) bytes can
32/// break out of cell rendering and inject arbitrary escape sequences
33/// (cursor moves, OSC 52 clipboard, title spoof, etc.) when flushed.
34/// Replacing with the replacement character keeps byte counts sane and
35/// makes the tampering visible.
36#[inline]
37fn sanitize_cell_char(ch: char) -> char {
38    let c = ch as u32;
39    if c < 0x20 || c == 0x7f || (0x80..=0x9f).contains(&c) {
40        '\u{FFFD}'
41    } else {
42        ch
43    }
44}
45
46/// Structured Kitty graphics protocol image placement.
47///
48/// Stored separately from raw escape sequences so the terminal can manage
49/// image IDs, compression, and placement lifecycle. Images are deduplicated
50/// by `content_hash` — identical pixel data is uploaded only once.
51#[derive(Clone, Debug)]
52#[allow(dead_code)]
53pub(crate) struct KittyPlacement {
54    /// Hash of the RGBA pixel data for dedup (avoids re-uploading).
55    pub content_hash: u64,
56    /// Reference-counted raw RGBA pixel data (shared across frames).
57    pub rgba: Arc<Vec<u8>>,
58    /// Source image width in pixels.
59    pub src_width: u32,
60    /// Source image height in pixels.
61    pub src_height: u32,
62    /// Screen cell position.
63    pub x: u32,
64    pub y: u32,
65    /// Cell columns/rows to display.
66    pub cols: u32,
67    pub rows: u32,
68    /// Source crop Y offset in pixels (for scroll clipping).
69    pub crop_y: u32,
70    /// Source crop height in pixels (0 = full height from crop_y).
71    pub crop_h: u32,
72}
73
74/// Compute a content hash for RGBA pixel data.
75pub(crate) fn hash_rgba(data: &[u8]) -> u64 {
76    let mut hasher = std::collections::hash_map::DefaultHasher::new();
77    data.hash(&mut hasher);
78    hasher.finish()
79}
80
81impl PartialEq for KittyPlacement {
82    fn eq(&self, other: &Self) -> bool {
83        self.content_hash == other.content_hash
84            && self.x == other.x
85            && self.y == other.y
86            && self.cols == other.cols
87            && self.rows == other.rows
88            && self.crop_y == other.crop_y
89            && self.crop_h == other.crop_h
90    }
91}
92
93/// Scroll clip information applied to Kitty image placements emitted inside a
94/// raw-draw callback.
95///
96/// Stored on a stack so that nested raw-draw regions restore the outer clip
97/// info on pop, rather than silently clobbering it.
98#[derive(Clone, Copy, Debug, PartialEq, Eq)]
99pub(crate) struct KittyClipInfo {
100    /// Rows of the source region already scrolled off the top.
101    pub top_clip_rows: u32,
102    /// Original total row count of the scrollable content.
103    pub original_height: u32,
104}
105
106/// A 2D grid of [`Cell`]s backing the terminal display.
107///
108/// Two buffers are kept (current + previous); only the diff is flushed to the
109/// terminal, giving immediate-mode ergonomics with retained-mode efficiency.
110///
111/// The buffer also maintains a clip stack. Push a [`Rect`] with
112/// [`Buffer::push_clip`] to restrict writes to that region, and pop it with
113/// [`Buffer::pop_clip`] when done.
114pub struct Buffer {
115    /// The area this buffer covers, in terminal coordinates.
116    pub area: Rect,
117    /// Flat row-major storage of all cells. Length equals `area.width * area.height`.
118    pub content: Vec<Cell>,
119    pub(crate) clip_stack: Vec<Rect>,
120    pub(crate) raw_sequences: Vec<(u32, u32, String)>,
121    pub(crate) kitty_placements: Vec<KittyPlacement>,
122    pub(crate) cursor_pos: Option<(u32, u32)>,
123    /// Stack of scroll clip infos set by the run loop before invoking draw
124    /// closures. The top entry is the active clip; nested raw-draw regions
125    /// push and pop without losing the outer clip.
126    pub(crate) kitty_clip_info_stack: Vec<KittyClipInfo>,
127}
128
129impl Buffer {
130    /// Create a buffer filled with blank cells covering `area`.
131    pub fn empty(area: Rect) -> Self {
132        let size = area.area() as usize;
133        Self {
134            area,
135            content: vec![Cell::default(); size],
136            clip_stack: Vec::new(),
137            raw_sequences: Vec::new(),
138            kitty_placements: Vec::new(),
139            cursor_pos: None,
140            kitty_clip_info_stack: Vec::new(),
141        }
142    }
143
144    /// Push a scroll clip info frame. Paired with [`Buffer::pop_kitty_clip`].
145    pub(crate) fn push_kitty_clip(&mut self, info: KittyClipInfo) {
146        self.kitty_clip_info_stack.push(info);
147    }
148
149    /// Pop the most recently pushed scroll clip info frame.
150    pub(crate) fn pop_kitty_clip(&mut self) -> Option<KittyClipInfo> {
151        self.kitty_clip_info_stack.pop()
152    }
153
154    /// Peek the currently active scroll clip info, if any.
155    pub(crate) fn current_kitty_clip(&self) -> Option<&KittyClipInfo> {
156        self.kitty_clip_info_stack.last()
157    }
158
159    pub(crate) fn set_cursor_pos(&mut self, x: u32, y: u32) {
160        self.cursor_pos = Some((x, y));
161    }
162
163    #[cfg(feature = "crossterm")]
164    pub(crate) fn cursor_pos(&self) -> Option<(u32, u32)> {
165        self.cursor_pos
166    }
167
168    /// Store a raw escape sequence to be written at position `(x, y)` during flush.
169    ///
170    /// Used for Sixel images and other passthrough sequences.
171    /// Respects the clip stack: sequences fully outside the current clip are skipped.
172    pub fn raw_sequence(&mut self, x: u32, y: u32, seq: String) {
173        if let Some(clip) = self.effective_clip() {
174            if x >= clip.right() || y >= clip.bottom() {
175                return;
176            }
177        }
178        self.raw_sequences.push((x, y, seq));
179    }
180
181    /// Store a structured Kitty graphics protocol placement.
182    ///
183    /// Unlike `raw_sequence`, Kitty placements are managed with image IDs,
184    /// compression, and placement lifecycle by the terminal flush code.
185    /// Scroll crop info is automatically applied from the top of the
186    /// `kitty_clip_info_stack` (set via [`Buffer::push_kitty_clip`]).
187    pub(crate) fn kitty_place(&mut self, mut p: KittyPlacement) {
188        // Apply clip check
189        if let Some(clip) = self.effective_clip() {
190            if p.x >= clip.right()
191                || p.y >= clip.bottom()
192                || p.x + p.cols <= clip.x
193                || p.y + p.rows <= clip.y
194            {
195                return;
196            }
197        }
198
199        // Apply scroll crop info if any frame is active
200        if let Some(info) = self.current_kitty_clip() {
201            let top_clip_rows = info.top_clip_rows;
202            let original_height = info.original_height;
203            if original_height > 0 && (top_clip_rows > 0 || p.rows < original_height) {
204                let ratio = p.src_height as f64 / original_height as f64;
205                p.crop_y = (top_clip_rows as f64 * ratio) as u32;
206                let bottom_clip = original_height.saturating_sub(top_clip_rows + p.rows);
207                let bottom_pixels = (bottom_clip as f64 * ratio) as u32;
208                p.crop_h = p.src_height.saturating_sub(p.crop_y + bottom_pixels);
209            }
210        }
211
212        self.kitty_placements.push(p);
213    }
214
215    /// Push a clipping rectangle onto the clip stack.
216    ///
217    /// Subsequent writes are restricted to the intersection of all active clip
218    /// regions. Nested calls intersect with the current clip, so the effective
219    /// clip can only shrink, never grow.
220    pub fn push_clip(&mut self, rect: Rect) {
221        let effective = if let Some(current) = self.clip_stack.last() {
222            intersect_rects(*current, rect)
223        } else {
224            rect
225        };
226        self.clip_stack.push(effective);
227    }
228
229    /// Pop the most recently pushed clipping rectangle.
230    ///
231    /// After this call, writes are clipped to the previous region (or
232    /// unclipped if the stack is now empty).
233    pub fn pop_clip(&mut self) {
234        self.clip_stack.pop();
235    }
236
237    fn effective_clip(&self) -> Option<&Rect> {
238        self.clip_stack.last()
239    }
240
241    #[inline]
242    fn index_of(&self, x: u32, y: u32) -> usize {
243        ((y - self.area.y) * self.area.width + (x - self.area.x)) as usize
244    }
245
246    /// Returns `true` if `(x, y)` is within the buffer's area.
247    #[inline]
248    pub fn in_bounds(&self, x: u32, y: u32) -> bool {
249        x >= self.area.x && x < self.area.right() && y >= self.area.y && y < self.area.bottom()
250    }
251
252    /// Return a reference to the cell at `(x, y)`.
253    ///
254    /// Panics if `(x, y)` is out of bounds. Use [`Buffer::try_get`] when the
255    /// coordinates may come from untrusted input.
256    #[inline]
257    pub fn get(&self, x: u32, y: u32) -> &Cell {
258        assert!(
259            self.in_bounds(x, y),
260            "Buffer::get({x}, {y}) out of bounds for area {:?}",
261            self.area
262        );
263        &self.content[self.index_of(x, y)]
264    }
265
266    /// Return a mutable reference to the cell at `(x, y)`.
267    ///
268    /// Panics if `(x, y)` is out of bounds. Use [`Buffer::try_get_mut`] when
269    /// the coordinates may come from untrusted input.
270    #[inline]
271    pub fn get_mut(&mut self, x: u32, y: u32) -> &mut Cell {
272        assert!(
273            self.in_bounds(x, y),
274            "Buffer::get_mut({x}, {y}) out of bounds for area {:?}",
275            self.area
276        );
277        let idx = self.index_of(x, y);
278        &mut self.content[idx]
279    }
280
281    /// Return a reference to the cell at `(x, y)`, or `None` if out of bounds.
282    ///
283    /// Non-panicking counterpart to [`Buffer::get`]. Prefer this inside
284    /// `draw()` closures when coordinates are computed from mouse input,
285    /// scroll offsets, or other sources that could land outside the buffer.
286    #[inline]
287    pub fn try_get(&self, x: u32, y: u32) -> Option<&Cell> {
288        if self.in_bounds(x, y) {
289            Some(&self.content[self.index_of(x, y)])
290        } else {
291            None
292        }
293    }
294
295    /// Return a mutable reference to the cell at `(x, y)`, or `None` if out
296    /// of bounds.
297    ///
298    /// Non-panicking counterpart to [`Buffer::get_mut`].
299    #[inline]
300    pub fn try_get_mut(&mut self, x: u32, y: u32) -> Option<&mut Cell> {
301        if self.in_bounds(x, y) {
302            let idx = self.index_of(x, y);
303            Some(&mut self.content[idx])
304        } else {
305            None
306        }
307    }
308
309    /// Write a string into the buffer starting at `(x, y)`.
310    ///
311    /// Respects cell boundaries and Unicode character widths. Wide characters
312    /// (e.g., CJK) occupy two columns; the trailing cell is blanked. Writes
313    /// that fall outside the current clip region are skipped but still advance
314    /// the cursor position.
315    pub fn set_string(&mut self, mut x: u32, y: u32, s: &str, style: Style) {
316        if y >= self.area.bottom() {
317            return;
318        }
319        let clip = self.effective_clip().copied();
320        for ch in s.chars() {
321            if x >= self.area.right() {
322                break;
323            }
324            let ch = sanitize_cell_char(ch);
325            let char_width = UnicodeWidthChar::width(ch).unwrap_or(0) as u32;
326            if char_width == 0 {
327                // Append zero-width char (combining mark, ZWJ, variation selector)
328                // to the previous cell so grapheme clusters stay intact.
329                if x > self.area.x {
330                    let prev_in_clip = clip.map_or(true, |clip| {
331                        (x - 1) >= clip.x
332                            && (x - 1) < clip.right()
333                            && y >= clip.y
334                            && y < clip.bottom()
335                    });
336                    if prev_in_clip {
337                        let prev = self.get_mut(x - 1, y);
338                        if prev.symbol.len() + ch.len_utf8() <= MAX_CELL_SYMBOL_BYTES {
339                            prev.symbol.push(ch);
340                        }
341                    }
342                }
343                continue;
344            }
345
346            let in_clip = clip.map_or(true, |clip| {
347                x >= clip.x && x < clip.right() && y >= clip.y && y < clip.bottom()
348            });
349
350            if !in_clip {
351                x = x.saturating_add(char_width);
352                continue;
353            }
354
355            let cell = self.get_mut(x, y);
356            cell.set_char(ch);
357            cell.set_style(style);
358
359            // Wide characters occupy two cells; blank the trailing cell.
360            if char_width > 1 {
361                let next_x = x + 1;
362                if next_x < self.area.right() {
363                    let next = self.get_mut(next_x, y);
364                    next.symbol.clear();
365                    next.style = style;
366                }
367            }
368
369            x = x.saturating_add(char_width);
370        }
371    }
372
373    /// Write a hyperlinked string into the buffer starting at `(x, y)`.
374    ///
375    /// Like [`Buffer::set_string`] but attaches an OSC 8 hyperlink URL to each
376    /// cell. The terminal renders these cells as clickable links.
377    pub fn set_string_linked(&mut self, mut x: u32, y: u32, s: &str, style: Style, url: &str) {
378        if y >= self.area.bottom() {
379            return;
380        }
381        let clip = self.effective_clip().copied();
382        let sanitized_url = sanitize_osc8_url(url);
383        let link = sanitized_url.map(compact_str::CompactString::new);
384        for ch in s.chars() {
385            if x >= self.area.right() {
386                break;
387            }
388            let ch = sanitize_cell_char(ch);
389            let char_width = UnicodeWidthChar::width(ch).unwrap_or(0) as u32;
390            if char_width == 0 {
391                if x > self.area.x {
392                    let prev_in_clip = clip.map_or(true, |clip| {
393                        (x - 1) >= clip.x
394                            && (x - 1) < clip.right()
395                            && y >= clip.y
396                            && y < clip.bottom()
397                    });
398                    if prev_in_clip {
399                        let prev = self.get_mut(x - 1, y);
400                        if prev.symbol.len() + ch.len_utf8() <= MAX_CELL_SYMBOL_BYTES {
401                            prev.symbol.push(ch);
402                        }
403                    }
404                }
405                continue;
406            }
407
408            let in_clip = clip.map_or(true, |clip| {
409                x >= clip.x && x < clip.right() && y >= clip.y && y < clip.bottom()
410            });
411
412            if !in_clip {
413                x = x.saturating_add(char_width);
414                continue;
415            }
416
417            let cell = self.get_mut(x, y);
418            cell.set_char(ch);
419            cell.set_style(style);
420            cell.hyperlink = link.clone();
421
422            if char_width > 1 {
423                let next_x = x + 1;
424                if next_x < self.area.right() {
425                    let next = self.get_mut(next_x, y);
426                    next.symbol.clear();
427                    next.style = style;
428                    next.hyperlink = link.clone();
429                }
430            }
431
432            x = x.saturating_add(char_width);
433        }
434    }
435
436    /// Write a single character at `(x, y)` with the given style.
437    ///
438    /// No-ops if `(x, y)` is out of bounds or outside the current clip region.
439    pub fn set_char(&mut self, x: u32, y: u32, ch: char, style: Style) {
440        let in_clip = self.effective_clip().map_or(true, |clip| {
441            x >= clip.x && x < clip.right() && y >= clip.y && y < clip.bottom()
442        });
443        if !self.in_bounds(x, y) || !in_clip {
444            return;
445        }
446        let cell = self.get_mut(x, y);
447        cell.set_char(ch);
448        cell.set_style(style);
449    }
450
451    /// Compute the diff between `self` (current) and `other` (previous).
452    ///
453    /// Returns `(x, y, cell)` tuples for every cell that changed. The run loop
454    /// uses this to emit only the minimal set of terminal escape sequences
455    /// needed to update the display.
456    pub fn diff<'a>(&'a self, other: &'a Buffer) -> Vec<(u32, u32, &'a Cell)> {
457        let mut updates = Vec::new();
458        for y in self.area.y..self.area.bottom() {
459            for x in self.area.x..self.area.right() {
460                let cur = self.get(x, y);
461                let prev = other.get(x, y);
462                if cur != prev {
463                    updates.push((x, y, cur));
464                }
465            }
466        }
467        updates
468    }
469
470    /// Reset every cell to a blank space with default style, and clear the clip stack.
471    pub fn reset(&mut self) {
472        for cell in &mut self.content {
473            cell.reset();
474        }
475        self.clip_stack.clear();
476        self.raw_sequences.clear();
477        self.kitty_placements.clear();
478        self.cursor_pos = None;
479        self.kitty_clip_info_stack.clear();
480    }
481
482    /// Reset every cell and apply a background color to all cells.
483    pub fn reset_with_bg(&mut self, bg: crate::style::Color) {
484        for cell in &mut self.content {
485            cell.reset();
486            cell.style.bg = Some(bg);
487        }
488        self.clip_stack.clear();
489        self.raw_sequences.clear();
490        self.kitty_placements.clear();
491        self.cursor_pos = None;
492        self.kitty_clip_info_stack.clear();
493    }
494
495    /// Resize the buffer to fit a new area, resetting all cells.
496    ///
497    /// If the new area is larger, new cells are initialized to blank. All
498    /// existing content is discarded.
499    pub fn resize(&mut self, area: Rect) {
500        self.area = area;
501        let size = area.area() as usize;
502        self.content.resize(size, Cell::default());
503        self.reset();
504    }
505}
506
507/// Validate an OSC 8 hyperlink URL, returning `Some(url)` if safe to emit.
508///
509/// Rejects URLs containing control bytes, the BEL terminator, or an
510/// embedded ST (`ESC \`). Those would let an attacker-controlled URL
511/// prematurely close the OSC 8 sequence and inject arbitrary follow-up
512/// commands (e.g., OSC 52 clipboard writes). Also caps length at 2048
513/// bytes — longer than any legitimate URL and enough to prevent DoS via
514/// balloon-sized hyperlinks.
515pub(crate) fn sanitize_osc8_url(url: &str) -> Option<String> {
516    const MAX_URL_BYTES: usize = 2048;
517    if url.is_empty() || url.len() > MAX_URL_BYTES {
518        return None;
519    }
520    let bytes = url.as_bytes();
521    let mut i = 0;
522    while i < bytes.len() {
523        let b = bytes[i];
524        // Reject all C0 controls (incl. BEL, ESC), DEL, and C1 control range.
525        if b < 0x20 || b == 0x7f {
526            return None;
527        }
528        // Reject raw ESC \ (ST terminator) just in case something sneaked through.
529        if b == 0x1b {
530            return None;
531        }
532        i += 1;
533    }
534    Some(url.to_string())
535}
536
537fn intersect_rects(a: Rect, b: Rect) -> Rect {
538    let x = a.x.max(b.x);
539    let y = a.y.max(b.y);
540    let right = a.right().min(b.right());
541    let bottom = a.bottom().min(b.bottom());
542    let width = right.saturating_sub(x);
543    let height = bottom.saturating_sub(y);
544    Rect::new(x, y, width, height)
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550
551    #[test]
552    fn clip_stack_intersects_nested_regions() {
553        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 5));
554        buf.push_clip(Rect::new(1, 1, 6, 3));
555        buf.push_clip(Rect::new(4, 0, 6, 4));
556
557        buf.set_char(3, 2, 'x', Style::new());
558        buf.set_char(4, 2, 'y', Style::new());
559
560        assert_eq!(buf.get(3, 2).symbol, " ");
561        assert_eq!(buf.get(4, 2).symbol, "y");
562    }
563
564    #[test]
565    fn set_string_advances_even_when_clipped() {
566        let mut buf = Buffer::empty(Rect::new(0, 0, 8, 1));
567        buf.push_clip(Rect::new(2, 0, 6, 1));
568
569        buf.set_string(0, 0, "abcd", Style::new());
570
571        assert_eq!(buf.get(2, 0).symbol, "c");
572        assert_eq!(buf.get(3, 0).symbol, "d");
573    }
574
575    #[test]
576    fn pop_clip_restores_previous_clip() {
577        let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
578        buf.push_clip(Rect::new(0, 0, 2, 1));
579        buf.push_clip(Rect::new(4, 0, 2, 1));
580
581        buf.set_char(1, 0, 'a', Style::new());
582        buf.pop_clip();
583        buf.set_char(1, 0, 'b', Style::new());
584
585        assert_eq!(buf.get(1, 0).symbol, "b");
586    }
587
588    #[test]
589    fn reset_clears_clip_stack() {
590        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
591        buf.push_clip(Rect::new(0, 0, 0, 0));
592        buf.reset();
593        buf.set_char(0, 0, 'z', Style::new());
594
595        assert_eq!(buf.get(0, 0).symbol, "z");
596    }
597
598    #[test]
599    fn set_string_replaces_control_chars_with_replacement() {
600        let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
601        // ESC must never land in a cell — a flushed ESC would let the
602        // string escape its cell and execute as a real terminal command.
603        buf.set_string(0, 0, "a\x1bbc", Style::new());
604        assert_eq!(buf.get(0, 0).symbol, "a");
605        assert_eq!(buf.get(1, 0).symbol, "\u{FFFD}");
606        assert_eq!(buf.get(2, 0).symbol, "b");
607        assert_eq!(buf.get(3, 0).symbol, "c");
608    }
609
610    #[test]
611    fn zero_width_combining_does_not_append_control_bytes() {
612        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
613        buf.set_char(0, 0, 'a', Style::new());
614        // BEL is zero-width per unicode_width; the pre-fix code would have
615        // pushed it onto cell(0,0).symbol. After sanitize_cell_char it is
616        // replaced with U+FFFD and then appended (width 1, still fits).
617        buf.set_string(1, 0, "\x07", Style::new());
618        let symbol = buf.get(1, 0).symbol.as_str();
619        assert!(!symbol.contains('\x07'), "BEL leaked into cell symbol");
620    }
621
622    #[test]
623    fn set_string_caps_combining_overflow() {
624        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
625        buf.set_char(0, 0, 'a', Style::new());
626        // 200 copies of an ASCII-printable zero-width-ish char would bypass
627        // the byte cap. Use a legitimate zero-width combining character —
628        // U+0301 (combining acute accent) — and confirm the cap kicks in.
629        let combining: String = "\u{0301}".repeat(200);
630        buf.set_string(1, 0, &combining, Style::new());
631        assert!(
632            buf.get(0, 0).symbol.len() <= MAX_CELL_SYMBOL_BYTES,
633            "cell symbol exceeded MAX_CELL_SYMBOL_BYTES cap"
634        );
635    }
636
637    #[test]
638    fn sanitize_osc8_url_rejects_control_chars_and_esc() {
639        assert!(sanitize_osc8_url("https://example.com").is_some());
640        assert!(sanitize_osc8_url("https://example.com?q=1&r=2").is_some());
641        // BEL — terminates OSC, would let follow-up text be interpreted.
642        assert!(sanitize_osc8_url("https://example.com\x07attack").is_none());
643        // ESC — can open ST (ESC \) or another OSC.
644        assert!(sanitize_osc8_url("https://example.com\x1b]52;c;hi\x1b\\").is_none());
645        // Empty / oversize.
646        assert!(sanitize_osc8_url("").is_none());
647        assert!(sanitize_osc8_url(&"a".repeat(2049)).is_none());
648    }
649
650    #[test]
651    fn try_get_out_of_bounds_returns_none() {
652        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
653        assert!(buf.try_get(0, 0).is_some());
654        assert!(buf.try_get(2, 0).is_none());
655        assert!(buf.try_get(0, 2).is_none());
656        assert!(buf.try_get_mut(5, 5).is_none());
657    }
658
659    #[test]
660    fn kitty_clip_stack_restores_outer_on_pop() {
661        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 4));
662        assert!(buf.current_kitty_clip().is_none());
663
664        let outer = KittyClipInfo {
665            top_clip_rows: 2,
666            original_height: 10,
667        };
668        let inner = KittyClipInfo {
669            top_clip_rows: 5,
670            original_height: 20,
671        };
672
673        buf.push_kitty_clip(outer);
674        assert_eq!(buf.current_kitty_clip(), Some(&outer));
675
676        // Nested region pushes its own frame.
677        buf.push_kitty_clip(inner);
678        assert_eq!(buf.current_kitty_clip(), Some(&inner));
679
680        // After inner pops, outer MUST still be active — the bug this
681        // refactor fixes is exactly that the outer was previously clobbered.
682        let popped_inner = buf.pop_kitty_clip();
683        assert_eq!(popped_inner, Some(inner));
684        assert_eq!(buf.current_kitty_clip(), Some(&outer));
685
686        let popped_outer = buf.pop_kitty_clip();
687        assert_eq!(popped_outer, Some(outer));
688        assert!(buf.current_kitty_clip().is_none());
689    }
690
691    #[test]
692    fn kitty_clip_stack_cleared_on_reset() {
693        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
694        buf.push_kitty_clip(KittyClipInfo {
695            top_clip_rows: 1,
696            original_height: 2,
697        });
698        buf.push_kitty_clip(KittyClipInfo {
699            top_clip_rows: 3,
700            original_height: 4,
701        });
702        buf.reset();
703        assert!(buf.kitty_clip_info_stack.is_empty());
704        assert!(buf.current_kitty_clip().is_none());
705    }
706
707    #[test]
708    fn kitty_clip_pop_on_empty_stack_is_none() {
709        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
710        assert!(buf.pop_kitty_clip().is_none());
711    }
712}