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    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 single character at `(x, y)` with the given style.
148    ///
149    /// No-ops if `(x, y)` is out of bounds or outside the current clip region.
150    pub fn set_char(&mut self, x: u32, y: u32, ch: char, style: Style) {
151        let in_clip = self.effective_clip().map_or(true, |clip| {
152            x >= clip.x && x < clip.right() && y >= clip.y && y < clip.bottom()
153        });
154        if !self.in_bounds(x, y) || !in_clip {
155            return;
156        }
157        let cell = self.get_mut(x, y);
158        cell.set_char(ch);
159        cell.set_style(style);
160    }
161
162    /// Compute the diff between `self` (current) and `other` (previous).
163    ///
164    /// Returns `(x, y, cell)` tuples for every cell that changed. The run loop
165    /// uses this to emit only the minimal set of terminal escape sequences
166    /// needed to update the display.
167    pub fn diff<'a>(&'a self, other: &'a Buffer) -> Vec<(u32, u32, &'a Cell)> {
168        let mut updates = Vec::new();
169        for y in self.area.y..self.area.bottom() {
170            for x in self.area.x..self.area.right() {
171                let cur = self.get(x, y);
172                let prev = other.get(x, y);
173                if cur != prev {
174                    updates.push((x, y, cur));
175                }
176            }
177        }
178        updates
179    }
180
181    /// Reset every cell to a blank space with default style, and clear the clip stack.
182    pub fn reset(&mut self) {
183        for cell in &mut self.content {
184            cell.reset();
185        }
186        self.clip_stack.clear();
187    }
188
189    /// Resize the buffer to fit a new area, resetting all cells.
190    ///
191    /// If the new area is larger, new cells are initialized to blank. All
192    /// existing content is discarded.
193    pub fn resize(&mut self, area: Rect) {
194        self.area = area;
195        let size = area.area() as usize;
196        self.content.resize(size, Cell::default());
197        self.reset();
198    }
199}
200
201fn intersect_rects(a: Rect, b: Rect) -> Rect {
202    let x = a.x.max(b.x);
203    let y = a.y.max(b.y);
204    let right = a.right().min(b.right());
205    let bottom = a.bottom().min(b.bottom());
206    let width = right.saturating_sub(x);
207    let height = bottom.saturating_sub(y);
208    Rect::new(x, y, width, height)
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn clip_stack_intersects_nested_regions() {
217        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 5));
218        buf.push_clip(Rect::new(1, 1, 6, 3));
219        buf.push_clip(Rect::new(4, 0, 6, 4));
220
221        buf.set_char(3, 2, 'x', Style::new());
222        buf.set_char(4, 2, 'y', Style::new());
223
224        assert_eq!(buf.get(3, 2).symbol, " ");
225        assert_eq!(buf.get(4, 2).symbol, "y");
226    }
227
228    #[test]
229    fn set_string_advances_even_when_clipped() {
230        let mut buf = Buffer::empty(Rect::new(0, 0, 8, 1));
231        buf.push_clip(Rect::new(2, 0, 6, 1));
232
233        buf.set_string(0, 0, "abcd", Style::new());
234
235        assert_eq!(buf.get(2, 0).symbol, "c");
236        assert_eq!(buf.get(3, 0).symbol, "d");
237    }
238
239    #[test]
240    fn pop_clip_restores_previous_clip() {
241        let mut buf = Buffer::empty(Rect::new(0, 0, 6, 1));
242        buf.push_clip(Rect::new(0, 0, 2, 1));
243        buf.push_clip(Rect::new(4, 0, 2, 1));
244
245        buf.set_char(1, 0, 'a', Style::new());
246        buf.pop_clip();
247        buf.set_char(1, 0, 'b', Style::new());
248
249        assert_eq!(buf.get(1, 0).symbol, "b");
250    }
251
252    #[test]
253    fn reset_clears_clip_stack() {
254        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
255        buf.push_clip(Rect::new(0, 0, 0, 0));
256        buf.reset();
257        buf.set_char(0, 0, 'z', Style::new());
258
259        assert_eq!(buf.get(0, 0).symbol, "z");
260    }
261}