Skip to main content

slt/
buffer.rs

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