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