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