Skip to main content

lattice_terminal/
reader.rs

1//! Reader task — drains the master PTY's read side into the
2//! published [`TerminalSnapshot`] cell.
3//!
4//! T2 substrate swap (2026-05-25): replaced the homegrown
5//! `TerminalGrid` placeholder with `alacritty_terminal::Term` +
6//! `vte::ansi::Processor`. Lattice now inherits a full
7//! VT/xterm parser (SGR colors / alt-screen / DECCKM /
8//! cursor-visibility / OSC titles / scrollback) from the
9//! battle-tested alacritty stack — same engine alacritty,
10//! zed, and neovide use.
11//!
12//! The renderer-facing contract (writes to
13//! `Arc<ArcSwap<TerminalSnapshot>>`) is unchanged. The cells
14//! we publish now carry real `TerminalColor::Named / Indexed /
15//! Rgb` values via [`map_cell`].
16
17use std::io::Read;
18use std::sync::Arc;
19use std::sync::atomic::{AtomicU64, Ordering};
20use std::time::Duration;
21
22use alacritty_terminal::event::{Event as AlacrittyEvent, EventListener};
23use alacritty_terminal::grid::{Dimensions, Scroll};
24use alacritty_terminal::index::{Column, Line, Point};
25use alacritty_terminal::term::TermMode;
26use alacritty_terminal::term::cell::Flags as AlacrittyFlags;
27use alacritty_terminal::term::{Config as TermConfig, Term};
28use alacritty_terminal::vte::ansi::{
29    Color as AnsiColor, CursorShape as AnsiCursorShape, NamedColor as AnsiNamedColor, Processor,
30};
31use arc_swap::ArcSwap;
32use parking_lot::Mutex;
33
34use crate::cell::{Cell, CellAttrs, CursorShape, NamedColor, TerminalColor};
35use crate::snapshot::TerminalSnapshot;
36
37/// Coalesce window: after publishing a snapshot, sleep this
38/// long before reading more. Bytes arriving during the sleep
39/// accumulate in the kernel pipe buffer and get drained into
40/// the grid by the next `reader.read()`, so a high-throughput
41/// program (cargo build, `cat huge.log`) batches into ~60
42/// publishes/sec instead of one per syscall.
43const REFRESH_WINDOW: Duration = Duration::from_millis(16);
44
45/// [`EventListener`] that answers the VT **queries** a TUI fires at startup so
46/// query-driven frameworks (opentui / opencode) render instead of blocking on a
47/// blank screen.
48///
49/// alacritty's parser turns queries like DSR cursor-position (`ESC[6n`), device
50/// attributes, DECRQM synchronized-output (`ESC[?2026$p`), and OSC colour
51/// (`]11;?`) into [`AlacrittyEvent`]s (`PtyWrite` / `ColorRequest`) carrying the
52/// reply — but the *reply has to be written back to the PTY*, exactly as a real
53/// terminal would. With no listener wired (the former `NoopListener`) those
54/// replies were dropped, so a TUI that waits for them hung invisibly. This
55/// listener writes them back through the master writer shared with
56/// [`PtyHandle`](crate::handle::PtyHandle) (`writer: None` = an inert no-op,
57/// for tests). Title / bell / clipboard events are still ignored (T3 polish).
58#[derive(Clone, Default)]
59pub(crate) struct PtyResponder {
60    writer: Option<crate::handle::SharedPtyWriter>,
61}
62
63impl PtyResponder {
64    /// Inert responder (no PTY writer) — for tests and direct-`build_term` use.
65    pub(crate) fn noop() -> Self {
66        Self { writer: None }
67    }
68
69    /// Responder that writes VT-query replies back through the shared master
70    /// writer.
71    pub(crate) fn responding(writer: crate::handle::SharedPtyWriter) -> Self {
72        Self {
73            writer: Some(writer),
74        }
75    }
76
77    fn respond(&self, bytes: &[u8]) {
78        if let Some(w) = &self.writer {
79            let mut w = w.lock();
80            let _ = w.write_all(bytes);
81            let _ = w.flush();
82        }
83    }
84}
85
86impl EventListener for PtyResponder {
87    fn send_event(&self, event: AlacrittyEvent) {
88        match event {
89            // DSR / DA / DECRQM / cursor-position replies etc. — the layout-
90            // critical ones a TUI blocks on. Write the bytes straight back.
91            AlacrittyEvent::PtyWrite(text) => self.respond(text.as_bytes()),
92            // OSC 10/11 colour probe (`]1x;?`). Answer with a conservative dark
93            // default so the probe completes; wiring the live theme colours is
94            // a cosmetic follow-up (this does not affect layout).
95            AlacrittyEvent::ColorRequest(_index, formatter) => {
96                let rgb = alacritty_terminal::vte::ansi::Rgb {
97                    r: 0x1e,
98                    g: 0x1e,
99                    b: 0x1e,
100                };
101                self.respond(formatter(rgb).as_bytes());
102            }
103            // Title / bell / clipboard / wakeup / exit — not surfaced yet.
104            _ => {}
105        }
106    }
107}
108
109/// Minimal [`Dimensions`] adapter for `Term::new`. The T1
110/// placeholder didn't track scrollback; T2 leaves the
111/// scrollback ring at zero so the grid behaves identically
112/// for the screen-only case. Scrollback exposure lands with
113/// T3 (`docs/dev/operations/slice-plans/terminal-mode.md`).
114struct PtyDimensions {
115    rows: u16,
116    cols: u16,
117}
118
119impl Dimensions for PtyDimensions {
120    fn total_lines(&self) -> usize {
121        self.rows as usize
122    }
123
124    fn screen_lines(&self) -> usize {
125        self.rows as usize
126    }
127
128    fn columns(&self) -> usize {
129        self.cols as usize
130    }
131}
132
133/// Build a fresh `Term` sized to the requested PTY dimensions.
134/// `scrollback_lines` configures alacritty's history ring; `0`
135/// disables scrollback entirely. Pulled out for the spawn path
136/// and the test helpers.
137pub(crate) fn build_term(rows: u16, cols: u16, scrollback_lines: u32) -> Term<PtyResponder> {
138    build_term_with(rows, cols, scrollback_lines, PtyResponder::noop())
139}
140
141/// [`build_term`] with an explicit [`PtyResponder`] — the spawn path passes a
142/// responding one (wired to the PTY writer) so VT queries get answered; tests
143/// use the inert `build_term`.
144pub(crate) fn build_term_with(
145    rows: u16,
146    cols: u16,
147    scrollback_lines: u32,
148    responder: PtyResponder,
149) -> Term<PtyResponder> {
150    let dims = PtyDimensions { rows, cols };
151    let config = TermConfig {
152        scrolling_history: scrollback_lines as usize,
153        ..TermConfig::default()
154    };
155    Term::new(config, &dims, responder)
156}
157
158/// Map alacritty's [`AnsiNamedColor`] (which covers extended
159/// vocabulary like `Foreground` / `BrightForeground` / dim
160/// variants) into Lattice's 16-entry [`NamedColor`] palette.
161/// Anything outside the 16 named palette folds to
162/// [`TerminalColor::Default`] so the renderer falls back to
163/// the theme's fg/bg.
164fn map_named_color(named: AnsiNamedColor) -> TerminalColor {
165    match named {
166        AnsiNamedColor::Black => TerminalColor::Named(NamedColor::Black),
167        AnsiNamedColor::Red => TerminalColor::Named(NamedColor::Red),
168        AnsiNamedColor::Green => TerminalColor::Named(NamedColor::Green),
169        AnsiNamedColor::Yellow => TerminalColor::Named(NamedColor::Yellow),
170        AnsiNamedColor::Blue => TerminalColor::Named(NamedColor::Blue),
171        AnsiNamedColor::Magenta => TerminalColor::Named(NamedColor::Magenta),
172        AnsiNamedColor::Cyan => TerminalColor::Named(NamedColor::Cyan),
173        AnsiNamedColor::White => TerminalColor::Named(NamedColor::White),
174        AnsiNamedColor::BrightBlack => TerminalColor::Named(NamedColor::BrightBlack),
175        AnsiNamedColor::BrightRed => TerminalColor::Named(NamedColor::BrightRed),
176        AnsiNamedColor::BrightGreen => TerminalColor::Named(NamedColor::BrightGreen),
177        AnsiNamedColor::BrightYellow => TerminalColor::Named(NamedColor::BrightYellow),
178        AnsiNamedColor::BrightBlue => TerminalColor::Named(NamedColor::BrightBlue),
179        AnsiNamedColor::BrightMagenta => TerminalColor::Named(NamedColor::BrightMagenta),
180        AnsiNamedColor::BrightCyan => TerminalColor::Named(NamedColor::BrightCyan),
181        AnsiNamedColor::BrightWhite => TerminalColor::Named(NamedColor::BrightWhite),
182        // `Foreground` / `Background` / `Cursor` / Dim* /
183        // BrightForeground / DimForeground all map to the
184        // theme's default — Lattice's terminal renderer picks
185        // the theme-supplied fg/bg when the cell carries
186        // `TerminalColor::Default`. Dim* additionally sets the
187        // `dim` attribute via the cell flags path.
188        _ => TerminalColor::Default,
189    }
190}
191
192fn map_color(c: AnsiColor) -> TerminalColor {
193    match c {
194        AnsiColor::Named(n) => map_named_color(n),
195        AnsiColor::Indexed(idx) => TerminalColor::Indexed(idx),
196        AnsiColor::Spec(rgb) => TerminalColor::Rgb(rgb.r, rgb.g, rgb.b),
197    }
198}
199
200fn map_flags(flags: AlacrittyFlags) -> CellAttrs {
201    // Alacritty's `Flags` doesn't carry a BLINK bit — blink is
202    // surfaced via cursor mode rather than per-cell attrs.
203    // `Lattice::CellAttrs::blink` keeps the slot for future
204    // wiring (e.g. terminal-bell or per-cell underline-blink
205    // when alacritty grows the bit) but defaults to `false`.
206    CellAttrs {
207        bold: flags.contains(AlacrittyFlags::BOLD),
208        italic: flags.contains(AlacrittyFlags::ITALIC),
209        underline: flags.contains(AlacrittyFlags::UNDERLINE),
210        reverse: flags.contains(AlacrittyFlags::INVERSE),
211        dim: flags.contains(AlacrittyFlags::DIM),
212        strikethrough: flags.contains(AlacrittyFlags::STRIKEOUT),
213        blink: false,
214    }
215}
216
217fn map_cell(a: &alacritty_terminal::term::cell::Cell) -> Cell {
218    Cell {
219        ch: a.c,
220        fg: map_color(a.fg),
221        bg: map_color(a.bg),
222        attrs: map_flags(a.flags),
223        // A width-2 glyph occupies two grid cells: the glyph
224        // (WIDE_CHAR) and this trailing placeholder. `LEADING_`
225        // is alacritty's variant for a wide glyph deferred off a
226        // line's last column. Surface it so renderers skip the
227        // spacer instead of emitting it as a stray space — see
228        // `docs/dev/audit/terminal-wide-char-ghosting.md`.
229        wide_spacer: a.flags.contains(AlacrittyFlags::WIDE_CHAR_SPACER)
230            || a.flags.contains(AlacrittyFlags::LEADING_WIDE_CHAR_SPACER),
231    }
232}
233
234fn map_cursor_shape(s: AnsiCursorShape) -> CursorShape {
235    match s {
236        AnsiCursorShape::Block | AnsiCursorShape::HollowBlock => CursorShape::Block,
237        AnsiCursorShape::Underline => CursorShape::Underline,
238        AnsiCursorShape::Beam => CursorShape::Bar,
239        AnsiCursorShape::Hidden => CursorShape::Hidden,
240    }
241}
242
243/// Render a published snapshot from the live `Term` state.
244/// T3 (2026-05-25): the visible window now honours
245/// `Grid::display_offset` so scrolled-back rows surface in the
246/// snapshot the renderer paints. When `display_offset == 0`
247/// the snapshot shows the live screen (`Line(0..rows)`);
248/// otherwise it shifts up into history.
249pub(crate) fn term_to_snapshot<T: EventListener>(term: &Term<T>, seq: u64) -> TerminalSnapshot {
250    let grid = term.grid();
251    let rows = grid.screen_lines();
252    let cols = grid.columns();
253    let display_offset = grid.display_offset();
254    let scrollback_max = grid.history_size();
255    let mut cells = Vec::with_capacity(rows * cols);
256    // Alacritty's screen window for the current display offset
257    // is `[Line(-display_offset), Line(-display_offset + rows))`.
258    // When the user scrolls up by N, the topmost visible row is
259    // `Line(-N)` (history row N rows above the live screen);
260    // when N == 0 the topmost visible row is `Line(0)` (the
261    // current live screen).
262    let top_line = -(display_offset as i32);
263    for r in 0..rows {
264        for c in 0..cols {
265            let point = Point::new(Line(top_line + r as i32), Column(c));
266            cells.push(map_cell(&grid[point]));
267        }
268    }
269    // Cursor is always reported relative to the live screen
270    // even when scrolled back, so the cursor's `cell_at` row
271    // may be off-screen. Renderers should hide the cursor
272    // splice when `cursor_row >= rows` after the shift.
273    let cursor_point = grid.cursor.point;
274    let live_cursor_row = cursor_point.line.0;
275    let shifted_cursor_row = live_cursor_row + display_offset as i32;
276    let cursor_visible_in_view = (0..rows as i32).contains(&shifted_cursor_row);
277    let cursor_row = shifted_cursor_row.max(0) as u16;
278    let cursor_col = cursor_point.column.0 as u16;
279    let mode = term.mode();
280    TerminalSnapshot {
281        cells: cells.into(),
282        rows: rows.min(u16::MAX as usize) as u16,
283        cols: cols.min(u16::MAX as usize) as u16,
284        cursor_row,
285        cursor_col,
286        // Hide the cursor when the user has scrolled past it —
287        // matches every terminal emulator's UX. Renderers that
288        // splice a cursor cell skip the splice when this is
289        // false; the hardware-cursor path (TUI) also skips
290        // `frame.set_cursor_position`.
291        cursor_visible: mode.contains(TermMode::SHOW_CURSOR) && cursor_visible_in_view,
292        cursor_shape: map_cursor_shape(term.cursor_style().shape),
293        // Title isn't surfaced through a public accessor on
294        // `Term` in alacritty_terminal 0.26 (the field is
295        // private and `pop_title` mutates the title stack —
296        // wrong shape for a per-frame read). T3 wires the
297        // EventListener::send_event(SetTitle) into a stable
298        // cell so the snapshot can carry it.
299        title: None,
300        alt_screen: mode.contains(TermMode::ALT_SCREEN),
301        scroll_offset: display_offset.min(u32::MAX as usize) as u32,
302        scrollback_rows: scrollback_max.min(u32::MAX as usize) as u32,
303        seq,
304    }
305}
306
307/// T3 (2026-05-25): how the caller asks the terminal to
308/// reposition the viewport over its scrollback. Mapped 1:1 to
309/// alacritty's `Scroll` enum at the dispatch site so the public
310/// API doesn't leak alacritty types.
311#[derive(Debug, Clone, Copy)]
312pub enum TerminalScrollKind {
313    /// Positive = up into history; negative = down toward live.
314    /// Matches alacritty's `Scroll::Delta` sign convention.
315    Delta(i32),
316    PageUp,
317    PageDown,
318    Top,
319    Bottom,
320}
321
322fn map_scroll(kind: TerminalScrollKind) -> Scroll {
323    match kind {
324        TerminalScrollKind::Delta(n) => Scroll::Delta(n),
325        TerminalScrollKind::PageUp => Scroll::PageUp,
326        TerminalScrollKind::PageDown => Scroll::PageDown,
327        TerminalScrollKind::Top => Scroll::Top,
328        TerminalScrollKind::Bottom => Scroll::Bottom,
329    }
330}
331
332/// Shared handle to the alacritty `Term` owned by a spawned
333/// terminal. The reader task locks the `Mutex` to advance bytes
334/// from the PTY; the host's dispatch path locks it to invoke
335/// scroll / resize / future-T2.c operations. Contention is
336/// minimal — the reader only holds the lock during chunk
337/// processing (microseconds per call) and dispatch operations
338/// are user-driven (one per keystroke).
339///
340/// The `snapshot` Arc + `paint_request` notifier match the ones
341/// the reader publishes to so dispatch-side state changes
342/// (e.g. scrolling into history) republish a fresh snapshot
343/// without waiting for the next PTY byte.
344#[derive(Clone)]
345pub struct SharedTerm {
346    pub(crate) inner: Arc<Mutex<Term<PtyResponder>>>,
347    snapshot: Arc<ArcSwap<TerminalSnapshot>>,
348    pub(crate) seq: Arc<AtomicU64>,
349    paint_request: Option<Arc<tokio::sync::Notify>>,
350}
351
352impl std::fmt::Debug for SharedTerm {
353    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
354        f.debug_struct("SharedTerm").finish_non_exhaustive()
355    }
356}
357
358/// T3.b (2026-05-25): result of [`SharedTerm::find_match`].
359/// Carries the alacritty `Line` and column of a search hit so
360/// the caller can scroll the viewport to the matching row.
361#[derive(Debug, Clone, Copy, PartialEq, Eq)]
362pub struct GridSearchHit {
363    /// Alacritty grid line. Negative values are scrollback;
364    /// `0..=screen_lines-1` are the live screen.
365    pub line: i32,
366    /// Column index where the match begins (in cells).
367    pub column: u16,
368    /// Match length in chars (cell-count approximation; CJK
369    /// double-wide cells count once).
370    pub len: u32,
371}
372
373/// T3.b: search direction. Mirrors
374/// `lattice_grammar::SearchDirection` without depending on the
375/// grammar crate from the substrate.
376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
377pub enum SearchDir {
378    Forward,
379    Backward,
380}
381
382impl SharedTerm {
383    /// T-snap-1 (2026-05-27): in-crate fixture constructor used
384    /// by sibling modules' unit tests. **Not for production code
385    /// paths** — production constructs via `spawn_reader` so the
386    /// OS-thread reader runs. Kept `pub(crate)` because the
387    /// signature references the crate-private `PtyResponder`;
388    /// external callers (e.g. the `term_snapshot` bench) use the
389    /// higher-level [`Self::fixture`] helper instead.
390    pub(crate) fn from_state(
391        inner: Arc<Mutex<Term<PtyResponder>>>,
392        snapshot: Arc<ArcSwap<TerminalSnapshot>>,
393        seq: Arc<AtomicU64>,
394    ) -> Self {
395        Self {
396            inner,
397            snapshot,
398            seq,
399            paint_request: None,
400        }
401    }
402
403    /// T-snap-1 (2026-05-27): build a fixture `SharedTerm` sized
404    /// to `(rows × cols)` with a `scrollback`-line history ring.
405    /// Empty grid, zero seq, fresh snapshot. Used by tests and
406    /// the `term_snapshot` bench; not for production paths.
407    pub fn fixture(rows: u16, cols: u16, scrollback: u32) -> Self {
408        let term = Arc::new(Mutex::new(build_term(rows, cols, scrollback)));
409        let snapshot = Arc::new(ArcSwap::from_pointee(TerminalSnapshot::empty()));
410        let seq = Arc::new(AtomicU64::new(0));
411        Self::from_state(term, snapshot, seq)
412    }
413
414    /// T-snap-1 (2026-05-27): feed VT bytes into a fixture-built
415    /// `SharedTerm`. Mirrors what the production reader task
416    /// does on each PTY read — runs the alacritty VT processor
417    /// against the byte stream so escape sequences, cursor
418    /// motions, and SGR attributes all land in the grid.
419    /// Test/bench-only.
420    pub fn feed_for_fixture(&self, bytes: &[u8]) {
421        let mut processor: alacritty_terminal::vte::ansi::Processor =
422            alacritty_terminal::vte::ansi::Processor::new();
423        let mut t = self.inner.lock();
424        processor.advance(&mut *t, bytes);
425    }
426
427    /// T3: re-position the scrollback viewport and republish a
428    /// fresh snapshot so the renderer paints history immediately
429    /// (no wait for the next PTY byte to wake the reader).
430    pub fn scroll(&self, kind: TerminalScrollKind) {
431        let mut term = self.inner.lock();
432        term.scroll_display(map_scroll(kind));
433        let seq = self.seq.fetch_add(1, Ordering::Relaxed) + 1;
434        let snap = term_to_snapshot(&*term, seq);
435        self.snapshot.store(Arc::new(snap));
436        if let Some(n) = &self.paint_request {
437            n.notify_one();
438        }
439    }
440
441    /// CB.3 (`docs/dev/architecture/clipboard.md` §6): whether the running
442    /// program has enabled DEC private mode 2004 (bracketed paste). Read
443    /// by the host's terminal-paste handler to decide whether to wrap
444    /// pasted text in `\x1b[200~` / `\x1b[201~` before writing it to the
445    /// PTY -- programs that understand bracketed paste (shells with
446    /// readline/zle, vim, etc.) use the markers to treat the whole paste
447    /// as literal text instead of interpreting it as typed keystrokes.
448    /// `inner` is crate-private, so this accessor is the published
449    /// primitive; the host never reaches into the `Term` directly.
450    pub fn bracketed_paste(&self) -> bool {
451        self.inner.lock().mode().contains(TermMode::BRACKETED_PASTE)
452    }
453
454    /// T3.b (2026-05-25): walk the grid (history + live screen)
455    /// row by row, matching each row's cell text against
456    /// `regex`. Returns the first hit in `direction`'s walk
457    /// order, or `None` if nothing matched.
458    ///
459    /// Forward = top of scrollback → live edge (oldest first).
460    /// Backward = live edge → top of scrollback (newest first).
461    /// Trailing-space padding on each row is trimmed before the
462    /// match so `$`-anchored regexes behave like vim's.
463    pub fn find_match(
464        &self,
465        regex: &fancy_regex::Regex,
466        direction: SearchDir,
467    ) -> Option<GridSearchHit> {
468        let term = self.inner.lock();
469        let grid = term.grid();
470        let topmost = grid.topmost_line().0;
471        let bottommost = grid.bottommost_line().0;
472        let cols = grid.columns();
473        let mut lines: Vec<i32> = (topmost..=bottommost).collect();
474        if matches!(direction, SearchDir::Backward) {
475            lines.reverse();
476        }
477        for line_idx in lines {
478            let mut row_text = String::with_capacity(cols);
479            for c in 0..cols {
480                let p = Point::new(Line(line_idx), Column(c));
481                row_text.push(grid[p].c);
482            }
483            // Strip the trailing-space padding terminal rows
484            // carry so users can anchor with `$` and pattern
485            // counts stay sensible.
486            let trimmed = row_text.trim_end_matches(' ');
487            if trimmed.is_empty() {
488                continue;
489            }
490            if let Ok(Some(m)) = regex.find(trimmed) {
491                let column = trimmed[..m.start()].chars().count() as u16;
492                let len = m.as_str().chars().count() as u32;
493                return Some(GridSearchHit {
494                    line: line_idx,
495                    column,
496                    len,
497                });
498            }
499        }
500        None
501    }
502
503    /// T3.b.2 (2026-05-25): extract cell text for the inclusive
504    /// alacritty grid line range `start_line..=end_line`. Used
505    /// by terminal-Visual linewise yank to copy selected rows
506    /// into a register. Each row is trimmed of trailing-space
507    /// padding (the cell grid pads short rows to column width)
508    /// and joined with `\n`; the result always ends with `\n`
509    /// so it round-trips as a linewise yank (matches vim's `V`
510    /// → `y` behaviour).
511    ///
512    /// Returns `String::new()` if the range is empty or falls
513    /// entirely outside the grid bounds.
514    pub fn line_range_text(&self, start_line: i32, end_line: i32) -> String {
515        let term = self.inner.lock();
516        let grid = term.grid();
517        let topmost = grid.topmost_line().0;
518        let bottommost = grid.bottommost_line().0;
519        let lo = start_line.max(topmost);
520        let hi = end_line.min(bottommost);
521        if lo > hi {
522            return String::new();
523        }
524        let cols = grid.columns();
525        let mut out = String::with_capacity(((hi - lo + 1) as usize) * (cols + 1));
526        for line_idx in lo..=hi {
527            let mut row_text = String::with_capacity(cols);
528            for c in 0..cols {
529                let p = Point::new(Line(line_idx), Column(c));
530                row_text.push(grid[p].c);
531            }
532            // Trim only trailing spaces — internal whitespace
533            // and tabs in shell output should round-trip.
534            out.push_str(row_text.trim_end_matches(' '));
535            out.push('\n');
536        }
537        out
538    }
539
540    /// T3.b.2.b (2026-05-25): extract cell text for the
541    /// inclusive blockwise rectangle [start_line..=end_line] ×
542    /// [start_col..=end_col]. Each row contributes the slice
543    /// for its column window. Rows are joined with `\n`; the
544    /// trailing newline mirrors `line_range_text` so paste
545    /// `p` adds a row below cleanly. Padding spaces are
546    /// preserved inside the rectangle (blockwise selections
547    /// keep alignment).
548    pub fn block_range_text(
549        &self,
550        start_line: i32,
551        end_line: i32,
552        start_col: u16,
553        end_col: u16,
554    ) -> String {
555        let term = self.inner.lock();
556        let grid = term.grid();
557        let topmost = grid.topmost_line().0;
558        let bottommost = grid.bottommost_line().0;
559        let lo = start_line.max(topmost);
560        let hi = end_line.min(bottommost);
561        if lo > hi {
562            return String::new();
563        }
564        let cols = grid.columns();
565        let lo_col = (start_col as usize).min(cols);
566        let hi_col = (end_col as usize + 1).min(cols);
567        if lo_col >= hi_col {
568            return String::new();
569        }
570        let mut out = String::with_capacity(((hi - lo + 1) as usize) * (hi_col - lo_col + 1));
571        for line_idx in lo..=hi {
572            for c in lo_col..hi_col {
573                let p = Point::new(Line(line_idx), Column(c));
574                out.push(grid[p].c);
575            }
576            out.push('\n');
577        }
578        out
579    }
580
581    /// T3.b.2.b (2026-05-25): extract cell text for a
582    /// character-wise selection from `(start_line, start_col)`
583    /// to `(end_line, end_col)` inclusive, in (line, col)
584    /// reading order. Multi-line selections include the tail
585    /// of the start row, full rows between, and the head of
586    /// the end row — same shape as vim's charwise yank. The
587    /// final row preserves its trailing-space padding inside
588    /// the selection so character-precision is exact.
589    pub fn char_range_text(
590        &self,
591        start_line: i32,
592        start_col: u16,
593        end_line: i32,
594        end_col: u16,
595    ) -> String {
596        let term = self.inner.lock();
597        let grid = term.grid();
598        let topmost = grid.topmost_line().0;
599        let bottommost = grid.bottommost_line().0;
600        let s_line = start_line.max(topmost);
601        let e_line = end_line.min(bottommost);
602        if s_line > e_line {
603            return String::new();
604        }
605        let cols = grid.columns();
606        let mut out = String::new();
607        for line_idx in s_line..=e_line {
608            let row_lo = if line_idx == s_line {
609                start_col as usize
610            } else {
611                0
612            };
613            let row_hi = if line_idx == e_line {
614                (end_col as usize + 1).min(cols)
615            } else {
616                cols
617            };
618            let mut row_text = String::with_capacity(row_hi.saturating_sub(row_lo));
619            for c in row_lo..row_hi {
620                let p = Point::new(Line(line_idx), Column(c));
621                row_text.push(grid[p].c);
622            }
623            if line_idx == e_line {
624                // Last row: keep trailing whitespace verbatim
625                // so the selection's right edge is honoured.
626                out.push_str(&row_text);
627            } else {
628                // Intermediate rows: trim the trailing pad,
629                // append a newline (matches the visible row
630                // break the user sees).
631                out.push_str(row_text.trim_end_matches(' '));
632                out.push('\n');
633            }
634        }
635        out
636    }
637
638    /// T3.b.2: row of the live cursor in alacritty grid coords.
639    /// Used as the initial anchor / head when the user enters
640    /// Visual mode on the live edge.
641    pub fn cursor_line(&self) -> i32 {
642        let term = self.inner.lock();
643        term.grid().cursor.point.line.0
644    }
645
646    /// T3.b.2: scrollback bounds (topmost / bottommost grid
647    /// lines). Used by Visual-extend so `j` / `k` can't push
648    /// the head past the available history / live edge.
649    pub fn line_bounds(&self) -> (i32, i32) {
650        let term = self.inner.lock();
651        let grid = term.grid();
652        (grid.topmost_line().0, grid.bottommost_line().0)
653    }
654
655    /// T2.c (2026-05-25): is the program in
656    /// application-cursor-keys mode (DECCKM)? Programs that
657    /// hand-roll fullscreen UIs (vim / less / htop / fzf) set
658    /// this with `ESC [ ? 1 h` so arrow keys arrive as
659    /// `ESC O <letter>` (SS3) rather than the default
660    /// `ESC [ <letter>` (CSI). The translate layer reads this
661    /// per keystroke when encoding arrow keys.
662    pub fn cursor_keys_application_mode(&self) -> bool {
663        let term = self.inner.lock();
664        term.mode().contains(TermMode::APP_CURSOR)
665    }
666
667    /// T4.1 (2026-05-25): resize the alacritty grid + republish
668    /// a fresh snapshot. Caller separately resizes the PTY via
669    /// `PtyHandle::resize` so the child sees a SIGWINCH; this
670    /// helper only updates Lattice's view of the grid. Safe to
671    /// call when nothing changed (no-op on identical dims).
672    pub fn resize(&self, rows: u16, cols: u16) {
673        if rows == 0 || cols == 0 {
674            return;
675        }
676        let mut term = self.inner.lock();
677        let cur_rows = term.grid().screen_lines();
678        let cur_cols = term.grid().columns();
679        if cur_rows == rows as usize && cur_cols == cols as usize {
680            return;
681        }
682        struct Dims(u16, u16);
683        impl Dimensions for Dims {
684            fn total_lines(&self) -> usize {
685                self.0 as usize
686            }
687            fn screen_lines(&self) -> usize {
688                self.0 as usize
689            }
690            fn columns(&self) -> usize {
691                self.1 as usize
692            }
693        }
694        term.resize(Dims(rows, cols));
695        let seq = self.seq.fetch_add(1, Ordering::Relaxed) + 1;
696        let snap = term_to_snapshot(&*term, seq);
697        self.snapshot.store(Arc::new(snap));
698        if let Some(n) = &self.paint_request {
699            n.notify_one();
700        }
701    }
702
703    /// T3.b.3 (2026-05-25): collect every match on every row.
704    /// Used by `hlsearch`-style overlay so renderers can paint
705    /// all occurrences in the visible window with a softer
706    /// highlight than the current-match. Bounded at 1024 hits
707    /// to keep the worst-case (`cat /dev/urandom | head -1k`
708    /// then `/.`) from running away.
709    pub fn find_all_matches(&self, regex: &fancy_regex::Regex) -> Vec<GridSearchHit> {
710        const CAP: usize = 1024;
711        let term = self.inner.lock();
712        let grid = term.grid();
713        let topmost = grid.topmost_line().0;
714        let bottommost = grid.bottommost_line().0;
715        let cols = grid.columns();
716        let mut out: Vec<GridSearchHit> = Vec::new();
717        for line_idx in topmost..=bottommost {
718            let mut row_text = String::with_capacity(cols);
719            for c in 0..cols {
720                let p = Point::new(Line(line_idx), Column(c));
721                row_text.push(grid[p].c);
722            }
723            let trimmed = row_text.trim_end_matches(' ');
724            if trimmed.is_empty() {
725                continue;
726            }
727            // Walk all non-overlapping matches on this row.
728            let mut from = 0usize;
729            while from <= trimmed.len() {
730                let slice = &trimmed[from..];
731                match regex.find(slice) {
732                    Ok(Some(m)) => {
733                        let abs_start = from + m.start();
734                        // Convert byte offset → cell column.
735                        let column = trimmed[..abs_start].chars().count() as u16;
736                        let len = m.as_str().chars().count() as u32;
737                        out.push(GridSearchHit {
738                            line: line_idx,
739                            column,
740                            len,
741                        });
742                        if out.len() >= CAP {
743                            return out;
744                        }
745                        // Advance at least one byte to avoid
746                        // zero-width-match infinite loops.
747                        let consumed = m.range().len().max(1);
748                        from = from + m.start() + consumed;
749                    }
750                    _ => break,
751                }
752            }
753        }
754        out
755    }
756
757    /// T3.b: re-position the viewport so `target` is visible at
758    /// the top of the screen window. Used by the search-jump
759    /// path after [`Self::find_match`] returns a hit on a
760    /// scrollback row. Snaps to the live edge if `target` is
761    /// already on-screen.
762    pub fn scroll_to_line(&self, target: i32) {
763        let mut term = self.inner.lock();
764        let current_offset = term.grid().display_offset() as i32;
765        let desired_offset = (-target).max(0);
766        let delta = desired_offset - current_offset;
767        if delta != 0 {
768            term.scroll_display(Scroll::Delta(delta));
769        }
770        let seq = self.seq.fetch_add(1, Ordering::Relaxed) + 1;
771        let snap = term_to_snapshot(&*term, seq);
772        self.snapshot.store(Arc::new(snap));
773        if let Some(n) = &self.paint_request {
774            n.notify_one();
775        }
776    }
777}
778
779/// Spawn the reader task on a detached OS thread. Returns the
780/// `SharedTerm` handle the caller stores alongside the snapshot
781/// so dispatch-time operations (scroll, resize) can reach into
782/// the alacritty `Term`.
783///
784/// 2026-05-25: dropped the `tokio::task::JoinHandle<()>` return +
785/// the abort_handle on TerminalBuffer. Reason: tokio's
786/// `Runtime::Drop` for the editor actor's `current_thread`
787/// runtime waits for in-flight blocking tasks to complete before
788/// finishing — and a PTY reader blocked on `read(&mut buf)`
789/// only returns once the child closes its slave fd. That waiter
790/// was the real cause of the `:q` freeze: even with SIGKILL
791/// firing from `TerminalBuffer::Drop`, the kernel takes some
792/// time to deliver the signal + reap the child, and the runtime
793/// drop wouldn't proceed until then. A plain `std::thread`
794/// detaches the reader from any runtime: the editor can exit
795/// at its own pace; the OS reclaims the thread on process
796/// teardown.
797pub fn spawn_reader(
798    mut reader: Box<dyn Read + Send>,
799    snapshot: Arc<ArcSwap<TerminalSnapshot>>,
800    rows: u16,
801    cols: u16,
802    scrollback_lines: u32,
803    paint_request: Option<Arc<tokio::sync::Notify>>,
804    writer: crate::handle::SharedPtyWriter,
805) -> SharedTerm {
806    tracing::info!(
807        target: "lattice_terminal::reader",
808        rows, cols, scrollback_lines,
809        "spawn_reader: spawning detached OS thread",
810    );
811    // Responding responder: VT queries the child sends (DSR / DA / DECRQM /
812    // colour) are answered back through the shared PTY writer, so query-driven
813    // TUIs render instead of blocking on a blank screen.
814    let term = Arc::new(Mutex::new(build_term_with(
815        rows,
816        cols,
817        scrollback_lines,
818        PtyResponder::responding(writer),
819    )));
820    let seq = Arc::new(AtomicU64::new(0));
821    let shared = SharedTerm {
822        inner: Arc::clone(&term),
823        snapshot: Arc::clone(&snapshot),
824        seq: Arc::clone(&seq),
825        paint_request: paint_request.clone(),
826    };
827    let _ = std::thread::Builder::new()
828        .name("lattice-pty-reader".to_string())
829        .spawn(move || {
830            tracing::info!(
831                target: "lattice_terminal::reader",
832                "reader task entered; waiting for first read",
833            );
834            let mut processor: Processor = Processor::new();
835            let mut buf = [0u8; 32 * 1024];
836            let mut total_bytes: u64 = 0;
837            loop {
838                let n = match reader.read(&mut buf) {
839                    Ok(0) => {
840                        tracing::info!(
841                            target: "lattice_terminal::reader",
842                            total_bytes,
843                            seq = seq.load(Ordering::Relaxed),
844                            "reader: EOF (child exited)",
845                        );
846                        break;
847                    }
848                    Ok(n) => n,
849                    Err(e) => {
850                        tracing::warn!(
851                            target: "lattice_terminal::reader",
852                            error = %e, total_bytes,
853                            "pty read error",
854                        );
855                        break;
856                    }
857                };
858                total_bytes += n as u64;
859                if total_bytes <= 256 {
860                    tracing::info!(
861                        target: "lattice_terminal::reader",
862                        n, total_bytes,
863                        "reader: read bytes",
864                    );
865                }
866                // Take the lock for the parse + publish pair so a
867                // concurrent `SharedTerm::scroll` sees a consistent
868                // grid. PTY chunks are small (<= 32 KiB) and
869                // alacritty's parser is fast (~µs per KiB), so the
870                // critical section stays sub-millisecond.
871                let snap = {
872                    let mut term = term.lock();
873                    processor.advance(&mut *term, &buf[..n]);
874                    let s = seq.fetch_add(1, Ordering::Relaxed) + 1;
875                    term_to_snapshot(&*term, s)
876                };
877                snapshot.store(Arc::new(snap));
878                if let Some(n) = paint_request.as_ref() {
879                    // Wake event-driven renderers (GPUI). Per-tick
880                    // renderers (TUI) observe the store on their
881                    // next tick.
882                    n.notify_one();
883                }
884                // Coalesce future bursts into ~60Hz batches.
885                std::thread::sleep(REFRESH_WINDOW);
886            }
887            // Final publish so the renderer sees the very last
888            // bytes even when the loop exited mid-window.
889            let snap = {
890                let term = term.lock();
891                let s = seq.fetch_add(1, Ordering::Relaxed) + 1;
892                term_to_snapshot(&*term, s)
893            };
894            snapshot.store(Arc::new(snap));
895            if let Some(n) = paint_request.as_ref() {
896                n.notify_one();
897            }
898        });
899    shared
900}
901
902#[cfg(test)]
903mod tests {
904    use super::*;
905
906    /// A `Write` that appends to a shared `Vec`, so a test can spawn a
907    /// [`PtyResponder::responding`] term and inspect what it wrote back.
908    struct SharedVecWriter(Arc<Mutex<Vec<u8>>>);
909    impl std::io::Write for SharedVecWriter {
910        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
911            self.0.lock().extend_from_slice(buf);
912            Ok(buf.len())
913        }
914        fn flush(&mut self) -> std::io::Result<()> {
915            Ok(())
916        }
917    }
918
919    /// The fix: a responding `PtyResponder` answers a VT query (DSR cursor
920    /// position, `ESC[6n`) by writing the reply back through the shared PTY
921    /// writer. Without this a query-driven TUI (opentui / opencode) blocks on a
922    /// blank screen waiting for the reply that a real terminal would send.
923    #[test]
924    fn responder_answers_dsr_cursor_position_query() {
925        let captured = Arc::new(Mutex::new(Vec::<u8>::new()));
926        let writer: crate::handle::SharedPtyWriter =
927            Arc::new(Mutex::new(Box::new(SharedVecWriter(captured.clone()))));
928        let mut term = build_term_with(24, 80, 0, PtyResponder::responding(writer));
929        let mut processor: Processor = Processor::new();
930
931        // Device Status Report — "report the cursor position".
932        processor.advance(&mut term, b"\x1b[6n");
933
934        let out = captured.lock().clone();
935        // Reply is a Cursor Position Report: ESC [ <row> ; <col> R.
936        assert!(!out.is_empty(), "responder wrote no DSR reply");
937        assert!(out.starts_with(b"\x1b["), "not a CSI reply: {out:?}");
938        assert_eq!(
939            out.last(),
940            Some(&b'R'),
941            "DSR reply must end in 'R': {out:?}"
942        );
943    }
944
945    /// The inert `noop` responder (tests / no PTY writer) writes nothing — the
946    /// former `NoopListener` behaviour, preserved for the writer-less paths.
947    #[test]
948    fn noop_responder_writes_nothing() {
949        let mut term = build_term(24, 80, 0); // noop responder
950        let mut processor: Processor = Processor::new();
951        // Just assert it doesn't panic advancing a query with no writer wired.
952        processor.advance(&mut term, b"\x1b[6n");
953    }
954
955    /// Helper: build a fresh term + processor pair and advance
956    /// the given bytes through it. The test asserts on the
957    /// resulting snapshot.
958    fn run(bytes: &[u8], rows: u16, cols: u16) -> TerminalSnapshot {
959        run_with_scrollback(bytes, rows, cols, 0)
960    }
961
962    fn run_with_scrollback(
963        bytes: &[u8],
964        rows: u16,
965        cols: u16,
966        scrollback: u32,
967    ) -> TerminalSnapshot {
968        let mut term = build_term(rows, cols, scrollback);
969        let mut processor: Processor = Processor::new();
970        processor.advance(&mut term, bytes);
971        term_to_snapshot(&term, 1)
972    }
973
974    // CB.3 (docs/dev/architecture/clipboard.md §6): the host's
975    // terminal-paste handler reads `SharedTerm::bracketed_paste` to decide
976    // whether to wrap pasted text in DEC-2004 markers.
977
978    #[test]
979    fn bracketed_paste_defaults_to_disabled() {
980        let term = SharedTerm::fixture(3, 10, 0);
981        assert!(!term.bracketed_paste());
982    }
983
984    #[test]
985    fn bracketed_paste_tracks_dec_private_mode_2004() {
986        let term = SharedTerm::fixture(3, 10, 0);
987        // CSI ? 2004 h -- enable bracketed paste.
988        term.feed_for_fixture(b"\x1b[?2004h");
989        assert!(term.bracketed_paste());
990        // CSI ? 2004 l -- disable it again.
991        term.feed_for_fixture(b"\x1b[?2004l");
992        assert!(!term.bracketed_paste());
993    }
994
995    #[test]
996    fn plain_ascii_lands_in_cells() {
997        let s = run(b"hi", 3, 10);
998        assert_eq!(s.cell_at(0, 0).ch, 'h');
999        assert_eq!(s.cell_at(0, 1).ch, 'i');
1000        assert_eq!(s.cursor_col, 2);
1001        assert_eq!(s.cursor_row, 0);
1002    }
1003
1004    #[test]
1005    fn newline_advances_row() {
1006        let s = run(b"a\r\nb", 3, 10);
1007        assert_eq!(s.cell_at(0, 0).ch, 'a');
1008        assert_eq!(s.cell_at(1, 0).ch, 'b');
1009        assert_eq!(s.cursor_row, 1);
1010        assert_eq!(s.cursor_col, 1);
1011    }
1012
1013    /// `<C-u>` repro carried forward from the T1 reader: the
1014    /// shell sends `\r\x1b[K` to clear the line and rewrite the
1015    /// prompt. After the swap the alacritty parser handles
1016    /// `\x1b[K` natively, so the leftover characters from
1017    /// `cargo test` are gone before the prompt redraws.
1018    #[test]
1019    fn erase_in_line_clears_to_end_of_line() {
1020        let s = run(b"cargo test\r\x1b[K", 2, 20);
1021        for c in 0..20 {
1022            assert_eq!(
1023                s.cell_at(0, c).ch,
1024                ' ',
1025                "col {c} should be cleared after \\r ESC[K",
1026            );
1027        }
1028        assert_eq!(s.cursor_col, 0);
1029        assert_eq!(s.cursor_row, 0);
1030    }
1031
1032    #[test]
1033    fn sgr_colors_apply_to_following_cells() {
1034        // Red foreground; alacritty parses `\x1b[31m`, paints
1035        // `hello` red, then `\x1b[0m` resets back to default
1036        // for the following space + chars.
1037        let s = run(b"\x1b[31mhello\x1b[0m world", 2, 20);
1038        for c in 0..5 {
1039            let cell = s.cell_at(0, c);
1040            assert_eq!(
1041                cell.fg,
1042                TerminalColor::Named(NamedColor::Red),
1043                "cell `{}` at col {c} should be red",
1044                cell.ch,
1045            );
1046        }
1047        // After the reset, cells should fall back to default fg.
1048        assert_eq!(s.cell_at(0, 5).fg, TerminalColor::Default);
1049        assert_eq!(s.cell_at(0, 6).fg, TerminalColor::Default);
1050        assert_eq!(s.cell_at(0, 6).ch, 'w');
1051    }
1052
1053    #[test]
1054    fn truecolor_sgr_lands_as_rgb_cell() {
1055        // 24-bit color: `\x1b[38;2;R;G;Bm`.
1056        let s = run(b"\x1b[38;2;100;150;200mhi", 2, 5);
1057        assert_eq!(s.cell_at(0, 0).fg, TerminalColor::Rgb(100, 150, 200));
1058        assert_eq!(s.cell_at(0, 1).fg, TerminalColor::Rgb(100, 150, 200));
1059    }
1060
1061    #[test]
1062    fn bold_attribute_survives_the_swap() {
1063        let s = run(b"\x1b[1mbold\x1b[22m", 2, 10);
1064        for c in 0..4 {
1065            assert!(s.cell_at(0, c).attrs.bold, "col {c} should be bold");
1066        }
1067    }
1068
1069    #[test]
1070    fn wide_glyph_marks_trailing_spacer_cell() {
1071        // A width-2 glyph (emoji) occupies TWO alacritty grid cells:
1072        // the WIDE_CHAR cell holding the glyph and a WIDE_CHAR_SPACER
1073        // placeholder after it. The snapshot must surface the spacer
1074        // flag so renderers skip it — otherwise the row emits one
1075        // display column too many per wide glyph (the ghosting bug in
1076        // docs/dev/audit/terminal-wide-char-ghosting.md).
1077        let s = run("🚀x".as_bytes(), 2, 10);
1078        assert_eq!(s.cell_at(0, 0).ch, '🚀');
1079        assert!(
1080            !s.cell_at(0, 0).wide_spacer,
1081            "the glyph cell itself is not a spacer",
1082        );
1083        assert!(
1084            s.cell_at(0, 1).wide_spacer,
1085            "the cell after a wide glyph must be flagged as a spacer",
1086        );
1087        assert_eq!(
1088            s.cell_at(0, 2).ch,
1089            'x',
1090            "content resumes in the third column, past the spacer",
1091        );
1092        assert!(!s.cell_at(0, 2).wide_spacer);
1093    }
1094
1095    #[test]
1096    fn cursor_visible_defaults_true_and_hides_on_civis() {
1097        let visible = run(b"hi", 2, 5);
1098        assert!(visible.cursor_visible);
1099        // `\x1b[?25l` = DECTCEM hide cursor.
1100        let hidden = run(b"\x1b[?25lhi", 2, 5);
1101        assert!(!hidden.cursor_visible);
1102    }
1103
1104    #[test]
1105    fn alt_screen_flag_flips_on_smcup() {
1106        // `\x1b[?1049h` enters alt-screen (smcup).
1107        let s = run(b"\x1b[?1049h", 2, 5);
1108        assert!(s.alt_screen);
1109    }
1110
1111    // ---- T3 (2026-05-25): scrollback ring + viewport ----
1112
1113    #[test]
1114    fn scrollback_disabled_when_lines_zero() {
1115        let s = run_with_scrollback(b"", 4, 10, 0);
1116        assert_eq!(s.scrollback_rows, 0);
1117        assert_eq!(s.scroll_offset, 0);
1118    }
1119
1120    #[test]
1121    fn scrollback_rows_grow_as_history_accumulates() {
1122        // 3-row screen with capacity for 32 history rows. Push
1123        // 6 rows of content; 3 land on-screen, the other 3 roll
1124        // into scrollback.
1125        let s = run_with_scrollback(b"r0\r\nr1\r\nr2\r\nr3\r\nr4\r\nr5", 3, 10, 32);
1126        assert_eq!(s.scroll_offset, 0);
1127        // 5 newlines on a 3-row screen ⇒ 3 history rows
1128        // populated (r0 / r1 / r2 rolled off above the live
1129        // window).
1130        assert!(
1131            s.scrollback_rows >= 3,
1132            "expected ≥3 scrollback rows, got {}",
1133            s.scrollback_rows,
1134        );
1135    }
1136
1137    #[test]
1138    fn snapshot_window_shifts_when_term_scrolled_back() {
1139        // Push 8 rows of content into a 3-row screen with 16
1140        // lines of scrollback. After the burst, the live screen
1141        // shows the last 3 rows; older rows live in scrollback.
1142        let mut term = build_term(3, 10, 16);
1143        let mut processor: Processor = Processor::new();
1144        let burst = b"r0\r\nr1\r\nr2\r\nr3\r\nr4\r\nr5\r\nr6\r\nr7";
1145        processor.advance(&mut term, burst);
1146        // Live edge — visible window shows r5/r6/r7.
1147        let live = term_to_snapshot(&term, 1);
1148        assert_eq!(live.scroll_offset, 0);
1149        assert_eq!(live.cell_at(0, 0).ch, 'r');
1150        assert_eq!(live.cell_at(0, 1).ch, '5');
1151        assert_eq!(live.cell_at(2, 1).ch, '7');
1152        // Scroll up by 2 lines via the public Term method.
1153        term.scroll_display(Scroll::Delta(2));
1154        let scrolled = term_to_snapshot(&term, 2);
1155        assert_eq!(scrolled.scroll_offset, 2);
1156        // Window now shows r3/r4/r5 (rows shifted up by 2).
1157        assert_eq!(scrolled.cell_at(0, 0).ch, 'r');
1158        assert_eq!(scrolled.cell_at(0, 1).ch, '3');
1159        assert_eq!(scrolled.cell_at(2, 1).ch, '5');
1160    }
1161
1162    #[test]
1163    fn cursor_hidden_when_scrolled_past_live_cursor() {
1164        // Cursor lives on the live edge; scrolling back beyond
1165        // its row hides the cursor in the rendered snapshot.
1166        let mut term = build_term(3, 10, 16);
1167        let mut processor: Processor = Processor::new();
1168        processor.advance(&mut term, b"a\r\nb\r\nc\r\nd\r\ne\r\nf\r\ng");
1169        let live = term_to_snapshot(&term, 1);
1170        assert!(live.cursor_visible);
1171        term.scroll_display(Scroll::Top);
1172        let top = term_to_snapshot(&term, 2);
1173        assert!(top.scroll_offset > 0);
1174        assert!(
1175            !top.cursor_visible,
1176            "cursor should be hidden when scrolled past it",
1177        );
1178    }
1179
1180    /// T3: simulates the snap-to-live-edge step that
1181    /// `Editor::do_enter_terminal_insert` performs before
1182    /// activating the minor mode. After scrolling back into
1183    /// history then issuing `Bottom`, the published snapshot
1184    /// reflects the live edge (`scroll_offset == 0`).
1185    #[test]
1186    fn scroll_bottom_snaps_back_to_live_edge() {
1187        let term = Arc::new(Mutex::new(build_term(3, 10, 16)));
1188        let snapshot = Arc::new(ArcSwap::from_pointee(TerminalSnapshot::empty()));
1189        let seq = Arc::new(AtomicU64::new(0));
1190        let shared = SharedTerm {
1191            inner: Arc::clone(&term),
1192            snapshot: Arc::clone(&snapshot),
1193            seq: Arc::clone(&seq),
1194            paint_request: None,
1195        };
1196        {
1197            let mut t = term.lock();
1198            let mut p: Processor = Processor::new();
1199            p.advance(&mut *t, b"r0\r\nr1\r\nr2\r\nr3\r\nr4\r\nr5\r\nr6");
1200        }
1201        // Scroll into history.
1202        shared.scroll(TerminalScrollKind::Top);
1203        assert!(
1204            snapshot.load().scroll_offset > 0,
1205            "expected non-zero scroll_offset after Top",
1206        );
1207        // Snap to bottom (mirroring `do_enter_terminal_insert`'s
1208        // pre-activation step).
1209        shared.scroll(TerminalScrollKind::Bottom);
1210        assert_eq!(
1211            snapshot.load().scroll_offset,
1212            0,
1213            "Bottom should reset the viewport to the live edge",
1214        );
1215    }
1216
1217    /// T3.b: forward search walks top-to-bottom and returns
1218    /// the first match. The hit's `line` field is in alacritty
1219    /// grid coordinates (negative = history; positive = live
1220    /// screen).
1221    #[test]
1222    fn find_match_forward_returns_oldest_hit() {
1223        let mut term = build_term(3, 20, 16);
1224        let mut processor: Processor = Processor::new();
1225        // Six rows pushed; "needle" appears on the first row
1226        // (which rolls into scrollback) and the fifth.
1227        processor.advance(
1228            &mut term,
1229            b"needle one\r\nrow1\r\nrow2\r\nrow3\r\nneedle two\r\nrow5",
1230        );
1231        let snapshot = Arc::new(ArcSwap::from_pointee(TerminalSnapshot::empty()));
1232        let seq = Arc::new(AtomicU64::new(0));
1233        let shared = SharedTerm {
1234            inner: Arc::new(Mutex::new(term)),
1235            snapshot,
1236            seq,
1237            paint_request: None,
1238        };
1239        let regex = fancy_regex::Regex::new("needle").unwrap();
1240        let hit = shared.find_match(&regex, SearchDir::Forward).unwrap();
1241        // The first (oldest) match should win. With 6 lines on
1242        // a 3-row screen, the first `needle one` lives at
1243        // alacritty line -3 (3 rows back); the second `needle
1244        // two` at line 1.
1245        assert!(
1246            hit.line < 0,
1247            "forward search should find scrollback first, got line {}",
1248            hit.line,
1249        );
1250        assert_eq!(hit.column, 0);
1251        assert_eq!(hit.len, 6);
1252    }
1253
1254    /// T3.b: backward search walks bottom-to-top and returns
1255    /// the newest match.
1256    #[test]
1257    fn find_match_backward_returns_newest_hit() {
1258        let mut term = build_term(3, 20, 16);
1259        let mut processor: Processor = Processor::new();
1260        processor.advance(
1261            &mut term,
1262            b"needle one\r\nrow1\r\nrow2\r\nrow3\r\nneedle two\r\nrow5",
1263        );
1264        let snapshot = Arc::new(ArcSwap::from_pointee(TerminalSnapshot::empty()));
1265        let seq = Arc::new(AtomicU64::new(0));
1266        let shared = SharedTerm {
1267            inner: Arc::new(Mutex::new(term)),
1268            snapshot,
1269            seq,
1270            paint_request: None,
1271        };
1272        let regex = fancy_regex::Regex::new("needle").unwrap();
1273        let hit = shared.find_match(&regex, SearchDir::Backward).unwrap();
1274        // Backward search should find the second (newer)
1275        // occurrence first; that one is on the live screen so
1276        // line >= 0.
1277        assert!(
1278            hit.line >= 0,
1279            "backward search should find live-edge match first, got line {}",
1280            hit.line,
1281        );
1282    }
1283
1284    /// T3.b: scroll_to_line snaps the viewport so the target
1285    /// row becomes the top of the visible window. Used by
1286    /// search to bring the match into view.
1287    #[test]
1288    fn scroll_to_line_moves_viewport_to_target() {
1289        let mut term = build_term(3, 10, 16);
1290        let mut processor: Processor = Processor::new();
1291        processor.advance(&mut term, b"r0\r\nr1\r\nr2\r\nr3\r\nr4\r\nr5");
1292        let snapshot = Arc::new(ArcSwap::from_pointee(TerminalSnapshot::empty()));
1293        let seq = Arc::new(AtomicU64::new(0));
1294        let shared = SharedTerm {
1295            inner: Arc::new(Mutex::new(term)),
1296            snapshot: Arc::clone(&snapshot),
1297            seq,
1298            paint_request: None,
1299        };
1300        // Scroll to line -3 (3 rows back in history).
1301        shared.scroll_to_line(-3);
1302        let snap = snapshot.load();
1303        assert_eq!(snap.scroll_offset, 3);
1304    }
1305
1306    /// T3.b.2: linewise yank — extract cell text for a grid
1307    /// line range. Trailing space padding is trimmed so the
1308    /// register doesn't end up with column-wide whitespace
1309    /// blobs. Result always ends in `\n` so it round-trips as a
1310    /// linewise yank.
1311    #[test]
1312    fn line_range_text_extracts_trimmed_rows() {
1313        let mut term = build_term(3, 20, 16);
1314        let mut processor: Processor = Processor::new();
1315        processor.advance(&mut term, b"first line\r\nsecond line\r\nthird line");
1316        let snapshot = Arc::new(ArcSwap::from_pointee(TerminalSnapshot::empty()));
1317        let seq = Arc::new(AtomicU64::new(0));
1318        let shared = SharedTerm {
1319            inner: Arc::new(Mutex::new(term)),
1320            snapshot,
1321            seq,
1322            paint_request: None,
1323        };
1324        // Live screen lines are 0, 1, 2 — yank all three.
1325        let text = shared.line_range_text(0, 2);
1326        assert_eq!(text, "first line\nsecond line\nthird line\n");
1327    }
1328
1329    /// T3.b.2.b: charwise extraction across two rows — first
1330    /// row picks up the tail from `start_col`, last row picks
1331    /// up the head through `end_col` inclusive. Intermediate
1332    /// rows aren't present here (s_line + 1 == e_line).
1333    #[test]
1334    fn char_range_text_spans_two_rows() {
1335        let mut term = build_term(3, 20, 16);
1336        let mut processor: Processor = Processor::new();
1337        processor.advance(&mut term, b"hello world\r\nfoo bar baz");
1338        let snapshot = Arc::new(ArcSwap::from_pointee(TerminalSnapshot::empty()));
1339        let seq = Arc::new(AtomicU64::new(0));
1340        let shared = SharedTerm {
1341            inner: Arc::new(Mutex::new(term)),
1342            snapshot,
1343            seq,
1344            paint_request: None,
1345        };
1346        // Select from (row 0, col 6) "world" through (row 1, col 2) "foo".
1347        let text = shared.char_range_text(0, 6, 1, 2);
1348        assert_eq!(text, "world\nfoo");
1349    }
1350
1351    /// T3.b.2.b: charwise extraction on a single row keeps
1352    /// exact cell text in the inclusive [start_col, end_col]
1353    /// window (including any embedded spaces).
1354    #[test]
1355    fn char_range_text_single_row_inclusive() {
1356        let mut term = build_term(2, 20, 4);
1357        let mut processor: Processor = Processor::new();
1358        processor.advance(&mut term, b"hello world");
1359        let snapshot = Arc::new(ArcSwap::from_pointee(TerminalSnapshot::empty()));
1360        let seq = Arc::new(AtomicU64::new(0));
1361        let shared = SharedTerm {
1362            inner: Arc::new(Mutex::new(term)),
1363            snapshot,
1364            seq,
1365            paint_request: None,
1366        };
1367        // Cols 6..=10 = "world".
1368        let text = shared.char_range_text(0, 6, 0, 10);
1369        assert_eq!(text, "world");
1370    }
1371
1372    /// T3.b.2.b: blockwise extraction is a rectangle. Each row
1373    /// contributes its slice; trailing pad inside the rectangle
1374    /// is preserved to keep column alignment (matches vim's
1375    /// `<C-v>` → `y` behaviour).
1376    #[test]
1377    fn block_range_text_extracts_rectangle() {
1378        let mut term = build_term(3, 20, 4);
1379        let mut processor: Processor = Processor::new();
1380        processor.advance(&mut term, b"abc def ghi\r\njkl mno pqr\r\nstu vwx yz1");
1381        let snapshot = Arc::new(ArcSwap::from_pointee(TerminalSnapshot::empty()));
1382        let seq = Arc::new(AtomicU64::new(0));
1383        let shared = SharedTerm {
1384            inner: Arc::new(Mutex::new(term)),
1385            snapshot,
1386            seq,
1387            paint_request: None,
1388        };
1389        // Cols 4..=6 (the "def" / "mno" / "vwx" middle column).
1390        let text = shared.block_range_text(0, 2, 4, 6);
1391        assert_eq!(text, "def\nmno\nvwx\n");
1392    }
1393
1394    /// T3.b.3: `find_all_matches` returns every occurrence of
1395    /// the pattern across history + live screen, bounded by
1396    /// the safety cap. Used by the hlsearch overlay so all
1397    /// hits in the visible window paint.
1398    #[test]
1399    fn find_all_matches_collects_every_occurrence() {
1400        let mut term = build_term(3, 30, 16);
1401        let mut processor: Processor = Processor::new();
1402        processor.advance(
1403            &mut term,
1404            b"error: one\r\nok\r\nerror: two\r\nerror: three\r\nok",
1405        );
1406        let snapshot = Arc::new(ArcSwap::from_pointee(TerminalSnapshot::empty()));
1407        let seq = Arc::new(AtomicU64::new(0));
1408        let shared = SharedTerm {
1409            inner: Arc::new(Mutex::new(term)),
1410            snapshot,
1411            seq,
1412            paint_request: None,
1413        };
1414        let regex = fancy_regex::Regex::new("error").unwrap();
1415        let hits = shared.find_all_matches(&regex);
1416        assert_eq!(hits.len(), 3, "expected 3 `error` hits, got {hits:?}");
1417        for h in &hits {
1418            assert_eq!(h.len, 5);
1419            assert_eq!(h.column, 0);
1420        }
1421    }
1422
1423    /// T3.b.2: yanking outside the available grid bounds clamps
1424    /// rather than panicking; an empty / no-overlap range
1425    /// returns an empty string.
1426    #[test]
1427    fn line_range_text_clamps_out_of_bounds() {
1428        let term = build_term(3, 10, 16);
1429        let snapshot = Arc::new(ArcSwap::from_pointee(TerminalSnapshot::empty()));
1430        let seq = Arc::new(AtomicU64::new(0));
1431        let shared = SharedTerm {
1432            inner: Arc::new(Mutex::new(term)),
1433            snapshot,
1434            seq,
1435            paint_request: None,
1436        };
1437        // 9999..=10000 is entirely past the live edge.
1438        assert_eq!(shared.line_range_text(9999, 10000), "");
1439        // start > end → empty.
1440        assert_eq!(shared.line_range_text(2, 1), "");
1441    }
1442
1443    /// T3.b: searching when the pattern doesn't match returns
1444    /// None without panicking.
1445    #[test]
1446    fn find_match_returns_none_for_unmatched_pattern() {
1447        let term = build_term(3, 10, 16);
1448        let snapshot = Arc::new(ArcSwap::from_pointee(TerminalSnapshot::empty()));
1449        let seq = Arc::new(AtomicU64::new(0));
1450        let shared = SharedTerm {
1451            inner: Arc::new(Mutex::new(term)),
1452            snapshot,
1453            seq,
1454            paint_request: None,
1455        };
1456        let regex = fancy_regex::Regex::new("nothing-here").unwrap();
1457        assert!(shared.find_match(&regex, SearchDir::Forward).is_none());
1458    }
1459
1460    #[test]
1461    fn shared_term_scroll_publishes_fresh_snapshot() {
1462        // Constructs a SharedTerm + writes some rows into it,
1463        // then drives a scroll via the public handle. The
1464        // snapshot Arc should update with the new scroll
1465        // offset.
1466        let term = Arc::new(Mutex::new(build_term(3, 10, 16)));
1467        let snapshot = Arc::new(ArcSwap::from_pointee(TerminalSnapshot::empty()));
1468        let seq = Arc::new(AtomicU64::new(0));
1469        let shared = SharedTerm {
1470            inner: Arc::clone(&term),
1471            snapshot: Arc::clone(&snapshot),
1472            seq: Arc::clone(&seq),
1473            paint_request: None,
1474        };
1475        {
1476            let mut t = term.lock();
1477            let mut p: Processor = Processor::new();
1478            p.advance(&mut *t, b"r0\r\nr1\r\nr2\r\nr3\r\nr4\r\nr5");
1479            // Publish a baseline snapshot at live edge.
1480            let s = term_to_snapshot(&*t, 1);
1481            snapshot.store(Arc::new(s));
1482            seq.store(1, Ordering::Relaxed);
1483        }
1484        assert_eq!(snapshot.load().scroll_offset, 0);
1485        shared.scroll(TerminalScrollKind::PageUp);
1486        let scrolled = snapshot.load();
1487        assert!(scrolled.scroll_offset > 0);
1488        assert!(scrolled.seq > 1);
1489    }
1490}