justerm_core/term.rs
1//! The terminal state model: a `vte::Perform` that maps parsed VT actions onto
2//! the grid, cursor, and pen. This is where the "hidden VT state" lives —
3//! pending-wrap, the wide-char spacer, and the pen (BCE seam).
4
5use std::collections::VecDeque;
6
7use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
8use vte::{Params, Perform};
9
10use crate::cell::{Cell, CellFlags};
11use crate::color::Color;
12use crate::cursor::{Cursor, CursorShape, Pen};
13use crate::damage::{LineBounds, LineDamage, ScrollOp, TermDamage};
14use crate::event::TermEvent;
15use crate::grid::{Grid, Row};
16use crate::input::{
17 KeyEvent, MouseEncoding, MouseEvent, MouseProtocol, encode_focus, encode_key, encode_mouse,
18 encode_paste,
19};
20use crate::logical::LogicalLine;
21use crate::search::{Match, SearchOptions};
22use crate::selection::{Anchor, BufferPoint, Selection, SelectionSpan, SelectionType, Side};
23use crate::serialize::{
24 Frame, FrameKind, MarkerId, MarkerKind, MarkerLine, MarkerPosition, Overlay, Span,
25};
26
27/// Owns the authoritative screen state and applies VT actions to it.
28pub struct Term {
29 grid: Grid,
30 /// The inactive screen. Swapped with `grid` on alt-screen enter/leave; holds
31 /// whichever of primary/alternate is not currently shown. The alt screen has
32 /// no scrollback (#3 only rings the primary).
33 alt_grid: Grid,
34 cursor: Cursor,
35 /// Cursor saved on alt-screen enter (DEC 1049), restored on leave.
36 saved_cursor: Cursor,
37 /// Whether the alternate screen is currently active. Guards enter/leave so a
38 /// double-enter or double-leave is a no-op.
39 on_alt: bool,
40 /// One flag per column: is there a tab stop here? Explicit per-column state
41 /// (HTS sets, TBC clears), not a fixed modulo. Default = every 8th column.
42 tabs: Vec<bool>,
43 /// Origin mode (DECOM ?6): when set, cursor addressing is relative to the
44 /// scroll region's top margin (and clamped to it).
45 origin_mode: bool,
46 /// Autowrap (DECAWM ?7): default on. When off, a glyph past the right margin
47 /// pins the cursor to the last column and overwrites in place instead of
48 /// wrapping to the next line (matches xterm.js) (#63).
49 autowrap: bool,
50 /// Insert mode (IRM, the non-private SM/RM mode 4): default off (replace).
51 /// When on, a printed glyph shifts the row's tail right first (#64).
52 insert_mode: bool,
53 /// New-line mode (LNM, the non-private SM/RM mode 20): default off. When on,
54 /// a line feed also carriage-returns (`convertEol`). Output-only — the Enter
55 /// key still encodes CR, matching xterm.js (#71).
56 newline_mode: bool,
57 /// Reverse wraparound (DEC ?45): default off. When on, a *backspace* at
58 /// column 0 of a soft-wrapped row moves back to the end of the previous row
59 /// (BS only, soft wraps only — matches xterm.js) (#80).
60 reverse_wraparound: bool,
61 /// Bracketed-paste mode (DEC ?2004). The engine owns the flag; the input
62 /// encoder (#11) reads it to decide whether to wrap pasted text in markers.
63 bracketed_paste: bool,
64 /// Synchronized output (DEC ?2026): the app brackets a frame of output so the
65 /// renderer can paint it atomically. The engine only *tracks* the flag — the
66 /// consumer owns the paint-hold and the spec-mandated timeout (#73).
67 synchronized_output: bool,
68 /// Color-scheme-update notifications (DEC ?2031): the app asked to be told
69 /// when the light/dark scheme changes. The engine is theme-agnostic — it only
70 /// tracks the flag; the consumer (which knows the scheme) drives the ?997
71 /// notification via `report_color_scheme` (#85).
72 color_scheme_updates: bool,
73 /// Grapheme-cluster mode (DEC ?2027, default OFF): the app opted into UAX #29 grapheme-cluster
74 /// width — a ZWJ / skin-tone / flag / emoji+VS16 sequence is clustered into ONE cell instead of
75 /// one cell per scalar (#295). OFF keeps the per-char (wcwidth-compatible) behaviour so the
76 /// cursor stays in sync with wcwidth apps — clustering is opt-in for exactly that reason (#301).
77 grapheme_clustering: bool,
78 /// win32-input-mode (DEC ?9001): the app asked for keys as raw Windows
79 /// key-records. The engine only *tracks* the flag — the raw record encoding
80 /// (`CSI Vk;Sc;Uc;Kd;Cs;Rc _`) is a non-goal (raw passthrough, no semantic
81 /// conversion), left to the ConPTY consumer; `encode_key` is unchanged (#86).
82 win32_input_mode: bool,
83 /// Application cursor keys (DECCKM ?1): when set, cursor keys / Home / End
84 /// encode as SS3 rather than CSI (see `input.rs`).
85 app_cursor_keys: bool,
86 /// Application keypad mode (DECNKM ?66 / DECKPAM `ESC =` / DECKPNM `ESC >`):
87 /// tracked for protocol completeness + DECRQM, but NOT yet acted on in key
88 /// encoding — xterm.js tracks it the same way and never reads it (#74).
89 application_keypad: bool,
90 /// VT52 compatibility mode (DECANM ?2 *reset*): when set, `esc_dispatch` is
91 /// re-routed into the pre-ANSI VT52 dialect (`ESC A`-style sequences) instead
92 /// of the ANSI meaning. `ESC <` clears it. Default off (ANSI). (#84)
93 vt52_mode: bool,
94 /// VT52 `ESC Y row col` direct-addressing state (#84). vte tokenizes `ESC Y`
95 /// as a final and returns to ground, so the two coordinate bytes arrive as
96 /// `print()` calls — not part of the escape sequence. This counts them down
97 /// (2 → 1 → 0; 0 = not addressing) and `vt52_y_row` parks the first (row)
98 /// until the second (col) lands. Each byte decodes as `value - 0x20`.
99 vt52_y_pending: u8,
100 vt52_y_row: usize,
101 /// Mouse tracking mode — what events the app asked to be reported
102 /// (?1000/?1002/?1003). `Off` by default.
103 mouse_protocol: MouseProtocol,
104 /// Mouse coordinate encoding (default X10 vs ?1006 SGR).
105 mouse_encoding: MouseEncoding,
106 /// Focus in/out reporting (?1004): emit `CSI I`/`CSI O` on focus change.
107 focus_events: bool,
108 /// Kitty keyboard-protocol progressive-enhancement flags currently in effect
109 /// (bit0 disambiguate, bit1 report-events, bit2 alt-keys, bit3 all-as-escape,
110 /// bit4 associated-text). 0 = legacy. `encode_key` consults these (#23).
111 kitty_flags: u8,
112 /// Saved `kitty_flags` for the protocol's push/pop stack (`CSI > u` pushes,
113 /// `CSI < u` pops). Capped depth — overflow drops the oldest entry.
114 kitty_stack: Vec<u8>,
115 /// Consumer events (title / bell / cwd) accumulated since the last
116 /// `drain_events` (#12). Pull, not push — see `event.rs`.
117 events: Vec<TermEvent>,
118 /// Outbound reply bytes (DA/DSR/DECRQM query answers, #27) accumulated
119 /// during `feed` for the consumer to write back to the PTY. Raw bytes →
120 /// PTY, kept separate from typed `events` → UI.
121 replies: Vec<u8>,
122 /// Hyperlink side-table (OSC 8): each entry is one link's URI, referenced by
123 /// `Cell.link` (1-based). Append-only (#26).
124 hyperlink_pool: Vec<String>,
125 /// The hyperlink currently open (OSC 8 with a URI), stamped onto every glyph
126 /// written until closed (OSC 8 with empty URI). Ambient pen-like state — not
127 /// part of the pen/SGR, and *not* cleared by an SGR reset.
128 current_link: Option<core::num::NonZeroU32>,
129 /// Scroll region top/bottom margins (DECSTBM), 0-based inclusive. A
130 /// line-feed at `scroll_bottom` scrolls only rows `[scroll_top..=scroll_bottom]`.
131 /// Default = the full screen.
132 scroll_top: usize,
133 scroll_bottom: usize,
134 /// Lines that have scrolled off the top of the primary screen, oldest at the
135 /// front. Accrues only on a top-anchored, primary-screen scroll.
136 scrollback: VecDeque<Row>,
137 /// How many lines the viewport is scrolled up from the bottom. 0 = following
138 /// the live screen; clamped to `[0, scrollback.len()]`.
139 display_offset: usize,
140 /// Maximum scrollback lines retained; the oldest are evicted past this.
141 scrollback_limit: usize,
142 /// A spare row buffer recycled across full-screen scrolls: the cap-evicted
143 /// oldest line is parked here and reused as the next scroll's blank bottom,
144 /// so a steady-state flood allocates nothing (ADR-0009).
145 recycled_row: Option<Row>,
146 /// Per-line damage bounds since the last `reset_damage` (ack), one per row.
147 line_damage: Vec<LineBounds>,
148 /// A first-class scroll recorded since the last `reset_damage`.
149 scroll: Option<ScrollOp>,
150 /// The whole screen changed (alt switch / clear / later resize+flood) — the
151 /// renderer must redraw everything.
152 full_damage: bool,
153 /// The cursor `(row, col)` at the last `reset_damage` (ack) — where the
154 /// consumer last saw the caret. A pure cursor move records no content
155 /// damage, so `damage()` folds this *old* cell plus the current one into the
156 /// frame; without it a cell-invert caret ghosts at the old spot (mirrors
157 /// Alacritty's `last_cursor`). #38.
158 prev_cursor: (usize, usize),
159 /// The live selection, in absolute buffer coordinates. `None` when nothing
160 /// is selected. See `selection.rs`.
161 selection: Option<Selection>,
162 /// The search highlights the consumer asked to paint (#108). Search
163 /// matches are consumer-owned (it drives next/prev), so the engine holds only
164 /// the set handed back via `set_search_highlights`, and `frame()` projects it
165 /// onto the viewport — the same anchoring path as the selection.
166 search_highlights: Vec<Match>,
167 /// Which member of `search_highlights` is the *active* (current) match
168 /// (#428) — an index designated by the consumer (next/prev is its policy).
169 /// An index keeps "active ⊆ highlights" structural: invalidating the set
170 /// leaves nothing to project, and `set_search_highlights` resets this to
171 /// `None` so a stale index can never light wrong content in a new set.
172 active_search_highlight: Option<usize>,
173 /// Engine-owned decoration markers (#118), split per buffer like xterm's
174 /// `BufferSet` (#177 S0): each a stable id bound to an absolute buffer line
175 /// that re-anchors through eviction/scroll/reflow like a selection anchor. The
176 /// active buffer's list is selected by `on_alt` — `markers`/`markers_mut`.
177 /// `alt_markers` stays empty while the alt guards (#158/#164) are in place; it
178 /// is disposed on alt-leave (xterm `clearAllMarkers`). `next_marker_id` hands
179 /// out monotonic ids across both buffers so ids never alias.
180 normal_markers: Vec<Marker>,
181 alt_markers: Vec<Marker>,
182 next_marker_id: u32,
183 /// Cursor state saved by DECSC (ESC 7), restored by DECRC (ESC 8). A slot
184 /// separate from `saved_cursor` (which is the alt-screen save). Defaults to
185 /// home/default so a DECRC with no prior DECSC restores a sane state.
186 decsc: SavedCursor,
187 /// SCS-designated character sets G0..G3 (#62). `gl` indexes the active (GL)
188 /// set, switched by SI (→G0) / SO (→G1). First cut uses G0/G1.
189 charsets: [Charset; 4],
190 gl: usize,
191}
192
193/// A character set designated by SCS (#62). First cut: ASCII (default), DEC
194/// Special Graphics (line-drawing), and UK. G2/G3 and the GR half are later.
195#[derive(Clone, Copy, PartialEq, Eq, Default)]
196enum Charset {
197 #[default]
198 Ascii,
199 DecSpecialGraphics,
200 Uk,
201}
202
203impl Charset {
204 /// Map one GL byte (a `char` in the 7-bit range) through this set. ASCII and
205 /// any out-of-range char pass through; UK swaps `#`→£; DEC Special Graphics
206 /// translates `_`..`~` to the line-drawing / symbol glyphs.
207 fn map(self, c: char) -> char {
208 match self {
209 Charset::Ascii => c,
210 Charset::Uk if c == '#' => '£',
211 Charset::Uk => c,
212 Charset::DecSpecialGraphics => dec_special_graphics(c),
213 }
214 }
215}
216
217/// The VT100 DEC Special Graphics set: bytes `_`..`~` (0x5F..0x7E) map to the
218/// box-drawing and symbol glyphs. Matches xterm/alacritty; anything outside the
219/// range passes through unchanged.
220fn dec_special_graphics(c: char) -> char {
221 // Keys ``..`~` only — `_` (0x5F) is deliberately absent, matching xterm.js /
222 // alacritty (it passes through as a literal underscore), not the strict-DEC
223 // "0x5F = blank" reading.
224 match c {
225 '`' => '◆',
226 'a' => '▒',
227 'b' => '␉',
228 'c' => '␌',
229 'd' => '␍',
230 'e' => '␊',
231 'f' => '°',
232 'g' => '±',
233 'h' => '',
234 'i' => '␋',
235 'j' => '┘',
236 'k' => '┐',
237 'l' => '┌',
238 'm' => '└',
239 'n' => '┼',
240 'o' => '⎺',
241 'p' => '⎻',
242 'q' => '─',
243 'r' => '⎼',
244 's' => '⎽',
245 't' => '├',
246 'u' => '┤',
247 'v' => '┴',
248 'w' => '┬',
249 'x' => '│',
250 'y' => '≤',
251 'z' => '≥',
252 '{' => 'π',
253 '|' => '≠',
254 '}' => '£',
255 '~' => '·',
256 other => other,
257 }
258}
259
260/// Default scrollback retention when not specified.
261const DEFAULT_SCROLLBACK: usize = 10_000;
262
263/// The state DECSC (ESC 7) saves and DECRC (ESC 8) restores: position, pen/SGR,
264/// pending-wrap, and origin mode (per ADR-0004 — DECRC restores origin mode,
265/// which Alacritty omits). Cursor *visibility* is deliberately not part of this
266/// (DECTCEM is separate from DECSC).
267#[derive(Clone, Copy, Default)]
268struct SavedCursor {
269 row: usize,
270 col: usize,
271 pen: Pen,
272 pending_wrap: bool,
273 origin_mode: bool,
274 /// SCS charset state at save time — DECSC/DECRC round-trip the designated
275 /// sets and the active GL shift (#62).
276 charsets: [Charset; 4],
277 gl: usize,
278}
279
280/// An engine-owned decoration marker (#118): a stable id bound to an absolute
281/// buffer line. The line shifts in lockstep with eviction/region scroll/reflow
282/// (the same coordinate moves the selection anchor tracks); the marker is
283/// dropped when its line leaves the buffer.
284struct Marker {
285 id: MarkerId,
286 line: usize,
287 /// The cursor column at emit time (#166). Meaningful for OSC-133 command
288 /// marks — CommandStart(B)/OutputStart(C) columns bound the *typed command*
289 /// (excluding the prompt), like VSCode's `commandStartX`/`commandExecutedX`.
290 /// Plain `add_marker` decorations are row-granular and carry `col = 0`.
291 col: usize,
292 /// Plain for a `add_marker` decoration; a command-boundary role for an
293 /// OSC 133 mark (#158). All kinds share the anchor/eviction machinery.
294 kind: MarkerKind,
295}
296
297/// One executed shell command recovered from OSC-133 marks (#166), for
298/// screen-reader command navigation. The consumer jumps prompt-to-prompt over
299/// these and announces `command` + a success/fail signal from `exit`.
300#[derive(Debug, Clone, PartialEq, Eq)]
301pub struct CommandLine {
302 /// The command's jump anchor as a *document* line — the logical-line index of
303 /// the CommandStart(B) mark within [`Term::accessible_text`], so the consumer
304 /// reveals the right row of the accessible view (soft-wrapped rows collapse to
305 /// one logical line). This is core's analog of VSCode's
306 /// `bufferToEditorLineMapping`; the frame-mode web side has no wrap info to
307 /// map it itself.
308 pub line: usize,
309 /// The typed command text, prompt- and output-excluded (B→C columns).
310 pub command: String,
311 /// The CommandFinished(D) exit code, if the shell reported one and the
312 /// command has finished.
313 pub exit: Option<i32>,
314}
315
316/// A selection resolved to absolute-coordinate bounds, ready for text extraction
317/// or viewport-span projection. Columns are half-open (`from..to`).
318enum Resolved {
319 /// Char/Word/Line: a run that joins soft-wrapped rows. Columns apply to the
320 /// first/last line; middle lines are whole.
321 Linear {
322 start_line: usize,
323 from: usize,
324 end_line: usize,
325 to: usize,
326 },
327 /// Block: a rectangle — the same `from..to` columns on every row.
328 Block {
329 line0: usize,
330 line1: usize,
331 from: usize,
332 to: usize,
333 },
334}
335
336/// Collect per-line damage bounds into damaged `LineDamage` spans (undamaged
337/// lines dropped). Shared by `damage` (content-only) and `frame_damage`
338/// (content + cursor cells).
339fn bounds_to_lines(bounds: &[LineBounds]) -> Vec<LineDamage> {
340 bounds
341 .iter()
342 .enumerate()
343 .filter(|(_, b)| b.is_damaged())
344 .map(|(line, b)| {
345 let (left, right) = b.span();
346 LineDamage { line, left, right }
347 })
348 .collect()
349}
350
351impl Term {
352 pub fn new(cols: usize, rows: usize) -> Self {
353 Self::with_scrollback(cols, rows, DEFAULT_SCROLLBACK)
354 }
355
356 pub fn with_scrollback(cols: usize, rows: usize, scrollback_limit: usize) -> Self {
357 Term {
358 grid: Grid::new(cols, rows),
359 alt_grid: Grid::new(cols, rows),
360 cursor: Cursor::default(),
361 saved_cursor: Cursor::default(),
362 on_alt: false,
363 origin_mode: false,
364 autowrap: true,
365 insert_mode: false,
366 newline_mode: false,
367 reverse_wraparound: false,
368 bracketed_paste: false,
369 synchronized_output: false,
370 color_scheme_updates: false,
371 grapheme_clustering: false,
372 win32_input_mode: false,
373 app_cursor_keys: false,
374 application_keypad: false,
375 vt52_mode: false,
376 vt52_y_pending: 0,
377 vt52_y_row: 0,
378 mouse_protocol: MouseProtocol::Off,
379 mouse_encoding: MouseEncoding::Default,
380 focus_events: false,
381 kitty_flags: 0,
382 kitty_stack: Vec::new(),
383 events: Vec::new(),
384 replies: Vec::new(),
385 hyperlink_pool: Vec::new(),
386 current_link: None,
387 tabs: default_tabs(cols),
388 scroll_top: 0,
389 scroll_bottom: rows - 1,
390 scrollback: VecDeque::new(),
391 display_offset: 0,
392 scrollback_limit,
393 recycled_row: None,
394 line_damage: vec![LineBounds::undamaged(cols); rows],
395 scroll: None,
396 full_damage: false,
397 prev_cursor: (0, 0), // matches the default cursor's home position
398 selection: None,
399 search_highlights: Vec::new(),
400 active_search_highlight: None,
401 normal_markers: Vec::new(),
402 alt_markers: Vec::new(),
403 next_marker_id: 0,
404 decsc: SavedCursor::default(),
405 charsets: [Charset::Ascii; 4],
406 gl: 0,
407 }
408 }
409
410 /// What changed since the last `reset_damage()` — line ranges, each with a
411 /// changed column span. See ADR-0003.
412 pub fn damage(&self) -> TermDamage {
413 if self.full_damage {
414 return TermDamage::Full;
415 }
416 // Scrolled up under follow-bottom "stay": the viewport is frozen, so
417 // screen changes below it are not visible — report nothing. (A user
418 // scroll that moves the viewport sets full_damage above.)
419 if self.display_offset > 0 {
420 return TermDamage::Partial(Vec::new());
421 }
422 TermDamage::Partial(bounds_to_lines(&self.line_damage))
423 }
424
425 /// Render damage: content damage plus the cursor cells, for [`Term::frame`].
426 ///
427 /// A pure cursor move changes no cell *content*, so [`Term::damage`] (which
428 /// stays content-only, the cadence/flow-control primitive) would miss it —
429 /// yet a cell-invert caret must clear its old spot and ink the new one. So
430 /// the frame producer folds the old (last-acked) + current cursor cells in,
431 /// but only when the cursor actually moved: a still cursor needs no redraw,
432 /// keeping an idle frame empty. Mirrors Alacritty's `last_cursor`. #38.
433 fn frame_damage(&self) -> TermDamage {
434 if self.full_damage {
435 return TermDamage::Full;
436 }
437 if self.display_offset > 0 {
438 return TermDamage::Partial(Vec::new());
439 }
440 let cur = self.cursor.point();
441 if cur == self.prev_cursor {
442 return TermDamage::Partial(bounds_to_lines(&self.line_damage));
443 }
444 let mut bounds = self.line_damage.clone();
445 bounds[cur.0].expand(cur.1, cur.1);
446 let pr = self.prev_cursor.0.min(self.grid.rows() - 1);
447 let pc = self.prev_cursor.1.min(self.grid.cols() - 1);
448 bounds[pr].expand(pc, pc);
449 TermDamage::Partial(bounds_to_lines(&bounds))
450 }
451
452 /// Clear accumulated damage. The consumer calls this after applying a frame
453 /// (the ack); the next `damage()` reflects only changes since.
454 pub fn reset_damage(&mut self) {
455 for b in &mut self.line_damage {
456 b.reset();
457 }
458 self.scroll = None;
459 self.full_damage = false;
460 // The consumer has now seen the caret at the current position; the next
461 // frame's cursor-move damage is measured from here (#38).
462 self.prev_cursor = self.cursor.point();
463 }
464
465 /// Mark the whole screen damaged (alt switch / clear / flood, and a consumer
466 /// reattach that needs a full re-sync — see [`crate::Engine::mark_fully_damaged`]).
467 pub fn mark_fully_damaged(&mut self) {
468 self.full_damage = true;
469 }
470
471 /// Record that columns `[left, right]` of `row` changed.
472 fn damage_span(&mut self, row: usize, left: usize, right: usize) {
473 self.line_damage[row].expand(left, right);
474 }
475
476 /// The first-class scroll recorded since the last `reset_damage`, if any.
477 /// Suppressed while scrolled up — a content scroll must not shift the frozen
478 /// viewport.
479 pub fn scroll_delta(&self) -> Option<ScrollOp> {
480 if self.display_offset > 0 {
481 return None;
482 }
483 self.scroll
484 }
485
486 /// Build a serializable [`Frame`] from the current damage + grid + grapheme
487 /// pool (#6). `Full` ships every row; `Partial` ships the damaged spans. The
488 /// global side-table is remapped to **frame-local** indices — the engine pool
489 /// is append-only and leaky, so a frame carries only the clusters its cells
490 /// reference, renumbered, with each cell's `extra` rewritten to the local id.
491 pub fn frame(&self) -> Frame {
492 let cols = self.grid.cols();
493 let rows = self.grid.rows();
494 let (kind, line_spans): (FrameKind, Vec<(usize, usize, usize)>) = match self.frame_damage()
495 {
496 TermDamage::Full => (
497 FrameKind::Full,
498 (0..rows).map(|l| (l, 0, cols - 1)).collect(),
499 ),
500 TermDamage::Partial(lines) => (
501 FrameKind::Partial,
502 lines
503 .into_iter()
504 .map(|d| (d.line, d.left, d.right))
505 .collect(),
506 ),
507 };
508
509 let mut side_table: Vec<Vec<char>> = Vec::new();
510 // Same frame-local renumber for the hyperlink side-table (#26).
511 let mut link_table: Vec<String> = Vec::new();
512 let mut link_remap = vec![0u16; self.hyperlink_pool.len() + 1];
513 // Cells come from the viewport at `display_offset`, not the live grid:
514 // viewport row `line` is absolute buffer line `top + line` (scrollback
515 // when scrolled up, the live grid when `display_offset == 0`, where
516 // `top == scrollback.len()` and this is identical to reading the grid).
517 // Without this, a wire consumer — cells reach it only through `frame()` —
518 // could never display scrollback (#48).
519 let top = self.scrollback.len() - self.display_offset;
520 let mut spans = Vec::with_capacity(line_spans.len());
521 for (line, left, right) in line_spans {
522 let mut cells = Vec::with_capacity(right - left + 1);
523 let mut combining = std::collections::BTreeMap::new();
524 let mut links = std::collections::BTreeMap::new();
525 let row = self.abs_row(top + line);
526 for col in left..=right {
527 let cell = row[col];
528 // Combining clusters and hyperlinks live in the row's maps; each
529 // tagged cell contributes its reference to the frame, recorded on
530 // the span by span-relative column (the cell holds only the bit).
531 if let Some(marks) = row.combining_at(col) {
532 side_table.push(marks.to_vec());
533 let idx = core::num::NonZeroU32::new(side_table.len() as u32)
534 .expect("side_table just pushed, len >= 1");
535 combining.insert(col - left, idx);
536 }
537 if let Some(lidx) = row.link_at(col) {
538 // Renumber the global pool index to a contiguous frame-local
539 // one (only referenced URIs ship), same as the old per-cell link.
540 let l = lidx.get() as usize;
541 if link_remap[l] == 0 {
542 link_table.push(self.hyperlink_pool[l - 1].clone());
543 link_remap[l] = link_table.len() as u16;
544 }
545 let fidx = core::num::NonZeroU32::new(link_remap[l] as u32)
546 .expect("link_remap just set, nonzero");
547 links.insert(col - left, fidx);
548 }
549 cells.push(cell);
550 }
551 spans.push(Span {
552 line: line as u16,
553 left: left as u16,
554 right: right as u16,
555 cells,
556 combining,
557 links,
558 });
559 }
560
561 Frame {
562 cols: cols as u16,
563 rows: rows as u16,
564 kind,
565 // The live cursor: position in screen coords + DECTCEM visibility.
566 // Reported, not drawn — the consumer renders the caret (#38).
567 cursor_row: self.cursor.row as u16,
568 cursor_col: self.cursor.col as u16,
569 // Hidden while scrolled up: the live cursor is off the frozen
570 // viewport, and a cell-invert caret would otherwise ink over
571 // scrollback. Consistent with the frozen-damage policy (no cursor
572 // damage is emitted while scrolled) and with xterm.js / alacritty,
573 // which hide the caret when it falls outside the visible rows (#48).
574 cursor_visible: self.cursor.visible && self.display_offset == 0,
575 cursor_shape: self.cursor.shape,
576 cursor_blink: self.cursor.blink,
577 // Viewport scroll position for the consumer's scrollbar (ADR-0013).
578 display_offset: self.display_offset as u32,
579 scrollback_len: self.scrollback.len() as u32,
580 // The mouse tracking mode as a routing mask (#129): which mouse events
581 // the app wants, derived from the protocol by the single source
582 // `encode_mouse` shares. The consumer routes app-vs-local on it.
583 mouse_events: self.mouse_protocol.wanted_events(),
584 // Alt-screen flag (#149): buffer-global state the consumer can't
585 // derive from viewport damage; the a11y announce policy gates on it.
586 alt_screen: self.on_alt,
587 scroll: self.scroll_delta(),
588 spans,
589 side_table,
590 link_table,
591 // Interaction overlays projected onto this viewport (#108): the
592 // engine-owned selection and the consumer-supplied search highlights,
593 // each re-projected here so the scroll offset is applied once, by the
594 // same authority that projects the cells.
595 overlay: Overlay {
596 selection: self.selection_range(),
597 matches: self
598 .search_highlights
599 .iter()
600 .flat_map(|m| self.match_spans(m))
601 .collect(),
602 // The consumer-designated active match (#428), projected through
603 // the same `match_spans` math — also present in `matches` above;
604 // the renderer's ranking resolves the overlap (#424 slice 1).
605 active_match: self
606 .active_search_highlight
607 .and_then(|i| self.search_highlights.get(i))
608 .map(|m| self.match_spans(m))
609 .unwrap_or_default(),
610 markers: self.marker_positions(),
611 marker_lines: self.all_marker_lines(),
612 },
613 }
614 }
615
616 /// Record a scroll of rows `[top, bottom]` by `count` (positive = up).
617 ///
618 /// Damage is indexed by row position, so it must follow the content the
619 /// scroll just moved: rotate the bounds the same way and mark the newly
620 /// exposed line fully damaged (it is new blank content for the consumer).
621 fn record_scroll(&mut self, top: usize, bottom: usize, count: isize) {
622 let cols = self.grid.cols();
623 match count {
624 1 => {
625 self.line_damage[top..=bottom].rotate_left(1);
626 self.line_damage[bottom] = LineBounds::fully_damaged(cols);
627 }
628 -1 => {
629 self.line_damage[top..=bottom].rotate_right(1);
630 self.line_damage[top] = LineBounds::fully_damaged(cols);
631 }
632 _ => {}
633 }
634 // Accumulate repeated scrolls of the same region into one op (flow
635 // control). A *different* region cannot be expressed as one op, so
636 // degrade to full rather than silently dropping the earlier scroll.
637 match self.scroll {
638 Some(op) if op.top == top && op.bottom == bottom => {
639 self.scroll = Some(ScrollOp {
640 top,
641 bottom,
642 count: op.count + count,
643 });
644 }
645 None => self.scroll = Some(ScrollOp { top, bottom, count }),
646 Some(_) => {
647 self.scroll = None;
648 self.mark_fully_damaged();
649 }
650 }
651 }
652
653 /// Number of lines currently held in scrollback history.
654 pub fn scrollback_len(&self) -> usize {
655 self.scrollback.len()
656 }
657
658 /// Whether the app has an open synchronized-output block (DEC ?2026, #73).
659 pub fn synchronized_output(&self) -> bool {
660 self.synchronized_output
661 }
662
663 /// Whether the app enabled color-scheme-update notifications (DEC ?2031, #85).
664 pub fn color_scheme_updates(&self) -> bool {
665 self.color_scheme_updates
666 }
667
668 /// Whether the app enabled grapheme-cluster mode (DEC ?2027, #295): emoji ZWJ / skin-tone /
669 /// flag / VS16 sequences are clustered into one cell. OFF (default) is per-char, wcwidth-compat.
670 pub fn grapheme_clustering(&self) -> bool {
671 self.grapheme_clustering
672 }
673
674 /// Whether the app enabled win32-input-mode (DEC ?9001, #86). The engine does
675 /// not encode the raw key-records itself (a non-goal); a ConPTY consumer reads
676 /// this to decide whether to emit them.
677 pub fn win32_input_mode(&self) -> bool {
678 self.win32_input_mode
679 }
680
681 /// Queue a color-scheme report (`CSI ? 997 ; 1 n` dark / `; 2 n` light) on the
682 /// reply channel. The consumer calls this to answer a `ColorSchemeQuery` event
683 /// or, when its scheme changes and `color_scheme_updates()` is set, to send the
684 /// unsolicited notification. The engine never stores or interprets the scheme
685 /// (#85).
686 pub fn report_color_scheme(&mut self, dark: bool) {
687 let ps = if dark { 1 } else { 2 };
688 self.replies
689 .extend_from_slice(format!("\x1b[?997;{ps}n").as_bytes());
690 }
691
692 /// OSC 10/11 set/query the default fg/bg, stacking the `;`-separated specs
693 /// across the `[foreground, background]` slots — xterm's
694 /// `_setOrReportSpecialColor` offset loop (#137). OSC 10 starts at slot 0
695 /// (fg → bg), OSC 11 at slot 1 (bg). A `?` spec is a query. xterm's 3rd slot
696 /// (cursor / OSC 12) is out of scope, so the stack caps at two slots — extra
697 /// specs are dropped.
698 fn special_color(&mut self, params: &[&[u8]], start: usize) {
699 for (i, &spec) in params[1..].iter().enumerate() {
700 let event = match start + i {
701 0 if spec == b"?" => TermEvent::QueryForeground,
702 0 => TermEvent::SetForeground(String::from_utf8_lossy(spec).into_owned()),
703 1 if spec == b"?" => TermEvent::QueryBackground,
704 1 => TermEvent::SetBackground(String::from_utf8_lossy(spec).into_owned()),
705 _ => break, // past [fg, bg] — cursor (OSC 12) unsupported
706 };
707 self.events.push(event);
708 }
709 }
710
711 /// Answer an OSC 4 palette query (#122): wrap the consumer-supplied spec for
712 /// `index` in the OSC 4 reply envelope, ST-terminated.
713 pub fn report_palette_color(&mut self, index: u8, spec: &str) {
714 self.replies
715 .extend_from_slice(format!("\x1b]4;{index};{spec}\x1b\\").as_bytes());
716 }
717
718 /// Answer an OSC 10 foreground query (#122): wrap the consumer-supplied spec
719 /// in the OSC 10 reply envelope, ST-terminated.
720 pub fn report_foreground(&mut self, spec: &str) {
721 self.replies
722 .extend_from_slice(format!("\x1b]10;{spec}\x1b\\").as_bytes());
723 }
724
725 /// Answer an OSC 11 background query (#122): wrap the consumer-supplied spec
726 /// (it knows its palette) in the OSC 11 reply envelope, ST-terminated. The
727 /// engine formats the envelope only — it never knows the colour.
728 pub fn report_background(&mut self, spec: &str) {
729 self.replies
730 .extend_from_slice(format!("\x1b]11;{spec}\x1b\\").as_bytes());
731 }
732
733 /// The cells of visible row `i` (0..rows) at the current scroll position.
734 /// The viewport windows into `[history.. ; screen..]`: rows above
735 /// `scrollback.len()` come from history, the rest from the live screen.
736 pub fn viewport_line(&self, i: usize) -> &[Cell] {
737 let top = self.scrollback.len() - self.display_offset;
738 let idx = top + i;
739 if idx < self.scrollback.len() {
740 &self.scrollback[idx]
741 } else {
742 self.grid.row(idx - self.scrollback.len())
743 }
744 }
745
746 /// Scroll the viewport up by `n` lines into history (clamped to the oldest).
747 pub fn scroll_up(&mut self, n: usize) {
748 let target = (self.display_offset + n).min(self.scrollback.len());
749 self.set_display_offset(target);
750 }
751
752 /// Scroll the viewport down by `n` lines toward the live screen.
753 pub fn scroll_down(&mut self, n: usize) {
754 let target = self.display_offset.saturating_sub(n);
755 self.set_display_offset(target);
756 }
757
758 /// Jump the viewport back to the live screen (follow the bottom).
759 pub fn scroll_to_bottom(&mut self) {
760 self.set_display_offset(0);
761 }
762
763 /// Move the viewport. A user scroll changes which lines are visible, so the
764 /// whole viewport is repainted (full damage) when the offset actually moves.
765 fn set_display_offset(&mut self, offset: usize) {
766 // The alt screen has no scrollback to view; scroll intents are no-ops.
767 if self.on_alt {
768 return;
769 }
770 if offset != self.display_offset {
771 self.display_offset = offset;
772 self.mark_fully_damaged();
773 }
774 }
775
776 // ---- selection -----------------------------------------------------------
777
778 /// Map a viewport cell `(row, col)` to an absolute buffer point. The top
779 /// visible row is `scrollback.len() - display_offset`, so viewport row `i`
780 /// is that plus `i`.
781 fn viewport_to_abs(&self, row: usize, col: usize) -> BufferPoint {
782 let top = self.scrollback.len() - self.display_offset;
783 BufferPoint {
784 line: top + row,
785 col,
786 }
787 }
788
789 /// The primary-screen grid, wherever it currently lives — swapped into
790 /// `alt_grid` while on the alt screen (#192). Command marks anchor *primary*
791 /// content, so extracting their text must read this, not the active grid.
792 fn primary_grid(&self) -> &Grid {
793 if self.on_alt {
794 &self.alt_grid
795 } else {
796 &self.grid
797 }
798 }
799
800 /// The cells of absolute buffer line `line`, reading the screen portion from
801 /// `grid` (scrollback is shared). Callers pick the active grid (`abs_line`) or
802 /// the primary grid (`primary_grid`, for command-mark text on the alt screen).
803 fn line_in<'a>(&'a self, grid: &'a Grid, line: usize) -> &'a [Cell] {
804 if line < self.scrollback.len() {
805 &self.scrollback[line]
806 } else {
807 grid.row(line - self.scrollback.len())
808 }
809 }
810
811 /// The whole row of absolute buffer line `line` from `grid` (see `line_in`).
812 fn row_in<'a>(&'a self, grid: &'a Grid, line: usize) -> &'a Row {
813 if line < self.scrollback.len() {
814 &self.scrollback[line]
815 } else {
816 grid.row_ref(line - self.scrollback.len())
817 }
818 }
819
820 /// The cells of absolute buffer line `line` on the *active* screen.
821 fn abs_line(&self, line: usize) -> &[Cell] {
822 self.line_in(&self.grid, line)
823 }
824
825 /// The whole row of absolute buffer line `line` on the *active* screen.
826 fn abs_row(&self, line: usize) -> &Row {
827 self.row_in(&self.grid, line)
828 }
829
830 /// The combining marks at absolute `(line, col)` reading `grid`, or `None` —
831 /// flag-gated through the row's map, so a stale entry is never surfaced.
832 fn combining_in<'a>(&'a self, grid: &'a Grid, line: usize, col: usize) -> Option<&'a [char]> {
833 self.row_in(grid, line).combining_at(col)
834 }
835
836 /// The combining marks at absolute `(line, col)` on the *active* screen.
837 fn combining_at(&self, line: usize, col: usize) -> Option<&[char]> {
838 self.combining_in(&self.grid, line, col)
839 }
840
841 /// The hyperlink-pool index at **screen** `(row, col)` (the live grid), or
842 /// `None` — flag-gated through the row's link map. Resolve to the URI with
843 /// [`Term::hyperlink`]. Mirrors `grid().cell(row, col)`.
844 pub(crate) fn screen_link_at(&self, row: usize, col: usize) -> Option<core::num::NonZeroU32> {
845 self.grid.row_ref(row).link_at(col)
846 }
847
848 /// The hyperlink-pool index at **viewport** `(row, col)` (visible window,
849 /// history included at the current scroll), or `None`. Mirrors
850 /// `viewport_line(row)`.
851 pub(crate) fn viewport_link_at(&self, row: usize, col: usize) -> Option<core::num::NonZeroU32> {
852 let idx = self.scrollback.len() - self.display_offset + row;
853 self.abs_row(idx).link_at(col)
854 }
855
856 /// The viewport's logical lines (#113/ADR-0017): each line's text plus a
857 /// per-char map to its viewport `(row, col)`. Wide-char spacers are skipped
858 /// and trailing blanks trimmed (so the text is 1:1 with `cells`). Empty rows
859 /// are dropped. The cell-aware assembly the consumer can't do in frame mode.
860 pub fn viewport_logical_lines(&self) -> Vec<LogicalLine> {
861 let rows = self.grid.rows();
862 let total = self.scrollback.len() + rows;
863 let top = self.scrollback.len() - self.display_offset; // abs line of viewport row 0
864 let bottom = top + rows; // abs lines [top, bottom) are on screen
865
866 // If viewport row 0 is a wrap-continuation, walk up into scrollback to
867 // the logical line's true start so an edge-spanning URL still matches.
868 // On the alt screen the scrollback belongs to the *primary* buffer, so
869 // the walk must stop at the screen top (`scrollback.len()`) — the alt
870 // buffer is separate (selection clears on alt-swap for the same reason).
871 let floor = if self.on_alt {
872 self.scrollback.len()
873 } else {
874 0
875 };
876 let mut start = top;
877 while start > floor
878 && self
879 .abs_line(start - 1)
880 .last()
881 .is_some_and(|c| c.is_wrapline())
882 {
883 start -= 1;
884 }
885
886 let mut out = Vec::new();
887 let mut line = start;
888 while line < bottom {
889 // Accumulate one logical line forward while each row soft-wraps; the
890 // tail may run past `bottom` (off-screen below) — included too.
891 let mut text = String::new();
892 let mut map: Vec<(i32, usize)> = Vec::new();
893 let mut cur = line;
894 loop {
895 let cells = self.abs_line(cur);
896 for (col, cell) in cells.iter().enumerate() {
897 if cell.is_spacer() {
898 continue;
899 }
900 // Signed viewport row: < 0 above the top, >= rows below.
901 let vrow = cur as i32 - top as i32;
902 text.push(cell.c());
903 map.push((vrow, col));
904 // Combining marks (#45) ride the same cell — append each and
905 // map it to that cell so `text` stays 1:1 with `cells`.
906 if let Some(marks) = self.combining_at(cur, col) {
907 for &m in marks {
908 text.push(m);
909 map.push((vrow, col));
910 }
911 }
912 }
913 let soft = cells.last().is_some_and(|c| c.is_wrapline());
914 if soft && cur + 1 < total {
915 cur += 1;
916 } else {
917 break;
918 }
919 }
920 // Trim trailing blanks (only the last row can have them), keeping
921 // `text` and `cells` in lockstep.
922 let trimmed = text.trim_end();
923 map.truncate(trimmed.chars().count());
924 text.truncate(trimmed.len());
925 if !text.is_empty() {
926 out.push(LogicalLine { text, cells: map });
927 }
928 line = cur + 1;
929 }
930 out
931 }
932
933 /// Literal search over the whole buffer (`[scrollback ++ screen]`), returning
934 /// every non-overlapping match top-to-bottom in absolute coordinates. Matches
935 /// cross soft-wrapped rows (one logical line) and skip wide-char spacers.
936 /// Smart-case: a query with no uppercase matches case-insensitively.
937 pub fn search(&self, query: &str) -> Vec<Match> {
938 self.search_with(query, SearchOptions::default())
939 }
940
941 /// Search with explicit [`SearchOptions`] — regex, whole-word, and a case-sensitivity override
942 /// on top of the literal + smart-case [`search`](Self::search) (#314). Same coordinates,
943 /// soft-wrap join, spacer skip, and grapheme-mark inclusion (#304) as `search`.
944 pub fn search_with(&self, query: &str, opts: SearchOptions) -> Vec<Match> {
945 let q: Vec<char> = query.chars().collect();
946 if q.is_empty() {
947 return Vec::new();
948 }
949 // Smart-case unless overridden: case-insensitive iff the query has no uppercase.
950 let ci = opts
951 .case_sensitive
952 .map_or_else(|| !q.iter().any(|c| c.is_uppercase()), |cs| !cs);
953 // Fold to a single representative char so the haystack stays 1:1 with its
954 // positions (rare multi-char case expansions take their first char).
955 let fold = |c: char| {
956 if ci {
957 c.to_lowercase().next().unwrap_or(c)
958 } else {
959 c
960 }
961 };
962 let needle: Vec<char> = q.iter().map(|&c| fold(c)).collect();
963 // Regex mode: build the pattern once (case-insensitivity from the same smart-case/override
964 // decision). An invalid pattern yields no matches rather than erroring (#314).
965 let re = if opts.regex {
966 match regex::RegexBuilder::new(query).case_insensitive(ci).build() {
967 Ok(re) => Some(re),
968 Err(_) => return Vec::new(),
969 }
970 } else {
971 None
972 };
973 let total = self.scrollback.len() + self.grid.rows();
974
975 // On the alt screen the scrollback belongs to the *primary* buffer, so the
976 // walk must start at the screen top (`scrollback.len()`): primary matches are
977 // unreachable on alt, and a primary WRAPLINE row would otherwise soft-wrap-join
978 // into the alt grid and corrupt the haystack at the boundary. Mirrors the
979 // `viewport_logical_lines` floor (#113) — the alt buffer is separate (selection
980 // clears on alt-swap for the same reason). (#144)
981 let floor = if self.on_alt {
982 self.scrollback.len()
983 } else {
984 0
985 };
986 let mut matches = Vec::new();
987 let mut r = floor;
988 while r < total {
989 // Build the logical line at `r`: join soft-wrapped rows, recording
990 // each char's source position and skipping wide-char spacers.
991 let mut hay: Vec<char> = Vec::new();
992 let mut pos: Vec<(usize, usize)> = Vec::new();
993 let mut line = r;
994 loop {
995 let cells = self.abs_line(line);
996 for (col, cell) in cells.iter().enumerate() {
997 if cell.is_spacer() {
998 continue;
999 }
1000 // Build the haystack UNFOLDED (regex needs the original text; its own
1001 // case-insensitive flag handles case). The literal path folds at compare time.
1002 hay.push(cell.c());
1003 pos.push((line, col));
1004 // Include the cell's grapheme side-table marks — combining marks, and under
1005 // mode 2027 the joined emoji scalars (2nd RI, ZWJ-joined emoji, skin tone) —
1006 // so a clustered scalar is findable, not just the base (#304). Each maps to the
1007 // same cell column, mirroring `append_cell`'s base+marks extraction.
1008 if let Some(marks) = self.combining_at(line, col) {
1009 for &m in marks {
1010 hay.push(m);
1011 pos.push((line, col));
1012 }
1013 }
1014 }
1015 let soft = cells.last().is_some_and(|c| c.is_wrapline());
1016 if soft && line + 1 < total {
1017 line += 1;
1018 } else {
1019 break;
1020 }
1021 }
1022 // Trim trailing blank padding (only a logical line's tail can be blank), so a regex `$`
1023 // anchor or a greedy `.*` doesn't run into the grid's blank cells — mirrors
1024 // `viewport_logical_lines`'s trim (#314 Lens 1). Keeps hay/pos in lockstep.
1025 while hay.last().is_some_and(|c| c.is_whitespace()) {
1026 hay.pop();
1027 pos.pop();
1028 }
1029
1030 // A match at char-index range [cs, ce) → a Match, whole-word-filtered and deduped.
1031 // (Marks map many hay entries to one column (#304), so a repeated in-cluster scalar can
1032 // yield consecutive identical Matches — collapse them.)
1033 let push_range = |cs: usize, ce: usize, matches: &mut Vec<Match>| {
1034 if opts.whole_word && !word_bounded(&hay, cs, ce - cs) {
1035 return;
1036 }
1037 let m = Match {
1038 start_line: pos[cs].0,
1039 start_col: pos[cs].1,
1040 end_line: pos[ce - 1].0,
1041 end_col: pos[ce - 1].1,
1042 };
1043 if matches.last() != Some(&m) {
1044 matches.push(m);
1045 }
1046 };
1047
1048 if let Some(re) = &re {
1049 // Regex over the (unfolded) logical line; map each match's byte range to char indices.
1050 let hay_str: String = hay.iter().collect();
1051 for mat in re.find_iter(&hay_str) {
1052 if mat.start() == mat.end() {
1053 continue; // skip empty matches (e.g. `a*` between chars)
1054 }
1055 let cs = hay_str[..mat.start()].chars().count();
1056 let ce = hay_str[..mat.end()].chars().count();
1057 push_range(cs, ce, &mut matches);
1058 }
1059 } else {
1060 // Slide the literal needle non-overlapping, folding each hay char at compare time.
1061 let mut i = 0;
1062 while needle.len() <= hay.len() && i + needle.len() <= hay.len() {
1063 let hit = hay[i..i + needle.len()]
1064 .iter()
1065 .enumerate()
1066 .all(|(k, &c)| fold(c) == needle[k]);
1067 if hit {
1068 let before = matches.len();
1069 push_range(i, i + needle.len(), &mut matches);
1070 // Advance past a real (accepted) match; a whole-word-rejected run advances by
1071 // one so a later, word-bounded position at an overlapping offset is still tried.
1072 i += if matches.len() > before {
1073 needle.len()
1074 } else {
1075 1
1076 };
1077 } else {
1078 i += 1;
1079 }
1080 }
1081 }
1082 r = line + 1;
1083 }
1084 matches
1085 }
1086
1087 /// Scroll the viewport so a match's start line is visible (placed at the top
1088 /// when it sits in history; the live view when it is already on screen).
1089 pub fn search_scroll_to(&mut self, m: &Match) {
1090 let target = self.scrollback.len().saturating_sub(m.start_line);
1091 self.set_display_offset(target);
1092 }
1093
1094 /// Project a match onto the current viewport as inclusive-column spans, one
1095 /// per visible row (off-screen parts dropped) — for the renderer to
1096 /// highlight, like `selection_range`.
1097 pub fn match_spans(&self, m: &Match) -> Vec<SelectionSpan> {
1098 let rows = self.grid.rows();
1099 let top = self.scrollback.len() - self.display_offset;
1100 let mut spans = Vec::new();
1101 for line in m.start_line..=m.end_line {
1102 if line < top {
1103 continue;
1104 }
1105 let row = line - top;
1106 if row >= rows {
1107 break;
1108 }
1109 let last = self.abs_line(line).len().saturating_sub(1);
1110 let left = if line == m.start_line { m.start_col } else { 0 };
1111 let right = if line == m.end_line {
1112 m.end_col.min(last)
1113 } else {
1114 last
1115 };
1116 if right >= left {
1117 spans.push(SelectionSpan { row, left, right });
1118 }
1119 }
1120 spans
1121 }
1122
1123 /// Set the search highlights to paint (#108). The consumer owns the
1124 /// `Vec<Match>` (it drives next/prev); handing it back here lets `frame()`
1125 /// project the highlights onto the viewport. An empty vec clears them.
1126 pub fn set_search_highlights(&mut self, matches: Vec<Match>) {
1127 self.search_highlights = matches;
1128 // A new set voids the designation: a stale index could be accidentally
1129 // in range and light wrong content (#428). The consumer re-designates.
1130 self.active_search_highlight = None;
1131 }
1132
1133 /// Designate which member of the held highlight set is the *active* match
1134 /// (#428) — the one the consumer's next/prev navigation currently points at.
1135 /// `frame()` projects it into `overlay.active_match` (it also stays in
1136 /// `overlay.matches`; the renderer's ranking resolves the overlap, #424).
1137 /// `None` or an out-of-range index projects nothing; the designation resets
1138 /// whenever a new set is passed to [`set_search_highlights`](Self::set_search_highlights).
1139 pub fn set_active_search_highlight(&mut self, index: Option<usize>) {
1140 self.active_search_highlight = index;
1141 }
1142
1143 /// Invalidate the held search highlights (#108). Called wherever a buffer
1144 /// mutation shifts the absolute coordinates the matches were found at — cap
1145 /// eviction, in-screen region/RI scroll, reflow. Search matches are
1146 /// query-derived (the engine holds matches, not the query, and the *set*
1147 /// itself may have changed), so unlike the user-authored selection they are
1148 /// dropped rather than re-anchored; the consumer re-searches on output
1149 /// (mirroring xterm/alacritty). Clearing avoids painting wrong content for
1150 /// the frame between the mutation and the consumer's refresh.
1151 fn invalidate_search_highlights(&mut self) {
1152 self.search_highlights.clear();
1153 }
1154
1155 /// Register a decoration marker at viewport `row`, returning its stable id
1156 /// (#118). The row is resolved to an absolute buffer line (like a selection
1157 /// anchor), so the marker tracks that content through scroll/eviction/reflow.
1158 /// The active buffer's marker list (#177 S0) — alt while on the alt screen,
1159 /// else normal. Add/rotate/project operate on this; primary-scoped queries
1160 /// (`command_marks`/`command_lines`) and scrollback eviction read
1161 /// `normal_markers` directly.
1162 fn markers(&self) -> &Vec<Marker> {
1163 if self.on_alt {
1164 &self.alt_markers
1165 } else {
1166 &self.normal_markers
1167 }
1168 }
1169
1170 /// Mutable [`Self::markers`].
1171 fn markers_mut(&mut self) -> &mut Vec<Marker> {
1172 if self.on_alt {
1173 &mut self.alt_markers
1174 } else {
1175 &mut self.normal_markers
1176 }
1177 }
1178
1179 pub fn add_marker(&mut self, row: usize) -> MarkerId {
1180 // On the alt screen this anchors an *alt-scoped* marker (#187): per-buffer
1181 // storage (#186) keeps it out of the primary list, and it is disposed on
1182 // alt-leave — xterm's per-buffer `addMarker` + `clearAllMarkers`. No dead
1183 // sentinel is needed anymore; `markers_mut` routes to the active buffer.
1184 let line = self.viewport_to_abs(row, 0).line;
1185 self.push_marker(line, 0, MarkerKind::Plain)
1186 }
1187
1188 /// Push a marker anchored at absolute `(line, col)` with `kind`, returning its
1189 /// id. The shared core of `add_marker` (viewport row, `col = 0`) and OSC-133
1190 /// command marks (cursor line + column) — one place owns id allocation + the
1191 /// `markers` list.
1192 fn push_marker(&mut self, line: usize, col: usize, kind: MarkerKind) -> MarkerId {
1193 let id = MarkerId(self.next_marker_id);
1194 self.next_marker_id += 1;
1195 self.markers_mut().push(Marker {
1196 id,
1197 line,
1198 col,
1199 kind,
1200 });
1201 id
1202 }
1203
1204 /// Record an OSC 133 command-boundary mark at the cursor's current line
1205 /// (#158). Ignored on the alt screen: unlike the decoration guards that
1206 /// per-buffer storage retired (#187), this one stands on a *semantic* — OSC
1207 /// 133 is shell integration, which only runs on the primary screen, so an alt
1208 /// 133 is meaningless (there is no command to bound). Command nav/announce read
1209 /// the *normal* buffer's marks (`command_marks`/`command_lines`, primary-scoped
1210 /// since #186), so even a stray alt 133 could not reach them — but there is no
1211 /// value in creating an alt-scoped command mark nothing consumes (#188). The
1212 /// cursor line is `scrollback ++ screen`-absolute, independent of
1213 /// `display_offset` (the cursor is always in the grid, never scrollback).
1214 fn add_command_mark(&mut self, kind: MarkerKind) {
1215 if self.on_alt {
1216 return;
1217 }
1218 let line = self.scrollback.len() + self.cursor.row;
1219 self.push_marker(line, self.cursor.col, kind);
1220 }
1221
1222 /// The OSC 133 command-boundary marks in buffer order — `(id, absolute line,
1223 /// kind)` (#158). Plain decoration markers (#118) are excluded. The consumer
1224 /// pairs prompt/command/finished marks and drives navigation/announce policy
1225 /// (#160); core only parses and anchors them.
1226 pub fn command_marks(&self) -> Vec<(MarkerId, usize, MarkerKind)> {
1227 // Primary-scoped: OSC-133 shell integration marks live on the normal
1228 // buffer, so command nav/announce read it even while on the alt screen.
1229 self.normal_markers
1230 .iter()
1231 .filter(|m| m.kind != MarkerKind::Plain)
1232 .map(|m| (m.id, m.line, m.kind))
1233 .collect()
1234 }
1235
1236 /// The executed shell commands recovered from OSC-133 marks, in buffer order
1237 /// (#166) — the data behind screen-reader command navigation. Each
1238 /// [`CommandLine`] pairs a CommandStart(B) with the following OutputStart(C)
1239 /// to extract the *typed command* (the prompt before B and the output after C
1240 /// excluded via the captured columns, VSCode `extractCommandLine` parity), and
1241 /// attaches the trailing CommandFinished(D) exit. A command still being typed
1242 /// (B with no C yet) is not navigable — its text has no bound — so it is
1243 /// omitted until output starts.
1244 pub fn command_lines(&self) -> Vec<CommandLine> {
1245 let mut out: Vec<CommandLine> = Vec::new();
1246 // (B line, B col) awaiting its matching C. Marks arrive in buffer order.
1247 let mut pending: Option<(usize, usize)> = None;
1248 // Primary-scoped (see `command_marks`): the normal buffer's marks.
1249 for m in &self.normal_markers {
1250 match m.kind {
1251 MarkerKind::CommandStart => pending = Some((m.line, m.col)),
1252 MarkerKind::OutputStart => {
1253 if let Some((b_line, b_col)) = pending.take() {
1254 // Columns bound the command precisely even though output was
1255 // written after C — `extract_lines` reads current cells but
1256 // clips to `[b_col, c_col)`, excluding both prompt and output.
1257 // Command marks anchor primary content — read the primary
1258 // grid so the text is right even while on the alt screen (#192).
1259 let command =
1260 self.extract_lines(self.primary_grid(), b_line, b_col, m.line, m.col);
1261 out.push(CommandLine {
1262 line: self.doc_line_of(self.primary_grid(), b_line),
1263 command,
1264 exit: None,
1265 });
1266 }
1267 }
1268 MarkerKind::CommandFinished(exit) => {
1269 // The exit belongs to the most recent command not yet closed;
1270 // the `is_none` guard stops a stray D from clobbering a code.
1271 if let Some(last) = out.last_mut()
1272 && last.exit.is_none()
1273 {
1274 last.exit = exit;
1275 }
1276 }
1277 MarkerKind::Plain | MarkerKind::PromptStart => {}
1278 }
1279 }
1280 out
1281 }
1282
1283 /// The document (logical) line index that absolute buffer line `abs` renders
1284 /// into within [`Term::accessible_text`] — the number of hard line-ends before
1285 /// it (soft-wrapped rows share one logical line). Primary-screen coordinates,
1286 /// matching `accessible_text`'s `start = 0` for the primary screen; command
1287 /// marks are primary-only. O(abs) per call — fine for an on-demand query over
1288 /// the handful of commands in a session.
1289 fn doc_line_of(&self, grid: &Grid, abs: usize) -> usize {
1290 (0..abs)
1291 .filter(|&l| {
1292 !self
1293 .line_in(grid, l)
1294 .last()
1295 .is_some_and(|c| c.is_wrapline())
1296 })
1297 .count()
1298 }
1299
1300 /// Remove a marker by id (#118). Disposing it fires `MarkerDisposed` so the
1301 /// consumer's cleanup is one path whether the marker left by eviction or by
1302 /// this explicit call (xterm's `dispose()` likewise always fires onDispose).
1303 /// A no-op for an unknown/already-disposed id.
1304 pub fn remove_marker(&mut self, id: MarkerId) {
1305 // Id-based, buffer-agnostic: search both lists (ids are unique across
1306 // buffers) so a marker is removed whichever screen it lives on (#177 S0).
1307 let before = self.normal_markers.len() + self.alt_markers.len();
1308 self.normal_markers.retain(|m| m.id != id);
1309 self.alt_markers.retain(|m| m.id != id);
1310 if self.normal_markers.len() + self.alt_markers.len() != before {
1311 self.events.push(TermEvent::MarkerDisposed(id));
1312 }
1313 }
1314
1315 /// Shift markers down one absolute line after the oldest history line is
1316 /// evicted; a marker *on* that line (abs 0) has left the buffer, so it is
1317 /// disposed and announced (#118) — the marker analogue of
1318 /// `selection_evict_oldest`, but a list with per-marker disposal.
1319 fn markers_evict_oldest(&mut self) {
1320 // Scrollback eviction is primary-only (the alt screen has none).
1321 let mut disposed = Vec::new();
1322 self.normal_markers.retain_mut(|m| {
1323 if m.line == 0 {
1324 disposed.push(m.id);
1325 false
1326 } else {
1327 m.line -= 1;
1328 true
1329 }
1330 });
1331 for id in disposed {
1332 self.events.push(TermEvent::MarkerDisposed(id));
1333 }
1334 }
1335
1336 /// Rotate markers within an in-screen region scroll of absolute lines
1337 /// `[top, bottom]` (`up` = a line dropped at `top`, else at `bottom`) — the
1338 /// marker analogue of `selection_rotate_region`. A marker on the dropped edge
1339 /// has left the buffer, so it is disposed and announced (#118).
1340 fn markers_rotate_region(&mut self, top: usize, bottom: usize, up: bool) {
1341 let mut disposed = Vec::new();
1342 self.markers_mut().retain_mut(|m| {
1343 if m.line < top || m.line > bottom {
1344 return true; // outside the region — unchanged
1345 }
1346 let dropped_edge = if up { top } else { bottom };
1347 if m.line == dropped_edge {
1348 disposed.push(m.id);
1349 false
1350 } else {
1351 m.line = if up { m.line - 1 } else { m.line + 1 };
1352 true
1353 }
1354 });
1355 for id in disposed {
1356 self.events.push(TermEvent::MarkerDisposed(id));
1357 }
1358 }
1359
1360 /// The active buffer's markers projected onto the current viewport — one
1361 /// `MarkerPosition` per marker whose line is visible, off-screen markers
1362 /// omitted. The alt screen projects its own (alt-scoped) markers now (#187);
1363 /// they are disposed on alt-leave, so a primary frame never shows them.
1364 fn marker_positions(&self) -> Vec<MarkerPosition> {
1365 let top = self.scrollback.len() - self.display_offset;
1366 let rows = self.grid.rows();
1367 self.markers()
1368 .iter()
1369 .filter_map(|m| {
1370 let row = m.line.checked_sub(top)?;
1371 (row < rows).then_some(MarkerPosition {
1372 id: m.id,
1373 row,
1374 kind: m.kind,
1375 })
1376 })
1377 .collect()
1378 }
1379
1380 /// Every live marker's absolute buffer line (#120 S3) — the off-viewport
1381 /// superset of `marker_positions`, for the overview ruler. No viewport filter:
1382 /// a marker scrolled out of view is still reported (that is the ruler's job),
1383 /// its `line` in the same `[0, scrollback + rows)` frame as the header's
1384 /// `scrollback_len`/`display_offset`.
1385 fn all_marker_lines(&self) -> Vec<MarkerLine> {
1386 self.markers()
1387 .iter()
1388 .map(|m| MarkerLine {
1389 id: m.id,
1390 line: m.line as u32,
1391 })
1392 .collect()
1393 }
1394
1395 /// Begin a selection of `ty` at viewport `(row, col)`, `side`.
1396 pub fn selection_begin(&mut self, row: usize, col: usize, side: Side, ty: SelectionType) {
1397 let anchor = Anchor {
1398 point: self.viewport_to_abs(row, col),
1399 side,
1400 };
1401 self.selection = Some(Selection {
1402 ty,
1403 anchor,
1404 focus: anchor,
1405 });
1406 }
1407
1408 /// Extend the live selection's focus to viewport `(row, col)`, `side`.
1409 pub fn selection_extend(&mut self, row: usize, col: usize, side: Side) {
1410 let focus = Anchor {
1411 point: self.viewport_to_abs(row, col),
1412 side,
1413 };
1414 if let Some(sel) = &mut self.selection {
1415 sel.focus = focus;
1416 }
1417 }
1418
1419 /// Clear the selection.
1420 pub fn selection_clear(&mut self) {
1421 self.selection = None;
1422 }
1423
1424 /// Shift the selection up by one absolute line after the oldest history line
1425 /// is evicted by the scrollback cap. An endpoint clamps to the new top; if
1426 /// the whole selection was on the evicted line, it is cleared.
1427 fn selection_evict_oldest(&mut self) {
1428 let Some((a, f)) = self
1429 .selection
1430 .as_ref()
1431 .map(|s| (s.anchor.point.line, s.focus.point.line))
1432 else {
1433 return;
1434 };
1435 if a == 0 && f == 0 {
1436 self.selection = None;
1437 return;
1438 }
1439 if let Some(sel) = &mut self.selection {
1440 sel.anchor.point.line = a.saturating_sub(1);
1441 sel.focus.point.line = f.saturating_sub(1);
1442 }
1443 }
1444
1445 /// Rotate the selection within an in-screen scroll of absolute lines
1446 /// `[top, bottom]`. `up` = content scrolled up (a line dropped at `top`);
1447 /// otherwise down (dropped at `bottom`). Called once per scrolled line (delta
1448 /// 1) by linefeed/RI/SU/SD/IL/DL.
1449 ///
1450 /// Mirrors alacritty `Selection::rotate`: an endpoint pushed past the region
1451 /// edge is *clamped* to that edge (upper → `top`/col 0/Left, lower →
1452 /// `bottom`/last col/Right; columns/side kept for Block), preserving the part
1453 /// of the selection still in the buffer. The whole selection clears only on a
1454 /// true *overtake* — the upper endpoint crossing the bottom while the lower
1455 /// stays inside, or the lower falling above the upper (a selection wholly on
1456 /// the dropped line). (#174: this replaced a policy that cleared on any
1457 /// endpoint touching the dropped edge, dropping still-valid content.)
1458 fn selection_rotate_region(&mut self, top: usize, bottom: usize, up: bool) {
1459 let (ty, anchor, focus) = match self.selection.as_ref() {
1460 Some(s) => (s.ty, s.anchor, s.focus),
1461 None => return,
1462 };
1463 let last_col = self.grid.cols().saturating_sub(1);
1464 // Order the endpoints by buffer position; the upper (`start`) clamps to
1465 // the region top, the lower (`end`) to the bottom. Remember which is the
1466 // anchor so the result writes back to the right field.
1467 let anchor_is_start = anchor.point <= focus.point;
1468 let (mut start, mut end) = if anchor_is_start {
1469 (anchor, focus)
1470 } else {
1471 (focus, anchor)
1472 };
1473
1474 let (top_i, bottom_i) = (top as isize, bottom as isize);
1475 // The endpoint's line after the one-line scroll, or `None` if it's outside
1476 // the region (untouched). The dropped-edge line shifts *past* the edge (to
1477 // be clamped/overtaken below), matching alacritty's `line - delta`.
1478 let shift = |line: usize| -> Option<isize> {
1479 if line < top || line > bottom {
1480 None
1481 } else if up {
1482 Some(line as isize - 1)
1483 } else {
1484 Some(line as isize + 1)
1485 }
1486 };
1487
1488 // Upper endpoint: clamp to the region top when pushed above it; clear if it
1489 // overtook the region bottom (down-scroll) while the lower stays inside.
1490 if let Some(nl) = shift(start.point.line) {
1491 if nl > bottom_i && (end.point.line as isize) <= bottom_i {
1492 self.selection = None;
1493 return;
1494 }
1495 if nl < top_i {
1496 start.point.line = top;
1497 if ty != SelectionType::Block {
1498 start.point.col = 0;
1499 start.side = Side::Left;
1500 }
1501 } else {
1502 start.point.line = nl as usize;
1503 }
1504 }
1505 // Lower endpoint: clear if it fell above the (rotated) upper endpoint;
1506 // else clamp to the region bottom when pushed below it.
1507 if let Some(nl) = shift(end.point.line) {
1508 if nl < start.point.line as isize {
1509 self.selection = None;
1510 return;
1511 }
1512 if nl > bottom_i {
1513 end.point.line = bottom;
1514 if ty != SelectionType::Block {
1515 end.point.col = last_col;
1516 end.side = Side::Right;
1517 }
1518 } else {
1519 end.point.line = nl as usize;
1520 }
1521 }
1522
1523 if let Some(sel) = &mut self.selection {
1524 if anchor_is_start {
1525 (sel.anchor, sel.focus) = (start, end);
1526 } else {
1527 (sel.anchor, sel.focus) = (end, start);
1528 }
1529 }
1530 }
1531
1532 /// The selection projected onto the current viewport: one inclusive-column
1533 /// span per visible row. Rows scrolled off-screen (above or below) are
1534 /// dropped. Empty when nothing is selected. See `SelectionSpan`.
1535 pub fn selection_range(&self) -> Vec<SelectionSpan> {
1536 let Some(resolved) = self.resolve() else {
1537 return Vec::new();
1538 };
1539 let rows = self.grid.rows();
1540 // Absolute index of viewport row 0.
1541 let top = self.scrollback.len() - self.display_offset;
1542 let mut spans = Vec::new();
1543
1544 // Add a span for absolute `line` with inclusive cols `left..=right`, if
1545 // the line is currently visible.
1546 let mut push = |line: usize, left: usize, right: usize| {
1547 if line >= top {
1548 let row = line - top;
1549 if row < rows {
1550 spans.push(SelectionSpan { row, left, right });
1551 }
1552 }
1553 };
1554
1555 match resolved {
1556 Resolved::Linear {
1557 start_line,
1558 from,
1559 end_line,
1560 to,
1561 } => {
1562 for line in start_line..=end_line {
1563 let len = self.abs_line(line).len();
1564 let left = if line == start_line { from } else { 0 };
1565 let right_excl = if line == end_line { to.min(len) } else { len };
1566 if right_excl > left {
1567 push(line, left, right_excl - 1);
1568 }
1569 }
1570 }
1571 Resolved::Block {
1572 line0,
1573 line1,
1574 from,
1575 to,
1576 } => {
1577 if to > from {
1578 for line in line0..=line1 {
1579 push(line, from, to - 1);
1580 }
1581 }
1582 }
1583 }
1584 spans
1585 }
1586
1587 /// Resolve the live selection into absolute-coordinate bounds per type:
1588 /// a `Linear` run (char/word/line, which join soft wraps) or a `Block`
1589 /// rectangle. `None` when nothing is selected. Columns are half-open
1590 /// (`from..to`). Shared by `selection_text` and `selection_range`.
1591 fn resolve(&self) -> Option<Resolved> {
1592 let sel = self.selection.as_ref()?;
1593 let (start, end) = sel.ordered();
1594 Some(match sel.ty {
1595 SelectionType::Char => {
1596 // Half-open columns: each side decides if its own cell is in.
1597 let from = match start.side {
1598 Side::Left => start.point.col,
1599 Side::Right => start.point.col + 1,
1600 };
1601 let to = match end.side {
1602 Side::Left => end.point.col,
1603 Side::Right => end.point.col + 1,
1604 };
1605 Resolved::Linear {
1606 start_line: start.point.line,
1607 from,
1608 end_line: end.point.line,
1609 to,
1610 }
1611 }
1612 SelectionType::Word => {
1613 // Snap both ends to word boundaries (side is ignored).
1614 let ws = self.word_start(start.point);
1615 let we = self.word_end(end.point);
1616 Resolved::Linear {
1617 start_line: ws.line,
1618 from: ws.col,
1619 end_line: we.line,
1620 to: we.col + 1,
1621 }
1622 }
1623 SelectionType::Line => Resolved::Linear {
1624 start_line: start.point.line,
1625 from: 0,
1626 end_line: end.point.line,
1627 to: self.grid.cols(),
1628 },
1629 SelectionType::Block => {
1630 // Rectangular: the same column range on every row. Columns come
1631 // from the two anchors (min/max, with each edge's side).
1632 let cols = self.grid.cols();
1633 let (a, b) = (sel.anchor, sel.focus);
1634 let (lcol, lside, rcol, rside) = if a.point.col <= b.point.col {
1635 (a.point.col, a.side, b.point.col, b.side)
1636 } else {
1637 (b.point.col, b.side, a.point.col, a.side)
1638 };
1639 let from = match lside {
1640 Side::Left => lcol,
1641 Side::Right => lcol + 1,
1642 };
1643 let to = match rside {
1644 Side::Left => rcol,
1645 Side::Right => rcol + 1,
1646 };
1647 Resolved::Block {
1648 line0: a.point.line.min(b.point.line),
1649 line1: a.point.line.max(b.point.line),
1650 from,
1651 to: to.min(cols).max(from),
1652 }
1653 }
1654 })
1655 }
1656
1657 /// The selected text (for copy), or `None` when nothing is selected.
1658 pub fn selection_text(&self) -> Option<String> {
1659 match self.resolve()? {
1660 Resolved::Linear {
1661 start_line,
1662 from,
1663 end_line,
1664 to,
1665 } => Some(self.extract_lines(&self.grid, start_line, from, end_line, to)),
1666 Resolved::Block {
1667 line0,
1668 line1,
1669 from,
1670 to,
1671 } => {
1672 // Each row independently — no soft-wrap joining.
1673 let mut out = String::new();
1674 for line in line0..=line1 {
1675 let hi = to.min(self.abs_line(line).len());
1676 let mut seg = String::new();
1677 for col in from..hi {
1678 self.append_cell(&self.grid, &mut seg, line, col);
1679 }
1680 out.push_str(seg.trim_end());
1681 if line != line1 {
1682 out.push('\n');
1683 }
1684 }
1685 Some(out)
1686 }
1687 }
1688 }
1689
1690 /// Append the text at absolute `(line, col)` — its base glyph plus any
1691 /// combining marks from the row's map — to `out`. Wide-char spacers
1692 /// contribute nothing.
1693 fn append_cell(&self, grid: &Grid, out: &mut String, line: usize, col: usize) {
1694 let cell = &self.line_in(grid, line)[col];
1695 if cell.is_spacer() {
1696 return;
1697 }
1698 out.push(cell.c());
1699 if let Some(marks) = self.combining_in(grid, line, col) {
1700 out.extend(marks);
1701 }
1702 }
1703
1704 /// The whole buffer as one text document (#150): scrollback + screen assembled
1705 /// into logical lines (soft-wrap joined, wide-spacers skipped, trailing blanks
1706 /// trimmed at the logical end) — the accessible-view a screen reader reads as
1707 /// a document, distinct from the viewport row tree (#119). Reuses the
1708 /// selection extraction ([`extract_lines`](Self::extract_lines)) over the full
1709 /// range. On the alt screen only the alt buffer is shown — its "scrollback" is
1710 /// the *primary* buffer's, not this app's — mirroring `viewport_logical_lines`'
1711 /// alt floor.
1712 pub fn accessible_text(&self) -> String {
1713 let total = self.scrollback.len() + self.grid.rows();
1714 if total == 0 {
1715 return String::new();
1716 }
1717 let start = if self.on_alt {
1718 self.scrollback.len()
1719 } else {
1720 0
1721 };
1722 let mut doc = self.extract_lines(&self.grid, start, 0, total - 1, usize::MAX);
1723 // Trim *trailing* empty lines (blank screen rows below the content) — pure
1724 // noise to a listener, and what a fresh screen would otherwise emit. Keep
1725 // *internal* blank lines (paragraph breaks between command outputs) — a
1726 // document wants those, unlike the viewport tree which drops all empties.
1727 doc.truncate(doc.trim_end_matches('\n').len());
1728 doc
1729 }
1730
1731 /// Concatenate the selected cells from `(start_line, from)` to
1732 /// `(end_line, to_end)` (half-open columns on the first/last line, whole
1733 /// lines between). Soft-wrapped rows (WRAPLINE) accumulate into one *logical*
1734 /// line so trailing-blank trimming happens only at the logical end — spaces
1735 /// at a wrap boundary are real content. A hard line-end flushes with `\n`.
1736 fn extract_lines(
1737 &self,
1738 grid: &Grid,
1739 start_line: usize,
1740 from: usize,
1741 end_line: usize,
1742 to_end: usize,
1743 ) -> String {
1744 let mut out = String::new();
1745 let mut current = String::new();
1746 for line in start_line..=end_line {
1747 let cells = self.line_in(grid, line);
1748 let left = if line == start_line { from } else { 0 };
1749 let right = if line == end_line {
1750 to_end.min(cells.len())
1751 } else {
1752 cells.len()
1753 };
1754 // A degenerate range (sides inverting one cell) gives left > right;
1755 // clamp to empty rather than panic on the slice.
1756 let right = right.max(left);
1757 for col in left..right {
1758 self.append_cell(grid, &mut current, line, col);
1759 }
1760
1761 let is_last = line == end_line;
1762 let soft = cells.last().is_some_and(|c| c.is_wrapline());
1763 if is_last || !soft {
1764 out.push_str(current.trim_end());
1765 current.clear();
1766 if !is_last {
1767 out.push('\n');
1768 }
1769 }
1770 }
1771 out
1772 }
1773
1774 /// The lowest absolute line a soft-wrap buffer walk may reach. On the alt screen
1775 /// `scrollback` holds the *primary* buffer's history — a separate logical space —
1776 /// so a walk floors at `scrollback.len()` (the alt grid's first line) and must not
1777 /// join across it. Mirrors the `search()` (#144) and `viewport_logical_lines`
1778 /// (#113) floors: justerm's single `[scrollback ++ grid]` buffer reproduces the
1779 /// primary↔alt isolation xterm gets from separate `Buffer` objects.
1780 fn abs_floor(&self) -> usize {
1781 if self.on_alt {
1782 self.scrollback.len()
1783 } else {
1784 0
1785 }
1786 }
1787
1788 /// The cell position before `(line, col)` in the *logical* line — the column
1789 /// to the left, or the end of the previous row if it soft-wrapped into this
1790 /// one. `None` at the buffer start or across a hard line-end.
1791 fn prev_pos(&self, line: usize, col: usize) -> Option<(usize, usize)> {
1792 if col > 0 {
1793 return Some((line, col - 1));
1794 }
1795 // Only step up while the previous row is still on *this* buffer (>= floor):
1796 // on alt, row 0 (`line == scrollback.len()`) must not join the primary
1797 // scrollback row below it, even when that row carries WRAPLINE (#207).
1798 if line > self.abs_floor() {
1799 let prev = self.abs_line(line - 1);
1800 if prev.last().is_some_and(|c| c.is_wrapline()) {
1801 return Some((line - 1, prev.len() - 1));
1802 }
1803 }
1804 None
1805 }
1806
1807 /// The cell position after `(line, col)` in the *logical* line — the column
1808 /// to the right, or the start of the next row if this row soft-wrapped.
1809 /// `None` at the buffer end or across a hard line-end.
1810 fn next_pos(&self, line: usize, col: usize) -> Option<(usize, usize)> {
1811 let cells = self.abs_line(line);
1812 if col + 1 < cells.len() {
1813 return Some((line, col + 1));
1814 }
1815 let total = self.scrollback.len() + self.grid.rows();
1816 // Symmetric floor guard (#207): a row below the floor (primary scrollback on
1817 // alt) must not soft-wrap-join down into the alt grid. `line >= floor` holds
1818 // for any position reachable on alt once `prev_pos` is floored; kept explicit
1819 // so no future caller can cross from a primary row.
1820 if line >= self.abs_floor()
1821 && line + 1 < total
1822 && cells.last().is_some_and(|c| c.is_wrapline())
1823 {
1824 return Some((line + 1, 0));
1825 }
1826 None
1827 }
1828
1829 /// Walk left to the first cell of `p`'s word (a maximal run of non-boundary
1830 /// chars), following a soft wrap into the previous row.
1831 fn word_start(&self, p: BufferPoint) -> BufferPoint {
1832 let cells = self.abs_line(p.line);
1833 let (mut line, mut col) = (p.line, p.col.min(cells.len().saturating_sub(1)));
1834 while let Some((pl, pc)) = self.prev_pos(line, col) {
1835 if is_word_boundary(self.abs_line(pl)[pc].c()) {
1836 break;
1837 }
1838 line = pl;
1839 col = pc;
1840 }
1841 BufferPoint { line, col }
1842 }
1843
1844 /// Walk right to the last cell of `p`'s word, following a soft wrap into the
1845 /// next row.
1846 fn word_end(&self, p: BufferPoint) -> BufferPoint {
1847 let cells = self.abs_line(p.line);
1848 let (mut line, mut col) = (p.line, p.col.min(cells.len().saturating_sub(1)));
1849 while let Some((nl, nc)) = self.next_pos(line, col) {
1850 if is_word_boundary(self.abs_line(nl)[nc].c()) {
1851 break;
1852 }
1853 line = nl;
1854 col = nc;
1855 }
1856 BufferPoint { line, col }
1857 }
1858
1859 /// Resize the screen to `cols` x `rows`. Rows dropped off the top (on shrink)
1860 /// enter scrollback. Column reflow of soft-wrapped lines is layered on top
1861 /// separately (#7). The whole screen is damaged.
1862 pub fn resize(&mut self, cols: usize, rows: usize) {
1863 // A terminal is never 0-wide/0-tall; clamp so the math below (rows - 1,
1864 // chunking by cols) can't underflow or divide by zero.
1865 let cols = cols.max(1);
1866 let rows = rows.max(1);
1867 let old_cols = self.grid.cols();
1868 let limit = self.scrollback_limit;
1869
1870 // A reflow moves match coordinates (and can change the match set), so the
1871 // query-derived highlights are invalidated; the consumer re-searches at
1872 // the new width. The selection re-anchors below — it is user-authored.
1873 self.invalidate_search_highlights();
1874
1875 // Both screens are resized. Scrollback pairs with the PRIMARY screen
1876 // (whichever is active) — the alt screen has no history of its own.
1877 let dims = ReflowDims {
1878 old_cols,
1879 cols,
1880 rows,
1881 limit,
1882 };
1883 let scrollback = std::mem::take(&mut self.scrollback);
1884 if self.on_alt {
1885 // Active = alt (cursor, no scrollback); inactive = primary. Selection
1886 // is primary-only and cleared on alt enter, so no anchors to track.
1887 // Alt markers DO ride this reflow (#187): justerm column-reflows the
1888 // alt grid, so a marker must follow its content or it drifts off. Their
1889 // stored line is `base + alt_row` (base = primary scrollback len), so
1890 // convert to alt-local rows for the pane, and re-anchor on the reflowed
1891 // base afterward (the primary scrollback below may rewrap its length).
1892 let old_base = scrollback.len();
1893 let alt_pts: Vec<(usize, usize)> = self
1894 .alt_markers
1895 .iter()
1896 .map(|m| (m.line - old_base, 0))
1897 .collect();
1898 let alt = self.grid.take_lines();
1899 let r_alt = reflow_pane(alt, VecDeque::new(), self.cursor.point(), &alt_pts, dims);
1900 self.grid.set_screen(r_alt.screen, cols, rows);
1901 self.cursor.set_point(r_alt.cursor, rows, cols);
1902
1903 // Primary is inactive here, but markers anchor *primary* content, so
1904 // they reflow with it (the selection is already cleared on alt enter).
1905 let marker_pts: Vec<(usize, usize)> =
1906 self.normal_markers.iter().map(|m| (m.line, 0)).collect();
1907 let primary = self.alt_grid.take_lines();
1908 let r = reflow_pane(
1909 primary,
1910 scrollback,
1911 self.saved_cursor.point(),
1912 &marker_pts,
1913 dims,
1914 );
1915 self.alt_grid.set_screen(r.screen, cols, rows);
1916 self.scrollback = r.scrollback;
1917 self.saved_cursor.set_point(r.cursor, rows, cols);
1918 for (i, m) in self.normal_markers.iter_mut().enumerate() {
1919 m.line = r.extras[i].0;
1920 }
1921 let new_base = self.scrollback.len();
1922 for (i, m) in self.alt_markers.iter_mut().enumerate() {
1923 m.line = new_base + r_alt.extras[i].0;
1924 }
1925 } else {
1926 // Active = primary (cursor, scrollback); inactive = alt. The selection
1927 // anchors (absolute) reflow alongside the cursor so they keep their
1928 // content across a column change.
1929 let sel_pts: Vec<(usize, usize)> = self
1930 .selection
1931 .as_ref()
1932 .map(|s| {
1933 vec![
1934 (s.anchor.point.line, s.anchor.point.col),
1935 (s.focus.point.line, s.focus.point.col),
1936 ]
1937 })
1938 .unwrap_or_default();
1939 // Markers reflow on the same pane by (line, col) — the column matters
1940 // for OSC-133 command marks, whose B/C columns bound the extracted
1941 // command text (#166). They ride after the selection points so each
1942 // reads its own reflowed slot back from `extras` (#118).
1943 let mut pts = sel_pts.clone();
1944 pts.extend(self.normal_markers.iter().map(|m| (m.line, m.col)));
1945
1946 let primary = self.grid.take_lines();
1947 let r = reflow_pane(primary, scrollback, self.cursor.point(), &pts, dims);
1948 self.grid.set_screen(r.screen, cols, rows);
1949 self.scrollback = r.scrollback;
1950 self.cursor.set_point(r.cursor, rows, cols);
1951 if let Some(sel) = &mut self.selection {
1952 sel.anchor.point = BufferPoint {
1953 line: r.extras[0].0,
1954 col: r.extras[0].1,
1955 };
1956 sel.focus.point = BufferPoint {
1957 line: r.extras[1].0,
1958 col: r.extras[1].1,
1959 };
1960 }
1961 let marker_off = sel_pts.len();
1962 for (i, m) in self.normal_markers.iter_mut().enumerate() {
1963 m.line = r.extras[marker_off + i].0;
1964 m.col = r.extras[marker_off + i].1;
1965 }
1966
1967 let alt = self.alt_grid.take_lines();
1968 let r = reflow_pane(alt, VecDeque::new(), (0, 0), &[], dims);
1969 self.alt_grid.set_screen(r.screen, cols, rows);
1970 }
1971
1972 // Margins reset to the full screen; tab stops reset to the default grid.
1973 self.cursor.pending_wrap = false;
1974 self.scroll_top = 0;
1975 self.scroll_bottom = rows - 1;
1976 self.tabs = default_tabs(cols);
1977 self.display_offset = self.display_offset.min(self.scrollback.len());
1978
1979 // Damage tracking is sized to the screen; a resize repaints everything,
1980 // so drop any pending scroll op (it points at the old rows).
1981 self.line_damage = vec![LineBounds::undamaged(cols); rows];
1982 self.scroll = None;
1983 self.mark_fully_damaged();
1984 }
1985
1986 pub fn grid(&self) -> &Grid {
1987 &self.grid
1988 }
1989
1990 pub fn cursor(&self) -> &Cursor {
1991 &self.cursor
1992 }
1993
1994 /// Whether bracketed-paste mode (DEC ?2004) is enabled. The input encoder
1995 /// (#11) reads this to decide whether to wrap pasted text in markers.
1996 pub fn bracketed_paste(&self) -> bool {
1997 self.bracketed_paste
1998 }
1999
2000 // ---- input encoding (#11) ------------------------------------------------
2001
2002 /// Encode a key event to bytes using the active cursor-key mode (DECCKM)
2003 /// and the kitty keyboard-protocol flags (`encode_key` consults both).
2004 pub fn encode_key(&self, ev: KeyEvent) -> Option<Vec<u8>> {
2005 encode_key(
2006 &ev,
2007 self.app_cursor_keys,
2008 self.application_keypad,
2009 self.kitty_flags,
2010 )
2011 }
2012
2013 /// Encode a mouse event using the active tracking mode + encoding. `None`
2014 /// when reporting is off or the event is filtered by the mode.
2015 pub fn encode_mouse(&self, ev: MouseEvent) -> Option<Vec<u8>> {
2016 encode_mouse(&ev, self.mouse_protocol, self.mouse_encoding)
2017 }
2018
2019 /// Encode pasted text, wrapping it in bracketed-paste markers when ?2004 is
2020 /// on.
2021 pub fn encode_paste(&self, text: &str) -> Vec<u8> {
2022 encode_paste(text, self.bracketed_paste)
2023 }
2024
2025 /// Encode a focus change (`CSI I`/`CSI O`), or `None` when focus reporting
2026 /// (?1004) is off.
2027 pub fn encode_focus(&self, focused: bool) -> Option<Vec<u8>> {
2028 encode_focus(focused, self.focus_events)
2029 }
2030
2031 /// Take the consumer events queued since the last drain, emptying the queue.
2032 pub fn drain_events(&mut self) -> Vec<TermEvent> {
2033 std::mem::take(&mut self.events)
2034 }
2035
2036 /// Take the reply bytes queued since the last drain (DA/DSR/DECRQM answers),
2037 /// emptying the buffer. The consumer writes them back to the PTY.
2038 pub fn drain_replies(&mut self) -> Vec<u8> {
2039 std::mem::take(&mut self.replies)
2040 }
2041
2042 /// Device Status Report (CSI Ps n): 6 = cursor position, 5 = operating
2043 /// status. Queues the reply for `drain_replies` (#27).
2044 fn device_status_report(&mut self, param: u16) {
2045 match param {
2046 6 => {
2047 // CSI row;col R, 1-based — region-relative under origin mode
2048 // (the coordinate system the app is addressing in).
2049 let row = if self.origin_mode {
2050 self.cursor.row.saturating_sub(self.scroll_top)
2051 } else {
2052 self.cursor.row
2053 } + 1;
2054 let col = self.cursor.col + 1;
2055 self.replies
2056 .extend_from_slice(format!("\x1b[{row};{col}R").as_bytes());
2057 }
2058 5 => self.replies.extend_from_slice(b"\x1b[0n"), // status: OK
2059 _ => {}
2060 }
2061 }
2062
2063 /// Kitty keyboard-protocol negotiation (#23). `lead` is the leading CSI
2064 /// intermediate: `?` query, `>` push, `=` set, `<` pop.
2065 fn kitty_dispatch(&mut self, lead: u8, params: &Params) {
2066 match lead {
2067 // Query → report the current flags as `CSI ? flags u` (#27 channel).
2068 b'?' => self
2069 .replies
2070 .extend_from_slice(format!("\x1b[?{}u", self.kitty_flags).as_bytes()),
2071 // Push: save the current flags, then set the new ones (default 0).
2072 b'>' => {
2073 const KITTY_STACK_CAP: usize = 16;
2074 if self.kitty_stack.len() >= KITTY_STACK_CAP {
2075 self.kitty_stack.remove(0); // drop the oldest on overflow
2076 }
2077 self.kitty_stack.push(self.kitty_flags);
2078 self.kitty_flags = param_or(params, 0, 0) as u8;
2079 }
2080 // Pop `n` (default 1): restore from the stack, 0 once empty.
2081 b'<' => {
2082 for _ in 0..param_or(params, 0, 1) {
2083 self.kitty_flags = self.kitty_stack.pop().unwrap_or(0);
2084 }
2085 }
2086 // Set in place (no push): mode 1 replace, 2 or-in, 3 and-not.
2087 b'=' => {
2088 let flags = param_or(params, 0, 0) as u8;
2089 self.kitty_flags = match param_or(params, 1, 1) {
2090 1 => flags,
2091 2 => self.kitty_flags | flags,
2092 3 => self.kitty_flags & !flags,
2093 _ => self.kitty_flags,
2094 };
2095 }
2096 _ => {}
2097 }
2098 }
2099
2100 /// DECRQM (CSI ? Ps $ p): report whether DEC private mode `Ps` is set —
2101 /// `CSI ? Ps ; val $ y` with val 1=set, 2=reset, 0=not recognized (#27).
2102 fn decrqm(&mut self, mode: u16) {
2103 let state = match mode {
2104 1 => Some(self.app_cursor_keys),
2105 // DECANM (#84): set = ANSI mode (the normal state), reset = VT52.
2106 2 => Some(!self.vt52_mode),
2107 6 => Some(self.origin_mode),
2108 // DECCOLM: derived from the actual width, never a tracked flag — a
2109 // flag would lie if the consumer ignored the resize request (#82).
2110 3 => Some(self.grid.cols() == 132),
2111 7 => Some(self.autowrap),
2112 45 => Some(self.reverse_wraparound),
2113 9 => Some(self.mouse_protocol == MouseProtocol::X10),
2114 66 => Some(self.application_keypad),
2115 12 => Some(self.cursor.blink),
2116 25 => Some(self.cursor.visible),
2117 // Mouse tracking is a single-state enum (the levels are mutually
2118 // exclusive — an app enables one), so querying ?1000 while ?1002 is
2119 // active reports "reset". Faithful to that model.
2120 1000 => Some(self.mouse_protocol == MouseProtocol::Normal),
2121 1002 => Some(self.mouse_protocol == MouseProtocol::ButtonEvent),
2122 1003 => Some(self.mouse_protocol == MouseProtocol::AnyEvent),
2123 1004 => Some(self.focus_events),
2124 1006 => Some(self.mouse_encoding == MouseEncoding::Sgr),
2125 1015 => Some(self.mouse_encoding == MouseEncoding::Urxvt),
2126 1005 => Some(self.mouse_encoding == MouseEncoding::Utf8),
2127 1016 => Some(self.mouse_encoding == MouseEncoding::SgrPixels),
2128 47 | 1047 | 1049 => Some(self.on_alt),
2129 2004 => Some(self.bracketed_paste),
2130 2026 => Some(self.synchronized_output),
2131 2027 => Some(self.grapheme_clustering),
2132 2031 => Some(self.color_scheme_updates),
2133 9001 => Some(self.win32_input_mode),
2134 _ => None,
2135 };
2136 let val = match state {
2137 Some(true) => 1,
2138 Some(false) => 2,
2139 None => 0,
2140 };
2141 self.replies
2142 .extend_from_slice(format!("\x1b[?{mode};{val}$y").as_bytes());
2143 }
2144
2145 /// Resolve a cell's `link` index (OSC 8) to its URI, or `None` if the index
2146 /// is out of range. The renderer reads `Cell.link`, then this, to make a
2147 /// cell clickable (#26).
2148 pub fn hyperlink(&self, link: core::num::NonZeroU32) -> Option<&str> {
2149 self.hyperlink_pool
2150 .get(link.get() as usize - 1)
2151 .map(String::as_str)
2152 }
2153
2154 // ---- cursor / scroll primitives ------------------------------------------
2155
2156 /// Move down one line. At the bottom margin, scroll the region instead;
2157 /// below the region, just descend (no scroll). Column is unchanged (raw LF;
2158 /// CR is what returns to column 0).
2159 fn linefeed(&mut self) {
2160 // New-line mode (LNM ?20): a line feed also returns to column 0 (#71).
2161 if self.newline_mode {
2162 self.carriage_return();
2163 }
2164 if self.cursor.row == self.scroll_bottom {
2165 // A top-anchored primary-screen scroll pushes the evicted top line
2166 // into scrollback history.
2167 if self.scroll_top == 0 && !self.on_alt {
2168 // Scrollback accrues whenever the scroll is top-anchored on the
2169 // primary screen (`scroll_top == 0`) — but the O(1) ring handshake
2170 // only applies to a *full-screen* scroll (`scroll_bottom` at the
2171 // last row). A top-anchored *sub-region* (`[0..k]`, k < rows-1)
2172 // still accrues, yet must scroll only its region, so it keeps the
2173 // copy + region scroll. These are distinct predicates (ADR-0009).
2174 let evicted = if self.scroll_bottom == self.grid.rows() - 1 {
2175 // Full-screen hot path: move the evicted top row out, install
2176 // a recycled blank as the new bottom (zero-alloc steady state).
2177 let blank = self
2178 .recycled_row
2179 .take()
2180 .unwrap_or_else(|| Row::from_cells(Vec::with_capacity(self.grid.cols())));
2181 self.grid.scroll_up_recycle(blank)
2182 } else {
2183 // Top-anchored sub-region: copy row 0, then region-scroll
2184 // `[0..=scroll_bottom]` (rows below stay fixed).
2185 let evicted = self.grid.row_owned(0);
2186 self.grid
2187 .scroll_up_region(self.scroll_top, self.scroll_bottom);
2188 evicted
2189 };
2190 self.scrollback.push_back(evicted);
2191 // Follow-bottom = stay: if the user is scrolled up, bump the
2192 // offset so the same lines stay in view instead of being yanked
2193 // to the bottom.
2194 if self.display_offset > 0 {
2195 self.display_offset = (self.display_offset + 1).min(self.scrollback.len());
2196 }
2197 // Cap: evict the oldest line past the limit. The view is anchored
2198 // to history, so dropping the front shifts the offset down too
2199 // (xterm.js trims ybase and ydisp together) — also keeps the
2200 // offset within `[0, len]`. The evicted row is parked for reuse.
2201 if self.scrollback.len() > self.scrollback_limit {
2202 self.recycled_row = self.scrollback.pop_front();
2203 // Every absolute index just shifted down by one; move the
2204 // selection with it so its anchors keep their content.
2205 self.selection_evict_oldest();
2206 // Query-derived highlights can't survive the index shift (see
2207 // the method doc); selection re-anchors, highlights invalidate.
2208 self.invalidate_search_highlights();
2209 // Markers are persistent anchors: shift them down with the
2210 // index, disposing any whose line was the evicted one (#118).
2211 self.markers_evict_oldest();
2212 if self.display_offset > 0 {
2213 // Scrolled up: evicting the oldest line advanced the
2214 // viewport, so it must be repainted (the "frozen while
2215 // scrolled" rule does not apply when the view itself moved).
2216 self.display_offset -= 1;
2217 self.mark_fully_damaged();
2218 }
2219 }
2220 } else {
2221 // Region (top margin > 0) or alt-screen scroll: the evicted line
2222 // does NOT enter scrollback, so content moves *within* the screen
2223 // and absolute indices in the region shift. Rotate the selection
2224 // up so it follows; an endpoint on the dropped line clears it.
2225 let base = self.scrollback.len();
2226 self.selection_rotate_region(
2227 base + self.scroll_top,
2228 base + self.scroll_bottom,
2229 true,
2230 );
2231 // Rotate the active buffer's markers with the content (#187):
2232 // per-buffer storage (#186) scopes them, so an alt scroll rotates
2233 // *alt* marks and leaves the frozen primary list untouched — no
2234 // guard needed. `markers_rotate_region` routes via `markers_mut`.
2235 self.markers_rotate_region(base + self.scroll_top, base + self.scroll_bottom, true);
2236 self.invalidate_search_highlights();
2237 self.grid
2238 .scroll_up_region(self.scroll_top, self.scroll_bottom);
2239 }
2240 self.record_scroll(self.scroll_top, self.scroll_bottom, 1);
2241 } else if self.cursor.row + 1 < self.grid.rows() {
2242 self.cursor.row += 1;
2243 }
2244 }
2245
2246 /// DECSTBM (CSI r): set the top/bottom scroll margins (1-based inclusive).
2247 /// An invalid region (top ≥ bottom) is ignored.
2248 fn set_scroll_region(&mut self, top: usize, bottom: usize) {
2249 let bottom = bottom.min(self.grid.rows());
2250 if top >= bottom {
2251 return;
2252 }
2253 self.scroll_top = top - 1;
2254 self.scroll_bottom = bottom - 1;
2255 self.goto(0, 0); // DECSTBM homes the cursor (absolute)
2256 }
2257
2258 // ---- alt screen (DEC 1049) -----------------------------------------------
2259
2260 /// Enter the alternate screen: save the cursor, swap in the other grid, and
2261 /// clear it.
2262 /// Save the cursor into the alt-screen slot — `?1048` set, and the first
2263 /// half of `?1049` enter (#72).
2264 fn save_alt_cursor(&mut self) {
2265 self.saved_cursor = self.cursor;
2266 }
2267
2268 /// Restore the cursor from the alt-screen slot — `?1048` reset, and the
2269 /// second half of `?1049` leave. DECTCEM visibility is a standalone mode, not
2270 /// part of the save, so preserve it across the restore (#38/#72).
2271 fn restore_alt_cursor(&mut self) {
2272 let visible = self.cursor.visible;
2273 self.cursor = self.saved_cursor;
2274 self.cursor.visible = visible;
2275 }
2276
2277 /// Switch to the (cleared) alternate buffer without touching the cursor —
2278 /// `?47`/`?1047` set, and the second half of `?1049` enter (#72).
2279 fn switch_to_alt(&mut self) {
2280 if self.on_alt {
2281 return;
2282 }
2283 std::mem::swap(&mut self.grid, &mut self.alt_grid);
2284 self.grid.clear();
2285 self.on_alt = true;
2286 self.display_offset = 0; // the alt screen has no scrollback to view
2287 self.selection = None; // a selection cannot survive a screen swap
2288 self.invalidate_search_highlights(); // matches index the primary buffer
2289 self.mark_fully_damaged();
2290 }
2291
2292 /// Switch back to the primary buffer without touching the cursor —
2293 /// `?47`/`?1047` reset, and the first half of `?1049` leave (#72).
2294 fn switch_to_primary(&mut self) {
2295 if !self.on_alt {
2296 return;
2297 }
2298 // Dispose the alt buffer's markers on leave — xterm `activateNormalBuffer`
2299 // → `clearAllMarkers` (#177 S0). Empty while the alt guards stand, so this
2300 // fires nothing today; it's the seam the alt-marker slices (#187) build on.
2301 for m in self.alt_markers.drain(..) {
2302 self.events.push(TermEvent::MarkerDisposed(m.id));
2303 }
2304 std::mem::swap(&mut self.grid, &mut self.alt_grid);
2305 self.on_alt = false;
2306 self.display_offset = 0; // return to the primary at its bottom
2307 self.selection = None; // a selection cannot survive a screen swap
2308 self.invalidate_search_highlights(); // matches index the swapped-out buffer
2309 self.mark_fully_damaged();
2310 }
2311
2312 fn enter_alt_screen(&mut self) {
2313 if self.on_alt {
2314 return;
2315 }
2316 self.save_alt_cursor();
2317 self.switch_to_alt();
2318 }
2319
2320 /// Leave the alternate screen: swap the primary grid back in and restore the
2321 /// saved cursor.
2322 fn leave_alt_screen(&mut self) {
2323 if !self.on_alt {
2324 return;
2325 }
2326 self.switch_to_primary();
2327 self.restore_alt_cursor();
2328 }
2329
2330 /// RI (ESC M): move up one line. At the top margin, scroll the region down
2331 /// instead.
2332 fn reverse_index(&mut self) {
2333 if self.cursor.row == self.scroll_top {
2334 // RI never enters scrollback; the region scrolls down within the
2335 // screen, so absolute indices in it shift down. Rotate the selection.
2336 let base = self.scrollback.len();
2337 self.selection_rotate_region(base + self.scroll_top, base + self.scroll_bottom, false);
2338 // Rotate the active buffer's markers (#187) — alt-scoped on the alt
2339 // screen, so no guard (see `linefeed`).
2340 self.markers_rotate_region(base + self.scroll_top, base + self.scroll_bottom, false);
2341 self.invalidate_search_highlights();
2342 self.grid
2343 .scroll_down_region(self.scroll_top, self.scroll_bottom);
2344 self.record_scroll(self.scroll_top, self.scroll_bottom, -1);
2345 } else if self.cursor.row > 0 {
2346 self.cursor.row -= 1;
2347 }
2348 }
2349
2350 // ---- cursor save/restore (DECSC / DECRC) ---------------------------------
2351
2352 /// DECSC (ESC 7): save the cursor position, pen, pending-wrap, and origin
2353 /// mode. Visibility is not saved (DECTCEM is separate).
2354 fn save_cursor(&mut self) {
2355 self.decsc = SavedCursor {
2356 row: self.cursor.row,
2357 col: self.cursor.col,
2358 pen: self.cursor.pen,
2359 pending_wrap: self.cursor.pending_wrap,
2360 origin_mode: self.origin_mode,
2361 charsets: self.charsets,
2362 gl: self.gl,
2363 };
2364 }
2365
2366 /// DECRC (ESC 8): restore what DECSC saved. Origin mode is restored (per
2367 /// ADR-0004); visibility is left as-is. The position is clamped to the
2368 /// current screen in case it shrank since the save.
2369 fn restore_cursor(&mut self) {
2370 let s = self.decsc;
2371 self.cursor.row = s.row.min(self.grid.rows() - 1);
2372 self.cursor.col = s.col.min(self.grid.cols() - 1);
2373 self.cursor.pen = s.pen;
2374 self.cursor.pending_wrap = s.pending_wrap;
2375 self.origin_mode = s.origin_mode;
2376 self.charsets = s.charsets;
2377 self.gl = s.gl;
2378 }
2379
2380 /// RIS (ESC c) — full reset to the power-on state (#53). Reconstruct every
2381 /// screen/mode field to its construction default (preserving only the
2382 /// dimensions and the scrollback cap), but keep the consumer-bound output
2383 /// queues (`replies`/`events`) that accrued earlier in this `feed`, and
2384 /// signal a full repaint. The vte parser lives outside `Term`, so replacing
2385 /// `self` does not disturb in-progress parsing. Mirrors xterm.js fullReset.
2386 fn full_reset(&mut self) {
2387 let replies = std::mem::take(&mut self.replies);
2388 let mut events = std::mem::take(&mut self.events);
2389 // RIS wipes the buffer, so every marker's line is gone — announce each
2390 // disposal so the consumer drops its decorations (and isn't confused when
2391 // the reset id counter reissues the same ids). The events survive the
2392 // reset below (#118).
2393 events.extend(
2394 self.normal_markers
2395 .iter()
2396 .chain(&self.alt_markers)
2397 .map(|m| TermEvent::MarkerDisposed(m.id)),
2398 );
2399 let (cols, rows) = (self.grid.cols(), self.grid.rows());
2400 *self = Term::with_scrollback(cols, rows, self.scrollback_limit);
2401 self.replies = replies;
2402 self.events = events;
2403 self.mark_fully_damaged();
2404 }
2405
2406 /// DECSTR (CSI ! p) — soft reset (#53). Resets a defined subset of modes to
2407 /// their defaults *without* destroying screen content or scrollback, moving
2408 /// the active cursor, or touching the mouse/focus reporting subsystem. Per
2409 /// xterm.js softReset, autowrap returns to ON (the xterm default), not off.
2410 fn soft_reset(&mut self) {
2411 self.cursor.visible = true;
2412 self.cursor.pen = Pen::default();
2413 self.scroll_top = 0;
2414 self.scroll_bottom = self.grid.rows() - 1;
2415 self.origin_mode = false;
2416 self.app_cursor_keys = false;
2417 self.bracketed_paste = false;
2418 self.grapheme_clustering = false; // ?2027 back to the wcwidth-compat default (#295)
2419 self.autowrap = true; // xterm default is ON (not the VT100 "off")
2420 self.insert_mode = false;
2421 self.charsets = [Charset::Ascii; 4];
2422 self.gl = 0;
2423 self.decsc = SavedCursor::default();
2424 }
2425
2426 fn carriage_return(&mut self) {
2427 self.cursor.col = 0;
2428 self.cursor.pending_wrap = false;
2429 }
2430
2431 /// DECSCUSR (CSI Ps SP q): set the caret shape + blink (#89). 0/2 = steady
2432 /// block, 1 = blinking block; 3/4 = blinking/steady underline; 5/6 =
2433 /// blinking/steady bar (odd = blink). 0 resets to the default (steady block).
2434 /// An unknown param leaves the style unchanged. Mirrors xterm.js.
2435 fn set_cursor_style(&mut self, param: u16) {
2436 let (shape, blink) = match param {
2437 0 | 2 => (CursorShape::Block, false),
2438 1 => (CursorShape::Block, true),
2439 3 => (CursorShape::Underline, true),
2440 4 => (CursorShape::Underline, false),
2441 5 => (CursorShape::Bar, true),
2442 6 => (CursorShape::Bar, false),
2443 _ => return,
2444 };
2445 self.cursor.shape = shape;
2446 self.cursor.blink = blink;
2447 }
2448
2449 /// Backspace (BS, 0x08): move the cursor one column left. With reverse
2450 /// wraparound (?45) a backspace at column 0 of a *soft-wrapped* row moves
2451 /// back to the last column of the previous row — undoing one autowrap. Only
2452 /// soft wraps reverse (the previous row carries `WRAPLINE`); a hard CR/LF
2453 /// line does not. BS only (not cursor-left), matching xterm.js (#80).
2454 fn backspace(&mut self) {
2455 self.cursor.pending_wrap = false;
2456 if self.cursor.col > 0 {
2457 self.cursor.col -= 1;
2458 return;
2459 }
2460 if self.reverse_wraparound
2461 && self.cursor.row > self.scroll_top
2462 && self.cursor.row <= self.scroll_bottom
2463 {
2464 let prev = self.cursor.row - 1;
2465 let last = self.grid.cols() - 1;
2466 if self.grid.cell(prev, last).is_wrapline() {
2467 self.grid
2468 .cell_mut(prev, last)
2469 .remove_flags(CellFlags::WRAPLINE);
2470 self.cursor.row = prev;
2471 self.cursor.col = last;
2472 }
2473 }
2474 }
2475
2476 /// Auto-wrap at end of line: line-feed then return to column 0.
2477 fn wrapline(&mut self) {
2478 self.linefeed();
2479 self.cursor.col = 0;
2480 self.cursor.pending_wrap = false;
2481 }
2482
2483 // ---- tab stops (HT / HTS / TBC) ------------------------------------------
2484
2485 /// HT: advance to the next set tab stop, or the last column if none remain
2486 /// (no wrap).
2487 fn put_tab(&mut self) {
2488 let cols = self.grid.cols();
2489 let mut col = self.cursor.col;
2490 while col + 1 < cols {
2491 col += 1;
2492 if self.tabs[col] {
2493 break;
2494 }
2495 }
2496 self.cursor.col = col;
2497 self.cursor.pending_wrap = false;
2498 }
2499
2500 /// HTS (ESC H): set a tab stop at the cursor column.
2501 fn set_tab_stop(&mut self) {
2502 let col = self.cursor.col;
2503 self.tabs[col] = true;
2504 }
2505
2506 /// TBC (CSI g): clear the tab stop at the cursor (mode 0) or all stops
2507 /// (mode 3).
2508 fn clear_tab_stop(&mut self, mode: u16) {
2509 match mode {
2510 0 => {
2511 let col = self.cursor.col;
2512 self.tabs[col] = false;
2513 }
2514 3 => self.tabs.iter_mut().for_each(|t| *t = false),
2515 _ => {}
2516 }
2517 }
2518
2519 // ---- printing ------------------------------------------------------------
2520
2521 /// Write one glyph at the cursor, handling deferred wrap and the wide-char
2522 /// spacer, then advance the cursor (deferring the wrap if it hits the edge).
2523 fn write_glyph(&mut self, c: char, width: usize) {
2524 let cols = self.grid.cols();
2525
2526 // Resolve a deferred last-column wrap before placing the next glyph.
2527 // The row being left soft-wrapped: mark its last cell so reflow (#7) can
2528 // tell it from a hard CR/LF line-end.
2529 if self.cursor.pending_wrap {
2530 let row = self.cursor.row;
2531 self.grid
2532 .cell_mut(row, cols - 1)
2533 .insert_flags(CellFlags::WRAPLINE);
2534 self.wrapline();
2535 }
2536
2537 // A width-2 glyph that cannot fit in the last column wraps first — unless
2538 // autowrap is off, in which case it is dropped (xterm.js `continue`), not
2539 // squeezed or wrapped.
2540 if width == 2 && self.cursor.col + 1 >= cols {
2541 if !self.autowrap {
2542 return;
2543 }
2544 // Mark the row soft-wrapped, like the pending-wrap path above: the
2545 // vacated last column is a continuation, not a hard line-end. Search,
2546 // logical lines (#113), and reflow (#7) all read WRAPLINE for the join.
2547 // Also tag it a leading spacer so the text extractors skip the blank
2548 // (xterm's LEADING_WIDE_CHAR_SPACER) instead of joining "ab한"→"ab 한".
2549 let vacated = self.grid.cell_mut(self.cursor.row, cols - 1);
2550 vacated.insert_flags(CellFlags::WRAPLINE);
2551 vacated.set_leading_spacer();
2552 self.wrapline();
2553 }
2554
2555 // Insert mode (IRM): open a `width`-wide gap at the cursor first, shifting
2556 // the row's tail right (off-edge cells discarded, wide halves repaired),
2557 // then write into the gap — mirrors xterm.js's insertCells (#64).
2558 if self.insert_mode {
2559 self.insert_chars(width);
2560 }
2561
2562 let (row, col) = (self.cursor.row, self.cursor.col);
2563
2564 // Overwriting one half of an existing wide glyph orphans the other —
2565 // clear it so no stray lead/spacer is left behind.
2566 let last = col + width - 1;
2567 if col > 0 && self.grid.cell(row, col).is_wide_spacer() {
2568 self.grid.cell_mut(row, col - 1).reset();
2569 }
2570 if last + 1 < cols && self.grid.cell(row, last).is_wide() {
2571 self.grid.cell_mut(row, last + 1).reset();
2572 }
2573
2574 let mut cell = self.cursor.pen.cell(c);
2575 if width == 2 {
2576 cell.insert_flags(CellFlags::WIDE_CHAR);
2577 }
2578 *self.grid.cell_mut(row, col) = cell;
2579 // Stamp the open hyperlink, if any, into the row's link map (#26/#46).
2580 if let Some(link) = self.current_link {
2581 self.grid.row_mut(row).set_link(col, link);
2582 }
2583
2584 // The trailing column of a wide glyph carries a distinct spacer marker —
2585 // and the same link, so a hover/selection over either half agrees.
2586 if width == 2 && col + 1 < cols {
2587 let mut spacer = self.cursor.pen.cell(' ');
2588 spacer.insert_flags(CellFlags::WIDE_CHAR_SPACER);
2589 *self.grid.cell_mut(row, col + 1) = spacer;
2590 if let Some(link) = self.current_link {
2591 self.grid.row_mut(row).set_link(col + 1, link);
2592 }
2593 }
2594
2595 // Record damage for the cell(s) just written.
2596 self.damage_span(row, col, col + width - 1);
2597
2598 // Advance. Reaching/passing the last column sets pending-wrap instead of
2599 // wrapping eagerly — the cursor parks on the last column.
2600 let new_col = col + width;
2601 if new_col >= cols {
2602 self.cursor.col = cols - 1;
2603 // With autowrap off (DECAWM ?7l) the cursor pins to the last column
2604 // and the next glyph overwrites in place — no deferred wrap (#63).
2605 self.cursor.pending_wrap = self.autowrap;
2606 } else {
2607 self.cursor.col = new_col;
2608 }
2609 }
2610
2611 /// Attach a combining mark (width-0 code point) to the grapheme it modifies —
2612 /// the cell the cursor just left. With pending-wrap the cursor still sits on
2613 /// the just-written last-column glyph, so attach in place (no back-up, no
2614 /// deferred wrap); otherwise step back one column, and once more over a
2615 /// wide-char spacer to reach its lead. Stored in the grapheme side-table.
2616 fn push_combining(&mut self, c: char) {
2617 let row = self.cursor.row;
2618 let mut col = if self.cursor.pending_wrap {
2619 self.cursor.col
2620 } else {
2621 self.cursor.col.saturating_sub(1)
2622 };
2623 if self.grid.cell(row, col).is_wide_spacer() {
2624 col = col.saturating_sub(1);
2625 }
2626 // Append the mark to the row's combining map at this column (setting the
2627 // cell's combining bit). No global pool — the cluster rides the row.
2628 self.grid.row_mut(row).push_combining(col, c);
2629 self.damage_span(row, col, col);
2630 }
2631
2632 /// Mode 2027 (#295): if `c` **extends** the previous cell's grapheme cluster (UAX #29), append
2633 /// it to that cell's side-table — no new cell, no cursor advance — and return `true`. Otherwise
2634 /// return `false` so `print` takes the normal per-scalar path (a break starts a new cell).
2635 ///
2636 /// The break state is reconstructed fresh from the previous cell's stored cluster (base scalar +
2637 /// side-table marks) rather than persisted across calls, so cursor moves / CR-LF can't corrupt
2638 /// it (mirrors ghostty). Width promotion for a narrow base (a flag's second RI, a text-base +
2639 /// VS16) is handled by the caller in a later step; here the base's existing width holds.
2640 fn try_grapheme_join(&mut self, c: char) -> bool {
2641 let row = self.cursor.row;
2642 // Locate the previous cluster's base cell, exactly as `push_combining`: with pending-wrap
2643 // the cursor still sits on the last glyph; else step back one, and over a wide spacer.
2644 let col = if self.cursor.pending_wrap {
2645 self.cursor.col
2646 } else if self.cursor.col == 0 {
2647 return false; // nothing precedes on this row
2648 } else {
2649 self.cursor.col - 1
2650 };
2651 let col = if self.grid.cell(row, col).is_wide_spacer() {
2652 col.saturating_sub(1)
2653 } else {
2654 col
2655 };
2656 // Reconstruct the previous cluster's text: base scalar + any already-joined scalars.
2657 let mut prev = String::new();
2658 prev.push(self.grid.cell(row, col).c());
2659 if let Some(marks) = self.grid.row_ref(row).combining_at(col) {
2660 prev.extend(marks.iter().copied());
2661 }
2662 if !crate::grapheme::grapheme_extends(&prev, c) {
2663 return false;
2664 }
2665 // Join: ride the side-table (no new cell).
2666 self.grid.row_mut(row).push_combining(col, c);
2667 // Width promotion: a flag's second regional indicator, or a text-base + VS16, grows the
2668 // cluster to width 2. `UnicodeWidthStr` gives the cluster width (RI-pair → 2, VS16 → 2). If
2669 // the base cell is still narrow, widen it in place.
2670 let cluster_w = {
2671 prev.push(c);
2672 UnicodeWidthStr::width(prev.as_str())
2673 };
2674 if cluster_w == 2 && !self.grid.cell(row, col).is_wide() {
2675 self.promote_cluster_to_wide(row, col);
2676 } else if cluster_w == 1 && self.grid.cell(row, col).is_wide() {
2677 // The mirror case: a default-wide emoji + VS15 (text selector) shrinks to width 1.
2678 self.demote_cluster_to_narrow(row, col);
2679 }
2680 self.damage_span(row, col, col);
2681 true
2682 }
2683
2684 /// Shrink a wide cluster cell back to a single-width cell (#295): a default-wide emoji joined by
2685 /// VS15 (U+FE0E, the text selector) requests text presentation → width 1. Remove `WIDE_CHAR`,
2686 /// free the spacer, and back the cursor up over it (the inverse of `promote_cluster_to_wide`).
2687 fn demote_cluster_to_narrow(&mut self, row: usize, col: usize) {
2688 let cols = self.grid.cols();
2689 self.grid
2690 .cell_mut(row, col)
2691 .remove_flags(CellFlags::WIDE_CHAR);
2692 if col + 1 < cols {
2693 self.grid.cell_mut(row, col + 1).reset(); // free the now-unused spacer
2694 }
2695 // The cluster shrank 2→1: the cursor sat just past the wide cell (col+2, or pending-wrap on
2696 // the last column); it now sits just past the single-width cell at col+1.
2697 self.cursor.pending_wrap = false;
2698 self.cursor.col = (col + 1).min(cols - 1);
2699 self.damage_span(row, col, (col + 1).min(cols - 1));
2700 }
2701
2702 /// Widen a narrow base cell to a double-width cluster in place (#295): set `WIDE_CHAR`, write
2703 /// its spacer, and step the cursor over it. Only reached when a joining scalar (flag's 2nd RI,
2704 /// VS16) promotes the cluster to width 2. A base pinned at the last column has no room for a
2705 /// spacer — relocation is a later step; until then it stays narrow (rare, renders single-width).
2706 fn promote_cluster_to_wide(&mut self, row: usize, col: usize) {
2707 let cols = self.grid.cols();
2708 if col + 1 >= cols {
2709 // No spacer room at the last column: relocate the whole cluster to the next line as a
2710 // wide cell (the row soft-wraps), mirroring write_glyph's wide-at-boundary wrap (#303).
2711 self.relocate_cluster_wide(row, col);
2712 return;
2713 }
2714 // Overwriting col+1 with the spacer can orphan the far half of a WIDE glyph standing there
2715 // (the cursor may have been repositioned before the joining scalar arrived). Reset that
2716 // orphan, exactly as write_glyph does (2462-2470), so no dangling spacer survives.
2717 if self.grid.cell(row, col + 1).is_wide() && col + 2 < cols {
2718 self.grid.cell_mut(row, col + 2).reset();
2719 }
2720 self.grid
2721 .cell_mut(row, col)
2722 .insert_flags(CellFlags::WIDE_CHAR);
2723 let mut spacer = self.cursor.pen.cell(' ');
2724 spacer.insert_flags(CellFlags::WIDE_CHAR_SPACER);
2725 *self.grid.cell_mut(row, col + 1) = spacer;
2726 // The cursor sat at col+1 (just past the narrow base); move it over the new spacer, applying
2727 // the same last-column pending-wrap rule as a wide write.
2728 let new_col = col + 2;
2729 if new_col >= cols {
2730 self.cursor.col = cols - 1;
2731 self.cursor.pending_wrap = self.autowrap;
2732 } else {
2733 self.cursor.col = new_col;
2734 }
2735 self.damage_span(row, col, col + 1);
2736 }
2737
2738 /// Relocate a last-column narrow cluster to the next line as a wide cell (#303): its base +
2739 /// side-table marks move to `(next_row, 0..=1)` and the vacated last column becomes a soft-wrap
2740 /// (WRAPLINE + leading spacer), exactly as `write_glyph` wraps a wide glyph that can't fit. With
2741 /// autowrap off, or a 1-column screen (no room for a wide cell anywhere), it stays narrow.
2742 fn relocate_cluster_wide(&mut self, row: usize, col: usize) {
2743 let cols = self.grid.cols();
2744 if cols < 2 || !self.autowrap {
2745 return; // nowhere to place a wide cell — leave it narrow
2746 }
2747 // Capture the base cell (glyph + attrs) and its marks before vacating.
2748 let base = *self.grid.cell(row, col);
2749 let marks: Vec<char> = self
2750 .combining_at(row, col)
2751 .map(<[char]>::to_vec)
2752 .unwrap_or_default();
2753 // Vacate the last column as a soft-wrap leading spacer (mirrors write_glyph 2457-2459).
2754 // reset() clears the base's combining bit, so its stale marks entry is never read again.
2755 let vacated = self.grid.cell_mut(row, col);
2756 vacated.reset();
2757 vacated.insert_flags(CellFlags::WRAPLINE);
2758 vacated.set_leading_spacer();
2759 self.damage_span(row, col, col);
2760 // Advance to the next line (scrolls if at the bottom); cursor lands at col 0.
2761 self.wrapline();
2762 let nr = self.cursor.row;
2763 // Re-place the base as a wide lead + spacer, re-attaching the marks fresh (drop the combining
2764 // bit so push_combining starts a clean cluster at the new column).
2765 let mut lead = base;
2766 lead.set_combined(false);
2767 lead.insert_flags(CellFlags::WIDE_CHAR);
2768 *self.grid.cell_mut(nr, 0) = lead;
2769 for m in marks {
2770 self.grid.row_mut(nr).push_combining(0, m);
2771 }
2772 let mut spacer = self.cursor.pen.cell(' ');
2773 spacer.insert_flags(CellFlags::WIDE_CHAR_SPACER);
2774 *self.grid.cell_mut(nr, 1) = spacer;
2775 // Cursor just past the wide cell (pending-wrap if it fills a 2-column row).
2776 if cols <= 2 {
2777 self.cursor.col = cols - 1;
2778 self.cursor.pending_wrap = self.autowrap;
2779 } else {
2780 self.cursor.col = 2;
2781 self.cursor.pending_wrap = false;
2782 }
2783 self.damage_span(nr, 0, 1);
2784 }
2785
2786 // ---- cursor movement (CSI A/B/C/D/G/d/H/f) -------------------------------
2787
2788 fn move_up(&mut self, n: usize) {
2789 self.cursor.row = self.cursor.row.saturating_sub(n);
2790 self.cursor.pending_wrap = false;
2791 }
2792
2793 fn move_down(&mut self, n: usize) {
2794 self.cursor.row = (self.cursor.row + n).min(self.grid.rows() - 1);
2795 self.cursor.pending_wrap = false;
2796 }
2797
2798 fn move_forward(&mut self, n: usize) {
2799 self.cursor.col = (self.cursor.col + n).min(self.grid.cols() - 1);
2800 self.cursor.pending_wrap = false;
2801 }
2802
2803 fn move_back(&mut self, n: usize) {
2804 self.cursor.col = self.cursor.col.saturating_sub(n);
2805 self.cursor.pending_wrap = false;
2806 }
2807
2808 fn set_col(&mut self, col: usize) {
2809 self.cursor.col = col.min(self.grid.cols() - 1);
2810 self.cursor.pending_wrap = false;
2811 }
2812
2813 fn set_row(&mut self, row: usize) {
2814 self.cursor.row = row.min(self.grid.rows() - 1);
2815 self.cursor.pending_wrap = false;
2816 }
2817
2818 fn goto(&mut self, row: usize, col: usize) {
2819 // Origin mode addresses rows relative to the scroll region's top margin
2820 // and clamps to its bottom; otherwise rows are absolute to the screen.
2821 let (offset, max_row) = if self.origin_mode {
2822 (self.scroll_top, self.scroll_bottom)
2823 } else {
2824 (0, self.grid.rows() - 1)
2825 };
2826 self.cursor.row = (row + offset).min(max_row);
2827 self.cursor.col = col.min(self.grid.cols() - 1);
2828 self.cursor.pending_wrap = false;
2829 }
2830
2831 // ---- erase (CSI J / K) ---------------------------------------------------
2832
2833 /// Clear cells `from..to` on `row`.
2834 ///
2835 /// Background Color Erase (BCE): erased cells carry the current SGR
2836 /// background only — fg and text attributes reset to default (matches
2837 /// xterm/alacritty, where the fill is `cursor.template.bg.into()`).
2838 fn clear_cells(&mut self, row: usize, from: usize, to: usize) {
2839 let cols = self.grid.cols();
2840 // Don't orphan a wide char straddling the erase boundary.
2841 if from > 0 && self.grid.cell(row, from).is_wide_spacer() {
2842 self.grid.cell_mut(row, from - 1).reset();
2843 }
2844 if to > from && to < cols && self.grid.cell(row, to - 1).is_wide() {
2845 self.grid.cell_mut(row, to).reset();
2846 }
2847
2848 let bg = self.cursor.pen.bg;
2849 for col in from..to {
2850 let cell = self.grid.cell_mut(row, col);
2851 cell.reset();
2852 cell.set_bg(bg);
2853 }
2854 if to > from {
2855 self.damage_span(row, from, to - 1);
2856 }
2857 }
2858
2859 /// Erase in display (ED): 0 = cursor→end, 1 = start→cursor, 2 = all.
2860 fn erase_display(&mut self, mode: u16) {
2861 let (cols, rows) = (self.grid.cols(), self.grid.rows());
2862 let (cr, cc) = (self.cursor.row, self.cursor.col);
2863 match mode {
2864 0 => {
2865 self.clear_cells(cr, cc, cols);
2866 for row in (cr + 1)..rows {
2867 self.clear_cells(row, 0, cols);
2868 }
2869 }
2870 1 => {
2871 for row in 0..cr {
2872 self.clear_cells(row, 0, cols);
2873 }
2874 self.clear_cells(cr, 0, cc + 1);
2875 }
2876 2 => {
2877 for row in 0..rows {
2878 self.clear_cells(row, 0, cols);
2879 }
2880 }
2881 _ => {}
2882 }
2883 }
2884
2885 /// Erase in line (EL): 0 = cursor→end, 1 = start→cursor, 2 = whole line.
2886 fn erase_line(&mut self, mode: u16) {
2887 let cols = self.grid.cols();
2888 let (cr, cc) = (self.cursor.row, self.cursor.col);
2889 match mode {
2890 0 => self.clear_cells(cr, cc, cols),
2891 1 => self.clear_cells(cr, 0, cc + 1),
2892 2 => self.clear_cells(cr, 0, cols),
2893 _ => {}
2894 }
2895 }
2896
2897 // ---- intra-line editing (ICH / DCH / ECH) --------------------------------
2898
2899 /// ECH (CSI Pn X): erase `n` cells in place from the cursor — no shift.
2900 /// BCE-filled (via `clear_cells`); pending-wrap is left untouched.
2901 fn erase_chars(&mut self, n: usize) {
2902 let cols = self.grid.cols();
2903 let (row, col) = (self.cursor.row, self.cursor.col);
2904 let to = (col + n).min(cols);
2905 self.clear_cells(row, col, to);
2906 }
2907
2908 /// ICH (CSI Pn @): insert `n` blanks at the cursor, shifting the rest of the
2909 /// line right; cells pushed past the right edge are lost. The opened gap is
2910 /// BCE-filled; pending-wrap is left untouched.
2911 fn insert_chars(&mut self, n: usize) {
2912 let cols = self.grid.cols();
2913 let (r, col) = (self.cursor.row, self.cursor.col);
2914 let n = n.min(cols - col);
2915 if n == 0 {
2916 return;
2917 }
2918 let bg = self.cursor.pen.bg;
2919 let row = self.grid.row_mut(r);
2920 // Shift [col .. cols-n) right by n; the tail falls off the edge. The
2921 // combining map follows the moved cells (the bit travels with the raw
2922 // copy, the cluster data must too).
2923 row.copy_within(col..cols - n, col + n);
2924 row.move_maps(col..cols - n, col + n);
2925 for cell in &mut row[col..col + n] {
2926 cell.reset();
2927 cell.set_bg(bg);
2928 }
2929 // Repair wide-char halves split at the seams (no-orphan invariant):
2930 // a lead just before the gap lost its spacer; the first shifted cell may
2931 // be a spacer whose lead did not move.
2932 if col > 0 && self.grid.cell(r, col - 1).is_wide() {
2933 self.grid.cell_mut(r, col - 1).reset();
2934 }
2935 if col + n < cols && self.grid.cell(r, col + n).is_wide_spacer() {
2936 self.grid.cell_mut(r, col + n).reset();
2937 }
2938 // A lead shifted to the last column lost its spacer off the edge.
2939 if self.grid.cell(r, cols - 1).is_wide() {
2940 self.grid.cell_mut(r, cols - 1).reset();
2941 }
2942 self.damage_span(r, col, cols - 1);
2943 }
2944
2945 /// DCH (CSI Pn P): delete `n` cells at the cursor, shifting the tail left; the
2946 /// vacated cells at the right are BCE-blanked. Pending-wrap is left untouched.
2947 fn delete_chars(&mut self, n: usize) {
2948 let cols = self.grid.cols();
2949 let (r, col) = (self.cursor.row, self.cursor.col);
2950 let n = n.min(cols - col);
2951 if n == 0 {
2952 return;
2953 }
2954 let bg = self.cursor.pen.bg;
2955 let row = self.grid.row_mut(r);
2956 // Shift [col+n .. cols) left to [col ..); BCE-fill the vacated tail. The
2957 // combining map follows the moved cells.
2958 row.copy_within(col + n..cols, col);
2959 row.move_maps(col + n..cols, col);
2960 for cell in &mut row[cols - n..cols] {
2961 cell.reset();
2962 cell.set_bg(bg);
2963 }
2964 // Repair wide-char halves split by the deletion (no-orphan invariant):
2965 // a lead just before the cut lost its spacer; the cell now at the cursor
2966 // may be a spacer whose lead was deleted.
2967 if col > 0 && self.grid.cell(r, col - 1).is_wide() {
2968 self.grid.cell_mut(r, col - 1).reset();
2969 }
2970 if self.grid.cell(r, col).is_wide_spacer() {
2971 self.grid.cell_mut(r, col).reset();
2972 }
2973 self.damage_span(r, col, cols - 1);
2974 }
2975
2976 // ---- line/region editing (IL / DL / SU / SD) -----------------------------
2977
2978 /// Scroll rows `[top..=bottom]` by `n` lines, BCE-filling the exposed lines.
2979 /// `down` inserts blanks at the top (content moves down); otherwise content
2980 /// moves up and blanks appear at the bottom. Reuses the one-line region scroll
2981 /// primitives (so damage + scroll-op accumulation come for free), then fills
2982 /// the exposed lines with the current SGR background.
2983 fn scroll_region_lines(&mut self, top: usize, bottom: usize, n: usize, down: bool) {
2984 let height = bottom - top + 1;
2985 let n = n.min(height);
2986 if n == 0 {
2987 return;
2988 }
2989 // Anchors (selection #3, markers #118/#158) live at absolute buffer lines;
2990 // SU/SD/IL/DL don't accrue scrollback, so `base` is stable across the loop.
2991 let base = self.scrollback.len();
2992 for _ in 0..n {
2993 if down {
2994 self.grid.scroll_down_region(top, bottom);
2995 self.record_scroll(top, bottom, -1);
2996 } else {
2997 self.grid.scroll_up_region(top, bottom);
2998 self.record_scroll(top, bottom, 1);
2999 }
3000 // Rotate anchors with the content, like `linefeed`/`reverse_index`
3001 // (#162). `up` = content moved up = the non-`down` case. Markers rotate
3002 // with the active buffer (#187) — alt-scoped on the alt screen, so no
3003 // guard; the selection is cleared on alt enter.
3004 self.selection_rotate_region(base + top, base + bottom, !down);
3005 self.markers_rotate_region(base + top, base + bottom, !down);
3006 }
3007 self.invalidate_search_highlights();
3008 // BCE-fill the n exposed lines (the primitives blank to default).
3009 let bg = self.cursor.pen.bg;
3010 let (fill_top, fill_end) = if down {
3011 (top, top + n)
3012 } else {
3013 (bottom + 1 - n, bottom + 1)
3014 };
3015 let cols = self.grid.cols();
3016 for r in fill_top..fill_end {
3017 for c in 0..cols {
3018 let cell = self.grid.cell_mut(r, c);
3019 cell.reset();
3020 cell.set_bg(bg);
3021 }
3022 }
3023 }
3024
3025 /// SU (CSI Pn S): scroll the scroll region up by `n`.
3026 fn scroll_up_lines(&mut self, n: usize) {
3027 self.scroll_region_lines(self.scroll_top, self.scroll_bottom, n, false);
3028 }
3029
3030 /// SD (CSI Pn T): scroll the scroll region down by `n`.
3031 fn scroll_down_lines(&mut self, n: usize) {
3032 self.scroll_region_lines(self.scroll_top, self.scroll_bottom, n, true);
3033 }
3034
3035 /// IL (CSI Pn L): insert `n` blank lines at the cursor, scrolling
3036 /// `[cursor..=scroll_bottom]` down. A no-op when the cursor is outside the
3037 /// scroll region.
3038 fn insert_lines(&mut self, n: usize) {
3039 let cur = self.cursor.row;
3040 if cur < self.scroll_top || cur > self.scroll_bottom {
3041 return;
3042 }
3043 self.scroll_region_lines(cur, self.scroll_bottom, n, true);
3044 }
3045
3046 /// DL (CSI Pn M): delete `n` lines at the cursor, scrolling
3047 /// `[cursor..=scroll_bottom]` up. A no-op when the cursor is outside the
3048 /// scroll region.
3049 fn delete_lines(&mut self, n: usize) {
3050 let cur = self.cursor.row;
3051 if cur < self.scroll_top || cur > self.scroll_bottom {
3052 return;
3053 }
3054 self.scroll_region_lines(cur, self.scroll_bottom, n, false);
3055 }
3056
3057 // ---- SGR (CSI m) ---------------------------------------------------------
3058
3059 fn sgr(&mut self, params: &Params) {
3060 let pen = &mut self.cursor.pen;
3061 let mut iter = params.iter();
3062 while let Some(param) = iter.next() {
3063 let code = param.first().copied().unwrap_or(0);
3064 match code {
3065 0 => pen.reset(),
3066 1 => pen.flags.insert(CellFlags::BOLD),
3067 2 => pen.flags.insert(CellFlags::DIM),
3068 3 => pen.flags.insert(CellFlags::ITALIC),
3069 4 => pen.flags.insert(CellFlags::UNDERLINE),
3070 5 => pen.flags.insert(CellFlags::BLINK),
3071 7 => pen.flags.insert(CellFlags::INVERSE),
3072 8 => pen.flags.insert(CellFlags::HIDDEN),
3073 9 => pen.flags.insert(CellFlags::STRIKETHROUGH),
3074 22 => pen.flags.remove(CellFlags::BOLD | CellFlags::DIM),
3075 23 => pen.flags.remove(CellFlags::ITALIC),
3076 24 => pen.flags.remove(CellFlags::UNDERLINE),
3077 25 => pen.flags.remove(CellFlags::BLINK),
3078 27 => pen.flags.remove(CellFlags::INVERSE),
3079 28 => pen.flags.remove(CellFlags::HIDDEN),
3080 29 => pen.flags.remove(CellFlags::STRIKETHROUGH),
3081 30..=37 => pen.fg = Color::Indexed((code - 30) as u8),
3082 38 => {
3083 if let Some(c) = parse_extended_color(param, &mut iter) {
3084 pen.fg = c;
3085 }
3086 }
3087 39 => pen.fg = Color::Default,
3088 40..=47 => pen.bg = Color::Indexed((code - 40) as u8),
3089 48 => {
3090 if let Some(c) = parse_extended_color(param, &mut iter) {
3091 pen.bg = c;
3092 }
3093 }
3094 49 => pen.bg = Color::Default,
3095 // bright foreground/background (aixterm) → palette 8..=15.
3096 90..=97 => pen.fg = Color::Indexed((code - 90 + 8) as u8),
3097 100..=107 => pen.bg = Color::Indexed((code - 100 + 8) as u8),
3098 _ => {}
3099 }
3100 }
3101 }
3102}
3103
3104/// Parse `38`/`48` extended colour, in either form:
3105/// - sub-parameter (colon) form inline in `param`: `38:5:n`, `38:2:r:g:b`
3106/// (optionally `38:2:cs:r:g:b` with a colorspace id), or
3107/// - legacy (semicolon) form: pull the following top-level params from `iter`.
3108fn parse_extended_color<'a, I>(param: &[u16], iter: &mut I) -> Option<Color>
3109where
3110 I: Iterator<Item = &'a [u16]>,
3111{
3112 if param.len() > 1 {
3113 // Colon sub-parameter form: kind is param[1].
3114 match param[1] {
3115 2 => {
3116 // 38:2:r:g:b (len 5) or 38:2:cs:r:g:b (len 6, colorspace skipped).
3117 let off = if param.len() >= 6 { 3 } else { 2 };
3118 let r = *param.get(off)? as u8;
3119 let g = *param.get(off + 1)? as u8;
3120 let b = *param.get(off + 2)? as u8;
3121 Some(Color::Rgb(r, g, b))
3122 }
3123 5 => Some(Color::Indexed(*param.get(2)? as u8)),
3124 _ => None,
3125 }
3126 } else {
3127 // Legacy semicolon form: kind, then its operands, are separate params.
3128 match iter.next()?.first().copied()? {
3129 2 => {
3130 let r = iter.next()?.first().copied()? as u8;
3131 let g = iter.next()?.first().copied()? as u8;
3132 let b = iter.next()?.first().copied()? as u8;
3133 Some(Color::Rgb(r, g, b))
3134 }
3135 5 => Some(Color::Indexed(iter.next()?.first().copied()? as u8)),
3136 _ => None,
3137 }
3138 }
3139}
3140
3141/// Reflow one screen (joined with its `scrollback`) to `cols` x `rows`, tracking
3142/// `point` (a cursor in screen coordinates). Returns the new screen rows, the new
3143/// scrollback (capped to `limit`), and the new point. The alt screen passes an
3144/// empty scrollback and discards the returned one.
3145/// The fixed dimensions a resize reflows toward.
3146#[derive(Clone, Copy)]
3147struct ReflowDims {
3148 old_cols: usize,
3149 cols: usize,
3150 rows: usize,
3151 limit: usize,
3152}
3153
3154/// The result of reflowing one pane.
3155struct PaneReflow {
3156 screen: Vec<Row>,
3157 scrollback: VecDeque<Row>,
3158 /// The cursor's new screen-relative position.
3159 cursor: (usize, usize),
3160 /// Each tracked extra point's new **absolute** position, index-aligned with
3161 /// the `extra_abs` argument.
3162 extras: Vec<(usize, usize)>,
3163}
3164
3165/// Reflow one pane (its `scrollback` joined with `screen`) to `dims`, tracking
3166/// the screen-relative cursor `point` plus any `extra_abs` points given in
3167/// **absolute** `[scrollback ++ screen]` coordinates (selection anchors).
3168fn reflow_pane(
3169 screen: Vec<Row>,
3170 scrollback: VecDeque<Row>,
3171 point: (usize, usize),
3172 extra_abs: &[(usize, usize)],
3173 dims: ReflowDims,
3174) -> PaneReflow {
3175 let scroll_len = scrollback.len();
3176 let mut all: Vec<Row> = scrollback.into();
3177 all.extend(screen);
3178
3179 // The cursor is screen-relative; lift it to absolute, then track it together
3180 // with the already-absolute extras.
3181 let mut pts: Vec<(usize, usize)> = Vec::with_capacity(1 + extra_abs.len());
3182 pts.push((scroll_len + point.0, point.1));
3183 pts.extend_from_slice(extra_abs);
3184
3185 let pts = if dims.cols != dims.old_cols {
3186 let (reflowed, np) = crate::grid::reflow(all, dims.cols, &pts);
3187 all = reflowed;
3188 np
3189 } else {
3190 pts
3191 };
3192
3193 let split = all.len().saturating_sub(dims.rows);
3194 let history: Vec<Row> = all.drain(0..split).collect();
3195 let mut sb: VecDeque<Row> = history.into();
3196 let mut dropped = 0usize;
3197 while sb.len() > dims.limit {
3198 sb.pop_front();
3199 dropped += 1;
3200 }
3201
3202 // The cursor returns to screen-relative (its absolute index minus the
3203 // history split). The extras stay absolute, shifted down by any lines the
3204 // cap dropped from the front of history.
3205 PaneReflow {
3206 cursor: (pts[0].0.saturating_sub(split), pts[0].1),
3207 extras: pts[1..]
3208 .iter()
3209 .map(|&(l, c)| (l.saturating_sub(dropped), c))
3210 .collect(),
3211 screen: all,
3212 scrollback: sb,
3213 }
3214}
3215
3216/// Whether `c` ends a word for Word (semantic) selection. Whitespace plus a
3217/// punctuation set mirroring Alacritty's default `semantic_escape_chars`, so
3218/// path/URL-ish runs (`.`, `/`, `-`) stay one word.
3219fn is_word_boundary(c: char) -> bool {
3220 c.is_whitespace() || ",│`|:\"'()[]{}<>".contains(c)
3221}
3222
3223/// Whether the run `hay[i..i+len]` is bounded by non-word characters on both sides — the `\bword\b`
3224/// sense for whole-word search (#314). A word char is alphanumeric or `_` (the regex `\w` set),
3225/// deliberately distinct from `is_word_boundary`'s wider semantic-selection set.
3226fn word_bounded(hay: &[char], i: usize, len: usize) -> bool {
3227 // A word char is alphanumeric, `_`, OR a grapheme-extending mark (width 0: combining marks,
3228 // ZWJ, variation selectors) — so a mark attached to a base is never read as a word boundary,
3229 // matching the regex `\b` sense (`\w` includes `\p{M}`) and staying consistent across the
3230 // literal and regex paths on decomposed graphemes (#314 Lens 1).
3231 let is_word = |c: char| c.is_alphanumeric() || c == '_' || c.width() == Some(0);
3232 let left = i == 0 || !is_word(hay[i - 1]);
3233 let right = i + len == hay.len() || !is_word(hay[i + len]);
3234 left && right
3235}
3236
3237/// Default tab stops: one every 8 columns (incl. column 0), matching xterm.
3238fn default_tabs(cols: usize) -> Vec<bool> {
3239 (0..cols).map(|i| i % 8 == 0).collect()
3240}
3241
3242/// First sub-parameter of CSI param `idx`, or `default` when absent or zero
3243/// (a zero/omitted numeric param means "1" for cursor movement and "0" for
3244/// erase — callers pass the right default).
3245fn param_or(params: &Params, idx: usize, default: u16) -> u16 {
3246 match params.iter().nth(idx).and_then(|p| p.first().copied()) {
3247 Some(v) if v != 0 => v,
3248 _ => default,
3249 }
3250}
3251
3252impl Term {
3253 /// Apply one DEC private mode set (`'h'`) or reset (`'l'`). DECSET/DECRST
3254 /// carry a list of modes, so `csi_dispatch` folds this over every parameter
3255 /// (#56); each mode is an independent toggle, not a stack.
3256 fn set_dec_private_mode(&mut self, action: char, mode: u16) {
3257 match (action, mode) {
3258 ('h', 1049) => self.enter_alt_screen(),
3259 ('l', 1049) => self.leave_alt_screen(),
3260 // Legacy alt-screen variants (#72): ?47/?1047 switch the buffer
3261 // without saving the cursor; ?1048 saves/restores the cursor without
3262 // switching. ?1049 is the two combined.
3263 ('h', 47) | ('h', 1047) => self.switch_to_alt(),
3264 ('l', 47) | ('l', 1047) => self.switch_to_primary(),
3265 ('h', 1048) => self.save_alt_cursor(),
3266 ('l', 1048) => self.restore_alt_cursor(),
3267 ('h', 6) => {
3268 // DECOM: set homes the cursor to the region top.
3269 self.origin_mode = true;
3270 self.goto(0, 0);
3271 }
3272 ('l', 6) => self.origin_mode = false, // unset leaves the cursor put
3273 ('h', 7) => self.autowrap = true, // DECAWM
3274 ('l', 7) => self.autowrap = false,
3275 ('h', 45) => self.reverse_wraparound = true, // reverse wraparound (#80)
3276 ('l', 45) => self.reverse_wraparound = false,
3277 // DECCOLM (#82): the engine is dimension-free, so emit a request the
3278 // consumer may honor by resizing — no screen/cursor/margin change here.
3279 ('h', 3) => self.events.push(TermEvent::ColumnMode { cols: 132 }),
3280 ('l', 3) => self.events.push(TermEvent::ColumnMode { cols: 80 }),
3281 ('h', 25) => self.cursor.visible = true, // DECTCEM show
3282 ('l', 25) => self.cursor.visible = false, // DECTCEM hide
3283 ('h', 12) => self.cursor.blink = true, // att610 cursor blink (#81)
3284 ('l', 12) => self.cursor.blink = false,
3285 ('h', 2004) => self.bracketed_paste = true,
3286 ('l', 2004) => self.bracketed_paste = false,
3287 ('h', 2026) => self.synchronized_output = true, // synchronized output (#73)
3288 ('l', 2026) => self.synchronized_output = false,
3289 ('h', 2027) => self.grapheme_clustering = true, // grapheme-cluster mode (#295)
3290 ('l', 2027) => self.grapheme_clustering = false,
3291 ('h', 2031) => self.color_scheme_updates = true, // color-scheme notifications (#85)
3292 ('l', 2031) => self.color_scheme_updates = false,
3293 ('h', 9001) => self.win32_input_mode = true, // win32-input-mode (#86)
3294 ('l', 9001) => self.win32_input_mode = false,
3295
3296 // Input-encoding modes (#11): DECCKM, mouse tracking + encoding,
3297 // focus reporting. Each set assigns the level; each reset clears
3298 // it (apps enable/disable the same mode, not a stack).
3299 ('h', 1) => self.app_cursor_keys = true, // DECCKM
3300 ('l', 1) => self.app_cursor_keys = false,
3301 ('h', 66) => self.application_keypad = true, // DECNKM (#74)
3302 ('l', 66) => self.application_keypad = false,
3303 // DECANM (#84): set = ANSI (the normal state); reset enters VT52. Only
3304 // the reset is meaningful — `?2h` is a no-op (already ANSI).
3305 ('l', 2) => self.vt52_mode = true,
3306 ('h', 9) => self.mouse_protocol = MouseProtocol::X10, // X10 mouse (#70)
3307 ('h', 1000) => self.mouse_protocol = MouseProtocol::Normal,
3308 ('h', 1002) => self.mouse_protocol = MouseProtocol::ButtonEvent,
3309 ('h', 1003) => self.mouse_protocol = MouseProtocol::AnyEvent,
3310 ('l', 9) | ('l', 1000) | ('l', 1002) | ('l', 1003) => {
3311 self.mouse_protocol = MouseProtocol::Off
3312 }
3313 ('h', 1006) => self.mouse_encoding = MouseEncoding::Sgr,
3314 ('l', 1006) => self.mouse_encoding = MouseEncoding::Default,
3315 ('h', 1015) => self.mouse_encoding = MouseEncoding::Urxvt,
3316 ('l', 1015) => self.mouse_encoding = MouseEncoding::Default,
3317 ('h', 1005) => self.mouse_encoding = MouseEncoding::Utf8,
3318 ('l', 1005) => self.mouse_encoding = MouseEncoding::Default,
3319 ('h', 1016) => self.mouse_encoding = MouseEncoding::SgrPixels,
3320 ('l', 1016) => self.mouse_encoding = MouseEncoding::Default,
3321 ('h', 1004) => self.focus_events = true,
3322 ('l', 1004) => self.focus_events = false,
3323
3324 _ => {} // other DEC modes are later slices
3325 }
3326 }
3327
3328 /// Dispatch one VT52 escape sequence (`ESC <final>`), reached only while
3329 /// `vt52_mode` is set (#84). VT52 is a pre-ANSI dialect: the cursor/erase
3330 /// finals map to the same `Term` primitives the ANSI path uses. `ESC <`
3331 /// returns to ANSI. Unknown finals are ignored.
3332 fn vt52_dispatch(&mut self, byte: u8) {
3333 match byte {
3334 b'A' => self.move_up(1), // cursor up
3335 b'B' => self.move_down(1), // cursor down
3336 b'C' => self.move_forward(1), // cursor right
3337 b'D' => self.move_back(1), // cursor left
3338 b'H' => self.goto(0, 0), // cursor home
3339 b'I' => self.reverse_index(), // reverse line feed
3340 b'J' => self.erase_display(0), // erase cursor → end of screen
3341 b'K' => self.erase_line(0), // erase cursor → end of line
3342 b'Y' => self.vt52_y_pending = 2, // direct address: two coord bytes follow
3343 // Identify (DECID): reply `ESC / Z` — "I am a VT52".
3344 b'Z' => self.replies.extend_from_slice(b"\x1b/Z"),
3345 b'=' => self.application_keypad = true, // enter alternate keypad
3346 b'>' => self.application_keypad = false, // exit alternate keypad
3347 b'<' => self.vt52_mode = false, // exit VT52, return to ANSI
3348 // RIS (`ESC c`) is honored even here: it is a hard "recover from any
3349 // state" reset, and `full_reset` rebuilds `Term` with `vt52_mode`
3350 // cleared, so RIS always escapes VT52 back to ANSI. VT52 defines no
3351 // other meaning for `ESC c`.
3352 b'c' => self.full_reset(),
3353 // Graphics mode (`ESC F`/`ESC G`) is a documented non-goal: the VT52
3354 // graphics glyph set differs from DEC Special Graphics, so reusing that
3355 // charset would render the wrong glyphs. No-op rather than approximate.
3356 b'F' | b'G' => {}
3357 _ => {} // unknown VT52 finals are ignored
3358 }
3359 }
3360
3361 /// Consume one `ESC Y` coordinate byte (#84). The first byte is the row, the
3362 /// second the column; each decodes as `value - 0x20`. On the second byte the
3363 /// cursor is addressed (`goto` clamps out-of-range coordinates). Reached only
3364 /// from `print` while `vt52_y_pending > 0`.
3365 fn vt52_take_coord(&mut self, c: char) {
3366 let coord = (c as usize).saturating_sub(0x20);
3367 if self.vt52_y_pending == 2 {
3368 self.vt52_y_row = coord;
3369 self.vt52_y_pending = 1;
3370 } else {
3371 self.vt52_y_pending = 0;
3372 self.goto(self.vt52_y_row, coord);
3373 }
3374 }
3375}
3376
3377impl Perform for Term {
3378 fn print(&mut self, c: char) {
3379 // VT52 `ESC Y` direct addressing (#84): vte delivers the two coordinate
3380 // bytes here (it returned to ground after the `Y` final), so intercept
3381 // them before they would be written as glyphs.
3382 if self.vt52_y_pending > 0 {
3383 self.vt52_take_coord(c);
3384 return;
3385 }
3386 // Translate through the active (GL) character set first (#62): under DEC
3387 // Special Graphics a printable byte becomes a line-drawing glyph.
3388 let c = self.charsets[self.gl].map(c);
3389 // Grapheme-cluster mode (DEC ?2027, #295): if `c` extends the previous cell's cluster,
3390 // join it there instead of placing a new cell. OFF → the per-char (wcwidth) path below.
3391 if self.grapheme_clustering && self.try_grapheme_join(c) {
3392 return;
3393 }
3394 match c.width() {
3395 // Zero-width (combining marks): the grapheme-cluster side-table is a
3396 // later slice; drop for now rather than mis-place it as its own cell.
3397 // A zero-width code point is a combining mark — attach it to the
3398 // previous base glyph rather than dropping it.
3399 Some(0) => self.push_combining(c),
3400 None => {}
3401 Some(width) => self.write_glyph(c, width),
3402 }
3403 }
3404
3405 fn execute(&mut self, byte: u8) {
3406 match byte {
3407 // LF, VT, FF all line-feed.
3408 b'\n' | 0x0b | 0x0c => self.linefeed(),
3409 b'\r' => self.carriage_return(),
3410 0x08 => self.backspace(),
3411 b'\t' => self.put_tab(),
3412 0x07 => self.events.push(TermEvent::Bell), // BEL (#12)
3413 0x0e => self.gl = 1, // SO (LS1): GL = G1 (#62)
3414 0x0f => self.gl = 0, // SI (LS0): GL = G0
3415 _ => {}
3416 }
3417 }
3418
3419 fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, action: char) {
3420 // Kitty keyboard-protocol negotiation: CSI > / = / < / ? ... u. The
3421 // leading intermediate distinguishes it from plain `CSI u` (SCORC) (#23).
3422 if action == 'u'
3423 && let Some(&lead) = intermediates.first()
3424 && matches!(lead, b'>' | b'<' | b'=' | b'?')
3425 {
3426 self.kitty_dispatch(lead, params);
3427 return;
3428 }
3429 // DEC private modes arrive with a '?' intermediate.
3430 if intermediates.first() == Some(&b'?') {
3431 // DECRQM (CSI ? Ps $ p) — report whether mode Ps is set. The '$'
3432 // intermediate distinguishes it from a plain `?...p`. It queries a
3433 // single mode, so it keys off the first parameter only.
3434 if action == 'p' && intermediates.contains(&b'$') {
3435 self.decrqm(param_or(params, 0, 0));
3436 return;
3437 }
3438 // Private DSR (CSI ? Ps n): ?996 = color-scheme query (#85). The
3439 // theme-agnostic engine relays it as an event for the consumer.
3440 if action == 'n' {
3441 if param_or(params, 0, 0) == 996 {
3442 self.events.push(TermEvent::ColorSchemeQuery);
3443 }
3444 return;
3445 }
3446 // DECSET/DECRST carry a *list* of modes; apply set/reset to EVERY
3447 // parameter, not just the first — htop batches `?1006;1000h` into one
3448 // CSI, so folding only params[0] dropped the 1000 (#56).
3449 for mode in params.iter().filter_map(|p| p.first().copied()) {
3450 self.set_dec_private_mode(action, mode);
3451 }
3452 return;
3453 }
3454 // DECSTR soft reset: CSI ! p (#53).
3455 if intermediates.first() == Some(&b'!') && action == 'p' {
3456 self.soft_reset();
3457 return;
3458 }
3459 // DECSCUSR set cursor style: CSI Ps SP q (space intermediate) (#89). An
3460 // absent param means 1 (block blink); an explicit 0 means reset — so the
3461 // raw value matters and `param_or` (which folds 0 to its default) is wrong.
3462 if intermediates.first() == Some(&b' ') && action == 'q' {
3463 let param = params.iter().next().and_then(|p| p.first().copied());
3464 self.set_cursor_style(param.unwrap_or(1));
3465 return;
3466 }
3467 // Other private/intermediate sequences are later slices; ignore them
3468 // rather than misinterpret.
3469 if !intermediates.is_empty() {
3470 return;
3471 }
3472 match action {
3473 'A' => self.move_up(param_or(params, 0, 1) as usize),
3474 'B' | 'e' => self.move_down(param_or(params, 0, 1) as usize),
3475 'C' | 'a' => self.move_forward(param_or(params, 0, 1) as usize),
3476 'D' => self.move_back(param_or(params, 0, 1) as usize),
3477 'G' | '`' => self.set_col(param_or(params, 0, 1) as usize - 1),
3478 'd' => self.set_row(param_or(params, 0, 1) as usize - 1),
3479 'H' | 'f' => {
3480 let row = param_or(params, 0, 1) as usize - 1;
3481 let col = param_or(params, 1, 1) as usize - 1;
3482 self.goto(row, col);
3483 }
3484 'J' => self.erase_display(param_or(params, 0, 0)),
3485 'K' => self.erase_line(param_or(params, 0, 0)),
3486 'X' => self.erase_chars(param_or(params, 0, 1) as usize),
3487 '@' => self.insert_chars(param_or(params, 0, 1) as usize),
3488 'P' => self.delete_chars(param_or(params, 0, 1) as usize),
3489 'S' => self.scroll_up_lines(param_or(params, 0, 1) as usize),
3490 'T' => self.scroll_down_lines(param_or(params, 0, 1) as usize),
3491 'L' => self.insert_lines(param_or(params, 0, 1) as usize),
3492 'M' => self.delete_lines(param_or(params, 0, 1) as usize),
3493 'g' => self.clear_tab_stop(param_or(params, 0, 0)),
3494 'r' => {
3495 let rows = self.grid.rows() as u16;
3496 let top = param_or(params, 0, 1) as usize;
3497 let bottom = param_or(params, 1, rows) as usize;
3498 self.set_scroll_region(top, bottom);
3499 }
3500 'm' => self.sgr(params),
3501 's' => self.save_cursor(), // SCOSC (CSI s) — alias of DECSC
3502 'u' => self.restore_cursor(), // SCORC (CSI u) — alias of DECRC
3503 // DA1 (primary device attributes, CSI c): advertise VT220 + ANSI
3504 // colour — the levels justerm actually implements (#27).
3505 'c' => self.replies.extend_from_slice(b"\x1b[?62;22c"),
3506 'n' => self.device_status_report(param_or(params, 0, 0)),
3507 // Non-private SM/RM. Folded over every parameter (modes can batch,
3508 // like the private path #56). IRM (4) and LNM (20) so far.
3509 'h' => {
3510 for m in params.iter().filter_map(|p| p.first().copied()) {
3511 match m {
3512 4 => self.insert_mode = true,
3513 20 => self.newline_mode = true,
3514 _ => {}
3515 }
3516 }
3517 }
3518 'l' => {
3519 for m in params.iter().filter_map(|p| p.first().copied()) {
3520 match m {
3521 4 => self.insert_mode = false,
3522 20 => self.newline_mode = false,
3523 _ => {}
3524 }
3525 }
3526 }
3527 _ => {}
3528 }
3529 }
3530
3531 fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, byte: u8) {
3532 // VT52 mode (#84): the pre-ANSI dialect reuses the same `ESC <final>`
3533 // tokens vte already produces, but with different meanings, so it is a
3534 // mode-gated branch here rather than a separate parser. All VT52 sequences
3535 // are intermediate-free; anything with an intermediate is not VT52.
3536 if self.vt52_mode && intermediates.is_empty() {
3537 self.vt52_dispatch(byte);
3538 return;
3539 }
3540 if let Some(&i) = intermediates.first() {
3541 // SCS: designate a charset to G0 (`ESC ( F`) or G1 (`ESC ) F`) (#62).
3542 if matches!(i, b'(' | b')') {
3543 let set = match byte {
3544 b'0' => Charset::DecSpecialGraphics,
3545 b'A' => Charset::Uk,
3546 b'B' => Charset::Ascii,
3547 _ => return, // other sets are later slices
3548 };
3549 self.charsets[if i == b'(' { 0 } else { 1 }] = set;
3550 }
3551 // Other intermediates (G2/G3 designators, etc.) are later slices.
3552 return;
3553 }
3554 match byte {
3555 b'D' => self.linefeed(), // IND (line-feed without CR)
3556 b'E' => {
3557 // NEL (next line): carriage return + line-feed.
3558 self.carriage_return();
3559 self.linefeed();
3560 }
3561 b'H' => self.set_tab_stop(), // HTS
3562 b'M' => self.reverse_index(), // RI
3563 b'7' => self.save_cursor(), // DECSC
3564 b'8' => self.restore_cursor(), // DECRC
3565 b'c' => self.full_reset(), // RIS (#53)
3566 b'=' => self.application_keypad = true, // DECKPAM (#74)
3567 b'>' => self.application_keypad = false, // DECKPNM
3568 _ => {}
3569 }
3570 }
3571
3572 /// OSC dispatch (#12 event surface): title (0/2), cwd (7). OSC 8 hyperlink
3573 /// is per-cell state, handled in its own slice (#26), not here.
3574 fn osc_dispatch(&mut self, params: &[&[u8]], _bell_terminated: bool) {
3575 // params[0] is the OSC number; params[1..] the payload fields.
3576 let Some(&number) = params.first() else {
3577 return;
3578 };
3579 match number {
3580 // OSC 0 = icon + window title, OSC 2 = window title. Both set title.
3581 b"0" | b"2" => {
3582 if let Some(&title) = params.get(1) {
3583 self.events.push(TermEvent::Title(
3584 String::from_utf8_lossy(title).into_owned(),
3585 ));
3586 }
3587 }
3588 // OSC 7 = current working directory (a file:// URI).
3589 b"7" => {
3590 if let Some(&cwd) = params.get(1) {
3591 self.events
3592 .push(TermEvent::Cwd(String::from_utf8_lossy(cwd).into_owned()));
3593 }
3594 }
3595 // OSC 133 = FinalTerm/iTerm2 shell-integration command marks (#158):
3596 // `A` prompt start, `B` command start, `C` output start, `D[;exit]`
3597 // command finished. Each anchors a kinded marker at the cursor line;
3598 // pairing + navigation is consumer policy (#160). Unknown subcommands
3599 // (or none) are ignored. `D`'s exit field parses to `i32`, else None.
3600 b"133" => match params.get(1).copied() {
3601 Some(b"A") => self.add_command_mark(MarkerKind::PromptStart),
3602 Some(b"B") => self.add_command_mark(MarkerKind::CommandStart),
3603 Some(b"C") => self.add_command_mark(MarkerKind::OutputStart),
3604 Some(b"D") => {
3605 let exit = params
3606 .get(2)
3607 .and_then(|p| core::str::from_utf8(p).ok())
3608 .and_then(|s| s.parse::<i32>().ok());
3609 self.add_command_mark(MarkerKind::CommandFinished(exit));
3610 }
3611 _ => {}
3612 },
3613 // OSC 8 = hyperlink: `OSC 8 ; params ; URI`. A non-empty URI opens a
3614 // link (interned + made current); an empty URI closes it. `params`
3615 // (e.g. `id=…`) is ignored for now — id-grouping is a later refinement.
3616 b"8" => {
3617 let uri = params.get(2).copied().unwrap_or(b"");
3618 if uri.is_empty() {
3619 self.current_link = None;
3620 } else {
3621 self.hyperlink_pool
3622 .push(String::from_utf8_lossy(uri).into_owned());
3623 self.current_link =
3624 core::num::NonZeroU32::new(self.hyperlink_pool.len() as u32);
3625 }
3626 }
3627 // OSC 4 = set/query an ANSI palette entry: `OSC 4 ; index ; spec`
3628 // (#122). The engine forwards index + raw spec; the consumer applies
3629 // it to its palette (theme-agnostic — the cell keeps `Indexed`).
3630 b"4" => {
3631 // One event per `index ; spec` pair (xterm's `while slots > 1`).
3632 let mut rest = ¶ms[1..];
3633 while let [idx, spec, tail @ ..] = rest {
3634 rest = tail;
3635 if let Ok(index) = String::from_utf8_lossy(idx).parse::<u8>() {
3636 if *spec == b"?" {
3637 self.events.push(TermEvent::QueryPaletteColor { index });
3638 } else {
3639 self.events.push(TermEvent::SetPaletteColor {
3640 index,
3641 spec: String::from_utf8_lossy(spec).into_owned(),
3642 });
3643 }
3644 }
3645 }
3646 }
3647 // OSC 104 = reset palette entries (#122): no arg resets the whole
3648 // table, else one event per named index.
3649 b"104" => {
3650 if params.len() <= 1 {
3651 self.events.push(TermEvent::ResetPaletteColor(None));
3652 } else {
3653 for &idx in ¶ms[1..] {
3654 if let Ok(index) = String::from_utf8_lossy(idx).parse::<u8>() {
3655 self.events.push(TermEvent::ResetPaletteColor(Some(index)));
3656 }
3657 }
3658 }
3659 }
3660 // OSC 10/11 = set/query the default foreground/background, stacking
3661 // specs across the [fg, bg] slots (#122, #137). OSC 10 starts at fg,
3662 // OSC 11 at bg. The engine forwards raw specs (theme-agnostic).
3663 b"10" => self.special_color(params, 0),
3664 b"11" => self.special_color(params, 1),
3665 // OSC 110 / 111 = reset the default foreground / background (#122).
3666 b"110" => self.events.push(TermEvent::ResetForeground),
3667 b"111" => self.events.push(TermEvent::ResetBackground),
3668 _ => {} // other OSCs are later slices
3669 }
3670 }
3671}