Skip to main content

justerm_core/
lib.rs

1//! justerm-core — the pure terminal engine of the `justerm` family.
2//!
3//! Feed VT bytes in; read terminal state out. The engine does no I/O, no IPC,
4//! no rendering, and is theme-agnostic (it stores colour *references*, never
5//! hex). See `CLAUDE.md` for the boundary invariants and `docs/architecture.md`
6//! for the full contract.
7//!
8//! ```
9//! use justerm_core::{Color, Engine};
10//!
11//! let mut term = Engine::new(80, 24);
12//! term.feed(b"\x1b[31mhi\x1b[0m");
13//! assert_eq!(term.grid().cell(0, 0).c(), 'h');
14//! assert_eq!(term.grid().cell(0, 0).fg(), Color::Indexed(1));
15//! ```
16
17mod cell;
18mod color;
19mod cursor;
20mod damage;
21mod event;
22mod grapheme;
23mod grid;
24mod input;
25mod logical;
26mod search;
27mod selection;
28mod serialize;
29mod term;
30
31pub use cell::{Cell, CellFlags};
32pub use color::Color;
33pub use cursor::{Cursor, CursorShape, Pen};
34pub use damage::{LineDamage, ScrollOp, TermDamage};
35pub use event::TermEvent;
36pub use grid::{Grid, Row};
37pub use input::{
38    Key, KeyAction, KeyEvent, KeypadKey, Modifiers, MouseAction, MouseButton, MouseEvent,
39    MouseEvents,
40};
41pub use logical::LogicalLine;
42pub use search::{Match, SearchOptions, is_valid_regex};
43pub use selection::{SelectionSpan, SelectionType, Side};
44pub use serialize::{
45    CELL_RECORD_LEN, DecodeError, Frame, FrameKind, MarkerId, MarkerKind, MarkerLine,
46    MarkerPosition, Overlay, Span, WIRE_VERSION, decode, encode, encode_cell_record, encode_color,
47};
48
49pub use term::{CommandLine, Term};
50
51use vte::Parser;
52
53/// The terminal engine: pairs the `vte` parser with our state model.
54///
55/// `Parser` and `Term` are kept as separate fields because `Parser::advance`
56/// borrows both the parser and the performer mutably at once — a single struct
57/// owning both could not satisfy the borrow checker.
58pub struct Engine {
59    parser: Parser,
60    term: Term,
61}
62
63impl Engine {
64    /// A blank engine with a `cols` × `rows` screen and a default scrollback cap.
65    pub fn new(cols: usize, rows: usize) -> Self {
66        Engine {
67            parser: Parser::new(),
68            term: Term::new(cols, rows),
69        }
70    }
71
72    /// Like [`Engine::new`] but with an explicit scrollback line limit.
73    pub fn with_scrollback(cols: usize, rows: usize, scrollback_limit: usize) -> Self {
74        Engine {
75            parser: Parser::new(),
76            term: Term::with_scrollback(cols, rows, scrollback_limit),
77        }
78    }
79
80    /// Push a slice of VT bytes. The caller owns the PTY/SSH/socket I/O — the
81    /// engine only consumes the bytes it is handed.
82    pub fn feed(&mut self, bytes: &[u8]) {
83        self.parser.advance(&mut self.term, bytes);
84    }
85
86    /// Resize the screen to `cols` x `rows`. Rows that scroll off the top enter
87    /// scrollback; the whole screen is damaged. (Soft-wrap reflow lands in #7.)
88    pub fn resize(&mut self, cols: usize, rows: usize) {
89        self.term.resize(cols, rows);
90    }
91
92    /// The current screen grid.
93    pub fn grid(&self) -> &Grid {
94        self.term.grid()
95    }
96
97    /// The current cursor (position, pending-wrap, pen).
98    pub fn cursor(&self) -> &Cursor {
99        self.term.cursor()
100    }
101
102    /// Whether bracketed-paste mode (DEC ?2004) is enabled. A consumer's input
103    /// encoder reads this to decide whether to wrap pasted text in markers.
104    pub fn bracketed_paste(&self) -> bool {
105        self.term.bracketed_paste()
106    }
107
108    /// Encode a key event to the bytes an application expects, honouring the
109    /// engine's cursor-key mode (DECCKM). The inverse of [`Engine::feed`] — the
110    /// consumer hands a decoded key event and writes the bytes to its PTY.
111    /// Returns `None` for a key with no defined encoding.
112    pub fn encode_key(&self, ev: KeyEvent) -> Option<Vec<u8>> {
113        self.term.encode_key(ev)
114    }
115
116    /// Encode a mouse event using the engine's active tracking mode + encoding.
117    /// Returns `None` when mouse reporting is off, or when the event is filtered
118    /// out by the mode (e.g. a bare move while only ?1000 is set).
119    pub fn encode_mouse(&self, ev: MouseEvent) -> Option<Vec<u8>> {
120        self.term.encode_mouse(ev)
121    }
122
123    /// Encode pasted text — wrapped in bracketed-paste markers when ?2004 is on,
124    /// raw otherwise.
125    pub fn encode_paste(&self, text: &str) -> Vec<u8> {
126        self.term.encode_paste(text)
127    }
128
129    /// Encode a focus change (`CSI I` on focus-in, `CSI O` on focus-out), or
130    /// `None` when focus reporting (?1004) is off.
131    pub fn encode_focus(&self, focused: bool) -> Option<Vec<u8>> {
132        self.term.encode_focus(focused)
133    }
134
135    /// Take the consumer events accumulated since the last drain (title / bell /
136    /// cwd — see [`TermEvent`]), emptying the queue. The pull counterpart to a
137    /// callback: poll this alongside [`Engine::frame`].
138    pub fn drain_events(&mut self) -> Vec<TermEvent> {
139        self.term.drain_events()
140    }
141
142    /// Take the reply bytes the engine produced for app queries (DA / DSR /
143    /// DECRQM) since the last drain — the consumer writes them straight back to
144    /// the PTY. The inbound-query counterpart to [`Engine::drain_events`].
145    pub fn drain_replies(&mut self) -> Vec<u8> {
146        self.term.drain_replies()
147    }
148
149    /// The OSC 8 hyperlink index at **screen** `(row, col)` — the live grid, same
150    /// coordinates as [`Engine::grid`]'s `cell(row, col)` — or `None`. Combining
151    /// and links no longer ride on the [`Cell`](crate::Cell) (#45/#46); read the
152    /// index here, then resolve it with [`Engine::hyperlink`].
153    pub fn link_at(&self, row: usize, col: usize) -> Option<core::num::NonZeroU32> {
154        self.term.screen_link_at(row, col)
155    }
156
157    /// The OSC 8 hyperlink index at **viewport** `(row, col)` — the visible
158    /// window including scrollback at the current scroll, same coordinates as
159    /// [`Engine::viewport_line`] — or `None`.
160    pub fn viewport_link_at(&self, row: usize, col: usize) -> Option<core::num::NonZeroU32> {
161        self.term.viewport_link_at(row, col)
162    }
163
164    /// Resolve a hyperlink index (from [`Engine::link_at`] /
165    /// [`Engine::viewport_link_at`], or a decoded `Span`'s `links`) to its URI,
166    /// to make a cell clickable.
167    pub fn hyperlink(&self, link: core::num::NonZeroU32) -> Option<&str> {
168        self.term.hyperlink(link)
169    }
170
171    /// Number of lines currently held in scrollback history.
172    pub fn scrollback_len(&self) -> usize {
173        self.term.scrollback_len()
174    }
175
176    /// Whether the app has an open **synchronized-output** block (DEC `?2026`):
177    /// it has asked that the next frame of output be painted atomically. The
178    /// engine only *reports* this — **the consumer owns the paint-hold and the
179    /// spec-mandated timeout** (a buggy app that never closes the block must not
180    /// freeze the screen forever, and the engine has no clock). Poll this after
181    /// `feed`; while it is `true`, defer applying frames, and apply once it
182    /// clears (or your own timeout fires). (#73)
183    pub fn synchronized_output(&self) -> bool {
184        self.term.synchronized_output()
185    }
186
187    /// Whether the app enabled color-scheme-update notifications (DEC `?2031`).
188    /// The engine is theme-agnostic — it never knows the scheme. The consumer
189    /// answers a [`TermEvent::ColorSchemeQuery`] (from `?996`) and, when its
190    /// scheme changes *and* this is `true`, sends an unsolicited notification, in
191    /// both cases by calling [`Engine::report_color_scheme`] (#85).
192    pub fn color_scheme_updates(&self) -> bool {
193        self.term.color_scheme_updates()
194    }
195
196    /// Report the current light/dark color scheme to the app as `CSI ? 997 ; 1 n`
197    /// (dark) / `; 2 n` (light), drained via [`Engine::drain_replies`]. Call this
198    /// to answer a [`TermEvent::ColorSchemeQuery`], or — guarded by
199    /// [`Engine::color_scheme_updates`] — when the scheme changes. The engine only
200    /// formats the bit you pass; it stores no scheme (#85).
201    pub fn report_color_scheme(&mut self, dark: bool) {
202        self.term.report_color_scheme(dark);
203    }
204
205    /// Answer an OSC 11 `QueryBackground` event (#122): the consumer hands back
206    /// the current background spec (it owns the palette) and the engine queues
207    /// the OSC 11 reply for `drain_replies`. Theme-agnostic — the engine never
208    /// knows the colour, only formats the envelope.
209    pub fn report_background(&mut self, spec: &str) {
210        self.term.report_background(spec);
211    }
212
213    /// Answer an OSC 10 `QueryForeground` event (#122): queue the OSC 10 reply
214    /// from the consumer-supplied spec. Theme-agnostic envelope-only.
215    pub fn report_foreground(&mut self, spec: &str) {
216        self.term.report_foreground(spec);
217    }
218
219    /// Answer an OSC 4 `QueryPaletteColor` event (#122): queue the OSC 4 reply for
220    /// `index` from the consumer-supplied spec. Theme-agnostic envelope-only.
221    pub fn report_palette_color(&mut self, index: u8, spec: &str) {
222        self.term.report_palette_color(index, spec);
223    }
224
225    /// Whether the app enabled **win32-input-mode** (DEC `?9001`): it asked for
226    /// keys as raw Windows key-records. The engine only tracks the flag — encoding
227    /// the records (`CSI Vk;Sc;Uc;Kd;Cs;Rc _`) is a non-goal (raw passthrough, no
228    /// semantic conversion), so [`Engine::encode_key`] is unchanged. A ConPTY
229    /// consumer reads this to decide whether to emit the records itself (#86).
230    pub fn win32_input_mode(&self) -> bool {
231        self.term.win32_input_mode()
232    }
233
234    /// What changed since the last [`Engine::reset_damage`] — line ranges each
235    /// with a changed column span (see ADR-0003).
236    pub fn damage(&self) -> TermDamage {
237        self.term.damage()
238    }
239
240    /// Build a serializable [`Frame`] of the current diff — the damaged spans
241    /// (or every row, when `Full`), the recorded scroll op, and a frame-local
242    /// grapheme side-table. Pass it to [`encode`] for the wire (see #6). Reading
243    /// a frame does not clear damage; call [`Engine::reset_damage`] on ack.
244    pub fn frame(&self) -> Frame {
245        self.term.frame()
246    }
247
248    /// Clear accumulated damage after a frame is applied (the consumer's ack).
249    pub fn reset_damage(&mut self) {
250        self.term.reset_damage();
251    }
252
253    /// Force the next [`Engine::frame`] to be a `Full` frame (every row), even if
254    /// little changed. The use case is **reattach / late subscribe**: a renderer
255    /// that connects after output has already been parsed needs the whole current
256    /// viewport once, then incremental diffs. Marks the screen fully damaged; the
257    /// next `frame()` reports `FrameKind::Full`.
258    pub fn mark_fully_damaged(&mut self) {
259        self.term.mark_fully_damaged();
260    }
261
262    /// The first-class scroll recorded since the last [`Engine::reset_damage`],
263    /// if any — lets the renderer shift rows instead of redrawing them.
264    pub fn scroll_delta(&self) -> Option<ScrollOp> {
265        self.term.scroll_delta()
266    }
267
268    /// The cells of visible row `i` (0..rows) at the current scroll position.
269    pub fn viewport_line(&self, i: usize) -> &[Cell] {
270        self.term.viewport_line(i)
271    }
272
273    /// Scroll the viewport up by `n` lines into scrollback history.
274    pub fn scroll_up(&mut self, n: usize) {
275        self.term.scroll_up(n);
276    }
277
278    /// Scroll the viewport down by `n` lines toward the live screen.
279    pub fn scroll_down(&mut self, n: usize) {
280        self.term.scroll_down(n);
281    }
282
283    /// Jump the viewport back to the live screen (follow the bottom).
284    pub fn scroll_to_bottom(&mut self) {
285        self.term.scroll_to_bottom();
286    }
287
288    /// Begin a selection of `ty` at viewport cell `(row, col)`, on `side` of the
289    /// cell. Coordinates are viewport-relative (what a mouse event carries).
290    pub fn selection_begin(&mut self, row: usize, col: usize, side: Side, ty: SelectionType) {
291        self.term.selection_begin(row, col, side, ty);
292    }
293
294    /// Extend the live selection to viewport cell `(row, col)`, on `side`.
295    pub fn selection_extend(&mut self, row: usize, col: usize, side: Side) {
296        self.term.selection_extend(row, col, side);
297    }
298
299    /// Clear the selection.
300    pub fn selection_clear(&mut self) {
301        self.term.selection_clear();
302    }
303
304    /// The selection projected onto the viewport: one inclusive-column span per
305    /// visible row, for the renderer to highlight. Empty when nothing is
306    /// selected or the selection is fully scrolled off-screen.
307    pub fn selection_range(&self) -> Vec<SelectionSpan> {
308        self.term.selection_range()
309    }
310
311    /// The selected text for copy (respects scrollback), or `None` if no
312    /// selection.
313    pub fn selection_text(&self) -> Option<String> {
314        self.term.selection_text()
315    }
316
317    /// Literal search over the grid + scrollback, returning every match in
318    /// absolute buffer coordinates (top-to-bottom). Smart-case: a query with no
319    /// uppercase matches case-insensitively. The consumer drives next/prev by
320    /// walking the returned `Vec` and calling [`Engine::scroll_to_match`].
321    pub fn search(&self, query: &str) -> Vec<Match> {
322        self.term.search(query)
323    }
324
325    /// Search with explicit [`SearchOptions`] — regex, whole-word, and a case-sensitivity override
326    /// beyond the literal + smart-case [`search`](Self::search) (#314).
327    pub fn search_with(&self, query: &str, opts: SearchOptions) -> Vec<Match> {
328        self.term.search_with(query, opts)
329    }
330
331    /// The viewport's logical lines (#113/ADR-0017): each soft-wrap-joined line's
332    /// text plus a per-char map to its viewport `(row, col)`. The buffer-wide
333    /// mechanism for consumer-side URL detection — the consumer runs its own
334    /// regex / `new URL()` over the text and maps matches back through `cells`.
335    /// Also serves the a11y mirror (#119).
336    pub fn viewport_logical_lines(&self) -> Vec<LogicalLine> {
337        self.term.viewport_logical_lines()
338    }
339
340    /// The whole buffer (scrollback + screen) as one text document for a
341    /// screen-reader accessible view (#150) — soft-wrap-joined, wide-spacers
342    /// skipped, trailing blanks trimmed at the logical end, `\n` between logical
343    /// lines. A query seam the consumer summons (frame mode: over IPC, like
344    /// [`selection_text`](Self::selection_text)); no wire-format change. On the
345    /// alt screen only the alt buffer is shown.
346    pub fn accessible_text(&self) -> String {
347        self.term.accessible_text()
348    }
349
350    /// Scroll the viewport so `m` is visible (next/prev navigation: the consumer
351    /// picks the match, the engine scrolls to it).
352    pub fn scroll_to_match(&mut self, m: &Match) {
353        self.term.search_scroll_to(m);
354    }
355
356    /// The match projected onto the viewport as inclusive-column spans per
357    /// visible row, for the renderer to highlight.
358    pub fn match_spans(&self, m: &Match) -> Vec<SelectionSpan> {
359        self.term.match_spans(m)
360    }
361
362    /// Set the active search highlights the frame should carry (#108). The
363    /// consumer owns match navigation, so it hands the set to highlight back
364    /// here; [`Engine::frame`] then projects them onto the viewport overlay
365    /// alongside the selection. An empty vec clears the highlights.
366    pub fn set_search_highlights(&mut self, matches: Vec<Match>) {
367        self.term.set_search_highlights(matches);
368    }
369
370    /// Register a decoration marker at viewport `row`, returning its stable id
371    /// (#118). The marker anchors the content currently on that row and tracks
372    /// it through scroll/eviction/reflow; [`Engine::frame`] reports its viewport
373    /// position while visible. Use the id to remove it or to match the
374    /// `TermEvent::MarkerDisposed` fired when its line leaves the buffer.
375    pub fn add_marker(&mut self, row: usize) -> MarkerId {
376        self.term.add_marker(row)
377    }
378
379    /// Remove a marker by id (#118), firing `TermEvent::MarkerDisposed`. A no-op
380    /// for an unknown or already-disposed id.
381    pub fn remove_marker(&mut self, id: MarkerId) {
382        self.term.remove_marker(id);
383    }
384
385    /// The OSC 133 shell-integration command marks in buffer order — `(id,
386    /// absolute line, kind)` (#158). Excludes plain `add_marker` decorations.
387    /// The consumer pairs prompt/command/finished marks to drive prompt-to-prompt
388    /// navigation and command/exit announcements (#160); the engine only parses
389    /// the `133;A/B/C/D` sequences and anchors the marks.
390    pub fn command_marks(&self) -> Vec<(MarkerId, usize, MarkerKind)> {
391        self.term.command_marks()
392    }
393
394    /// The executed shell commands recovered from OSC-133 marks, in buffer order
395    /// (#166) — the query behind screen-reader command navigation. Each
396    /// [`CommandLine`] carries the typed command text (prompt/output excluded via
397    /// the captured columns), its jump line (CommandStart), and the exit code.
398    /// This is a full-buffer query, wired to the frame-mode consumer over IPC like
399    /// [`Engine::accessible_text`]; the web side has no scrollback cells to derive
400    /// it (ADR-0017 — buffer-wide text is core's).
401    pub fn command_lines(&self) -> Vec<CommandLine> {
402        self.term.command_lines()
403    }
404}