Skip to main content

lattice_terminal/
buffer.rs

1use std::{path::PathBuf, sync::Arc};
2
3use arc_swap::ArcSwap;
4use lattice_core::BufferId;
5
6use crate::reader::{GridSearchHit, SharedTerm};
7use crate::{PtyHandle, TerminalSnapshot};
8
9/// PTY-backed terminal buffer entry held in the host's
10/// buffer registry. Owns the writer handle, the published
11/// snapshot cell, and the reader task's `AbortHandle` so
12/// dropping the buffer kills the reader (and, transitively,
13/// the child via PTY close on SIGHUP).
14///
15/// Construct via [`TerminalBuffer::from_spawn`] — the host
16/// never names the internal field shape directly so adding
17/// fields stays non-breaking.
18#[derive(Debug)]
19pub struct TerminalBuffer {
20    pub id: BufferId,
21    pub pty: Arc<PtyHandle>,
22    pub cwd: Option<PathBuf>,
23    pub label: String,
24    /// 2026-05-25: short display name for the spawned program
25    /// (e.g. `zsh`, `bash`, `cargo`). Captured at spawn from
26    /// the program path's basename so the modeline can surface
27    /// "what's running here" instead of just "terminal #N".
28    pub program_name: String,
29    pub snapshot: Arc<ArcSwap<TerminalSnapshot>>,
30    /// T3 (2026-05-25): shared handle to the alacritty `Term`.
31    /// Dispatch-side scroll / resize ops lock the inner Mutex
32    /// and republish the snapshot. Same handle the reader task
33    /// holds — cheap to clone (Arc + ArcSwap + Notify).
34    pub term: SharedTerm,
35    /// T3.b.3 (2026-05-25): the most recent search hit on this
36    /// terminal, set by `submit_search` / `repeat_search` for
37    /// Terminal buffers and cleared by `cancel_search`.
38    /// Renderers read this in their per-frame paint and overlay
39    /// the matched cells with the search-highlight style.
40    /// `None` when no active search is in flight.
41    pub current_match: Option<GridSearchHit>,
42    /// T3.b.2 (2026-05-25): linewise Visual-mode selection state
43    /// on this terminal buffer. `None` outside Visual. Both rows
44    /// are alacritty grid lines (negative = history). Renderers
45    /// paint the inclusive `min(anchor,head)..=max(anchor,head)`
46    /// row range with the selection bg; `run_terminal_invocation`
47    /// extends `head_line` on `j` / `k` while Visual is active.
48    pub visual: Option<TerminalVisualState>,
49    /// T3.b.3 (2026-05-25): captured prior-Visual state on
50    /// terminal — restored by `gv`. Same shape as `visual`.
51    /// `None` when no Visual session has been completed yet.
52    pub last_visual: Option<TerminalVisualState>,
53    /// T2.c (2026-05-25): `true` after the user has pressed
54    /// `<C-\>` in Terminal-Insert and we're waiting for the
55    /// second key of the exit chord. Cleared by either the
56    /// confirm key (`<C-n>` → exit) or any other key (which
57    /// emits `\x1c` plus that key's PTY bytes).
58    pub insert_exit_pending: bool,
59    /// 2026-05-25: Normal-in-terminal navigation cursor in
60    /// alacritty grid coords (line: negative = history;
61    /// positive = live screen; col: cell column). `None` means
62    /// "snap to live PTY cursor" — the initial state and what
63    /// EnterTerminalInsert resets to. Set on the first
64    /// `j` / `k` / `h` / `l` / `gg` / `G` and tracked
65    /// thereafter. Renderers paint a block cursor at this
66    /// position when set; Visual entry uses it as the initial
67    /// anchor / head.
68    pub nav_cursor: Option<(i32, u16)>,
69    /// T3.b.3 (2026-05-25): every regex match across the grid
70    /// for the current search pattern. Populated alongside
71    /// `current_match` in submit_search / repeat_search and
72    /// cleared by cancel_search / enter-Insert. Renderers
73    /// paint these with the softer hlsearch overlay; the
74    /// distinguished `current_match` keeps the stronger
75    /// current-hit style.
76    pub all_matches: Vec<GridSearchHit>,
77    /// T-mode-1 (2026-05-27): frozen scrollback snapshot when
78    /// `TerminalNormalMode` is active on this buffer; `None`
79    /// otherwise. Populated by the mode's `on_activate` hook
80    /// (rope build from `term.build_normal_snapshot()`) and
81    /// dropped by the mode's Guard. The central vim grammar
82    /// operates on this rope during Normal / Visual sub-states;
83    /// see `docs/dev/architecture/terminal-as-document.md`.
84    /// `Arc` so the host's render-state publish path can clone
85    /// out a cheap reference without holding the buffer lock.
86    pub synthetic: Option<Arc<crate::synthetic::SyntheticDoc>>,
87    pub created_at: std::time::SystemTime,
88    /// 2026-05-25: killer for the spawned child process. On
89    /// Drop we call `killer.kill()` to force the shell to
90    /// exit; the cloned-reader fd held by the reader task
91    /// keeps the master PTY alive past `PtyHandle::Drop`, so
92    /// closing the master alone is not enough to wake the
93    /// reader's blocking `read()`. Without the kill, the
94    /// reader's tokio `spawn_blocking` task never exits and
95    /// the editor freezes on `:q`. Wrapped in `Option` so Drop
96    /// can `take()` and call the &mut method on the inner
97    /// killer (the trait isn't object-safe with shared refs).
98    child_killer: Option<Box<dyn portable_pty::ChildKiller + Send + Sync>>,
99    /// 2026-05-25: child PID, used by Drop on Unix as a SIGKILL
100    /// fallback when portable_pty's SIGHUP-based `kill()` doesn't
101    /// terminate the child fast enough (some shells / environments
102    /// don't honour SIGHUP within the actor runtime's Drop window).
103    /// `None` when the platform doesn't expose the PID.
104    child_pid: Option<u32>,
105}
106
107impl Drop for TerminalBuffer {
108    fn drop(&mut self) {
109        // 2026-05-25: kill the child shell so process tables
110        // don't leak orphan processes. The detached reader
111        // thread (std::thread::spawn, not tokio) will see
112        // read() return 0 (EOF) once the master fd closes,
113        // and then exit on its own; we no longer wait for it
114        // (a previous design used spawn_blocking + JoinHandle
115        // which deadlocked the editor exit because tokio's
116        // Runtime::Drop synchronously waits for in-flight
117        // blocking tasks).
118        if let Some(mut killer) = self.child_killer.take() {
119            let _ = killer.kill();
120        }
121        #[cfg(unix)]
122        if let Some(pid) = self.child_pid.take() {
123            // Workspace lint denies unsafe; the libc FFI call
124            // is the only viable way to guarantee SIGKILL
125            // delivery (portable_pty only exposes SIGHUP).
126            // Passing a non-running PID returns ESRCH which we
127            // ignore (the child may already have exited).
128            #[allow(unsafe_code)]
129            unsafe {
130                libc::kill(pid as libc::pid_t, libc::SIGKILL);
131            }
132        }
133    }
134}
135
136pub struct ScrollbackView {
137    // TODO: add lifetime if exposing references
138    pub total_rows: u32,
139    /// 0 = bottom (live); N = N rows up
140    pub viewport_row: u32,
141}
142
143/// T3.b.2 (2026-05-25): Visual-mode flavour on a terminal
144/// buffer. Mirrors `lattice_grammar::VisualKind` without taking
145/// a dep on the grammar crate from the substrate.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum VisualKind {
148    /// `v` — character-wise selection from anchor to head.
149    Char,
150    /// `V` — full-row selection between anchor_line and head_line.
151    Line,
152    /// `<C-v>` — rectangular selection [min_col..=max_col] ×
153    /// [min_line..=max_line].
154    Block,
155}
156
157/// T3.b.2 (2026-05-25, extended T3.b.2.b): Visual-mode
158/// selection over a terminal's cell grid. Lines are alacritty
159/// grid coords (negative = scrollback history; positive = live
160/// screen); cols are cell columns. Stored on
161/// [`TerminalBuffer::visual`] while the user is in
162/// Terminal-Visual mode; `None` otherwise.
163///
164/// Linewise entries leave the columns at 0; renderers and the
165/// yank text extractor ignore them. Charwise / blockwise track
166/// both axes — see `VisualKind`.
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub struct TerminalVisualState {
169    pub kind: VisualKind,
170    /// Where the selection started — set on `v` / `V` / `<C-v>`
171    /// entry.
172    pub anchor_line: i32,
173    pub anchor_col: u16,
174    /// Where the head is — moves with `h` / `j` / `k` / `l`,
175    /// scroll, `gg` / `G`, etc.
176    pub head_line: i32,
177    pub head_col: u16,
178}
179
180impl TerminalVisualState {
181    /// Inclusive `[min, max]` of the two endpoint lines so
182    /// renderers can iterate the selected rows regardless of
183    /// which direction the user dragged.
184    pub fn line_range(self) -> (i32, i32) {
185        (
186            self.anchor_line.min(self.head_line),
187            self.anchor_line.max(self.head_line),
188        )
189    }
190
191    /// Inclusive `[min, max]` of the two endpoint columns —
192    /// only meaningful for `Block` selections (chars and lines
193    /// don't use a rectangular column window).
194    pub fn block_col_range(self) -> (u16, u16) {
195        (
196            self.anchor_col.min(self.head_col),
197            self.anchor_col.max(self.head_col),
198        )
199    }
200
201    /// Sorted `(start, end)` endpoints for character-wise
202    /// selections, where `start <= end` in (line, col) order.
203    /// Used by the yank extractor to walk the selection in
204    /// reading order.
205    pub fn char_endpoints(self) -> ((i32, u16), (i32, u16)) {
206        let a = (self.anchor_line, self.anchor_col);
207        let h = (self.head_line, self.head_col);
208        if a <= h { (a, h) } else { (h, a) }
209    }
210}
211
212impl TerminalBuffer {
213    /// Build a buffer entry from freshly-spawned PTY handles +
214    /// the host-assigned identity. Centralises the
215    /// `TerminalBuffer` field list so the host stays insulated
216    /// from substrate-internal field changes.
217    pub fn from_spawn(
218        id: BufferId,
219        label: String,
220        cwd: Option<PathBuf>,
221        program_name: String,
222        handles: crate::spawner::SpawnHandles,
223    ) -> Self {
224        let crate::spawner::SpawnHandles {
225            pty,
226            snapshot,
227            term,
228            child_killer,
229            child_pid,
230        } = handles;
231        Self {
232            id,
233            pty: Arc::new(pty),
234            cwd,
235            label,
236            program_name,
237            snapshot,
238            term,
239            current_match: None,
240            visual: None,
241            last_visual: None,
242            all_matches: Vec::new(),
243            synthetic: None,
244            insert_exit_pending: false,
245            nav_cursor: None,
246            created_at: std::time::SystemTime::now(),
247            child_killer: Some(child_killer),
248            child_pid,
249        }
250    }
251
252    pub fn scrollback_view(&self) -> ScrollbackView {
253        // T1 stub: scrollback not yet implemented
254        ScrollbackView {
255            total_rows: 0,
256            viewport_row: 0,
257        }
258    }
259}