justerm_core/lib.rs
1// The crate-level docs and the compiled usage example both live in README.md,
2// pulled in here so the published crates.io front page and this doctest are one
3// source: the `cargo test` doc pass compiles the README's `rust` block, so the
4// published usage snippet cannot drift from the real API (#483, and the #473
5// rule that a shipped usage snippet must compile against the real types).
6#![doc = include_str!("../README.md")]
7
8mod base64;
9mod cell;
10mod color;
11mod cursor;
12mod damage;
13mod event;
14mod grapheme;
15mod grid;
16mod input;
17mod logical;
18mod search;
19mod selection;
20mod serialize;
21mod term;
22
23pub use cell::{Cell, CellFlags, UnderlineStyle};
24pub use color::Color;
25pub use cursor::{Cursor, CursorShape, Pen};
26pub use damage::{LineDamage, ScrollOp, TermDamage};
27pub use event::{ClipboardTarget, TermEvent, Terminator};
28pub use grid::{Grid, Row};
29pub use input::{
30 Key, KeyAction, KeyEvent, KeypadKey, ModifiedKeys, Modifiers, MouseAction, MouseButton,
31 MouseEvent, MouseEvents,
32};
33pub use logical::LogicalLine;
34pub use search::{Match, SearchOptions, is_valid_regex};
35pub use selection::{SelectionSpan, SelectionType, Side};
36pub use serialize::{
37 CELL_RECORD_LEN, DecodeError, Frame, FrameKind, MarkerId, MarkerKind, MarkerPosition, Overlay,
38 Span, WIRE_VERSION, decode, encode, encode_cell_record, encode_color,
39};
40
41pub use term::{
42 CommandLine, DEFAULT_WORD_SEPARATORS, Hyperlink, MAX_COLUMNS, MAX_COMMAND_TEXT, MAX_MARKERS,
43 MAX_ROWS, MIN_COLUMNS, MarkerEntry, MarkerIndex, Term, TrackedId,
44};
45
46use vte::Parser;
47
48/// The terminal engine: pairs the `vte` parser with our state model.
49///
50/// `Parser` and `Term` are kept as separate fields because `Parser::advance`
51/// borrows both the parser and the performer mutably at once — a single struct
52/// owning both could not satisfy the borrow checker.
53///
54/// **No `#[non_exhaustive]` ([#844](https://github.com/kihyun1998/justerm/issues/844)): nothing outside this crate has a reason to build one.** No
55/// public function accepts it — the engine hands it out — and there are zero out-of-crate literal
56/// sites, so the attribute would bind nothing it does not already bind.
57pub struct Engine {
58 parser: Parser,
59 term: Term,
60}
61
62impl Engine {
63 /// A blank engine with a `cols` × `rows` screen and a default scrollback cap.
64 ///
65 /// `cols` is widened to [`MIN_COLUMNS`] — a narrower screen cannot represent a
66 /// width-2 glyph, so the engine clamps rather than accepting a size it would
67 /// have three different answers for.
68 pub fn new(cols: usize, rows: usize) -> Self {
69 Engine {
70 parser: Parser::new(),
71 term: Term::new(cols, rows),
72 }
73 }
74
75 /// Like [`Engine::new`] but with an explicit scrollback line limit. `cols` is
76 /// clamped to [`MIN_COLUMNS`] the same way.
77 pub fn with_scrollback(cols: usize, rows: usize, scrollback_limit: usize) -> Self {
78 Engine {
79 parser: Parser::new(),
80 term: Term::with_scrollback(cols, rows, scrollback_limit),
81 }
82 }
83
84 /// Push a slice of VT bytes. The caller owns the PTY/SSH/socket I/O — the
85 /// engine only consumes the bytes it is handed.
86 ///
87 /// **The stream is UTF-8, and a lone `0x80..=0x9F` byte is ill-formed input
88 /// rather than a C1 control.** So the 8-bit forms of the C1 controls are
89 /// not interpreted: `0x9B` does not open a CSI, `0x9D` an OSC, `0x90` a DCS, and
90 /// `0x9C` does not terminate a string — nor does `C2 9C`, the well-formed UTF-8
91 /// encoding of U+009C. Send the 7-bit forms, which every one of them has:
92 /// `ESC [`, `ESC ]`, `ESC P`, `ESC \`.
93 ///
94 /// **This is a contract, not a gap**, and the reason is that an OSC payload
95 /// legitimately carries 8-bit text. `0x9C` is the last byte of `한` (`ED 95 9C`)
96 /// — all six occurrences of it in this repository's recorded captures are
97 /// exactly that — so honouring it as `ST` in a byte-wise parser would cut a
98 /// title mid-character. xterm arrives at the same place from the other side:
99 /// under UTF-8 it maps an ill-formed byte to U+FFFD and *ignores* a properly
100 /// encoded C1, with both escape hatches (`EXP_C2_CONTROLS`, `allowC1Printable`)
101 /// off by default. Measurements and reference sites are in
102 /// [`docs/agents/reference-facts.md`](https://github.com/kihyun1998/justerm/blob/master/docs/agents/reference-facts.md).
103 ///
104 /// Two visible consequences, stated so they are not re-discovered as bugs: an
105 /// unrecognised 8-bit introducer leaves its payload to print as ordinary text,
106 /// and an OSC "closed" with `0x9C` stays open, accumulating everything after it
107 /// until a `BEL`, `ESC`, `CAN` or `SUB` arrives.
108 pub fn feed(&mut self, bytes: &[u8]) {
109 self.parser.advance(&mut self.term, bytes);
110 }
111
112 /// Resize the screen to `cols` x `rows`. Rows that scroll off the top enter
113 /// scrollback; the whole screen is damaged.
114 ///
115 /// **The primary screen reflows; the alternate screen does not.** On the
116 /// primary, soft-wrapped logical lines are re-split at the new width — scrollback
117 /// included, since it is one buffer with the screen — so a long line keeps its tail
118 /// instead of being truncated. Reflow is *not* gated on DECAWM: the wrap flag records
119 /// that a row continues into the next one, which stays true after a re-split, and
120 /// re-reading a momentary mode at resize time would decide the fate of history written
121 /// under the opposite setting. The alt screen is re-fit only — rows are dropped or added
122 /// to reach the new size and nothing re-wraps, because a full-screen application places
123 /// its own lines and re-wrapping them would change what it drew.
124 ///
125 /// **What a consumer must redo afterwards.** Query-derived state is *invalidated* and
126 /// user-authored state is *re-anchored*: search highlights are dropped (re-run the
127 /// search at the new width — a reflow moves match coordinates and can change the match
128 /// set), while the selection is carried to its new coordinates for you.
129 ///
130 /// **Two pieces of application-written state, answered differently.** The tab-stop table is
131 /// *extended*, never rebuilt: a resize that changes only the row count — or one that changes
132 /// nothing, since this call has no early return — leaves it exactly as the application set it,
133 /// and a stop pushed outside a narrowed grid returns when the grid widens again. The
134 /// DECSTBM scroll region is **reset** to the full screen — but only when the geometry actually
135 /// changed, since it is a range over the current screen and a resize to the size you already
136 /// have redefines nothing. An application that set one re-sends it after a real resize; nothing
137 /// tells it the region is gone, so a call that discarded one needlessly could never be repaired.
138 ///
139 /// `cols` is widened to [`MIN_COLUMNS`] **silently**: a `resize(1, rows)` during
140 /// a pane drag yields a two-column screen with no error. Read the resulting
141 /// width back from [`Engine::grid`] or the frame header rather than assuming the
142 /// value passed here, and size the PTY from that same width.
143 pub fn resize(&mut self, cols: usize, rows: usize) {
144 self.term.resize(cols, rows);
145 }
146
147 /// The current screen grid.
148 pub fn grid(&self) -> &Grid {
149 self.term.grid()
150 }
151
152 /// The current cursor (position, pending-wrap, pen).
153 pub fn cursor(&self) -> &Cursor {
154 self.term.cursor()
155 }
156
157 /// Whether bracketed-paste mode (DEC ?2004) is enabled. A consumer's input
158 /// encoder reads this to decide whether to wrap pasted text in markers.
159 pub fn bracketed_paste(&self) -> bool {
160 self.term.bracketed_paste()
161 }
162
163 /// Encode a key event to the bytes an application expects. The inverse of
164 /// [`Engine::feed`] — the consumer hands a decoded key event and writes the bytes
165 /// to its PTY. Returns `None` for a key with no defined encoding.
166 ///
167 /// **The same key encodes differently at different moments**, because four modes the
168 /// engine learned from the *output* stream decide it: application cursor keys
169 /// (DECCKM), application keypad, the kitty keyboard protocol's flag stack, and
170 /// `modifyOtherKeys` level 2 — the last of which is why `Ctrl+I` may arrive as `0x09`
171 /// or as `CSI 27;5;105~` depending on what the application printed earlier. A
172 /// consumer that caches an encoding across a `feed` is caching a mode it cannot see.
173 pub fn encode_key(&self, ev: KeyEvent) -> Option<Vec<u8>> {
174 self.term.encode_key(ev)
175 }
176
177 /// Encode a mouse event using the engine's active tracking mode + encoding.
178 /// Returns `None` when mouse reporting is off, or when the event is filtered
179 /// out by the mode (e.g. a bare move while only ?1000 is set).
180 pub fn encode_mouse(&self, ev: MouseEvent) -> Option<Vec<u8>> {
181 self.term.encode_mouse(ev)
182 }
183
184 /// Encode pasted text — wrapped in bracketed-paste markers when ?2004 is on,
185 /// raw otherwise.
186 pub fn encode_paste(&self, text: &str) -> Vec<u8> {
187 self.term.encode_paste(text)
188 }
189
190 /// Encode a focus change (`CSI I` on focus-in, `CSI O` on focus-out), or
191 /// `None` when focus reporting (?1004) is off.
192 pub fn encode_focus(&self, focused: bool) -> Option<Vec<u8>> {
193 self.term.encode_focus(focused)
194 }
195
196 /// Take the consumer events accumulated since the last drain (title / bell /
197 /// cwd — see [`TermEvent`]), emptying the queue. The pull counterpart to a
198 /// callback: poll this alongside [`Engine::frame`].
199 pub fn drain_events(&mut self) -> Vec<TermEvent> {
200 self.term.drain_events()
201 }
202
203 /// Take the reply bytes the engine produced for app queries (DA / DSR /
204 /// DECRQM) since the last drain — the consumer writes them straight back to
205 /// the PTY. The inbound-query counterpart to [`Engine::drain_events`].
206 pub fn drain_replies(&mut self) -> Vec<u8> {
207 self.term.drain_replies()
208 }
209
210 /// The OSC 8 hyperlink **URI** at **screen** `(row, col)` — the live grid, same
211 /// coordinates as [`Engine::grid`]'s `cell(row, col)` — or `None` if that cell
212 /// carries no declared link.
213 ///
214 /// **One call, not two.** An earlier shape returned a `NonZeroU32` index that a
215 /// second method resolved against a buffer-wide pool; the pool is gone (it was never
216 /// reclaimed, and nothing interned across opens that a shared `Arc` does not), so
217 /// there is no index left to hand out.
218 ///
219 /// **Owned, not borrowed** — a `&str` into the row's map would be tied to `&Engine`,
220 /// so a hover handler could not keep it across the next [`Engine::feed`]. Measured:
221 /// the borrow reads at 0.75 ns but cannot be held at all, and the caller's workaround
222 /// (copying the string) costs 62.6 ns against this handle's 17.9 ns. See
223 /// [`Hyperlink`].
224 ///
225 /// Do **not** confuse this with a decoded `Span`'s `links`, which is a *frame-local*
226 /// index into that frame's `link_table` and belongs to the wire, not to the engine.
227 /// The old two-call form invited exactly that mix-up and its doc-comment recommended
228 /// it: the two index spaces coincide only when a frame carries a single link.
229 pub fn link_at(&self, row: usize, col: usize) -> Option<Hyperlink> {
230 self.term.screen_link_at(row, col)
231 }
232
233 /// The underline colour (SGR 58) at **screen** `(row, col)` — same
234 /// coordinates as [`Engine::grid`]'s `cell(row, col)`. A theme-agnostic
235 /// [`Color`] reference; [`Color::Default`] means the underline follows the
236 /// glyph's foreground (the common case, and what a cell with no SGR 58 returns).
237 /// Like the hyperlink, the colour rides a per-row side table, not the 12-byte
238 /// [`Cell`].
239 pub fn underline_color_at(&self, row: usize, col: usize) -> Color {
240 self.term.screen_underline_color_at(row, col)
241 }
242
243 /// The OSC 8 hyperlink **URI** at **viewport** `(row, col)` — the visible window
244 /// including scrollback at the current scroll, same coordinates as
245 /// [`Engine::viewport_line`] — or `None`. Mirror of [`Engine::link_at`], including
246 /// its note about the vanished index.
247 pub fn viewport_link_at(&self, row: usize, col: usize) -> Option<Hyperlink> {
248 self.term.viewport_link_at(row, col)
249 }
250
251 /// Number of lines currently held in scrollback history.
252 pub fn scrollback_len(&self) -> usize {
253 self.term.scrollback_len()
254 }
255
256 /// Whether the app has an open **synchronized-output** block (DEC `?2026`):
257 /// it has asked that the next frame of output be painted atomically. The
258 /// engine only *reports* this — **the consumer owns the paint-hold and the
259 /// spec-mandated timeout** (a buggy app that never closes the block must not
260 /// freeze the screen forever, and the engine has no clock). Poll this after
261 /// `feed`; while it is `true`, defer applying frames, and apply once it
262 /// clears (or your own timeout fires).
263 pub fn synchronized_output(&self) -> bool {
264 self.term.synchronized_output()
265 }
266
267 /// Whether the app enabled color-scheme-update notifications (DEC `?2031`).
268 /// The engine is theme-agnostic — it never knows the scheme. The consumer
269 /// answers a [`TermEvent::ColorSchemeQuery`] (from `?996`) and, when its
270 /// scheme changes *and* this is `true`, sends an unsolicited notification, in
271 /// both cases by calling [`Engine::report_color_scheme`].
272 pub fn color_scheme_updates(&self) -> bool {
273 self.term.color_scheme_updates()
274 }
275
276 /// Report the current light/dark color scheme to the app as `CSI ? 997 ; 1 n`
277 /// (dark) / `; 2 n` (light), drained via [`Engine::drain_replies`]. Call this
278 /// to answer a [`TermEvent::ColorSchemeQuery`], or — guarded by
279 /// [`Engine::color_scheme_updates`] — when the scheme changes. The engine only
280 /// formats the bit you pass; it stores no scheme.
281 pub fn report_color_scheme(&mut self, dark: bool) {
282 self.term.report_color_scheme(dark);
283 }
284
285 /// Answer an OSC 11 `QueryBackground` event: the consumer hands back
286 /// the current background spec (it owns the palette) and the engine queues
287 /// the OSC 11 reply for `drain_replies`. Theme-agnostic — the engine never
288 /// knows the colour, only formats the envelope.
289 pub fn report_background(&mut self, spec: &str, terminator: Terminator) {
290 self.term.report_background(spec, terminator);
291 }
292
293 /// Answer an OSC 10 `QueryForeground` event: queue the OSC 10 reply
294 /// from the consumer-supplied spec. Theme-agnostic envelope-only.
295 pub fn report_foreground(&mut self, spec: &str, terminator: Terminator) {
296 self.term.report_foreground(spec, terminator);
297 }
298
299 /// Answer an OSC 12 `QueryCursorColor` event: queue the OSC 12 reply
300 /// from the consumer-supplied spec. Theme-agnostic envelope-only, like its
301 /// foreground and background siblings.
302 pub fn report_cursor_color(&mut self, spec: &str, terminator: Terminator) {
303 self.term.report_cursor_color(spec, terminator);
304 }
305
306 /// Answer an OSC 4 `QueryPaletteColor` event: queue the OSC 4 reply for
307 /// `index` from the consumer-supplied spec. Theme-agnostic envelope-only.
308 pub fn report_palette_color(&mut self, index: u8, spec: &str, terminator: Terminator) {
309 self.term.report_palette_color(index, spec, terminator);
310 }
311
312 /// Answer an OSC 52 [`TermEvent::QueryClipboard`] event: base64-encode
313 /// the consumer's clipboard text into the OSC 52 reply envelope for
314 /// [`Engine::drain_replies`].
315 ///
316 /// The engine holds no clipboard — the text comes from the consumer, which
317 /// owns it along with every policy about it. **Not calling this is how a read
318 /// is refused**, independently of whether stores are honoured, and nothing is
319 /// queued until you do.
320 pub fn report_clipboard(
321 &mut self,
322 target: ClipboardTarget,
323 text: &str,
324 terminator: Terminator,
325 ) {
326 self.term.report_clipboard(target, text, terminator);
327 }
328
329 /// Whether the app enabled **win32-input-mode** (DEC `?9001`): it asked for
330 /// keys as raw Windows key-records. The engine only tracks the flag — encoding
331 /// the records (`CSI Vk;Sc;Uc;Kd;Cs;Rc _`) is a non-goal (raw passthrough, no
332 /// semantic conversion), so [`Engine::encode_key`] is unchanged. A ConPTY
333 /// consumer reads this to decide whether to emit the records itself.
334 pub fn win32_input_mode(&self) -> bool {
335 self.term.win32_input_mode()
336 }
337
338 /// What changed since the last [`Engine::reset_damage`] — line ranges each
339 /// with a changed column span (see [ADR-0003](https://github.com/kihyun1998/justerm/blob/master/docs/adr/0003-damage-model-incremental-bounds.md)).
340 pub fn damage(&self) -> TermDamage {
341 self.term.damage()
342 }
343
344 /// Build a serializable [`Frame`] of the current diff — the damaged spans
345 /// (or every row, when `Full`), the recorded scroll op, and a frame-local
346 /// grapheme side-table. Pass it to [`encode`] for the wire. Reading
347 /// a frame does not clear damage; call [`Engine::reset_damage`] on ack.
348 pub fn frame(&self) -> Frame {
349 self.term.frame()
350 }
351
352 /// Clear accumulated damage after a frame is applied (the consumer's ack).
353 pub fn reset_damage(&mut self) {
354 self.term.reset_damage();
355 }
356
357 /// Force the next [`Engine::frame`] to be a `Full` frame (every row), even if
358 /// little changed. The use case is **reattach / late subscribe**: a renderer
359 /// that connects after output has already been parsed needs the whole current
360 /// viewport once, then incremental diffs. Marks the screen fully damaged; the
361 /// next `frame()` reports `FrameKind::Full`.
362 pub fn mark_fully_damaged(&mut self) {
363 self.term.mark_fully_damaged();
364 }
365
366 /// The first-class scroll recorded since the last [`Engine::reset_damage`],
367 /// if any — lets the renderer shift rows instead of redrawing them.
368 ///
369 /// **`count` is capped at the scroll region's own height.** Repeated
370 /// scrolls of one region accumulate into a single op between acks, and a flood
371 /// accumulates far past the region: 32 KB of newlines in one [`Engine::feed`] is
372 /// enough. Shifting a region by more than its height already moves every source
373 /// row outside it, so the surplus names nothing a consumer can act on — while it
374 /// did overflow the `i16` this value rides on the wire and arrive as a scroll in
375 /// the *opposite* direction. Suppressed entirely while the viewport is scrolled
376 /// up, since a content scroll must not shift a frozen view.
377 pub fn scroll_delta(&self) -> Option<ScrollOp> {
378 self.term.scroll_delta()
379 }
380
381 /// The cells of visible row `i` (0..rows) at the current scroll position.
382 pub fn viewport_line(&self, i: usize) -> &[Cell] {
383 self.term.viewport_line(i)
384 }
385
386 /// Scroll the viewport up by `n` lines into scrollback history.
387 pub fn scroll_up(&mut self, n: usize) {
388 self.term.scroll_up(n);
389 }
390
391 /// Scroll the viewport down by `n` lines toward the live screen.
392 pub fn scroll_down(&mut self, n: usize) {
393 self.term.scroll_down(n);
394 }
395
396 /// Jump the viewport back to the live screen (follow the bottom).
397 pub fn scroll_to_bottom(&mut self) {
398 self.term.scroll_to_bottom();
399 }
400
401 /// Begin a selection of `ty` at viewport cell `(row, col)`, on `side` of the
402 /// cell. Coordinates are viewport-relative (what a mouse event carries).
403 pub fn selection_begin(&mut self, row: usize, col: usize, side: Side, ty: SelectionType) {
404 self.term.selection_begin(row, col, side, ty);
405 }
406
407 /// Extend the live selection to viewport cell `(row, col)`, on `side`.
408 pub fn selection_extend(&mut self, row: usize, col: usize, side: Side) {
409 self.term.selection_extend(row, col, side);
410 }
411
412 /// Select the whole active buffer — scrollback and screen, or the alt screen alone —
413 /// from its first non-blank cell to its last, without moving the view. Nothing is
414 /// selected when every cell is blank.
415 pub fn select_all(&mut self) {
416 self.term.select_all();
417 }
418
419 /// Clear the screen and scrollback, keeping the cursor's line at the top — its
420 /// whole logical line down to the cursor, so a wrapped prompt keeps its start. A
421 /// terminal's Clear command. Out of band: it does not go through the parser, so
422 /// bytes the host is still writing are unaffected. Clears the selection and
423 /// search highlights; markers off the kept rows are disposed
424 /// (announced as [`TermEvent::MarkerDisposed`]), and `evicted_total` advances by
425 /// the lines dropped. The next [`Engine::frame`] is `Full`.
426 ///
427 /// Does nothing on the alt screen and returns `false`, so a consumer can leave
428 /// the keystroke to the application; `true` otherwise.
429 pub fn clear(&mut self) -> bool {
430 self.term.clear()
431 }
432
433 /// Replace the characters that end a word for [`SelectionType::Word`] — consumer
434 /// policy injected into a core mechanism ([ADR-0017](https://github.com/kihyun1998/justerm/blob/master/docs/adr/0017-core-consumer-boundary-mechanism-vs-policy.md)). Defaults to
435 /// [`DEFAULT_WORD_SEPARATORS`]. `' '` is forced in; see [`Term::set_word_separators`]
436 /// for why that floor is load-bearing rather than defensive.
437 pub fn set_word_separators(&mut self, separators: &str) {
438 self.term.set_word_separators(separators);
439 }
440
441 /// The word-boundary set currently in force (including the forced `' '`).
442 pub fn word_separators(&self) -> &str {
443 self.term.word_separators()
444 }
445
446 /// Clear the selection.
447 pub fn selection_clear(&mut self) {
448 self.term.selection_clear();
449 }
450
451 /// The selection projected onto the viewport: one inclusive-column span per
452 /// visible row, for the renderer to highlight. Empty when nothing is
453 /// selected or the selection is fully scrolled off-screen.
454 ///
455 /// **A span never ends inside a wide glyph**. An endpoint landing on
456 /// half of a width-2 pair takes the whole pair, so a highlight cannot split
457 /// a CJK glyph down the middle — which also means a span may be one column
458 /// wider than the columns the caller's gesture named. On a `Block`
459 /// selection that widening is per row, so the rectangle's rows can differ
460 /// in width. [`selection_text`](Self::selection_text) widens identically;
461 /// the two never disagree.
462 pub fn selection_range(&self) -> Vec<SelectionSpan> {
463 self.term.selection_range()
464 }
465
466 /// The selected text for copy (respects scrollback), or `None` if no
467 /// selection.
468 ///
469 /// Widened onto whole wide-glyph pairs exactly as
470 /// [`selection_range`](Self::selection_range) is — a spacer extracts
471 /// as nothing, so a range ending inside a pair would copy text the
472 /// highlight does not show.
473 pub fn selection_text(&self) -> Option<String> {
474 self.term.selection_text()
475 }
476
477 /// Literal search over the grid + scrollback, returning every match in
478 /// absolute buffer coordinates (top-to-bottom). Smart-case: a query with no
479 /// uppercase matches case-insensitively. The consumer drives next/prev by
480 /// walking the returned `Vec` and calling [`Engine::scroll_to_match`].
481 pub fn search(&self, query: &str) -> Vec<Match> {
482 self.term.search(query)
483 }
484
485 /// Search with explicit [`SearchOptions`] — regex, whole-word, and a case-sensitivity override
486 /// beyond the literal + smart-case [`search`](Self::search).
487 pub fn search_with(&self, query: &str, opts: SearchOptions) -> Vec<Match> {
488 self.term.search_with(query, opts)
489 }
490
491 /// The viewport's logical lines ([ADR-0017](https://github.com/kihyun1998/justerm/blob/master/docs/adr/0017-core-consumer-boundary-mechanism-vs-policy.md)): each soft-wrap-joined line's
492 /// text plus a per-char map to its viewport `(row, col)`. The buffer-wide
493 /// mechanism for consumer-side URL detection — the consumer runs its own
494 /// regex / `new URL()` over the text and maps matches back through `cells`.
495 /// Also serves the a11y mirror.
496 pub fn viewport_logical_lines(&self) -> Vec<LogicalLine> {
497 self.term.viewport_logical_lines()
498 }
499
500 /// The whole buffer (scrollback + screen) as one text document for a
501 /// screen-reader accessible view — soft-wrap-joined, wide-spacers
502 /// skipped, trailing blanks trimmed at the logical end, `\n` between logical
503 /// lines. A query seam the consumer summons (frame mode: over IPC, like
504 /// [`selection_text`](Self::selection_text)); no wire-format change. On the
505 /// alt screen only the alt buffer is shown.
506 ///
507 /// **This is the document [`CommandLine::line`] indexes, which makes that last
508 /// sentence a pairing obligation rather than a detail:** ask both in the
509 /// same breath and keep them together, because a document line is meaningless
510 /// against a document sampled at another instant — and while the alt screen is up
511 /// the two queries are about different buffers entirely. See
512 /// [`Engine::command_lines`].
513 pub fn accessible_text(&self) -> String {
514 self.term.accessible_text()
515 }
516
517 /// Scroll the viewport so `m` is visible (next/prev navigation: the consumer
518 /// picks the match, the engine scrolls to it).
519 pub fn scroll_to_match(&mut self, m: &Match) {
520 self.term.search_scroll_to(m);
521 }
522
523 /// The match projected onto the viewport as inclusive-column spans per
524 /// visible row, for the renderer to highlight.
525 ///
526 /// **A span never ends inside a wide glyph**, the same widening
527 /// [`selection_range`](Self::selection_range) applies. It matters more here,
528 /// because a `Match` may be one the *caller* assembled: an out-of-range
529 /// column is bounded onto the row's last cell, which is a trailing spacer
530 /// whenever the row ends in a wide glyph — so without the widening a
531 /// highlight could be that glyph's right half alone.
532 pub fn match_spans(&self, m: &Match) -> Vec<SelectionSpan> {
533 self.term.match_spans(m)
534 }
535
536 /// Set the search highlights the frame should carry. The
537 /// consumer owns match navigation, so it hands the set to highlight back
538 /// here; [`Engine::frame`] then projects them onto the viewport overlay
539 /// alongside the selection. An empty vec clears the highlights.
540 pub fn set_search_highlights(&mut self, matches: Vec<Match>) {
541 self.term.set_search_highlights(matches);
542 }
543
544 /// Designate which member of the held highlight set is the *active* match
545 /// — the one next/prev navigation currently points at (that choice is
546 /// the consumer's policy). [`Engine::frame`] projects it into the overlay's
547 /// `active_match` group; it also stays in `matches`, and the renderer's
548 /// highlight ranking resolves the overlap. `None` or an out-of-range
549 /// index projects nothing. Passing a new set to
550 /// [`set_search_highlights`](Self::set_search_highlights) resets the
551 /// designation, so re-designate after every hand-over.
552 pub fn set_active_search_highlight(&mut self, index: Option<usize>) {
553 self.term.set_active_search_highlight(index);
554 }
555
556 /// Designate the *active* match by its absolute span, independent of
557 /// the held highlight set — the past-cap path. A backend that caps its
558 /// hand-over (the documented 1000, xterm's `highlightLimit`) can still give
559 /// the current match its active emphasis: xterm builds its active
560 /// decoration from the found result *outside* the capped list, and this is
561 /// that model. The span projects through the same wrap-aware viewport math
562 /// as any match; past the cap it paints the ACTIVE colour only (no plain
563 /// highlight underneath — honest about the cap). `None` clears. Same
564 /// lifecycle as the index form: reset on every
565 /// [`set_search_highlights`](Self::set_search_highlights) hand-over and on
566 /// any coordinate-shifting invalidation (eviction, region scroll, reflow,
567 /// alt-screen swaps), so re-designate after each hand-over.
568 pub fn set_active_search_match(&mut self, m: Option<Match>) {
569 self.term.set_active_search_match(m);
570 }
571
572 /// Register a decoration marker at viewport `row`, returning its stable id.
573 /// The marker anchors the content currently on that row and tracks
574 /// it through scroll/eviction/reflow; [`Engine::frame`] reports its viewport
575 /// position while visible. Use the id to remove it or to match the
576 /// `TermEvent::MarkerDisposed` fired when its line leaves the buffer.
577 ///
578 /// A buffer holds at most [`MAX_MARKERS`] live markers — the population is
579 /// also grown by the *stream*, through OSC 133 command marks, so it is bounded.
580 /// Past the cap the **oldest** marker is retired and announced through the same
581 /// `MarkerDisposed` event, so a consumer that already handles disposal needs no new
582 /// handling; a consumer that ignores it can leave a decoration bound to a dead id.
583 pub fn add_marker(&mut self, row: usize) -> MarkerId {
584 self.term.add_marker(row)
585 }
586
587 /// Remove a marker by id, firing `TermEvent::MarkerDisposed`. A no-op
588 /// for an unknown or already-disposed id.
589 pub fn remove_marker(&mut self, id: MarkerId) {
590 self.term.remove_marker(id);
591 }
592
593 /// Track absolute buffer `(line, col)`, returning a stable id: the
594 /// engine keeps the position on the content that is there now, through
595 /// scrollback eviction, region scrolls and reflow.
596 ///
597 /// This is what an absolute coordinate held *outside* the engine needs to stay
598 /// meaningful — a search anchor carrying an emphasis across a re-search is the
599 /// case it exists for. The engine renumbers this space (evicting the oldest
600 /// history line shifts every index down by one), and it renumbers it in the
601 /// consumer's absence, so a remembered `Match` silently comes to name
602 /// different text.
603 ///
604 /// Mechanism only: which position is worth remembering, and what to do once it
605 /// is gone, stay with the consumer ([ADR-0017](https://github.com/kihyun1998/justerm/blob/master/docs/adr/0017-core-consumer-boundary-mechanism-vs-policy.md)). Release it with
606 /// [`Engine::untrack_point`] — the engine cannot know when you are done.
607 ///
608 /// **The line is maintained; the column is carried, not tracked.** In-row edits
609 /// (ICH / DCH) shift cells past a tracked column without moving it, so a point
610 /// on text that was pushed sideways names the wrong cell in that row. No
611 /// reference maintains a column here either — xterm's markers carry none at
612 /// all, and ghostty's pins are untouched by its `insertChars`/`deleteChars` —
613 /// so this is the convergent behaviour rather than an omission.
614 pub fn track_point(&mut self, line: usize, col: usize) -> TrackedId {
615 self.term.track_point(line, col)
616 }
617
618 /// Where the point registered as `id` sits now, in the **active** screen's
619 /// coordinates — or `None`.
620 ///
621 /// `None` covers three cases, and a caller does not need to tell them apart:
622 /// the content has left the buffer, the id is unknown or released, or the point
623 /// belongs to *the other screen*. The last one is not a limitation but the only
624 /// honest answer: the primary grid and the alt grid occupy the **same** absolute
625 /// indices, so a number alone cannot say which screen it means. All three say
626 /// *do not move anything on account of this point*.
627 ///
628 /// An out-of-range coordinate is clamped rather than rejected, at both ends
629 /// ([ADR-0026](https://github.com/kihyun1998/justerm/blob/master/docs/adr/0026-outside-coordinates-are-bounded-once.md) D2/D3): the line into the buffer's range, the column to the grid
630 /// width. That bound is applied here, at the read; a coordinate that was never
631 /// in range to begin with is also **resolved by a reflow** (it maps to the top
632 /// of the buffer), so "bounded once" holds for the site, not for the value.
633 pub fn tracked_point(&self, id: TrackedId) -> Option<(usize, usize)> {
634 self.term.tracked_point(id)
635 }
636
637 /// Release a tracked point. A no-op for an unknown or already-released
638 /// id.
639 pub fn untrack_point(&mut self, id: TrackedId) {
640 self.term.untrack_point(id);
641 }
642
643 /// The OSC 133 shell-integration command marks in buffer order — `(id,
644 /// absolute line, kind)`. Excludes plain `add_marker` decorations.
645 /// The consumer pairs prompt/command/finished marks to drive prompt-to-prompt
646 /// navigation and command/exit announcements; the engine only parses
647 /// the `133;A/B/C/D` sequences and anchors the marks.
648 ///
649 /// **The answer is instantaneous — it describes the buffer it was asked of, and
650 /// nothing on it dates it. Re-ask; never keep it and never rebase it.**
651 /// The lines move on *both* of the axes [`MarkerIndex`] carries a scalar for:
652 /// scrollback eviction shifts every mark by the same amount, and a top-anchored
653 /// `DECSTBM` region shifts the marks below its margin once per output line — the
654 /// second inside a single [`Engine::feed`], with no resize anywhere.
655 ///
656 /// **Why this is not shaped like its sibling.** [`Engine::marker_index`] carries a
657 /// basis and an epoch because a consumer *must* hold its answer: it feeds an
658 /// overview ruler that has to be current in every frame, and re-pulling per frame
659 /// is the `O(M)`-per-frame payload [ADR-0020](https://github.com/kihyun1998/justerm/blob/master/docs/adr/0020-what-qualifies-for-the-frame-snapshot.md) R3 exists to forbid. This query is
660 /// consumed when a user acts, so re-asking **is** the natural act — and here a
661 /// re-ask always answers, because this population's frame of reference never
662 /// changes. That is the property the sibling lacks, and the reason it needed the
663 /// epoch rather than a reason this one does: an alt switch is one of the four
664 /// moves that epoch announces.
665 ///
666 /// **The lines are `[scrollback ++ primary]`, always — including while the alt
667 /// screen is up.** They do not name the *active* buffer. The two buffers occupy the
668 /// same absolute indices, so one integer from here and the same integer from
669 /// [`Engine::marker_index`] name different content, and neither tuple nor struct
670 /// says which. [`Engine::tracked_point`] meets that ambiguity and answers `None`
671 /// rather than a number ([ADR-0026](https://github.com/kihyun1998/justerm/blob/master/docs/adr/0026-outside-coordinates-are-bounded-once.md) D2/D3); it can, because it is asked about *one*
672 /// point of unknown origin. This query enumerates a population whose screen is
673 /// fixed by definition, so it answers — and states the screen here instead.
674 ///
675 /// Consequently an empty answer means every mark was disposed and can mean nothing
676 /// else, where `marker_index`'s silence is ambiguous between that and *"you are on
677 /// the other screen"*.
678 ///
679 /// **A mark also dies when a whole row is blanked where it stands.** Until
680 /// then the only deaths were the buffer *moving* — eviction, a region rotate, a
681 /// reflow — and a `clear` left every mark on the screen alive over blank rows. `ED`
682 /// now retires the marks on each whole row it blanks, through the same
683 /// `TermEvent::MarkerDisposed` a consumer already handles, so this query going empty
684 /// after a `clear` is the ordinary meaning above and not a new one. **`EL` and `ECH`
685 /// deliberately do not**, whatever they blank: a line editor redraws its input line
686 /// with `\r ESC[K` on every keystroke, and the `CommandStart` of the command being
687 /// typed is on that row.
688 pub fn command_marks(&self) -> Vec<(MarkerId, usize, MarkerKind)> {
689 self.term.command_marks()
690 }
691
692 /// The executed shell commands recovered from OSC-133 marks, in buffer order
693 /// — the query behind screen-reader command navigation. Each
694 /// [`CommandLine`] carries the typed command text (prompt/output excluded via
695 /// the captured columns), its jump line (CommandStart), and the exit code.
696 /// This is a full-buffer query, wired to the frame-mode consumer over IPC like
697 /// [`Engine::accessible_text`]; the web side has no scrollback cells to derive
698 /// it ([ADR-0017](https://github.com/kihyun1998/justerm/blob/master/docs/adr/0017-core-consumer-boundary-mechanism-vs-policy.md) — buffer-wide text is core's).
699 ///
700 /// **The text and the exit are frozen when the stream reveals them; only the line
701 /// is derived.** [`CommandLine::command`] is captured at the `133;C` that
702 /// closes the command — the instant it is complete and on screen — and
703 /// [`CommandLine::exit`] is written down when `133;D` is parsed. Neither is
704 /// recoverable afterwards: re-reading the text through the recorded columns names
705 /// whatever *now* occupies those cells, which a plain overwrite, `ICH`, `DCH` and an
706 /// erase all arrange, and an exit code is in no cell at any time. A capture is
707 /// bounded at [`MAX_COMMAND_TEXT`] `char`s, truncated at a `char` boundary, for the
708 /// reason [`MAX_MARKERS`] exists: the stream chooses the distance between `B` and
709 /// `C`. [`CommandLine::line`] stays derived, because it is the half the anchor
710 /// fixups already maintain.
711 ///
712 /// **The answer is instantaneous — it describes the buffer it was asked of, and
713 /// nothing on it dates it. Re-ask; never keep it past the document it
714 /// indexes, and never rebase it.** Same discharge as [`Engine::command_marks`] and
715 /// for the same two reasons ([ADR-0029](https://github.com/kihyun1998/justerm/blob/master/docs/adr/0029-a-published-coordinate-carries-its-instant-or-is-re-asked.md) D3): the clock is a user action, so the ask
716 /// *is* the act; and this population's frame of reference never flips, so a re-ask
717 /// always answers. Absence means the command is gone **or** that its output has not
718 /// started yet — both of which the next ask resolves. What absence never means is
719 /// *"you are on the other screen"*, which is the meaning no re-ask could undo.
720 ///
721 /// **Do not rebase by [`MarkerIndex::evicted_total`].** That dates the *absolute*
722 /// space. [`CommandLine::line`] is a **document** line, and the two spaces move
723 /// apart in both directions: an eviction that pops a soft-wrap continuation row
724 /// moves the absolute lines and not this one, and flipping a row's wrap bit — which
725 /// ordinary output does — moves this one while the absolute lines and both of
726 /// `MarkerIndex`'s scalars stay put. They agree most of the time, which is what
727 /// makes rebasing look correct right up until it silently is not.
728 ///
729 /// **Ask [`Engine::accessible_text`] in the same breath, and only on the primary
730 /// screen.** The lines index that document; while the alt screen is up it returns
731 /// the *alt* document instead, and these lines are indices into the primary one. If
732 /// the alt screen is taller than the held index — a full-screen TUI, which is the
733 /// normal case — the index still **resolves**, onto unrelated content, so a bounds
734 /// check does not save a caller here. The query keeps answering on the alt screen
735 /// deliberately: emptying it would give absence the one meaning a re-ask cannot
736 /// recover from, which is what the discharge above rests on. Pairing the two is the
737 /// caller's, and this is where it is said.
738 pub fn command_lines(&self) -> Vec<CommandLine> {
739 self.term.command_lines()
740 }
741
742 /// Every live marker of the active buffer with its **absolute** buffer line, plus
743 /// the basis that says how long the answer stays usable.
744 ///
745 /// The pull half of the marker surface. It shares [`Engine::command_lines`]'s
746 /// *shape* — the consumer asks once and keeps the answer, rather than being handed
747 /// every live marker inside every frame, which is `O(M)` payload per frame for a
748 /// quantity unrelated to what changed ([ADR-0020](https://github.com/kihyun1998/justerm/blob/master/docs/adr/0020-what-qualifies-for-the-frame-snapshot.md) R3). It does **not** share its
749 /// coordinate: only the lines *here* are buffer-absolute and rebasable by the
750 /// `evicted_total` delta. [`CommandLine::line`] is a **document** line over
751 /// [`Engine::accessible_text`], where soft-wrapped rows collapse — eviction moves it
752 /// by an amount no scalar on this surface expresses, so it is an answer to keep only
753 /// as long as the buffer it was asked of.
754 ///
755 /// Ask again when [`MarkerIndex::epoch`] differs from the one you hold. Drop an
756 /// entry when its `TermEvent::MarkerDisposed` arrives, and append one when
757 /// `TermEvent::MarkerCreated` does — neither deliberately moves the epoch, so
758 /// neither costs a re-pull. **Append it on the instant the event carries, not on the
759 /// newest frame's**: a `feed` can create a marker and then evict, and those are two
760 /// different origins.
761 ///
762 /// **Adopt a birth only into the generation it names.** The event carries this
763 /// pull's whole triple — line, basis, [`MarkerIndex::epoch`] — because the basis dates
764 /// only a *uniform* move. A reflow or a region rotate moves markers individually, so a
765 /// line dated to the generation before one is not stale by a delta; it is an answer
766 /// about a different buffer, and the re-pull the epoch already forces is what supplies
767 /// the marker instead. Compare generations for **equality**: the counter wraps.
768 ///
769 /// **Draining before you read the frame is then a cost preference, not a correctness
770 /// one.** Reading the frame first leaves `marker_count` one ahead of an index that has
771 /// not been told yet, so a consumer comparing the two spends an `O(M)` re-pull
772 /// reconciling a fact this event delivered at `O(1)`. Placement does not depend on the
773 /// order, on either axis.
774 pub fn marker_index(&self) -> MarkerIndex {
775 self.term.marker_index()
776 }
777}