Skip to main content

lattice_terminal/
synthetic.rs

1//! T-snap-1 (2026-05-27): synthetic, read-only Document built
2//! from the alacritty scrollback + visible grid. The central
3//! vim grammar operates on this Document during the Normal /
4//! Visual sub-state of a terminal buffer; the renderer
5//! continues to paint the cell grid. A coord adapter (T-paint-1)
6//! translates document-space selection ranges back to cell
7//! coordinates at publish time so renderer paths stay
8//! buffer-kind-agnostic.
9//!
10//! Design fragment: `docs/dev/architecture/terminal-as-
11//! document.md`. This module owns just the construction +
12//! coord-translation primitives; the lifecycle (when the
13//! snapshot is built / dropped) lives on `TerminalNormalMode`
14//! (T-mode-1).
15//!
16//! The snapshot is **frozen** for the duration of the Normal
17//! sub-state. PTY output continues to feed alacritty in the
18//! background; the SyntheticDoc does not auto-refresh. Re-entry
19//! to Insert drops the snapshot. This matches vim's `:terminal`
20//! semantics and gives the user a stable target for motions /
21//! marks / search.
22
23use std::sync::Arc;
24
25use alacritty_terminal::{
26    grid::Dimensions,
27    index::{Column, Line, Point},
28    term::TermMode,
29};
30use lattice_core::{Buffer, BufferId};
31use lattice_protocol::position::Position;
32
33use crate::reader::SharedTerm;
34
35/// Frozen read-only view of the terminal's scrollback + visible
36/// grid at the moment of an Insert → Normal transition. See
37/// `docs/dev/architecture/terminal-as-document.md` §3.2 for the
38/// build rules and §3.3 for the coord-translation invariants.
39#[derive(Debug, Clone)]
40pub struct SyntheticDoc {
41    /// Rope-backed buffer of the snapshot text. One rope line
42    /// per grid line. Trailing-blank padding is stripped from
43    /// each row so `$` lands on the last visible character
44    /// (matches what the user sees).
45    pub buffer: Buffer,
46    /// Doc-space cursor at the moment of snapshot — the
47    /// alacritty grid cursor translated to (rope line, byte
48    /// column). For ASCII content, `byte` equals the grid
49    /// column; wide-char handling is the open question in
50    /// §7 of the design fragment.
51    pub cursor: Position,
52    /// Topmost alacritty grid line at build time. Doc line `N`
53    /// corresponds to grid line `origin_top_line + N`. The
54    /// publish-time coord adapter (T-paint-1) uses this to remap
55    /// document-space ranges back to cell coordinates.
56    pub origin_top_line: i32,
57    /// Snapshot sequence captured at build time. Used by the
58    /// jumplist re-resolution path to detect when the
59    /// underlying scrollback has rolled and a recorded position
60    /// needs best-effort re-resolution.
61    pub frozen_at: u64,
62    /// True if the program was on the alt screen at build time.
63    /// Alt-screen snapshots cover only the visible region (alt
64    /// screen has no scrollback semantics).
65    pub alt_screen: bool,
66}
67
68impl SharedTerm {
69    /// Build a [`SyntheticDoc`] from the current grid state.
70    ///
71    /// - **Primary screen**: includes scrollback (`top..=bot` =
72    ///   `topmost_line..=bottommost_line`).
73    /// - **Alt screen**: visible region only (alt screen has no
74    ///   scrollback semantics; programs like vim / less / htop
75    ///   own the canvas).
76    ///
77    /// Trailing-blank padding is stripped per row before the
78    /// rope is built. The alacritty grid cursor is translated to
79    /// document-space `(line, byte)` coordinates and clamped to
80    /// the row's visible length (vim's "cursor can't sit in
81    /// virtual whitespace by default" rule).
82    ///
83    /// Cost is O(rows × cols). Bounded by
84    /// `terminal.scrollback-lines × cols`; default 10 000 × 200.
85    /// See the `term_snapshot_build` bench for the perf gate.
86    pub fn build_normal_snapshot(&self) -> SyntheticDoc {
87        let term = self.inner.lock();
88        let grid = term.grid();
89        let alt_screen = term.mode().contains(TermMode::ALT_SCREEN);
90        let (top, bot) = if alt_screen {
91            // Visible region only — alt screen has no scrollback.
92            (0i32, grid.screen_lines() as i32 - 1)
93        } else {
94            (grid.topmost_line().0, grid.bottommost_line().0)
95        };
96        let cols = grid.columns();
97        // Pre-size for the worst case (no trim) so the rope
98        // build is a single allocation.
99        let mut text = String::with_capacity(((bot - top + 1).max(0) as usize) * (cols + 1));
100        let cursor_grid_line = grid.cursor.point.line.0;
101        let cursor_grid_col = grid.cursor.point.column.0 as u32;
102        let mut cursor_line_doc: u32 = 0;
103        let mut cursor_col_doc: u32 = 0;
104        let mut cursor_found = false;
105        for line_idx in top..=bot {
106            let mut row_text = String::with_capacity(cols);
107            for c in 0..cols {
108                let p = Point::new(Line(line_idx), Column(c));
109                row_text.push(grid[p].c);
110            }
111            let trimmed = row_text.trim_end_matches(' ');
112            if line_idx == cursor_grid_line {
113                cursor_line_doc = (line_idx - top) as u32;
114                // Clamp to trimmed.len() so the cursor doesn't
115                // sit in stripped padding. For ASCII the byte
116                // count matches the grid column; wide-char
117                // handling is the §7 open question.
118                cursor_col_doc = std::cmp::min(cursor_grid_col, trimmed.len() as u32);
119                cursor_found = true;
120            }
121            text.push_str(trimmed);
122            text.push('\n');
123        }
124        // Drop the trailing newline so the rope has exactly N
125        // lines for an N-row grid (vim convention: last line is
126        // content-bearing, not a phantom empty line). Without
127        // this, ropey would see N+1 lines and the publish-time
128        // coord adapter's `origin_top_line + doc_line = grid_line`
129        // round-trip would slip by one at the bottom edge.
130        if text.ends_with('\n') {
131            text.pop();
132        }
133        // Defensive: if the cursor sits outside `(top..=bot)` for
134        // any reason (alt-screen edge cases, future grid
135        // weirdness), land it at (0, 0) rather than panicking.
136        if !cursor_found {
137            cursor_line_doc = 0;
138            cursor_col_doc = 0;
139        }
140        let frozen_at = self.seq.load(std::sync::atomic::Ordering::Relaxed);
141        drop(term);
142        SyntheticDoc {
143            buffer: Buffer::from_text(&text),
144            cursor: Position::new(cursor_line_doc, cursor_col_doc),
145            origin_top_line: top,
146            frozen_at,
147            alt_screen,
148        }
149    }
150}
151
152/// T-mode-1 (2026-05-27): service trait that `TerminalNormalMode`
153/// uses to build / drop the SyntheticDoc on a `TerminalBuffer`
154/// from its lifecycle hooks. The implementation lives on the
155/// host's `BufferRegistry`; the mode pulls a handle to it via
156/// `ModeContext::service::<TerminalStoreHandle>()`.
157///
158/// Two methods only: install (build + stash) and clear (drop).
159/// Keeping it minimal avoids leaking the `with_terminal_mut`
160/// closure surface across the crate boundary; the host wraps
161/// `with_terminal_mut` internally to satisfy these calls.
162pub trait TerminalStore: Send + Sync {
163    /// Build a SyntheticDoc from the terminal buffer's current
164    /// grid state and stash it on the buffer. Returns `true` if
165    /// a terminal buffer existed for `id` (and the doc was
166    /// installed); `false` otherwise. Idempotent — re-calling
167    /// rebuilds the doc.
168    fn install_synthetic(&self, id: BufferId) -> bool;
169
170    /// Drop any existing SyntheticDoc on the buffer. Idempotent.
171    /// Returns `true` if a terminal buffer existed for `id`;
172    /// `false` otherwise.
173    fn clear_synthetic(&self, id: BufferId) -> bool;
174}
175
176/// Cheap-clone handle to a [`TerminalStore`]. Mirrors the
177/// `BufferStoreHandle` pattern — the host registers a wrapping
178/// handle in the `ServiceRegistry` at boot; modes pull it via
179/// `ModeContext::service::<TerminalStoreHandle>()`.
180#[derive(Clone)]
181pub struct TerminalStoreHandle {
182    inner: Arc<dyn TerminalStore>,
183}
184
185impl TerminalStoreHandle {
186    pub fn new(store: Arc<dyn TerminalStore>) -> Self {
187        Self { inner: store }
188    }
189
190    pub fn install_synthetic(&self, id: BufferId) -> bool {
191        self.inner.install_synthetic(id)
192    }
193
194    pub fn clear_synthetic(&self, id: BufferId) -> bool {
195        self.inner.clear_synthetic(id)
196    }
197}
198
199impl std::fmt::Debug for TerminalStoreHandle {
200    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201        f.debug_struct("TerminalStoreHandle")
202            .finish_non_exhaustive()
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use std::sync::atomic::Ordering;
210
211    fn make_shared(rows: u16, cols: u16, scrollback: u32) -> SharedTerm {
212        SharedTerm::fixture(rows, cols, scrollback)
213    }
214
215    fn feed(shared: &SharedTerm, bytes: &[u8]) {
216        shared.feed_for_fixture(bytes);
217    }
218
219    #[test]
220    fn empty_grid_yields_blank_rope() {
221        let shared = make_shared(3, 10, 16);
222        let snap = shared.build_normal_snapshot();
223        // 3 visible rows, all blank. Build strips the trailing
224        // `\n` so the rope holds exactly N lines (vim
225        // convention). Three rows → "\n\n" (2 separating
226        // newlines + 3 empty content lines).
227        assert_eq!(snap.buffer.as_string(), "\n\n");
228        // ROPE space, deliberately — see `rows_are_rope_lines_not_content_lines`.
229        assert_eq!(snap.buffer.rope_line_count(), 3);
230        assert_eq!(snap.cursor, Position::new(0, 0));
231        assert!(!snap.alt_screen);
232        assert_eq!(snap.origin_top_line, 0);
233    }
234
235    #[test]
236    fn ascii_strips_trailing_blanks() {
237        let shared = make_shared(3, 10, 16);
238        feed(&shared, b"hi\r\nyo\r\nok");
239        let snap = shared.build_normal_snapshot();
240        // Per-row trailing-blank strip (grid stores 10 cells
241        // per row, padded with spaces). Trailing `\n` is
242        // dropped at the rope level (vim last-line convention).
243        assert_eq!(snap.buffer.as_string(), "hi\nyo\nok");
244        assert_eq!(snap.buffer.rope_line_count(), 3);
245    }
246
247    #[test]
248    fn cursor_translates_to_doc_coords() {
249        let shared = make_shared(3, 10, 16);
250        feed(&shared, b"hi\r\nworld");
251        let snap = shared.build_normal_snapshot();
252        // After "hi\r\nworld" on a 3×10 grid the cursor sits at
253        // grid row 1, col 5 (just past "world"). Doc cursor
254        // should reflect that.
255        assert_eq!(snap.cursor.line, 1);
256        assert_eq!(snap.cursor.byte, 5);
257    }
258
259    #[test]
260    fn cursor_clamps_to_trimmed_length() {
261        // Cursor parked on a blank row should clamp to col 0
262        // (the row trims to empty so byte 0 is the only valid
263        // landing). Achieved via ESC [ 3 ; 6 H (CUP to row 3,
264        // col 6) on an otherwise-empty row.
265        let shared = make_shared(3, 10, 16);
266        feed(&shared, b"top\r\n\x1b[3;6H");
267        let snap = shared.build_normal_snapshot();
268        assert_eq!(snap.cursor.line, 2);
269        assert_eq!(
270            snap.cursor.byte, 0,
271            "cursor on a blank row should clamp to col 0",
272        );
273    }
274
275    #[test]
276    fn scrollback_is_included_on_primary_screen() {
277        let shared = make_shared(3, 10, 16);
278        for i in 0..8u8 {
279            feed(&shared, format!("r{i}\r\n").as_bytes());
280        }
281        let snap = shared.build_normal_snapshot();
282        assert!(
283            snap.origin_top_line < 0,
284            "primary scrollback should yield negative origin_top_line, got {}",
285            snap.origin_top_line,
286        );
287        let text = snap.buffer.as_string();
288        assert!(
289            text.contains("r0"),
290            "earliest scrollback line missing: {text:?}",
291        );
292    }
293
294    #[test]
295    fn alt_screen_excludes_scrollback() {
296        let shared = make_shared(3, 10, 16);
297        for i in 0..8u8 {
298            feed(&shared, format!("r{i}\r\n").as_bytes());
299        }
300        // DECSET 1049 = enable alt-screen + save cursor.
301        feed(&shared, b"\x1b[?1049h");
302        feed(&shared, b"ALT");
303        let snap = shared.build_normal_snapshot();
304        assert!(snap.alt_screen);
305        assert_eq!(
306            snap.origin_top_line, 0,
307            "alt-screen snapshot should start at grid line 0",
308        );
309        let text = snap.buffer.as_string();
310        assert!(
311            !text.contains("r0"),
312            "alt-screen snapshot should not include primary scrollback: {text:?}",
313        );
314        assert!(text.contains("ALT"), "missing alt content: {text:?}");
315    }
316
317    /// CV.3 converted `line_count()` call sites to `content_line_count()`
318    /// across the tree, and misfiled this module's three. This pins WHY they
319    /// are rope space, so a future sweep does not re-break them.
320    ///
321    /// `content_line_count` exists to strip the phantom empty line a
322    /// terminating newline creates. **`build_normal_snapshot` already
323    /// stripped it** (see the `text.pop()` and its comment): the rope holds
324    /// exactly one line per grid row. So applying the correction a second
325    /// time removes a REAL row — and it only shows when the bottom row is
326    /// blank, which is why the two failures were an off-by-one at the bottom
327    /// edge rather than everywhere.
328    ///
329    /// The invariant the coord adapter needs is
330    /// `rope_line_count() == grid rows`, and that is what this asserts, for
331    /// both a blank bottom row and a content-bearing one.
332    #[test]
333    fn rows_are_rope_lines_not_content_lines() {
334        // Bottom row blank: the rope ends in `\n`, which `content_line_count`
335        // reads as a terminator and the terminal means as a row.
336        let shared = make_shared(3, 10, 16);
337        feed(&shared, b"hi\r\n");
338        let snap = shared.build_normal_snapshot();
339        assert_eq!(snap.buffer.as_string(), "hi\n\n");
340        assert_eq!(
341            snap.buffer.rope_line_count(),
342            3,
343            "one rope line per grid row, always"
344        );
345        assert_eq!(
346            snap.buffer.content_line_count(),
347            2,
348            "content_line_count swallows the blank bottom row — correct for a \
349             FILE, wrong for a grid, and the reason these sites are rope space"
350        );
351
352        // Bottom row with content: the two agree, which is exactly why the
353        // misclassification survived — `ascii_strips_trailing_blanks` passed.
354        let shared = make_shared(3, 10, 16);
355        feed(&shared, b"hi\r\nyo\r\nok");
356        let snap = shared.build_normal_snapshot();
357        assert_eq!(snap.buffer.rope_line_count(), 3);
358        assert_eq!(snap.buffer.content_line_count(), 3);
359    }
360
361    #[test]
362    fn frozen_at_captures_current_seq() {
363        let shared = make_shared(3, 10, 16);
364        shared.seq.store(42, Ordering::Relaxed);
365        let snap = shared.build_normal_snapshot();
366        assert_eq!(snap.frozen_at, 42);
367    }
368
369    #[test]
370    fn doc_line_to_grid_line_round_trips() {
371        // T-paint-1's coord adapter inverts the build:
372        // grid_line = origin_top_line + doc_line. Sanity-check
373        // the relationship holds at construction time.
374        let shared = make_shared(3, 10, 16);
375        for i in 0..8u8 {
376            feed(&shared, format!("r{i}\r\n").as_bytes());
377        }
378        let snap = shared.build_normal_snapshot();
379        // ROPE space: the adapter maps every GRID ROW, and the build
380        // already dropped the phantom trailing line, so there is nothing
381        // for `content_line_count` to correct — it would strip a real row.
382        let line_count = snap.buffer.rope_line_count();
383        let last_doc_line = line_count - 1;
384        let grid_line = snap.origin_top_line + last_doc_line as i32;
385        let bot = {
386            let t = shared.inner.lock();
387            t.grid().bottommost_line().0
388        };
389        assert_eq!(grid_line, bot);
390    }
391}