lattice_terminal/snapshot.rs
1//! TerminalSnapshot — the immutable per-frame view the
2//! renderer paints. Published via `ArcSwap` from the reader
3//! task; loaded wait-free by the paint hot path.
4
5use std::sync::Arc;
6
7use crate::cell::{Cell, CursorShape};
8
9/// One published frame. Renderer reads via
10/// `Arc<ArcSwap<TerminalSnapshot>>` — `.load()` is wait-free.
11///
12/// The `cells` slice is row-major: cell at (row, col) lives at
13/// `cells[row * cols + col]`. Always exactly `rows * cols`
14/// long.
15///
16/// `seq` is a monotonic frame counter — renderers can skip a
17/// repaint when the loaded snapshot's `seq` matches the
18/// last-painted one.
19#[derive(Debug, Clone)]
20pub struct TerminalSnapshot {
21 pub cells: Arc<[Cell]>,
22 pub rows: u16,
23 pub cols: u16,
24 pub cursor_row: u16,
25 pub cursor_col: u16,
26 pub cursor_visible: bool,
27 pub cursor_shape: CursorShape,
28 /// `None` = no OSC 0/2 title received yet; renderer falls
29 /// back to the buffer's label.
30 pub title: Option<String>,
31 /// True while the program is on the xterm "alternate
32 /// screen" buffer (vim, less, htop, etc.). Renderers can
33 /// use this to disable the modeline-scrollback indicator.
34 pub alt_screen: bool,
35 /// T3 (2026-05-25): rows scrolled back from the live edge.
36 /// `0` = live; positive values expose scrollback. Renderers
37 /// surface this on the modeline so the user can tell at a
38 /// glance they're viewing history.
39 pub scroll_offset: u32,
40 /// T3 (2026-05-25): current number of rows held in
41 /// scrollback (history that has scrolled off the live
42 /// screen). Grows as rows roll off; bounded by the
43 /// configured `terminal.scrollback-lines`. Used by the
44 /// modeline / status line to render a "row N of M"
45 /// indicator and to gate scroll-up motions.
46 pub scrollback_rows: u32,
47 /// Monotonic per-terminal frame counter.
48 pub seq: u64,
49}
50
51impl TerminalSnapshot {
52 /// Empty 24×80 snapshot — the vim/xterm-conventional fallback size for
53 /// contexts with no real spawn geometry yet (tests, `Default`). NOT used
54 /// by `spawn()`'s initial publish — see [`Self::empty_sized`].
55 pub fn empty() -> Self {
56 Self::empty_sized(24, 80)
57 }
58
59 /// Empty snapshot at a CALLER-SUPPLIED size. `spawn()` publishes this
60 /// (at the real `SpawnConfig::{rows,cols}`) as the placeholder shown
61 /// before the reader thread processes the child's first output chunk.
62 ///
63 /// Regression (2026-07-02): `spawn()` used to publish `Self::empty()`
64 /// unconditionally — a hardcoded 24×80 disconnected from the real spawn
65 /// size. The alacritty `Term` (`build_term`) and the kernel PTY
66 /// (`openpty`) were ALWAYS sized correctly from `SpawnConfig`; only this
67 /// placeholder lied about it. When the real pane is taller than 24 rows
68 /// (the common case), a paint landing in the placeholder's brief window
69 /// caps `rows_to_paint` at 24, and — critically — the mismatch does NOT
70 /// self-correct via a renderer resize the way the old doc comment
71 /// claimed: `SetPaneViewport`'s diff-then-send gate only fires a PTY
72 /// resize when the computed row count DIFFERS from the pane's already-
73 /// published `viewport_height`, which `do_terminal_spawn` already read
74 /// to size the spawn — so there is no delta to trigger a correcting
75 /// resize. The window closes only once the reader thread republishes a
76 /// snapshot from real output, whenever that first arrives.
77 pub fn empty_sized(rows: u16, cols: u16) -> Self {
78 let cells = vec![Cell::default(); rows as usize * cols as usize];
79 Self {
80 cells: cells.into(),
81 rows,
82 cols,
83 cursor_row: 0,
84 cursor_col: 0,
85 cursor_visible: true,
86 cursor_shape: CursorShape::Block,
87 title: None,
88 alt_screen: false,
89 scroll_offset: 0,
90 scrollback_rows: 0,
91 seq: 0,
92 }
93 }
94
95 /// Cell at (row, col). Returns the default cell (space on
96 /// default bg/fg) for out-of-range indices so renderers
97 /// can iterate naively without bounds checks.
98 pub fn cell_at(&self, row: u16, col: u16) -> Cell {
99 if row >= self.rows || col >= self.cols {
100 return Cell::default();
101 }
102 let idx = row as usize * self.cols as usize + col as usize;
103 self.cells.get(idx).copied().unwrap_or_default()
104 }
105}
106
107impl Default for TerminalSnapshot {
108 fn default() -> Self {
109 Self::empty()
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
118 fn empty_snapshot_is_24_x_80_of_default_cells() {
119 let s = TerminalSnapshot::empty();
120 assert_eq!(s.rows, 24);
121 assert_eq!(s.cols, 80);
122 assert_eq!(s.cells.len(), 24 * 80);
123 assert!(s.cells.iter().all(|c| *c == Cell::default()));
124 assert_eq!(s.seq, 0);
125 }
126
127 #[test]
128 fn cell_at_out_of_range_returns_default() {
129 let s = TerminalSnapshot::empty();
130 assert_eq!(s.cell_at(99, 99), Cell::default());
131 assert_eq!(s.cell_at(s.rows, 0), Cell::default());
132 }
133
134 /// Regression guard for the "last line clipped" bug: `spawn()`'s
135 /// initial placeholder must report the REAL spawn geometry, not a
136 /// hardcoded 24×80 disconnected from it. A pane taller than 24 rows
137 /// (the common case) painting during the placeholder's window would
138 /// otherwise cap `rows_to_paint` at 24 and clip everything below —
139 /// see `empty_sized`'s doc comment for why this doesn't self-correct
140 /// on the next frame the way a stale-size bug normally would.
141 #[test]
142 fn empty_sized_reports_the_caller_supplied_geometry() {
143 let s = TerminalSnapshot::empty_sized(45, 120);
144 assert_eq!(s.rows, 45);
145 assert_eq!(s.cols, 120);
146 assert_eq!(s.cells.len(), 45 * 120);
147 assert!(s.cells.iter().all(|c| *c == Cell::default()));
148 }
149}