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}