Skip to main content

atomcode_tuix/render/
screen.rs

1// crates/atomcode-tuix/src/render/screen.rs
2//
3// Retained-mode screen buffer — the backbone of the Ink-style
4// renderer. Owns two parallel `W × H` cell grids:
5//
6//   * `cells`      — the frame we are *currently building*. Widget
7//                    draws (footer / body / menu) mutate this before
8//                    `render_diff` is called.
9//   * `prev_cells` — the frame we *last emitted to the terminal*.
10//                    Diff basis for the next paint.
11//
12// `render_diff` computes the patch stream from (prev → current),
13// serialises it to ANSI bytes, swaps the frames (current becomes
14// prev, prev becomes the fresh scratch we'll next rebuild into) and
15// blanks the new scratch so partial draws don't leave stale cells.
16//
17// Design notes vs. the previous immediate-mode path:
18//
19//   * **No DECSTBM scroll region**: footer and body share one grid.
20//     Scrolling the body is `scroll_up(bottom, n)` — an O(bottom)
21//     memcpy inside the grid; terminal-side scrolling happens only
22//     via the diff (blank cells appear at the bottom, content that
23//     was there now lives higher).
24//
25//   * **No separate cache invalidation path**: `invalidate()` fills
26//     `prev_cells` with blanks so the next `render_diff` emits
27//     everything currently in `cells` as if cold-starting. Covers
28//     resume-from-external, resize, and any "terminal state is
29//     unknown" situation uniformly.
30//
31//   * **Cursor and visibility** are frame-level state, emitted once
32//     per diff at the tail of the patch stream, so they don't
33//     bounce around between cell writes.
34
35use std::io::Write as _;
36
37use super::cell::{diff_cell_frames, serialize_patches, Cell};
38
39/// Retained W×H cell grid + current/prev frames.
40///
41/// Indexing: `cells[row][col]` with `row ∈ 0..height`,
42/// `col ∈ 0..width`. ANSI emit converts to 1-indexed at the
43/// boundary.
44pub struct Screen {
45    cells: Vec<Vec<Cell>>,
46    prev_cells: Vec<Vec<Cell>>,
47    width: u16,
48    height: u16,
49    /// Where to park the terminal cursor after the frame emits.
50    /// `None` means "leave it wherever the last patch left it" —
51    /// typically only useful in tests.
52    cursor: Option<(u16, u16)>,
53    cursor_visible: bool,
54}
55
56impl Screen {
57    pub fn new(width: u16, height: u16) -> Self {
58        let row = vec![Cell::blank(); width as usize];
59        let frame = vec![row; height as usize];
60        Self {
61            cells: frame.clone(),
62            prev_cells: frame,
63            width,
64            height,
65            cursor: None,
66            cursor_visible: true,
67        }
68    }
69
70    pub fn width(&self) -> u16 {
71        self.width
72    }
73
74    pub fn height(&self) -> u16 {
75        self.height
76    }
77
78    /// Reset every cell of the current frame to a blank with default
79    /// style. O(W·H). Typically called by `render_diff` after a swap
80    /// so the next draw cycle starts from a clean scratch.
81    pub fn clear(&mut self) {
82        let blank = Cell::blank();
83        for row in &mut self.cells {
84            for c in row {
85                *c = blank.clone();
86            }
87        }
88    }
89
90    /// Write `cells` starting at `(row, col)` in the current frame.
91    /// Out-of-bounds rows are silently skipped (so callers don't
92    /// need to clamp every time); cols beyond `width` are truncated
93    /// to the right edge.
94    ///
95    /// Cells with `width == 2` (wide CJK / emoji) should have a
96    /// following `Cell::continuation()` from the caller — this method
97    /// itself doesn't auto-insert them. `push_str_cells` on the
98    /// caller side handles that invariant.
99    pub fn draw_row(&mut self, row: usize, col: usize, cells: &[Cell]) {
100        if row >= self.cells.len() {
101            return;
102        }
103        let target = &mut self.cells[row];
104        for (i, cell) in cells.iter().enumerate() {
105            let dst_col = col + i;
106            if dst_col >= target.len() {
107                break;
108            }
109            target[dst_col] = cell.clone();
110        }
111    }
112
113    /// Park the terminal cursor at `(row, col)` (1-indexed ANSI
114    /// coords) at the end of the next `render_diff`. Typically
115    /// pointed at the input prompt's insertion cell.
116    pub fn set_cursor(&mut self, row: u16, col: u16) {
117        self.cursor = Some((row, col));
118    }
119
120    /// Toggle DECTCEM cursor visibility for the next `render_diff`.
121    /// Used to hide the cursor while a live body spinner is animating
122    /// (otherwise it sits at the end of "Pondering… · 5s" and blinks).
123    /// `render_diff` re-emits this every frame, so flipping the flag
124    /// once is enough — every subsequent paint reasserts it.
125    pub fn set_cursor_visible(&mut self, visible: bool) {
126        self.cursor_visible = visible;
127    }
128
129    /// Scroll the top `bottom` rows up by `n`. Rows `[0..n)` are
130    /// dropped; rows `[n..bottom)` slide to `[0..bottom-n)`; rows
131    /// `[bottom-n..bottom)` become blank, ready for new content.
132    /// Rows `[bottom..height)` (typically the fixed footer) are
133    /// untouched.
134    ///
135    /// Used for body "append a line" semantics in retained mode:
136    /// scroll the whole body region up by one, then draw the new
137    /// line at `bottom - 1`.
138    pub fn scroll_up(&mut self, bottom: usize, n: usize) {
139        if n == 0 || bottom == 0 {
140            return;
141        }
142        let n = n.min(bottom);
143        let blank_row = vec![Cell::blank(); self.width as usize];
144        // `rotate_left` on the `[0..bottom)` slice slides the first
145        // `n` rows to the end of the slice — logically "scroll up".
146        // `Vec<Cell>` isn't `Copy`, so `copy_within` won't work;
147        // `rotate_left` moves (not copies) so it's valid for owned
148        // row vectors.
149        self.cells[0..bottom].rotate_left(n);
150        // The rows we just rotated to the end of the window hold
151        // stale content (what was at the top). Blank them for new
152        // content to land into.
153        for row_idx in (bottom - n)..bottom {
154            self.cells[row_idx] = blank_row.clone();
155        }
156    }
157
158    /// Produce the ANSI patch stream for (prev → current). Swaps
159    /// frames at the end so the `cells` we just rendered becomes
160    /// the next diff's `prev_cells`. Scratches `cells` to blank so
161    /// the next draw cycle starts clean — callers must re-draw
162    /// every widget every frame (retained-mode invariant).
163    pub fn render_diff(&mut self) -> Vec<u8> {
164        let patches = diff_cell_frames(&self.prev_cells, &self.cells);
165        let mut out = serialize_patches(&patches);
166        if let Some((r, c)) = self.cursor {
167            let _ = write!(&mut out, "\x1b[{};{}H", r, c);
168        }
169        if self.cursor_visible {
170            out.extend_from_slice(b"\x1b[?25h");
171        } else {
172            out.extend_from_slice(b"\x1b[?25l");
173        }
174        std::mem::swap(&mut self.prev_cells, &mut self.cells);
175        // Clear the new scratch. Without this, stale cells from
176        // N frames ago would be diffed against next frame and
177        // generate patches that erase content that actually
178        // belongs on screen.
179        self.clear();
180        out
181    }
182
183    /// Force the next `render_diff` to emit every non-blank cell as
184    /// if prev were all-blank. Called after `resume_from_external`,
185    /// `resize`, or any other event that leaves terminal state
186    /// unknown. Safe to call even when prev is already blank
187    /// (just produces no additional emit).
188    pub fn invalidate(&mut self) {
189        let blank_row = vec![Cell::blank(); self.width as usize];
190        for row in &mut self.prev_cells {
191            *row = blank_row.clone();
192        }
193    }
194
195    /// Rebuild for new dimensions. Current and prev frames are
196    /// discarded — the caller must re-draw every widget before
197    /// the next `render_diff`.
198    pub fn resize(&mut self, width: u16, height: u16) {
199        *self = Self::new(width, height);
200    }
201
202    /// Peek at the last-emitted frame. Used by tests and the
203    /// diagnostic trace path (`tuix_trace!("FOOT", ...)`) to
204    /// inspect "what is actually on screen right now" without
205    /// reconstructing state from the ANSI byte stream. Not meant
206    /// for normal rendering — that goes through `render_diff`.
207    pub fn prev_cells_for_test(&self) -> &[Vec<Cell>] {
208        &self.prev_cells
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::render::cell::{push_str_cells, CellStyle};
216
217    #[test]
218    fn new_screen_empty_frame_produces_no_content_patches() {
219        // Two all-blank frames → diff emits zero cell patches. Only
220        // trailing cursor-visibility control survives (the SGR reset
221        // also does NOT emit because serialize_patches skips it when
222        // no SGR was ever turned on).
223        let mut s = Screen::new(10, 3);
224        let bytes = s.render_diff();
225        let out = String::from_utf8(bytes).unwrap();
226        // Expect exactly the cursor-show sequence, nothing else.
227        assert_eq!(out, "\x1b[?25h", "unexpected bytes: {:?}", out);
228    }
229
230    #[test]
231    fn draw_row_emits_content_at_1_indexed_coords() {
232        let mut s = Screen::new(20, 5);
233        let mut cells = Vec::new();
234        push_str_cells(&mut cells, "hello", &CellStyle::default());
235        s.draw_row(2, 3, &cells);
236        let bytes = s.render_diff();
237        let out = String::from_utf8_lossy(&bytes);
238        assert!(out.contains("hello"), "missing content: {:?}", out);
239        // Row 2 (0-indexed) → ANSI row 3; col 3 → ANSI col 4.
240        assert!(out.contains("\x1b[3;4H"), "wrong cursor target: {:?}", out);
241    }
242
243    #[test]
244    fn second_frame_with_same_content_emits_no_cells() {
245        let mut s = Screen::new(20, 5);
246        let mut cells = Vec::new();
247        push_str_cells(&mut cells, "x", &CellStyle::default());
248        s.draw_row(0, 0, &cells);
249        let _ = s.render_diff(); // first frame emits 'x'
250                                 // Redraw identical content — the render_diff above cleared
251                                 // the scratch to blank, so we need to re-push.
252        s.draw_row(0, 0, &cells);
253        let bytes = s.render_diff();
254        let out = String::from_utf8_lossy(&bytes);
255        assert!(
256            !out.contains('x'),
257            "identical re-draw should be a no-op diff: {:?}",
258            out
259        );
260    }
261
262    #[test]
263    fn scroll_up_shifts_rows_drops_top() {
264        let mut s = Screen::new(10, 5);
265        let mut a = Vec::new();
266        push_str_cells(&mut a, "AAA", &CellStyle::default());
267        let mut b = Vec::new();
268        push_str_cells(&mut b, "BBB", &CellStyle::default());
269        // Populate rows 0, 1.
270        s.draw_row(0, 0, &a);
271        s.draw_row(1, 0, &b);
272        let _ = s.render_diff(); // swaps into prev, clears scratch
273                                 // Re-draw the same content then scroll.
274        s.draw_row(0, 0, &a);
275        s.draw_row(1, 0, &b);
276        s.scroll_up(2, 1);
277        // After scroll_up(bottom=2, n=1):
278        //   cells[0] = what was cells[1] = "BBB"
279        //   cells[1] = blank
280        // Diff against prev (row0="AAA", row1="BBB") →
281        //   row 0: prev "AAA" vs now "BBB" → patches
282        //   row 1: prev "BBB" vs now blank → blank patches
283        let bytes = s.render_diff();
284        let out = String::from_utf8_lossy(&bytes);
285        assert!(out.contains("BBB"), "row 0 should now show BBB");
286    }
287
288    #[test]
289    fn invalidate_forces_cold_start_on_next_diff() {
290        let mut s = Screen::new(10, 3);
291        let mut cells = Vec::new();
292        push_str_cells(&mut cells, "hi", &CellStyle::default());
293        s.draw_row(0, 0, &cells);
294        let _ = s.render_diff();
295        // Same content, but invalidate → next diff emits full
296        // cold-start patches for every non-blank cell.
297        s.draw_row(0, 0, &cells);
298        s.invalidate();
299        let bytes = s.render_diff();
300        let out = String::from_utf8_lossy(&bytes);
301        assert!(
302            out.contains("hi"),
303            "invalidate must force re-emit: {:?}",
304            out
305        );
306    }
307
308    #[test]
309    fn resize_blanks_both_frames() {
310        let mut s = Screen::new(10, 3);
311        let mut cells = Vec::new();
312        push_str_cells(&mut cells, "stuff", &CellStyle::default());
313        s.draw_row(0, 0, &cells);
314        let _ = s.render_diff();
315        s.resize(20, 5);
316        assert_eq!(s.width(), 20);
317        assert_eq!(s.height(), 5);
318        // After resize, drawing no content → empty diff.
319        let bytes = s.render_diff();
320        let out = String::from_utf8_lossy(&bytes);
321        assert!(
322            !out.contains("stuff"),
323            "old content must be gone after resize: {:?}",
324            out
325        );
326    }
327
328    #[test]
329    fn set_cursor_emits_final_position() {
330        let mut s = Screen::new(10, 3);
331        s.set_cursor(2, 5);
332        let bytes = s.render_diff();
333        let out = String::from_utf8_lossy(&bytes);
334        assert!(out.contains("\x1b[2;5H"), "cursor park missing: {:?}", out);
335    }
336}