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/// Structured Kitty graphics protocol image placement.
16///
17/// Stored separately from raw escape sequences so the terminal can manage
18/// image IDs, compression, and placement lifecycle. Images are deduplicated
19/// by `content_hash` — identical pixel data is uploaded only once.
20#[derive(Clone, Debug)]
21#[allow(dead_code)]
22pub(crate) struct KittyPlacement {
23    /// Hash of the RGBA pixel data for dedup (avoids re-uploading).
24    pub content_hash: u64,
25    /// Reference-counted raw RGBA pixel data (shared across frames).
26    pub rgba: Arc<Vec<u8>>,
27    /// Source image width in pixels.
28    pub src_width: u32,
29    /// Source image height in pixels.
30    pub src_height: u32,
31    /// Screen cell position.
32    pub x: u32,
33    pub y: u32,
34    /// Cell columns/rows to display.
35    pub cols: u32,
36    pub rows: u32,
37    /// Source crop Y offset in pixels (for scroll clipping).
38    pub crop_y: u32,
39    /// Source crop height in pixels (0 = full height from crop_y).
40    pub crop_h: u32,
41}
42
43/// Compute a content hash for RGBA pixel data.
44pub(crate) fn hash_rgba(data: &[u8]) -> u64 {
45    let mut hasher = std::collections::hash_map::DefaultHasher::new();
46    data.hash(&mut hasher);
47    hasher.finish()
48}
49
50impl PartialEq for KittyPlacement {
51    fn eq(&self, other: &Self) -> bool {
52        self.content_hash == other.content_hash
53            && self.x == other.x
54            && self.y == other.y
55            && self.cols == other.cols
56            && self.rows == other.rows
57            && self.crop_y == other.crop_y
58            && self.crop_h == other.crop_h
59    }
60}
61
62/// A 2D grid of [`Cell`]s backing the terminal display.
63///
64/// Two buffers are kept (current + previous); only the diff is flushed to the
65/// terminal, giving immediate-mode ergonomics with retained-mode efficiency.
66///
67/// The buffer also maintains a clip stack. Push a [`Rect`] with
68/// [`Buffer::push_clip`] to restrict writes to that region, and pop it with
69/// [`Buffer::pop_clip`] when done.
70pub struct Buffer {
71    /// The area this buffer covers, in terminal coordinates.
72    pub area: Rect,
73    /// Flat row-major storage of all cells. Length equals `area.width * area.height`.
74    pub content: Vec<Cell>,
75    pub(crate) clip_stack: Vec<Rect>,
76    pub(crate) raw_sequences: Vec<(u32, u32, String)>,
77    pub(crate) kitty_placements: Vec<KittyPlacement>,
78    pub(crate) cursor_pos: Option<(u32, u32)>,
79    /// Scroll clip info set by the run loop before invoking draw closures:
80    /// `(top_clip_rows, original_total_rows)`.
81    pub(crate) kitty_clip_info: Option<(u32, u32)>,
82}
83
84impl Buffer {
85    /// Create a buffer filled with blank cells covering `area`.
86    pub fn empty(area: Rect) -> Self {
87        let size = area.area() as usize;
88        Self {
89            area,
90            content: vec![Cell::default(); size],
91            clip_stack: Vec::new(),
92            raw_sequences: Vec::new(),
93            kitty_placements: Vec::new(),
94            cursor_pos: None,
95            kitty_clip_info: None,
96        }
97    }
98
99    pub(crate) fn set_cursor_pos(&mut self, x: u32, y: u32) {
100        self.cursor_pos = Some((x, y));
101    }
102
103    #[cfg(feature = "crossterm")]
104    pub(crate) fn cursor_pos(&self) -> Option<(u32, u32)> {
105        self.cursor_pos
106    }
107
108    /// Store a raw escape sequence to be written at position `(x, y)` during flush.
109    ///
110    /// Used for Sixel images and other passthrough sequences.
111    /// Respects the clip stack: sequences fully outside the current clip are skipped.
112    pub fn raw_sequence(&mut self, x: u32, y: u32, seq: String) {
113        if let Some(clip) = self.effective_clip() {
114            if x >= clip.right() || y >= clip.bottom() {
115                return;
116            }
117        }
118        self.raw_sequences.push((x, y, seq));
119    }
120
121    /// Store a structured Kitty graphics protocol placement.
122    ///
123    /// Unlike `raw_sequence`, Kitty placements are managed with image IDs,
124    /// compression, and placement lifecycle by the terminal flush code.
125    /// Scroll crop info is automatically applied from `kitty_clip_info`.
126    pub(crate) fn kitty_place(&mut self, mut p: KittyPlacement) {
127        // Apply clip check
128        if let Some(clip) = self.effective_clip() {
129            if p.x >= clip.right()
130                || p.y >= clip.bottom()
131                || p.x + p.cols <= clip.x
132                || p.y + p.rows <= clip.y
133            {
134                return;
135            }
136        }
137
138        // Apply scroll crop info if set
139        if let Some((top_clip_rows, original_height)) = self.kitty_clip_info {
140            if original_height > 0 && (top_clip_rows > 0 || p.rows < original_height) {
141                let ratio = p.src_height as f64 / original_height as f64;
142                p.crop_y = (top_clip_rows as f64 * ratio) as u32;
143                let bottom_clip = original_height.saturating_sub(top_clip_rows + p.rows);
144                let bottom_pixels = (bottom_clip as f64 * ratio) as u32;
145                p.crop_h = p.src_height.saturating_sub(p.crop_y + bottom_pixels);
146            }
147        }
148
149        self.kitty_placements.push(p);
150    }
151
152    /// Push a clipping rectangle onto the clip stack.
153    ///
154    /// Subsequent writes are restricted to the intersection of all active clip
155    /// regions. Nested calls intersect with the current clip, so the effective
156    /// clip can only shrink, never grow.
157    pub fn push_clip(&mut self, rect: Rect) {
158        let effective = if let Some(current) = self.clip_stack.last() {
159            intersect_rects(*current, rect)
160        } else {
161            rect
162        };
163        self.clip_stack.push(effective);
164    }
165
166    /// Pop the most recently pushed clipping rectangle.
167    ///
168    /// After this call, writes are clipped to the previous region (or
169    /// unclipped if the stack is now empty).
170    pub fn pop_clip(&mut self) {
171        self.clip_stack.pop();
172    }
173
174    fn effective_clip(&self) -> Option<&Rect> {
175        self.clip_stack.last()
176    }
177
178    #[inline]
179    fn index_of(&self, x: u32, y: u32) -> usize {
180        ((y - self.area.y) * self.area.width + (x - self.area.x)) as usize
181    }
182
183    /// Returns `true` if `(x, y)` is within the buffer's area.
184    #[inline]
185    pub fn in_bounds(&self, x: u32, y: u32) -> bool {
186        x >= self.area.x && x < self.area.right() && y >= self.area.y && y < self.area.bottom()
187    }
188
189    /// Return a reference to the cell at `(x, y)`.
190    ///
191    /// Panics if `(x, y)` is out of bounds.
192    #[inline]
193    pub fn get(&self, x: u32, y: u32) -> &Cell {
194        debug_assert!(
195            self.in_bounds(x, y),
196            "Buffer::get({x}, {y}) out of bounds for area {:?}",
197            self.area
198        );
199        &self.content[self.index_of(x, y)]
200    }
201
202    /// Return a mutable reference to the cell at `(x, y)`.
203    ///
204    /// Panics if `(x, y)` is out of bounds.
205    #[inline]
206    pub fn get_mut(&mut self, x: u32, y: u32) -> &mut Cell {
207        debug_assert!(
208            self.in_bounds(x, y),
209            "Buffer::get_mut({x}, {y}) out of bounds for area {:?}",
210            self.area
211        );
212        let idx = self.index_of(x, y);
213        &mut self.content[idx]
214    }
215
216    /// Write a string into the buffer starting at `(x, y)`.
217    ///
218    /// Respects cell boundaries and Unicode character widths. Wide characters
219    /// (e.g., CJK) occupy two columns; the trailing cell is blanked. Writes
220    /// that fall outside the current clip region are skipped but still advance
221    /// the cursor position.
222    pub fn set_string(&mut self, mut x: u32, y: u32, s: &str, style: Style) {
223        if y >= self.area.bottom() {
224            return;
225        }
226        let clip = self.effective_clip().copied();
227        for ch in s.chars() {
228            if x >= self.area.right() {
229                break;
230            }
231            let char_width = UnicodeWidthChar::width(ch).unwrap_or(0) as u32;
232            if char_width == 0 {
233                // Append zero-width char (combining mark, ZWJ, variation selector)
234                // to the previous cell so grapheme clusters stay intact.
235                if x > self.area.x {
236                    let prev_in_clip = clip.map_or(true, |clip| {
237                        (x - 1) >= clip.x
238                            && (x - 1) < clip.right()
239                            && y >= clip.y
240                            && y < clip.bottom()
241                    });
242                    if prev_in_clip {
243                        self.get_mut(x - 1, y).symbol.push(ch);
244                    }
245                }
246                continue;
247            }
248
249            let in_clip = clip.map_or(true, |clip| {
250                x >= clip.x && x < clip.right() && y >= clip.y && y < clip.bottom()
251            });
252
253            if !in_clip {
254                x = x.saturating_add(char_width);
255                continue;
256            }
257
258            let cell = self.get_mut(x, y);
259            cell.set_char(ch);
260            cell.set_style(style);
261
262            // Wide characters occupy two cells; blank the trailing cell.
263            if char_width > 1 {
264                let next_x = x + 1;
265                if next_x < self.area.right() {
266                    let next = self.get_mut(next_x, y);
267                    next.symbol.clear();
268                    next.style = style;
269                }
270            }
271
272            x = x.saturating_add(char_width);
273        }
274    }
275
276    /// Write a hyperlinked string into the buffer starting at `(x, y)`.
277    ///
278    /// Like [`Buffer::set_string`] but attaches an OSC 8 hyperlink URL to each
279    /// cell. The terminal renders these cells as clickable links.
280    pub fn set_string_linked(&mut self, mut x: u32, y: u32, s: &str, style: Style, url: &str) {
281        if y >= self.area.bottom() {
282            return;
283        }
284        let clip = self.effective_clip().copied();
285        let link = Some(compact_str::CompactString::new(url));
286        for ch in s.chars() {
287            if x >= self.area.right() {
288                break;
289            }
290            let char_width = UnicodeWidthChar::width(ch).unwrap_or(0) as u32;
291            if char_width == 0 {
292                if x > self.area.x {
293                    let prev_in_clip = clip.map_or(true, |clip| {
294                        (x - 1) >= clip.x
295                            && (x - 1) < clip.right()
296                            && y >= clip.y
297                            && y < clip.bottom()
298                    });
299                    if prev_in_clip {
300                        self.get_mut(x - 1, y).symbol.push(ch);
301                    }
302                }
303                continue;
304            }
305
306            let in_clip = clip.map_or(true, |clip| {
307                x >= clip.x && x < clip.right() && y >= clip.y && y < clip.bottom()
308            });
309
310            if !in_clip {
311                x = x.saturating_add(char_width);
312                continue;
313            }
314
315            let cell = self.get_mut(x, y);
316            cell.set_char(ch);
317            cell.set_style(style);
318            cell.hyperlink = link.clone();
319
320            if char_width > 1 {
321                let next_x = x + 1;
322                if next_x < self.area.right() {
323                    let next = self.get_mut(next_x, y);
324                    next.symbol.clear();
325                    next.style = style;
326                    next.hyperlink = link.clone();
327                }
328            }
329
330            x = x.saturating_add(char_width);
331        }
332    }
333
334    /// Write a single character at `(x, y)` with the given style.
335    ///
336    /// No-ops if `(x, y)` is out of bounds or outside the current clip region.
337    pub fn set_char(&mut self, x: u32, y: u32, ch: char, style: Style) {
338        let in_clip = self.effective_clip().map_or(true, |clip| {
339            x >= clip.x && x < clip.right() && y >= clip.y && y < clip.bottom()
340        });
341        if !self.in_bounds(x, y) || !in_clip {
342            return;
343        }
344        let cell = self.get_mut(x, y);
345        cell.set_char(ch);
346        cell.set_style(style);
347    }
348
349    /// Compute the diff between `self` (current) and `other` (previous).
350    ///
351    /// Returns `(x, y, cell)` tuples for every cell that changed. The run loop
352    /// uses this to emit only the minimal set of terminal escape sequences
353    /// needed to update the display.
354    pub fn diff<'a>(&'a self, other: &'a Buffer) -> Vec<(u32, u32, &'a Cell)> {
355        let mut updates = Vec::new();
356        for y in self.area.y..self.area.bottom() {
357            for x in self.area.x..self.area.right() {
358                let cur = self.get(x, y);
359                let prev = other.get(x, y);
360                if cur != prev {
361                    updates.push((x, y, cur));
362                }
363            }
364        }
365        updates
366    }
367
368    /// Reset every cell to a blank space with default style, and clear the clip stack.
369    pub fn reset(&mut self) {
370        for cell in &mut self.content {
371            cell.reset();
372        }
373        self.clip_stack.clear();
374        self.raw_sequences.clear();
375        self.kitty_placements.clear();
376        self.cursor_pos = None;
377        self.kitty_clip_info = None;
378    }
379
380    /// Reset every cell and apply a background color to all cells.
381    pub fn reset_with_bg(&mut self, bg: crate::style::Color) {
382        for cell in &mut self.content {
383            cell.reset();
384            cell.style.bg = Some(bg);
385        }
386        self.clip_stack.clear();
387        self.raw_sequences.clear();
388        self.kitty_placements.clear();
389        self.cursor_pos = None;
390        self.kitty_clip_info = None;
391    }
392
393    /// Resize the buffer to fit a new area, resetting all cells.
394    ///
395    /// If the new area is larger, new cells are initialized to blank. All
396    /// existing content is discarded.
397    pub fn resize(&mut self, area: Rect) {
398        self.area = area;
399        let size = area.area() as usize;
400        self.content.resize(size, Cell::default());
401        self.reset();
402    }
403}
404
405fn intersect_rects(a: Rect, b: Rect) -> Rect {
406    let x = a.x.max(b.x);
407    let y = a.y.max(b.y);
408    let right = a.right().min(b.right());
409    let bottom = a.bottom().min(b.bottom());
410    let width = right.saturating_sub(x);
411    let height = bottom.saturating_sub(y);
412    Rect::new(x, y, width, height)
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    #[test]
420    fn clip_stack_intersects_nested_regions() {
421        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 5));
422        buf.push_clip(Rect::new(1, 1, 6, 3));
423        buf.push_clip(Rect::new(4, 0, 6, 4));
424
425        buf.set_char(3, 2, 'x', Style::new());
426        buf.set_char(4, 2, 'y', Style::new());
427
428        assert_eq!(buf.get(3, 2).symbol, " ");
429        assert_eq!(buf.get(4, 2).symbol, "y");
430    }
431
432    #[test]
433    fn set_string_advances_even_when_clipped() {
434        let mut buf = Buffer::empty(Rect::new(0, 0, 8, 1));
435        buf.push_clip(Rect::new(2, 0, 6, 1));
436
437        buf.set_string(0, 0, "abcd", Style::new());
438
439        assert_eq!(buf.get(2, 0).symbol, "c");
440        assert_eq!(buf.get(3, 0).symbol, "d");
441    }
442
443    #[test]
444    fn pop_clip_restores_previous_clip() {
445        let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
446        buf.push_clip(Rect::new(0, 0, 2, 1));
447        buf.push_clip(Rect::new(4, 0, 2, 1));
448
449        buf.set_char(1, 0, 'a', Style::new());
450        buf.pop_clip();
451        buf.set_char(1, 0, 'b', Style::new());
452
453        assert_eq!(buf.get(1, 0).symbol, "b");
454    }
455
456    #[test]
457    fn reset_clears_clip_stack() {
458        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
459        buf.push_clip(Rect::new(0, 0, 0, 0));
460        buf.reset();
461        buf.set_char(0, 0, 'z', Style::new());
462
463        assert_eq!(buf.get(0, 0).symbol, "z");
464    }
465}