Skip to main content

telex/
buffer.rs

1use crossterm::style::Color;
2use std::fmt;
3use unicode_segmentation::UnicodeSegmentation;
4use unicode_width::UnicodeWidthStr;
5
6/// Parameters for cell styling.
7#[derive(Debug, Clone, Copy)]
8struct StyleParams {
9    fg: Color,
10    bg: Color,
11    bold: bool,
12    italic: bool,
13    underline: bool,
14    dim: bool,
15}
16
17/// A single cell in the terminal buffer.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct Cell {
20    pub ch: char,
21    pub fg: Color,
22    pub bg: Color,
23    pub bold: bool,
24    pub italic: bool,
25    pub underline: bool,
26    pub dim: bool,
27    /// True if this cell is the second half of a wide character (e.g., emoji, CJK).
28    /// The renderer should skip these cells since the wide char already occupies the space.
29    pub wide_continuation: bool,
30}
31
32impl Default for Cell {
33    fn default() -> Self {
34        Self {
35            ch: ' ',
36            fg: Color::Reset,
37            bg: Color::Reset,
38            bold: false,
39            italic: false,
40            underline: false,
41            dim: false,
42            wide_continuation: false,
43        }
44    }
45}
46
47impl Cell {
48    /// Create a new cell with just character and colors.
49    pub fn new(ch: char, fg: Color, bg: Color) -> Self {
50        Self {
51            ch,
52            fg,
53            bg,
54            ..Default::default()
55        }
56    }
57
58    /// Create a styled cell.
59    pub fn styled(
60        ch: char,
61        fg: Color,
62        bg: Color,
63        bold: bool,
64        italic: bool,
65        underline: bool,
66        dim: bool,
67    ) -> Self {
68        Self {
69            ch,
70            fg,
71            bg,
72            bold,
73            italic,
74            underline,
75            dim,
76            wide_continuation: false,
77        }
78    }
79
80    /// Create a wide character continuation cell (second half of emoji/CJK).
81    /// The renderer should skip these cells.
82    pub fn wide_continuation(fg: Color, bg: Color) -> Self {
83        Self {
84            ch: ' ',
85            fg,
86            bg,
87            wide_continuation: true,
88            ..Default::default()
89        }
90    }
91
92    /// Create a styled wide character continuation cell.
93    pub fn wide_continuation_styled(
94        fg: Color,
95        bg: Color,
96        bold: bool,
97        italic: bool,
98        underline: bool,
99        dim: bool,
100    ) -> Self {
101        Self {
102            ch: ' ',
103            fg,
104            bg,
105            bold,
106            italic,
107            underline,
108            dim,
109            wide_continuation: true,
110        }
111    }
112}
113
114/// A rectangular region within the buffer.
115#[derive(Debug, Clone, Copy)]
116pub struct Rect {
117    pub x: u16,
118    pub y: u16,
119    pub width: u16,
120    pub height: u16,
121}
122
123impl Rect {
124    pub fn new(x: u16, y: u16, width: u16, height: u16) -> Self {
125        Self {
126            x,
127            y,
128            width,
129            height,
130        }
131    }
132}
133
134/// A 2D buffer of cells representing the terminal screen.
135#[derive(Debug, Clone)]
136pub struct Buffer {
137    cells: Vec<Cell>,
138    pub width: u16,
139    pub height: u16,
140}
141
142impl Buffer {
143    /// Create a new buffer filled with empty cells.
144    pub fn new(width: u16, height: u16) -> Self {
145        let size = (width as usize) * (height as usize);
146        Self {
147            cells: vec![Cell::default(); size],
148            width,
149            height,
150        }
151    }
152
153    /// Get the index into the cells vector for a given position.
154    fn index(&self, x: u16, y: u16) -> Option<usize> {
155        if x < self.width && y < self.height {
156            Some((y as usize) * (self.width as usize) + (x as usize))
157        } else {
158            None
159        }
160    }
161
162    /// Get a cell at a position.
163    pub fn get(&self, x: u16, y: u16) -> Option<&Cell> {
164        self.index(x, y).map(|i| &self.cells[i])
165    }
166
167    /// Set a cell at a position.
168    pub fn set_cell(&mut self, x: u16, y: u16, cell: Cell) {
169        if let Some(i) = self.index(x, y) {
170            self.cells[i] = cell;
171        }
172    }
173
174    /// Set a character at a position with colors.
175    pub fn set(&mut self, x: u16, y: u16, ch: char, fg: Color, bg: Color) {
176        self.set_cell(x, y, Cell::new(ch, fg, bg));
177    }
178
179    /// Write a string at a position, clipping at buffer boundaries.
180    ///
181    /// Handles grapheme clusters and wide characters (CJK, emoji) properly:
182    /// - Iterates by grapheme clusters (user-perceived characters)
183    /// - Wide characters (display width 2) advance the column by 2
184    /// - Wide char continuations are marked so the renderer can skip them
185    pub fn write_str(&mut self, x: u16, y: u16, s: &str, fg: Color, bg: Color) {
186        let mut col = x;
187        for grapheme in s.graphemes(true) {
188            if col >= self.width {
189                break;
190            }
191            let width = UnicodeWidthStr::width(grapheme);
192            if width == 0 {
193                continue; // Skip zero-width characters
194            }
195
196            // For wide characters, check if we have room for both columns
197            if width == 2 && col + 1 >= self.width {
198                // Wide char would overflow - write a space instead and stop
199                self.set_cell(col, y, Cell::new(' ', fg, bg));
200                break;
201            }
202
203            // Use first char of grapheme for the cell
204            // (Full grapheme cluster support would require Cell to store String)
205            let ch = grapheme.chars().next().unwrap_or(' ');
206            self.set_cell(col, y, Cell::new(ch, fg, bg));
207
208            // For wide characters, mark the next cell as a continuation
209            if width == 2 {
210                self.set_cell(col + 1, y, Cell::wide_continuation(fg, bg));
211            }
212            col += width as u16;
213        }
214    }
215
216    /// Write a styled string at a position, clipping at buffer boundaries.
217    ///
218    /// Handles grapheme clusters and wide characters (CJK, emoji) properly:
219    /// - Iterates by grapheme clusters (user-perceived characters)
220    /// - Wide characters (display width 2) advance the column by 2
221    /// - Wide char continuations are marked so the renderer can skip them
222    #[allow(clippy::too_many_arguments)]
223    pub fn write_str_styled(
224        &mut self,
225        x: u16,
226        y: u16,
227        s: &str,
228        fg: Color,
229        bg: Color,
230        bold: bool,
231        italic: bool,
232        underline: bool,
233        dim: bool,
234    ) {
235        let style = StyleParams {
236            fg,
237            bg,
238            bold,
239            italic,
240            underline,
241            dim,
242        };
243        self.write_str_styled_impl(x, y, s, style);
244    }
245
246    /// Internal implementation of write_str_styled using StyleParams.
247    fn write_str_styled_impl(&mut self, x: u16, y: u16, s: &str, style: StyleParams) {
248        let mut col = x;
249        for grapheme in s.graphemes(true) {
250            if col >= self.width {
251                break;
252            }
253            let width = UnicodeWidthStr::width(grapheme);
254            if width == 0 {
255                continue; // Skip zero-width characters
256            }
257
258            // For wide characters, check if we have room for both columns
259            if width == 2 && col + 1 >= self.width {
260                // Wide char would overflow - write a space instead and stop
261                self.set_cell(
262                    col,
263                    y,
264                    Cell::styled(' ', style.fg, style.bg, style.bold, style.italic, style.underline, style.dim),
265                );
266                break;
267            }
268
269            // Use first char of grapheme for the cell
270            // (Full grapheme cluster support would require Cell to store String)
271            let ch = grapheme.chars().next().unwrap_or(' ');
272            self.set_cell(
273                col,
274                y,
275                Cell::styled(ch, style.fg, style.bg, style.bold, style.italic, style.underline, style.dim),
276            );
277
278            // For wide characters, mark the next cell as a continuation
279            if width == 2 {
280                self.set_cell(
281                    col + 1,
282                    y,
283                    Cell::wide_continuation_styled(style.fg, style.bg, style.bold, style.italic, style.underline, style.dim),
284                );
285            }
286            col += width as u16;
287        }
288    }
289
290    /// Get the full rect of this buffer.
291    pub fn rect(&self) -> Rect {
292        Rect::new(0, 0, self.width, self.height)
293    }
294
295    /// Clear the buffer to empty cells.
296    #[allow(dead_code)]
297    pub fn clear(&mut self) {
298        for cell in &mut self.cells {
299            *cell = Cell::default();
300        }
301    }
302
303    /// Fill the buffer with a specific foreground and background color.
304    pub fn fill(&mut self, fg: Color, bg: Color) {
305        for cell in &mut self.cells {
306            cell.ch = ' ';
307            cell.fg = fg;
308            cell.bg = bg;
309            cell.bold = false;
310            cell.italic = false;
311            cell.underline = false;
312            cell.dim = false;
313        }
314    }
315
316    /// Convert the buffer to a string (for testing/snapshots).
317    fn buffer_to_string(&self) -> String {
318        let mut result = String::new();
319        for y in 0..self.height {
320            for x in 0..self.width {
321                if let Some(cell) = self.get(x, y) {
322                    // Skip wide character continuations - the wide char already added its character
323                    if cell.wide_continuation {
324                        continue;
325                    }
326                    result.push(cell.ch);
327                }
328            }
329            // Trim trailing spaces from each line
330            let trimmed = result.trim_end_matches(' ');
331            result.truncate(trimmed.len());
332            result.push('\n');
333        }
334        // Remove trailing empty lines
335        while result.ends_with("\n\n") {
336            result.pop();
337        }
338        result
339    }
340
341    /// Compute the differences between this buffer and another.
342    /// Returns a list of (x, y, cell) for cells that differ.
343    pub fn diff<'a>(&'a self, other: &'a Buffer) -> Vec<(u16, u16, &'a Cell)> {
344        let mut changes = Vec::new();
345
346        for y in 0..self.height {
347            for x in 0..self.width {
348                let self_cell = self.get(x, y);
349                let other_cell = other.get(x, y);
350
351                match (self_cell, other_cell) {
352                    (Some(a), Some(b)) if a != b => {
353                        changes.push((x, y, a));
354                    }
355                    (Some(a), None) => {
356                        changes.push((x, y, a));
357                    }
358                    _ => {}
359                }
360            }
361        }
362
363        changes
364    }
365}
366
367impl fmt::Display for Buffer {
368    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
369        write!(f, "{}", self.buffer_to_string())
370    }
371}