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}