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 grid;
23mod input;
24mod search;
25mod selection;
26mod serialize;
27mod term;
28
29pub use cell::{Cell, CellFlags};
30pub use color::Color;
31pub use cursor::{Cursor, CursorShape, Pen};
32pub use damage::{LineDamage, ScrollOp, TermDamage};
33pub use event::TermEvent;
34pub use grid::{Grid, Row};
35pub use input::{
36    Key, KeyAction, KeyEvent, KeypadKey, Modifiers, MouseAction, MouseButton, MouseEvent,
37};
38pub use search::Match;
39pub use selection::{SelectionSpan, SelectionType, Side};
40pub use serialize::{
41    CELL_RECORD_LEN, DecodeError, Frame, FrameKind, Span, WIRE_VERSION, decode, encode,
42    encode_cell_record, encode_color,
43};
44
45pub use term::Term;
46
47use vte::Parser;
48
49/// The terminal engine: pairs the `vte` parser with our state model.
50///
51/// `Parser` and `Term` are kept as separate fields because `Parser::advance`
52/// borrows both the parser and the performer mutably at once — a single struct
53/// owning both could not satisfy the borrow checker.
54pub struct Engine {
55    parser: Parser,
56    term: Term,
57}
58
59impl Engine {
60    /// A blank engine with a `cols` × `rows` screen and a default scrollback cap.
61    pub fn new(cols: usize, rows: usize) -> Self {
62        Engine {
63            parser: Parser::new(),
64            term: Term::new(cols, rows),
65        }
66    }
67
68    /// Like [`Engine::new`] but with an explicit scrollback line limit.
69    pub fn with_scrollback(cols: usize, rows: usize, scrollback_limit: usize) -> Self {
70        Engine {
71            parser: Parser::new(),
72            term: Term::with_scrollback(cols, rows, scrollback_limit),
73        }
74    }
75
76    /// Push a slice of VT bytes. The caller owns the PTY/SSH/socket I/O — the
77    /// engine only consumes the bytes it is handed.
78    pub fn feed(&mut self, bytes: &[u8]) {
79        self.parser.advance(&mut self.term, bytes);
80    }
81
82    /// Resize the screen to `cols` x `rows`. Rows that scroll off the top enter
83    /// scrollback; the whole screen is damaged. (Soft-wrap reflow lands in #7.)
84    pub fn resize(&mut self, cols: usize, rows: usize) {
85        self.term.resize(cols, rows);
86    }
87
88    /// The current screen grid.
89    pub fn grid(&self) -> &Grid {
90        self.term.grid()
91    }
92
93    /// The current cursor (position, pending-wrap, pen).
94    pub fn cursor(&self) -> &Cursor {
95        self.term.cursor()
96    }
97
98    /// Whether bracketed-paste mode (DEC ?2004) is enabled. A consumer's input
99    /// encoder reads this to decide whether to wrap pasted text in markers.
100    pub fn bracketed_paste(&self) -> bool {
101        self.term.bracketed_paste()
102    }
103
104    /// Encode a key event to the bytes an application expects, honouring the
105    /// engine's cursor-key mode (DECCKM). The inverse of [`Engine::feed`] — the
106    /// consumer hands a decoded key event and writes the bytes to its PTY.
107    /// Returns `None` for a key with no defined encoding.
108    pub fn encode_key(&self, ev: KeyEvent) -> Option<Vec<u8>> {
109        self.term.encode_key(ev)
110    }
111
112    /// Encode a mouse event using the engine's active tracking mode + encoding.
113    /// Returns `None` when mouse reporting is off, or when the event is filtered
114    /// out by the mode (e.g. a bare move while only ?1000 is set).
115    pub fn encode_mouse(&self, ev: MouseEvent) -> Option<Vec<u8>> {
116        self.term.encode_mouse(ev)
117    }
118
119    /// Encode pasted text — wrapped in bracketed-paste markers when ?2004 is on,
120    /// raw otherwise.
121    pub fn encode_paste(&self, text: &str) -> Vec<u8> {
122        self.term.encode_paste(text)
123    }
124
125    /// Encode a focus change (`CSI I` on focus-in, `CSI O` on focus-out), or
126    /// `None` when focus reporting (?1004) is off.
127    pub fn encode_focus(&self, focused: bool) -> Option<Vec<u8>> {
128        self.term.encode_focus(focused)
129    }
130
131    /// Take the consumer events accumulated since the last drain (title / bell /
132    /// cwd — see [`TermEvent`]), emptying the queue. The pull counterpart to a
133    /// callback: poll this alongside [`Engine::frame`].
134    pub fn drain_events(&mut self) -> Vec<TermEvent> {
135        self.term.drain_events()
136    }
137
138    /// Take the reply bytes the engine produced for app queries (DA / DSR /
139    /// DECRQM) since the last drain — the consumer writes them straight back to
140    /// the PTY. The inbound-query counterpart to [`Engine::drain_events`].
141    pub fn drain_replies(&mut self) -> Vec<u8> {
142        self.term.drain_replies()
143    }
144
145    /// The OSC 8 hyperlink index at **screen** `(row, col)` — the live grid, same
146    /// coordinates as [`Engine::grid`]'s `cell(row, col)` — or `None`. Combining
147    /// and links no longer ride on the [`Cell`](crate::Cell) (#45/#46); read the
148    /// index here, then resolve it with [`Engine::hyperlink`].
149    pub fn link_at(&self, row: usize, col: usize) -> Option<core::num::NonZeroU32> {
150        self.term.screen_link_at(row, col)
151    }
152
153    /// The OSC 8 hyperlink index at **viewport** `(row, col)` — the visible
154    /// window including scrollback at the current scroll, same coordinates as
155    /// [`Engine::viewport_line`] — or `None`.
156    pub fn viewport_link_at(&self, row: usize, col: usize) -> Option<core::num::NonZeroU32> {
157        self.term.viewport_link_at(row, col)
158    }
159
160    /// Resolve a hyperlink index (from [`Engine::link_at`] /
161    /// [`Engine::viewport_link_at`], or a decoded `Span`'s `links`) to its URI,
162    /// to make a cell clickable.
163    pub fn hyperlink(&self, link: core::num::NonZeroU32) -> Option<&str> {
164        self.term.hyperlink(link)
165    }
166
167    /// Number of lines currently held in scrollback history.
168    pub fn scrollback_len(&self) -> usize {
169        self.term.scrollback_len()
170    }
171
172    /// Whether the app has an open **synchronized-output** block (DEC `?2026`):
173    /// it has asked that the next frame of output be painted atomically. The
174    /// engine only *reports* this — **the consumer owns the paint-hold and the
175    /// spec-mandated timeout** (a buggy app that never closes the block must not
176    /// freeze the screen forever, and the engine has no clock). Poll this after
177    /// `feed`; while it is `true`, defer applying frames, and apply once it
178    /// clears (or your own timeout fires). (#73)
179    pub fn synchronized_output(&self) -> bool {
180        self.term.synchronized_output()
181    }
182
183    /// Whether the app enabled color-scheme-update notifications (DEC `?2031`).
184    /// The engine is theme-agnostic — it never knows the scheme. The consumer
185    /// answers a [`TermEvent::ColorSchemeQuery`] (from `?996`) and, when its
186    /// scheme changes *and* this is `true`, sends an unsolicited notification, in
187    /// both cases by calling [`Engine::report_color_scheme`] (#85).
188    pub fn color_scheme_updates(&self) -> bool {
189        self.term.color_scheme_updates()
190    }
191
192    /// Report the current light/dark color scheme to the app as `CSI ? 997 ; 1 n`
193    /// (dark) / `; 2 n` (light), drained via [`Engine::drain_replies`]. Call this
194    /// to answer a [`TermEvent::ColorSchemeQuery`], or — guarded by
195    /// [`Engine::color_scheme_updates`] — when the scheme changes. The engine only
196    /// formats the bit you pass; it stores no scheme (#85).
197    pub fn report_color_scheme(&mut self, dark: bool) {
198        self.term.report_color_scheme(dark);
199    }
200
201    /// Whether the app enabled **win32-input-mode** (DEC `?9001`): it asked for
202    /// keys as raw Windows key-records. The engine only tracks the flag — encoding
203    /// the records (`CSI Vk;Sc;Uc;Kd;Cs;Rc _`) is a non-goal (raw passthrough, no
204    /// semantic conversion), so [`Engine::encode_key`] is unchanged. A ConPTY
205    /// consumer reads this to decide whether to emit the records itself (#86).
206    pub fn win32_input_mode(&self) -> bool {
207        self.term.win32_input_mode()
208    }
209
210    /// What changed since the last [`Engine::reset_damage`] — line ranges each
211    /// with a changed column span (see ADR-0003).
212    pub fn damage(&self) -> TermDamage {
213        self.term.damage()
214    }
215
216    /// Build a serializable [`Frame`] of the current diff — the damaged spans
217    /// (or every row, when `Full`), the recorded scroll op, and a frame-local
218    /// grapheme side-table. Pass it to [`encode`] for the wire (see #6). Reading
219    /// a frame does not clear damage; call [`Engine::reset_damage`] on ack.
220    pub fn frame(&self) -> Frame {
221        self.term.frame()
222    }
223
224    /// Clear accumulated damage after a frame is applied (the consumer's ack).
225    pub fn reset_damage(&mut self) {
226        self.term.reset_damage();
227    }
228
229    /// Force the next [`Engine::frame`] to be a `Full` frame (every row), even if
230    /// little changed. The use case is **reattach / late subscribe**: a renderer
231    /// that connects after output has already been parsed needs the whole current
232    /// viewport once, then incremental diffs. Marks the screen fully damaged; the
233    /// next `frame()` reports `FrameKind::Full`.
234    pub fn mark_fully_damaged(&mut self) {
235        self.term.mark_fully_damaged();
236    }
237
238    /// The first-class scroll recorded since the last [`Engine::reset_damage`],
239    /// if any — lets the renderer shift rows instead of redrawing them.
240    pub fn scroll_delta(&self) -> Option<ScrollOp> {
241        self.term.scroll_delta()
242    }
243
244    /// The cells of visible row `i` (0..rows) at the current scroll position.
245    pub fn viewport_line(&self, i: usize) -> &[Cell] {
246        self.term.viewport_line(i)
247    }
248
249    /// Scroll the viewport up by `n` lines into scrollback history.
250    pub fn scroll_up(&mut self, n: usize) {
251        self.term.scroll_up(n);
252    }
253
254    /// Scroll the viewport down by `n` lines toward the live screen.
255    pub fn scroll_down(&mut self, n: usize) {
256        self.term.scroll_down(n);
257    }
258
259    /// Jump the viewport back to the live screen (follow the bottom).
260    pub fn scroll_to_bottom(&mut self) {
261        self.term.scroll_to_bottom();
262    }
263
264    /// Begin a selection of `ty` at viewport cell `(row, col)`, on `side` of the
265    /// cell. Coordinates are viewport-relative (what a mouse event carries).
266    pub fn selection_begin(&mut self, row: usize, col: usize, side: Side, ty: SelectionType) {
267        self.term.selection_begin(row, col, side, ty);
268    }
269
270    /// Extend the live selection to viewport cell `(row, col)`, on `side`.
271    pub fn selection_extend(&mut self, row: usize, col: usize, side: Side) {
272        self.term.selection_extend(row, col, side);
273    }
274
275    /// Clear the selection.
276    pub fn selection_clear(&mut self) {
277        self.term.selection_clear();
278    }
279
280    /// The selection projected onto the viewport: one inclusive-column span per
281    /// visible row, for the renderer to highlight. Empty when nothing is
282    /// selected or the selection is fully scrolled off-screen.
283    pub fn selection_range(&self) -> Vec<SelectionSpan> {
284        self.term.selection_range()
285    }
286
287    /// The selected text for copy (respects scrollback), or `None` if no
288    /// selection.
289    pub fn selection_text(&self) -> Option<String> {
290        self.term.selection_text()
291    }
292
293    /// Literal search over the grid + scrollback, returning every match in
294    /// absolute buffer coordinates (top-to-bottom). Smart-case: a query with no
295    /// uppercase matches case-insensitively. The consumer drives next/prev by
296    /// walking the returned `Vec` and calling [`Engine::scroll_to_match`].
297    pub fn search(&self, query: &str) -> Vec<Match> {
298        self.term.search(query)
299    }
300
301    /// Scroll the viewport so `m` is visible (next/prev navigation: the consumer
302    /// picks the match, the engine scrolls to it).
303    pub fn scroll_to_match(&mut self, m: &Match) {
304        self.term.search_scroll_to(m);
305    }
306
307    /// The match projected onto the viewport as inclusive-column spans per
308    /// visible row, for the renderer to highlight.
309    pub fn match_spans(&self, m: &Match) -> Vec<SelectionSpan> {
310        self.term.match_spans(m)
311    }
312}