Skip to main content

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}