Skip to main content

a3s_tui/
grid.rs

1//! Cell grid for terminal rendering.
2//!
3//! The grid is a 2D array of [`Cell`]s representing the terminal screen buffer.
4//! Each cell holds a character and its styling attributes.
5
6use crate::style::Color;
7
8/// A single terminal cell with character and style.
9#[derive(Clone, PartialEq, Eq, Debug)]
10pub struct Cell {
11    pub ch: char,
12    pub combining: String,
13    pub fg: Option<Color>,
14    pub bg: Option<Color>,
15    pub bold: bool,
16    pub italic: bool,
17    pub underline: bool,
18    pub reverse: bool,
19    pub dim: bool,
20    pub strikethrough: bool,
21}
22
23static EMPTY_CELL: Cell = Cell {
24    ch: ' ',
25    combining: String::new(),
26    fg: None,
27    bg: None,
28    bold: false,
29    italic: false,
30    underline: false,
31    reverse: false,
32    dim: false,
33    strikethrough: false,
34};
35
36const WIDE_CONTINUATION: char = '\0';
37
38impl Default for Cell {
39    fn default() -> Self {
40        EMPTY_CELL.clone()
41    }
42}
43
44impl Cell {
45    pub fn with_char(ch: char) -> Self {
46        Self {
47            ch,
48            ..Default::default()
49        }
50    }
51
52    pub fn styled(ch: char, style: &CellStyle) -> Self {
53        Self {
54            ch,
55            combining: String::new(),
56            fg: style.fg,
57            bg: style.bg,
58            bold: style.bold,
59            italic: style.italic,
60            underline: style.underline,
61            reverse: style.reverse,
62            dim: style.dim,
63            strikethrough: style.strikethrough,
64        }
65    }
66
67    pub fn to_ansi(&self) -> String {
68        if self.ch == WIDE_CONTINUATION {
69            return String::new();
70        }
71
72        use std::fmt::Write;
73
74        let has_style = self.bold
75            || self.dim
76            || self.italic
77            || self.underline
78            || self.reverse
79            || self.strikethrough
80            || self.fg.is_some()
81            || self.bg.is_some();
82
83        if !has_style {
84            let mut out = self.ch.to_string();
85            out.push_str(&self.combining);
86            return out;
87        }
88
89        let mut out = String::with_capacity(24);
90        out.push_str("\x1b[");
91        let mut first = true;
92
93        macro_rules! push_code {
94            ($code:expr) => {
95                if !first {
96                    out.push(';');
97                }
98                let _ = write!(out, "{}", $code);
99                #[allow(unused_assignments)]
100                {
101                    first = false;
102                }
103            };
104        }
105
106        if self.bold {
107            push_code!("1");
108        }
109        if self.dim {
110            push_code!("2");
111        }
112        if self.italic {
113            push_code!("3");
114        }
115        if self.underline {
116            push_code!("4");
117        }
118        if self.reverse {
119            push_code!("7");
120        }
121        if self.strikethrough {
122            push_code!("9");
123        }
124        if let Some(ref c) = self.fg {
125            push_code!(c.fg_ansi());
126        }
127        if let Some(ref c) = self.bg {
128            push_code!(c.bg_ansi());
129        }
130
131        out.push('m');
132        out.push(self.ch);
133        out.push_str(&self.combining);
134        out.push_str("\x1b[0m");
135        out
136    }
137
138    /// Write ANSI representation into an existing buffer (avoids allocation).
139    pub fn write_ansi(&self, buf: &mut String) {
140        if self.ch == WIDE_CONTINUATION {
141            return;
142        }
143
144        use std::fmt::Write;
145
146        let has_style = self.bold
147            || self.dim
148            || self.italic
149            || self.underline
150            || self.reverse
151            || self.strikethrough
152            || self.fg.is_some()
153            || self.bg.is_some();
154
155        if !has_style {
156            buf.push(self.ch);
157            buf.push_str(&self.combining);
158            return;
159        }
160
161        buf.push_str("\x1b[");
162        let mut first = true;
163
164        macro_rules! push_code {
165            ($code:expr) => {
166                if !first {
167                    buf.push(';');
168                }
169                let _ = write!(buf, "{}", $code);
170                #[allow(unused_assignments)]
171                {
172                    first = false;
173                }
174            };
175        }
176
177        if self.bold {
178            push_code!("1");
179        }
180        if self.dim {
181            push_code!("2");
182        }
183        if self.italic {
184            push_code!("3");
185        }
186        if self.underline {
187            push_code!("4");
188        }
189        if self.reverse {
190            push_code!("7");
191        }
192        if self.strikethrough {
193            push_code!("9");
194        }
195        if let Some(ref c) = self.fg {
196            push_code!(c.fg_ansi());
197        }
198        if let Some(ref c) = self.bg {
199            push_code!(c.bg_ansi());
200        }
201
202        buf.push('m');
203        buf.push(self.ch);
204        buf.push_str(&self.combining);
205        buf.push_str("\x1b[0m");
206    }
207}
208
209#[derive(Clone, Debug, Default)]
210pub struct CellStyle {
211    pub fg: Option<Color>,
212    pub bg: Option<Color>,
213    pub bold: bool,
214    pub italic: bool,
215    pub underline: bool,
216    pub reverse: bool,
217    pub dim: bool,
218    pub strikethrough: bool,
219}
220
221pub struct Grid {
222    pub cells: Vec<Vec<Cell>>,
223    pub width: u16,
224    pub height: u16,
225}
226
227impl Grid {
228    pub fn new(width: u16, height: u16) -> Self {
229        let cells = vec![vec![Cell::default(); width as usize]; height as usize];
230        Self {
231            cells,
232            width,
233            height,
234        }
235    }
236
237    pub fn get(&self, x: u16, y: u16) -> &Cell {
238        self.try_get(x, y).unwrap_or(&EMPTY_CELL)
239    }
240
241    pub fn try_get(&self, x: u16, y: u16) -> Option<&Cell> {
242        self.cells
243            .get(y as usize)
244            .and_then(|row| row.get(x as usize))
245    }
246
247    pub fn set(&mut self, x: u16, y: u16, cell: Cell) {
248        if x < self.width && y < self.height {
249            let row = y as usize;
250            let col = x as usize;
251            let width = unicode_width::UnicodeWidthChar::width(cell.ch).unwrap_or(1);
252            if width == 0 || col.saturating_add(width) > self.width as usize {
253                return;
254            }
255            for target_col in col..col.saturating_add(width).max(col + 1) {
256                self.clear_wide_span_at(row, target_col);
257            }
258            self.cells[row][col] = cell.clone();
259            for offset in 1..width {
260                let mut continuation = cell.clone();
261                continuation.ch = WIDE_CONTINUATION;
262                continuation.combining.clear();
263                self.cells[row][col + offset] = continuation;
264            }
265        }
266    }
267
268    pub fn write_str(&mut self, x: u16, y: u16, text: &str, style: &CellStyle) {
269        let mut col = x as usize;
270        let row = y as usize;
271        if row >= self.height as usize {
272            return;
273        }
274        let mut last_base_col: Option<usize> = None;
275        for ch in text.chars() {
276            let width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
277            if width == 0 {
278                if let Some(base_col) =
279                    last_base_col.or_else(|| self.leading_combining_base_col(row, col))
280                {
281                    self.cells[row][base_col].combining.push(ch);
282                }
283                continue;
284            }
285            if col.saturating_add(width) > self.width as usize {
286                break;
287            }
288            for target_col in col..col.saturating_add(width).max(col + 1) {
289                self.clear_wide_span_at(row, target_col);
290            }
291            self.cells[row][col] = Cell::styled(ch, style);
292            for offset in 1..width {
293                self.cells[row][col + offset] = Cell::styled(WIDE_CONTINUATION, style);
294            }
295            last_base_col = Some(col);
296            col += width;
297        }
298    }
299
300    fn leading_combining_base_col(&self, row: usize, col: usize) -> Option<usize> {
301        if col == 0 || col >= self.width as usize {
302            return None;
303        }
304
305        let (start, _) = self.wide_span_bounds_at(row, col - 1);
306        let cell = self.cells.get(row)?.get(start)?;
307        if cell.ch == ' ' || cell.ch == WIDE_CONTINUATION {
308            return None;
309        }
310
311        Some(start)
312    }
313
314    fn clear_wide_span_at(&mut self, row: usize, col: usize) {
315        if row >= self.height as usize || col >= self.width as usize {
316            return;
317        }
318
319        let row_cells = &mut self.cells[row];
320        let mut start = col;
321        if row_cells[col].ch == WIDE_CONTINUATION {
322            while start > 0 && row_cells[start].ch == WIDE_CONTINUATION {
323                start -= 1;
324            }
325        }
326
327        let width = unicode_width::UnicodeWidthChar::width(row_cells[start].ch).unwrap_or(1);
328        if width <= 1 || start.saturating_add(width) <= col {
329            row_cells[col] = Cell::default();
330            return;
331        }
332
333        let end = start.saturating_add(width).min(row_cells.len());
334        for cell in row_cells.iter_mut().take(end).skip(start) {
335            *cell = Cell::default();
336        }
337    }
338
339    fn wide_span_bounds_at(&self, row: usize, col: usize) -> (usize, usize) {
340        let Some(row_cells) = self.cells.get(row) else {
341            return (col, col);
342        };
343        if col >= row_cells.len() {
344            return (col, col);
345        }
346
347        let mut start = col;
348        if row_cells[col].ch == WIDE_CONTINUATION {
349            while start > 0 && row_cells[start].ch == WIDE_CONTINUATION {
350                start -= 1;
351            }
352        }
353
354        let width = unicode_width::UnicodeWidthChar::width(row_cells[start].ch).unwrap_or(1);
355        if width <= 1 || start.saturating_add(width) <= col {
356            return (col, col.saturating_add(1).min(row_cells.len()));
357        }
358
359        (start, start.saturating_add(width).min(row_cells.len()))
360    }
361
362    pub fn fill_bg(&mut self, x: u16, y: u16, w: u16, h: u16, color: Color) {
363        let end_y = y.saturating_add(h).min(self.height);
364        let end_x = x.saturating_add(w).min(self.width);
365        for row in y as usize..end_y as usize {
366            let mut col = x as usize;
367            while col < end_x as usize {
368                let (span_start, span_end) = self.wide_span_bounds_at(row, col);
369                for target_col in span_start..span_end {
370                    self.cells[row][target_col].bg = Some(color);
371                }
372                col = span_end.max(col.saturating_add(1));
373            }
374        }
375    }
376
377    pub fn diff(&self, other: &Grid) -> Vec<CellChange> {
378        let mut changes = Vec::new();
379        let max_h = self.height.min(other.height);
380        let max_w = self.width.min(other.width);
381
382        for y in 0..max_h {
383            for x in 0..max_w {
384                let old = &self.cells[y as usize][x as usize];
385                let new = &other.cells[y as usize][x as usize];
386                if old != new {
387                    changes.push(CellChange {
388                        x,
389                        y,
390                        cell: new.clone(),
391                    });
392                }
393            }
394
395            if other.width > self.width {
396                for x in self.width..other.width {
397                    let cell = &other.cells[y as usize][x as usize];
398                    if *cell != Cell::default() {
399                        changes.push(CellChange {
400                            x,
401                            y,
402                            cell: cell.clone(),
403                        });
404                    }
405                }
406            }
407        }
408
409        if other.height > self.height {
410            for y in self.height..other.height {
411                for x in 0..other.width {
412                    let cell = &other.cells[y as usize][x as usize];
413                    if *cell != Cell::default() {
414                        changes.push(CellChange {
415                            x,
416                            y,
417                            cell: cell.clone(),
418                        });
419                    }
420                }
421            }
422        }
423
424        changes
425    }
426
427    /// Convert grid to a string representation (for testing/debugging).
428    pub fn render_to_string(&self) -> String {
429        let mut output = String::with_capacity((self.width as usize * self.height as usize) * 4);
430        for y in 0..self.height {
431            if y > 0 {
432                output.push('\n');
433            }
434            for x in 0..self.width {
435                self.cells[y as usize][x as usize].write_ansi(&mut output);
436            }
437        }
438        output
439    }
440}
441
442#[derive(Debug)]
443pub struct CellChange {
444    pub x: u16,
445    pub y: u16,
446    pub cell: Cell,
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    #[test]
454    fn grid_new_creates_empty_cells() {
455        let grid = Grid::new(10, 5);
456        assert_eq!(grid.width, 10);
457        assert_eq!(grid.height, 5);
458        assert_eq!(grid.cells.len(), 5);
459        assert_eq!(grid.cells[0].len(), 10);
460        assert_eq!(grid.get(0, 0).ch, ' ');
461    }
462
463    #[test]
464    fn grid_set_and_get() {
465        let mut grid = Grid::new(10, 5);
466        let cell = Cell::with_char('X');
467        grid.set(3, 2, cell.clone());
468        assert_eq!(grid.get(3, 2).ch, 'X');
469        assert_eq!(grid.try_get(3, 2), Some(&cell));
470    }
471
472    #[test]
473    fn grid_get_out_of_bounds_returns_empty_cell() {
474        let grid = Grid::new(2, 1);
475        assert!(grid.try_get(2, 0).is_none());
476        assert!(grid.try_get(0, 1).is_none());
477        assert_eq!(grid.get(2, 0), &Cell::default());
478        assert_eq!(grid.get(0, 1), &Cell::default());
479    }
480
481    #[test]
482    fn grid_set_out_of_bounds_is_noop() {
483        let mut grid = Grid::new(10, 5);
484        grid.set(15, 2, Cell::with_char('X'));
485        grid.set(3, 10, Cell::with_char('Y'));
486    }
487
488    #[test]
489    fn grid_write_str() {
490        let mut grid = Grid::new(20, 5);
491        let style = CellStyle::default();
492        grid.write_str(2, 1, "hello", &style);
493        assert_eq!(grid.get(2, 1).ch, 'h');
494        assert_eq!(grid.get(3, 1).ch, 'e');
495        assert_eq!(grid.get(4, 1).ch, 'l');
496        assert_eq!(grid.get(5, 1).ch, 'l');
497        assert_eq!(grid.get(6, 1).ch, 'o');
498    }
499
500    #[test]
501    fn grid_write_str_clips_at_boundary() {
502        let mut grid = Grid::new(5, 1);
503        let style = CellStyle::default();
504        grid.write_str(3, 0, "hello", &style);
505        assert_eq!(grid.get(3, 0).ch, 'h');
506        assert_eq!(grid.get(4, 0).ch, 'e');
507    }
508
509    #[test]
510    fn grid_write_str_drops_wide_char_that_would_overflow() {
511        let mut grid = Grid::new(4, 1);
512        let style = CellStyle::default();
513
514        grid.write_str(3, 0, "界", &style);
515
516        assert_eq!(grid.get(3, 0).ch, ' ');
517    }
518
519    #[test]
520    fn grid_write_str_keeps_zero_width_marks_with_base_glyph() {
521        let mut grid = Grid::new(2, 1);
522        let style = CellStyle::default();
523
524        grid.write_str(0, 0, "ab\u{0301}", &style);
525        grid.write_str(2, 0, "\u{0301}", &style);
526
527        assert_eq!(grid.get(1, 0).ch, 'b');
528        assert_eq!(grid.get(1, 0).combining, "\u{0301}");
529        assert_eq!(grid.render_to_string(), "ab\u{0301}");
530        assert_eq!(crate::style::visible_len(&grid.render_to_string()), 2);
531    }
532
533    #[test]
534    fn grid_write_str_attaches_leading_zero_width_mark_to_previous_cell() {
535        let mut grid = Grid::new(2, 1);
536        let style = CellStyle::default();
537
538        grid.write_str(0, 0, "a", &style);
539        grid.write_str(1, 0, "\u{0301}", &style);
540
541        assert_eq!(grid.get(0, 0).ch, 'a');
542        assert_eq!(grid.get(0, 0).combining, "\u{0301}");
543        assert_eq!(grid.render_to_string(), "a\u{0301} ");
544    }
545
546    #[test]
547    fn grid_write_str_attaches_leading_zero_width_mark_to_previous_wide_cell() {
548        let mut grid = Grid::new(3, 1);
549        let style = CellStyle::default();
550
551        grid.write_str(0, 0, "界", &style);
552        grid.write_str(2, 0, "\u{0301}", &style);
553
554        assert_eq!(grid.get(0, 0).ch, '界');
555        assert_eq!(grid.get(0, 0).combining, "\u{0301}");
556        assert_eq!(grid.get(1, 0).ch, WIDE_CONTINUATION);
557        assert_eq!(crate::style::visible_len(&grid.render_to_string()), 3);
558    }
559
560    #[test]
561    fn grid_render_does_not_add_visible_space_after_wide_char() {
562        let mut grid = Grid::new(2, 1);
563        let style = CellStyle::default();
564
565        grid.write_str(0, 0, "界", &style);
566
567        assert_eq!(crate::style::visible_len(&grid.render_to_string()), 2);
568    }
569
570    #[test]
571    fn grid_write_str_clears_stale_wide_continuation_after_narrow_overwrite() {
572        let mut grid = Grid::new(2, 1);
573        let style = CellStyle::default();
574
575        grid.write_str(0, 0, "界", &style);
576        grid.write_str(0, 0, "A", &style);
577
578        assert_eq!(grid.render_to_string(), "A ");
579    }
580
581    #[test]
582    fn grid_write_str_clears_wide_char_when_overwriting_its_continuation() {
583        let mut grid = Grid::new(2, 1);
584        let style = CellStyle::default();
585
586        grid.write_str(0, 0, "界", &style);
587        grid.write_str(1, 0, "B", &style);
588
589        assert_eq!(grid.render_to_string(), " B");
590    }
591
592    #[test]
593    fn grid_set_clears_stale_wide_continuation_after_narrow_overwrite() {
594        let mut grid = Grid::new(2, 1);
595        let style = CellStyle::default();
596
597        grid.write_str(0, 0, "界", &style);
598        grid.set(0, 0, Cell::with_char('A'));
599
600        assert_eq!(grid.render_to_string(), "A ");
601    }
602
603    #[test]
604    fn grid_set_clears_wide_char_when_overwriting_its_continuation() {
605        let mut grid = Grid::new(2, 1);
606        let style = CellStyle::default();
607
608        grid.write_str(0, 0, "界", &style);
609        grid.set(1, 0, Cell::with_char('B'));
610
611        assert_eq!(grid.render_to_string(), " B");
612    }
613
614    #[test]
615    fn grid_set_marks_wide_char_continuation() {
616        let mut grid = Grid::new(2, 1);
617
618        grid.set(0, 0, Cell::with_char('界'));
619
620        assert_eq!(grid.get(0, 0).ch, '界');
621        assert_eq!(grid.get(1, 0).ch, WIDE_CONTINUATION);
622        assert_eq!(crate::style::visible_len(&grid.render_to_string()), 2);
623    }
624
625    #[test]
626    fn grid_set_drops_wide_char_that_would_overflow() {
627        let mut grid = Grid::new(2, 1);
628
629        grid.set(1, 0, Cell::with_char('界'));
630
631        assert_eq!(grid.render_to_string(), "  ");
632    }
633
634    #[test]
635    fn grid_fill_bg() {
636        let mut grid = Grid::new(10, 5);
637        grid.fill_bg(1, 1, 3, 2, Color::Red);
638        assert_eq!(grid.get(1, 1).bg, Some(Color::Red));
639        assert_eq!(grid.get(3, 2).bg, Some(Color::Red));
640        assert_eq!(grid.get(0, 0).bg, None);
641    }
642
643    #[test]
644    fn grid_fill_bg_styles_wide_span_when_continuation_is_covered() {
645        let mut grid = Grid::new(2, 1);
646        let style = CellStyle::default();
647
648        grid.write_str(0, 0, "界", &style);
649        grid.fill_bg(1, 0, 1, 1, Color::Blue);
650
651        assert_eq!(grid.get(0, 0).bg, Some(Color::Blue));
652        assert_eq!(grid.get(1, 0).bg, Some(Color::Blue));
653        assert!(grid.render_to_string().contains("\x1b["));
654    }
655
656    #[test]
657    fn grid_fill_bg_saturates_overflowing_bounds() {
658        let mut grid = Grid::new(4, 2);
659        grid.fill_bg(u16::MAX - 1, u16::MAX - 1, 10, 10, Color::Red);
660        assert!(grid.cells.iter().flatten().all(|cell| cell.bg.is_none()));
661
662        grid.fill_bg(2, 1, u16::MAX, u16::MAX, Color::Blue);
663        assert_eq!(grid.get(2, 1).bg, Some(Color::Blue));
664        assert_eq!(grid.get(3, 1).bg, Some(Color::Blue));
665        assert_eq!(grid.get(1, 1).bg, None);
666        assert_eq!(grid.get(2, 0).bg, None);
667    }
668
669    #[test]
670    fn grid_diff_detects_changes() {
671        let grid1 = Grid::new(5, 3);
672        let mut grid2 = Grid::new(5, 3);
673        grid2.set(2, 1, Cell::with_char('A'));
674
675        let changes = grid1.diff(&grid2);
676        assert_eq!(changes.len(), 1);
677        assert_eq!(changes[0].x, 2);
678        assert_eq!(changes[0].y, 1);
679        assert_eq!(changes[0].cell.ch, 'A');
680    }
681
682    #[test]
683    fn grid_diff_no_changes() {
684        let grid1 = Grid::new(5, 3);
685        let grid2 = Grid::new(5, 3);
686        let changes = grid1.diff(&grid2);
687        assert!(changes.is_empty());
688    }
689
690    #[test]
691    fn grid_diff_detects_non_empty_cells_in_new_columns() {
692        let grid1 = Grid::new(1, 2);
693        let mut grid2 = Grid::new(3, 2);
694        grid2.set(2, 1, Cell::with_char('B'));
695
696        let changes = grid1.diff(&grid2);
697
698        assert_eq!(changes.len(), 1);
699        assert_eq!(changes[0].x, 2);
700        assert_eq!(changes[0].y, 1);
701        assert_eq!(changes[0].cell.ch, 'B');
702    }
703
704    #[test]
705    fn cell_ansi_plain() {
706        let cell = Cell::with_char('A');
707        assert_eq!(cell.to_ansi(), "A");
708    }
709
710    #[test]
711    fn cell_ansi_styled() {
712        let cell = Cell {
713            ch: 'B',
714            bold: true,
715            reverse: true,
716            fg: Some(Color::Red),
717            ..Default::default()
718        };
719        let ansi = cell.to_ansi();
720        assert!(ansi.contains("\x1b["));
721        assert!(ansi.contains("1"));
722        assert!(ansi.contains("7"));
723        assert!(ansi.contains('B'));
724    }
725}