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        debug_assert!(
91            self.in_bounds(x, y),
92            "Buffer::get({x}, {y}) out of bounds for area {:?}",
93            self.area
94        );
95        &self.content[self.index_of(x, y)]
96    }
97
98    /// Return a mutable reference to the cell at `(x, y)`.
99    ///
100    /// Panics if `(x, y)` is out of bounds.
101    #[inline]
102    pub fn get_mut(&mut self, x: u32, y: u32) -> &mut Cell {
103        debug_assert!(
104            self.in_bounds(x, y),
105            "Buffer::get_mut({x}, {y}) out of bounds for area {:?}",
106            self.area
107        );
108        let idx = self.index_of(x, y);
109        &mut self.content[idx]
110    }
111
112    /// Write a string into the buffer starting at `(x, y)`.
113    ///
114    /// Respects cell boundaries and Unicode character widths. Wide characters
115    /// (e.g., CJK) occupy two columns; the trailing cell is blanked. Writes
116    /// that fall outside the current clip region are skipped but still advance
117    /// the cursor position.
118    pub fn set_string(&mut self, mut x: u32, y: u32, s: &str, style: Style) {
119        if y >= self.area.bottom() {
120            return;
121        }
122        let clip = self.effective_clip().copied();
123        for ch in s.chars() {
124            if x >= self.area.right() {
125                break;
126            }
127            let char_width = UnicodeWidthChar::width(ch).unwrap_or(0) as u32;
128            if char_width == 0 {
129                // Append zero-width char (combining mark, ZWJ, variation selector)
130                // to the previous cell so grapheme clusters stay intact.
131                if x > self.area.x {
132                    let prev_in_clip = clip.map_or(true, |clip| {
133                        (x - 1) >= clip.x
134                            && (x - 1) < clip.right()
135                            && y >= clip.y
136                            && y < clip.bottom()
137                    });
138                    if prev_in_clip {
139                        self.get_mut(x - 1, y).symbol.push(ch);
140                    }
141                }
142                continue;
143            }
144
145            let in_clip = clip.map_or(true, |clip| {
146                x >= clip.x && x < clip.right() && y >= clip.y && y < clip.bottom()
147            });
148
149            if !in_clip {
150                x = x.saturating_add(char_width);
151                continue;
152            }
153
154            let cell = self.get_mut(x, y);
155            cell.set_char(ch);
156            cell.set_style(style);
157
158            // Wide characters occupy two cells; blank the trailing cell.
159            if char_width > 1 {
160                let next_x = x + 1;
161                if next_x < self.area.right() {
162                    let next = self.get_mut(next_x, y);
163                    next.symbol.clear();
164                    next.style = style;
165                }
166            }
167
168            x = x.saturating_add(char_width);
169        }
170    }
171
172    /// Write a hyperlinked string into the buffer starting at `(x, y)`.
173    ///
174    /// Like [`Buffer::set_string`] but attaches an OSC 8 hyperlink URL to each
175    /// cell. The terminal renders these cells as clickable links.
176    pub fn set_string_linked(&mut self, mut x: u32, y: u32, s: &str, style: Style, url: &str) {
177        if y >= self.area.bottom() {
178            return;
179        }
180        let clip = self.effective_clip().copied();
181        let link = Some(compact_str::CompactString::new(url));
182        for ch in s.chars() {
183            if x >= self.area.right() {
184                break;
185            }
186            let char_width = UnicodeWidthChar::width(ch).unwrap_or(0) as u32;
187            if char_width == 0 {
188                if x > self.area.x {
189                    let prev_in_clip = clip.map_or(true, |clip| {
190                        (x - 1) >= clip.x
191                            && (x - 1) < clip.right()
192                            && y >= clip.y
193                            && y < clip.bottom()
194                    });
195                    if prev_in_clip {
196                        self.get_mut(x - 1, y).symbol.push(ch);
197                    }
198                }
199                continue;
200            }
201
202            let in_clip = clip.map_or(true, |clip| {
203                x >= clip.x && x < clip.right() && y >= clip.y && y < clip.bottom()
204            });
205
206            if !in_clip {
207                x = x.saturating_add(char_width);
208                continue;
209            }
210
211            let cell = self.get_mut(x, y);
212            cell.set_char(ch);
213            cell.set_style(style);
214            cell.hyperlink = link.clone();
215
216            if char_width > 1 {
217                let next_x = x + 1;
218                if next_x < self.area.right() {
219                    let next = self.get_mut(next_x, y);
220                    next.symbol.clear();
221                    next.style = style;
222                    next.hyperlink = link.clone();
223                }
224            }
225
226            x = x.saturating_add(char_width);
227        }
228    }
229
230    /// Write a single character at `(x, y)` with the given style.
231    ///
232    /// No-ops if `(x, y)` is out of bounds or outside the current clip region.
233    pub fn set_char(&mut self, x: u32, y: u32, ch: char, style: Style) {
234        let in_clip = self.effective_clip().map_or(true, |clip| {
235            x >= clip.x && x < clip.right() && y >= clip.y && y < clip.bottom()
236        });
237        if !self.in_bounds(x, y) || !in_clip {
238            return;
239        }
240        let cell = self.get_mut(x, y);
241        cell.set_char(ch);
242        cell.set_style(style);
243    }
244
245    /// Compute the diff between `self` (current) and `other` (previous).
246    ///
247    /// Returns `(x, y, cell)` tuples for every cell that changed. The run loop
248    /// uses this to emit only the minimal set of terminal escape sequences
249    /// needed to update the display.
250    pub fn diff<'a>(&'a self, other: &'a Buffer) -> Vec<(u32, u32, &'a Cell)> {
251        let mut updates = Vec::new();
252        for y in self.area.y..self.area.bottom() {
253            for x in self.area.x..self.area.right() {
254                let cur = self.get(x, y);
255                let prev = other.get(x, y);
256                if cur != prev {
257                    updates.push((x, y, cur));
258                }
259            }
260        }
261        updates
262    }
263
264    /// Reset every cell to a blank space with default style, and clear the clip stack.
265    pub fn reset(&mut self) {
266        for cell in &mut self.content {
267            cell.reset();
268        }
269        self.clip_stack.clear();
270        self.raw_sequences.clear();
271    }
272
273    /// Reset every cell and apply a background color to all cells.
274    pub fn reset_with_bg(&mut self, bg: crate::style::Color) {
275        for cell in &mut self.content {
276            cell.reset();
277            cell.style.bg = Some(bg);
278        }
279        self.clip_stack.clear();
280        self.raw_sequences.clear();
281    }
282
283    /// Resize the buffer to fit a new area, resetting all cells.
284    ///
285    /// If the new area is larger, new cells are initialized to blank. All
286    /// existing content is discarded.
287    pub fn resize(&mut self, area: Rect) {
288        self.area = area;
289        let size = area.area() as usize;
290        self.content.resize(size, Cell::default());
291        self.reset();
292    }
293}
294
295fn intersect_rects(a: Rect, b: Rect) -> Rect {
296    let x = a.x.max(b.x);
297    let y = a.y.max(b.y);
298    let right = a.right().min(b.right());
299    let bottom = a.bottom().min(b.bottom());
300    let width = right.saturating_sub(x);
301    let height = bottom.saturating_sub(y);
302    Rect::new(x, y, width, height)
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn clip_stack_intersects_nested_regions() {
311        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 5));
312        buf.push_clip(Rect::new(1, 1, 6, 3));
313        buf.push_clip(Rect::new(4, 0, 6, 4));
314
315        buf.set_char(3, 2, 'x', Style::new());
316        buf.set_char(4, 2, 'y', Style::new());
317
318        assert_eq!(buf.get(3, 2).symbol, " ");
319        assert_eq!(buf.get(4, 2).symbol, "y");
320    }
321
322    #[test]
323    fn set_string_advances_even_when_clipped() {
324        let mut buf = Buffer::empty(Rect::new(0, 0, 8, 1));
325        buf.push_clip(Rect::new(2, 0, 6, 1));
326
327        buf.set_string(0, 0, "abcd", Style::new());
328
329        assert_eq!(buf.get(2, 0).symbol, "c");
330        assert_eq!(buf.get(3, 0).symbol, "d");
331    }
332
333    #[test]
334    fn pop_clip_restores_previous_clip() {
335        let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
336        buf.push_clip(Rect::new(0, 0, 2, 1));
337        buf.push_clip(Rect::new(4, 0, 2, 1));
338
339        buf.set_char(1, 0, 'a', Style::new());
340        buf.pop_clip();
341        buf.set_char(1, 0, 'b', Style::new());
342
343        assert_eq!(buf.get(1, 0).symbol, "b");
344    }
345
346    #[test]
347    fn reset_clears_clip_stack() {
348        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
349        buf.push_clip(Rect::new(0, 0, 0, 0));
350        buf.reset();
351        buf.set_char(0, 0, 'z', Style::new());
352
353        assert_eq!(buf.get(0, 0).symbol, "z");
354    }
355}