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}