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 crate::cell::Cell;
8use crate::rect::Rect;
9use crate::style::Style;
10use unicode_width::UnicodeWidthChar;
11
12/// A 2D grid of [`Cell`]s backing the terminal display.
13///
14/// Two buffers are kept (current + previous); only the diff is flushed to the
15/// terminal, giving immediate-mode ergonomics with retained-mode efficiency.
16///
17/// The buffer also maintains a clip stack. Push a [`Rect`] with
18/// [`Buffer::push_clip`] to restrict writes to that region, and pop it with
19/// [`Buffer::pop_clip`] when done.
20pub struct Buffer {
21    /// The area this buffer covers, in terminal coordinates.
22    pub area: Rect,
23    /// Flat row-major storage of all cells. Length equals `area.width * area.height`.
24    pub content: Vec<Cell>,
25    pub(crate) clip_stack: Vec<Rect>,
26    pub(crate) raw_sequences: Vec<(u32, u32, String)>,
27}
28
29impl Buffer {
30    /// Create a buffer filled with blank cells covering `area`.
31    pub fn empty(area: Rect) -> Self {
32        let size = area.area() as usize;
33        Self {
34            area,
35            content: vec![Cell::default(); size],
36            clip_stack: Vec::new(),
37            raw_sequences: Vec::new(),
38        }
39    }
40
41    /// Store a raw escape sequence to be written at position `(x, y)` during flush.
42    ///
43    /// Used for Kitty graphics protocol and other passthrough sequences.
44    pub fn raw_sequence(&mut self, x: u32, y: u32, seq: String) {
45        self.raw_sequences.push((x, y, seq));
46    }
47
48    /// Push a clipping rectangle onto the clip stack.
49    ///
50    /// Subsequent writes are restricted to the intersection of all active clip
51    /// regions. Nested calls intersect with the current clip, so the effective
52    /// clip can only shrink, never grow.
53    pub fn push_clip(&mut self, rect: Rect) {
54        let effective = if let Some(current) = self.clip_stack.last() {
55            intersect_rects(*current, rect)
56        } else {
57            rect
58        };
59        self.clip_stack.push(effective);
60    }
61
62    /// Pop the most recently pushed clipping rectangle.
63    ///
64    /// After this call, writes are clipped to the previous region (or
65    /// unclipped if the stack is now empty).
66    pub fn pop_clip(&mut self) {
67        self.clip_stack.pop();
68    }
69
70    fn effective_clip(&self) -> Option<&Rect> {
71        self.clip_stack.last()
72    }
73
74    #[inline]
75    fn index_of(&self, x: u32, y: u32) -> usize {
76        ((y - self.area.y) * self.area.width + (x - self.area.x)) as usize
77    }
78
79    /// Returns `true` if `(x, y)` is within the buffer's area.
80    #[inline]
81    pub fn in_bounds(&self, x: u32, y: u32) -> bool {
82        x >= self.area.x && x < self.area.right() && y >= self.area.y && y < self.area.bottom()
83    }
84
85    /// Return a reference to the cell at `(x, y)`.
86    ///
87    /// Panics if `(x, y)` is out of bounds.
88    #[inline]
89    pub fn get(&self, x: u32, y: u32) -> &Cell {
90        &self.content[self.index_of(x, y)]
91    }
92
93    /// Return a mutable reference to the cell at `(x, y)`.
94    ///
95    /// Panics if `(x, y)` is out of bounds.
96    #[inline]
97    pub fn get_mut(&mut self, x: u32, y: u32) -> &mut Cell {
98        let idx = self.index_of(x, y);
99        &mut self.content[idx]
100    }
101
102    /// Write a string into the buffer starting at `(x, y)`.
103    ///
104    /// Respects cell boundaries and Unicode character widths. Wide characters
105    /// (e.g., CJK) occupy two columns; the trailing cell is blanked. Writes
106    /// that fall outside the current clip region are skipped but still advance
107    /// the cursor position.
108    pub fn set_string(&mut self, mut x: u32, y: u32, s: &str, style: Style) {
109        if y >= self.area.bottom() {
110            return;
111        }
112        let clip = self.effective_clip().copied();
113        for ch in s.chars() {
114            if x >= self.area.right() {
115                break;
116            }
117            let char_width = UnicodeWidthChar::width(ch).unwrap_or(0) as u32;
118            if char_width == 0 {
119                // Append zero-width char (combining mark, ZWJ, variation selector)
120                // to the previous cell so grapheme clusters stay intact.
121                if x > self.area.x {
122                    let prev_in_clip = clip.map_or(true, |clip| {
123                        (x - 1) >= clip.x
124                            && (x - 1) < clip.right()
125                            && y >= clip.y
126                            && y < clip.bottom()
127                    });
128                    if prev_in_clip {
129                        self.get_mut(x - 1, y).symbol.push(ch);
130                    }
131                }
132                continue;
133            }
134
135            let in_clip = clip.map_or(true, |clip| {
136                x >= clip.x && x < clip.right() && y >= clip.y && y < clip.bottom()
137            });
138
139            if !in_clip {
140                x = x.saturating_add(char_width);
141                continue;
142            }
143
144            let cell = self.get_mut(x, y);
145            cell.set_char(ch);
146            cell.set_style(style);
147
148            // Wide characters occupy two cells; blank the trailing cell.
149            if char_width > 1 {
150                let next_x = x + 1;
151                if next_x < self.area.right() {
152                    let next = self.get_mut(next_x, y);
153                    next.symbol.clear();
154                    next.style = style;
155                }
156            }
157
158            x = x.saturating_add(char_width);
159        }
160    }
161
162    /// Write a hyperlinked string into the buffer starting at `(x, y)`.
163    ///
164    /// Like [`Buffer::set_string`] but attaches an OSC 8 hyperlink URL to each
165    /// cell. The terminal renders these cells as clickable links.
166    pub fn set_string_linked(&mut self, mut x: u32, y: u32, s: &str, style: Style, url: &str) {
167        if y >= self.area.bottom() {
168            return;
169        }
170        let clip = self.effective_clip().copied();
171        let link = Some(compact_str::CompactString::new(url));
172        for ch in s.chars() {
173            if x >= self.area.right() {
174                break;
175            }
176            let char_width = UnicodeWidthChar::width(ch).unwrap_or(0) as u32;
177            if char_width == 0 {
178                if x > self.area.x {
179                    let prev_in_clip = clip.map_or(true, |clip| {
180                        (x - 1) >= clip.x
181                            && (x - 1) < clip.right()
182                            && y >= clip.y
183                            && y < clip.bottom()
184                    });
185                    if prev_in_clip {
186                        self.get_mut(x - 1, y).symbol.push(ch);
187                    }
188                }
189                continue;
190            }
191
192            let in_clip = clip.map_or(true, |clip| {
193                x >= clip.x && x < clip.right() && y >= clip.y && y < clip.bottom()
194            });
195
196            if !in_clip {
197                x = x.saturating_add(char_width);
198                continue;
199            }
200
201            let cell = self.get_mut(x, y);
202            cell.set_char(ch);
203            cell.set_style(style);
204            cell.hyperlink = link.clone();
205
206            if char_width > 1 {
207                let next_x = x + 1;
208                if next_x < self.area.right() {
209                    let next = self.get_mut(next_x, y);
210                    next.symbol.clear();
211                    next.style = style;
212                    next.hyperlink = link.clone();
213                }
214            }
215
216            x = x.saturating_add(char_width);
217        }
218    }
219
220    /// Write a single character at `(x, y)` with the given style.
221    ///
222    /// No-ops if `(x, y)` is out of bounds or outside the current clip region.
223    pub fn set_char(&mut self, x: u32, y: u32, ch: char, style: Style) {
224        let in_clip = self.effective_clip().map_or(true, |clip| {
225            x >= clip.x && x < clip.right() && y >= clip.y && y < clip.bottom()
226        });
227        if !self.in_bounds(x, y) || !in_clip {
228            return;
229        }
230        let cell = self.get_mut(x, y);
231        cell.set_char(ch);
232        cell.set_style(style);
233    }
234
235    /// Compute the diff between `self` (current) and `other` (previous).
236    ///
237    /// Returns `(x, y, cell)` tuples for every cell that changed. The run loop
238    /// uses this to emit only the minimal set of terminal escape sequences
239    /// needed to update the display.
240    pub fn diff<'a>(&'a self, other: &'a Buffer) -> Vec<(u32, u32, &'a Cell)> {
241        let mut updates = Vec::new();
242        for y in self.area.y..self.area.bottom() {
243            for x in self.area.x..self.area.right() {
244                let cur = self.get(x, y);
245                let prev = other.get(x, y);
246                if cur != prev {
247                    updates.push((x, y, cur));
248                }
249            }
250        }
251        updates
252    }
253
254    /// Reset every cell to a blank space with default style, and clear the clip stack.
255    pub fn reset(&mut self) {
256        for cell in &mut self.content {
257            cell.reset();
258        }
259        self.clip_stack.clear();
260        self.raw_sequences.clear();
261    }
262
263    /// Reset every cell and apply a background color to all cells.
264    pub fn reset_with_bg(&mut self, bg: crate::style::Color) {
265        for cell in &mut self.content {
266            cell.reset();
267            cell.style.bg = Some(bg);
268        }
269        self.clip_stack.clear();
270        self.raw_sequences.clear();
271    }
272
273    /// Resize the buffer to fit a new area, resetting all cells.
274    ///
275    /// If the new area is larger, new cells are initialized to blank. All
276    /// existing content is discarded.
277    pub fn resize(&mut self, area: Rect) {
278        self.area = area;
279        let size = area.area() as usize;
280        self.content.resize(size, Cell::default());
281        self.reset();
282    }
283}
284
285fn intersect_rects(a: Rect, b: Rect) -> Rect {
286    let x = a.x.max(b.x);
287    let y = a.y.max(b.y);
288    let right = a.right().min(b.right());
289    let bottom = a.bottom().min(b.bottom());
290    let width = right.saturating_sub(x);
291    let height = bottom.saturating_sub(y);
292    Rect::new(x, y, width, height)
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn clip_stack_intersects_nested_regions() {
301        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 5));
302        buf.push_clip(Rect::new(1, 1, 6, 3));
303        buf.push_clip(Rect::new(4, 0, 6, 4));
304
305        buf.set_char(3, 2, 'x', Style::new());
306        buf.set_char(4, 2, 'y', Style::new());
307
308        assert_eq!(buf.get(3, 2).symbol, " ");
309        assert_eq!(buf.get(4, 2).symbol, "y");
310    }
311
312    #[test]
313    fn set_string_advances_even_when_clipped() {
314        let mut buf = Buffer::empty(Rect::new(0, 0, 8, 1));
315        buf.push_clip(Rect::new(2, 0, 6, 1));
316
317        buf.set_string(0, 0, "abcd", Style::new());
318
319        assert_eq!(buf.get(2, 0).symbol, "c");
320        assert_eq!(buf.get(3, 0).symbol, "d");
321    }
322
323    #[test]
324    fn pop_clip_restores_previous_clip() {
325        let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
326        buf.push_clip(Rect::new(0, 0, 2, 1));
327        buf.push_clip(Rect::new(4, 0, 2, 1));
328
329        buf.set_char(1, 0, 'a', Style::new());
330        buf.pop_clip();
331        buf.set_char(1, 0, 'b', Style::new());
332
333        assert_eq!(buf.get(1, 0).symbol, "b");
334    }
335
336    #[test]
337    fn reset_clears_clip_stack() {
338        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
339        buf.push_clip(Rect::new(0, 0, 0, 0));
340        buf.reset();
341        buf.set_char(0, 0, 'z', Style::new());
342
343        assert_eq!(buf.get(0, 0).symbol, "z");
344    }
345}