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;
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::search::Match;
21use crate::selection::{Anchor, BufferPoint, Selection, SelectionSpan, SelectionType, Side};
22use crate::serialize::{Frame, FrameKind, Span};
23
24/// Owns the authoritative screen state and applies VT actions to it.
25pub struct Term {
26 grid: Grid,
27 /// The inactive screen. Swapped with `grid` on alt-screen enter/leave; holds
28 /// whichever of primary/alternate is not currently shown. The alt screen has
29 /// no scrollback (#3 only rings the primary).
30 alt_grid: Grid,
31 cursor: Cursor,
32 /// Cursor saved on alt-screen enter (DEC 1049), restored on leave.
33 saved_cursor: Cursor,
34 /// Whether the alternate screen is currently active. Guards enter/leave so a
35 /// double-enter or double-leave is a no-op.
36 on_alt: bool,
37 /// One flag per column: is there a tab stop here? Explicit per-column state
38 /// (HTS sets, TBC clears), not a fixed modulo. Default = every 8th column.
39 tabs: Vec<bool>,
40 /// Origin mode (DECOM ?6): when set, cursor addressing is relative to the
41 /// scroll region's top margin (and clamped to it).
42 origin_mode: bool,
43 /// Autowrap (DECAWM ?7): default on. When off, a glyph past the right margin
44 /// pins the cursor to the last column and overwrites in place instead of
45 /// wrapping to the next line (matches xterm.js) (#63).
46 autowrap: bool,
47 /// Insert mode (IRM, the non-private SM/RM mode 4): default off (replace).
48 /// When on, a printed glyph shifts the row's tail right first (#64).
49 insert_mode: bool,
50 /// New-line mode (LNM, the non-private SM/RM mode 20): default off. When on,
51 /// a line feed also carriage-returns (`convertEol`). Output-only — the Enter
52 /// key still encodes CR, matching xterm.js (#71).
53 newline_mode: bool,
54 /// Reverse wraparound (DEC ?45): default off. When on, a *backspace* at
55 /// column 0 of a soft-wrapped row moves back to the end of the previous row
56 /// (BS only, soft wraps only — matches xterm.js) (#80).
57 reverse_wraparound: bool,
58 /// Bracketed-paste mode (DEC ?2004). The engine owns the flag; the input
59 /// encoder (#11) reads it to decide whether to wrap pasted text in markers.
60 bracketed_paste: bool,
61 /// Synchronized output (DEC ?2026): the app brackets a frame of output so the
62 /// renderer can paint it atomically. The engine only *tracks* the flag — the
63 /// consumer owns the paint-hold and the spec-mandated timeout (#73).
64 synchronized_output: bool,
65 /// Color-scheme-update notifications (DEC ?2031): the app asked to be told
66 /// when the light/dark scheme changes. The engine is theme-agnostic — it only
67 /// tracks the flag; the consumer (which knows the scheme) drives the ?997
68 /// notification via `report_color_scheme` (#85).
69 color_scheme_updates: bool,
70 /// win32-input-mode (DEC ?9001): the app asked for keys as raw Windows
71 /// key-records. The engine only *tracks* the flag — the raw record encoding
72 /// (`CSI Vk;Sc;Uc;Kd;Cs;Rc _`) is a non-goal (raw passthrough, no semantic
73 /// conversion), left to the ConPTY consumer; `encode_key` is unchanged (#86).
74 win32_input_mode: bool,
75 /// Application cursor keys (DECCKM ?1): when set, cursor keys / Home / End
76 /// encode as SS3 rather than CSI (see `input.rs`).
77 app_cursor_keys: bool,
78 /// Application keypad mode (DECNKM ?66 / DECKPAM `ESC =` / DECKPNM `ESC >`):
79 /// tracked for protocol completeness + DECRQM, but NOT yet acted on in key
80 /// encoding — xterm.js tracks it the same way and never reads it (#74).
81 application_keypad: bool,
82 /// VT52 compatibility mode (DECANM ?2 *reset*): when set, `esc_dispatch` is
83 /// re-routed into the pre-ANSI VT52 dialect (`ESC A`-style sequences) instead
84 /// of the ANSI meaning. `ESC <` clears it. Default off (ANSI). (#84)
85 vt52_mode: bool,
86 /// VT52 `ESC Y row col` direct-addressing state (#84). vte tokenizes `ESC Y`
87 /// as a final and returns to ground, so the two coordinate bytes arrive as
88 /// `print()` calls — not part of the escape sequence. This counts them down
89 /// (2 → 1 → 0; 0 = not addressing) and `vt52_y_row` parks the first (row)
90 /// until the second (col) lands. Each byte decodes as `value - 0x20`.
91 vt52_y_pending: u8,
92 vt52_y_row: usize,
93 /// Mouse tracking mode — what events the app asked to be reported
94 /// (?1000/?1002/?1003). `Off` by default.
95 mouse_protocol: MouseProtocol,
96 /// Mouse coordinate encoding (default X10 vs ?1006 SGR).
97 mouse_encoding: MouseEncoding,
98 /// Focus in/out reporting (?1004): emit `CSI I`/`CSI O` on focus change.
99 focus_events: bool,
100 /// Kitty keyboard-protocol progressive-enhancement flags currently in effect
101 /// (bit0 disambiguate, bit1 report-events, bit2 alt-keys, bit3 all-as-escape,
102 /// bit4 associated-text). 0 = legacy. `encode_key` consults these (#23).
103 kitty_flags: u8,
104 /// Saved `kitty_flags` for the protocol's push/pop stack (`CSI > u` pushes,
105 /// `CSI < u` pops). Capped depth — overflow drops the oldest entry.
106 kitty_stack: Vec<u8>,
107 /// Consumer events (title / bell / cwd) accumulated since the last
108 /// `drain_events` (#12). Pull, not push — see `event.rs`.
109 events: Vec<TermEvent>,
110 /// Outbound reply bytes (DA/DSR/DECRQM query answers, #27) accumulated
111 /// during `feed` for the consumer to write back to the PTY. Raw bytes →
112 /// PTY, kept separate from typed `events` → UI.
113 replies: Vec<u8>,
114 /// Hyperlink side-table (OSC 8): each entry is one link's URI, referenced by
115 /// `Cell.link` (1-based). Append-only (#26).
116 hyperlink_pool: Vec<String>,
117 /// The hyperlink currently open (OSC 8 with a URI), stamped onto every glyph
118 /// written until closed (OSC 8 with empty URI). Ambient pen-like state — not
119 /// part of the pen/SGR, and *not* cleared by an SGR reset.
120 current_link: Option<core::num::NonZeroU32>,
121 /// Scroll region top/bottom margins (DECSTBM), 0-based inclusive. A
122 /// line-feed at `scroll_bottom` scrolls only rows `[scroll_top..=scroll_bottom]`.
123 /// Default = the full screen.
124 scroll_top: usize,
125 scroll_bottom: usize,
126 /// Lines that have scrolled off the top of the primary screen, oldest at the
127 /// front. Accrues only on a top-anchored, primary-screen scroll.
128 scrollback: VecDeque<Row>,
129 /// How many lines the viewport is scrolled up from the bottom. 0 = following
130 /// the live screen; clamped to `[0, scrollback.len()]`.
131 display_offset: usize,
132 /// Maximum scrollback lines retained; the oldest are evicted past this.
133 scrollback_limit: usize,
134 /// A spare row buffer recycled across full-screen scrolls: the cap-evicted
135 /// oldest line is parked here and reused as the next scroll's blank bottom,
136 /// so a steady-state flood allocates nothing (ADR-0009).
137 recycled_row: Option<Row>,
138 /// Per-line damage bounds since the last `reset_damage` (ack), one per row.
139 line_damage: Vec<LineBounds>,
140 /// A first-class scroll recorded since the last `reset_damage`.
141 scroll: Option<ScrollOp>,
142 /// The whole screen changed (alt switch / clear / later resize+flood) — the
143 /// renderer must redraw everything.
144 full_damage: bool,
145 /// The cursor `(row, col)` at the last `reset_damage` (ack) — where the
146 /// consumer last saw the caret. A pure cursor move records no content
147 /// damage, so `damage()` folds this *old* cell plus the current one into the
148 /// frame; without it a cell-invert caret ghosts at the old spot (mirrors
149 /// Alacritty's `last_cursor`). #38.
150 prev_cursor: (usize, usize),
151 /// The live selection, in absolute buffer coordinates. `None` when nothing
152 /// is selected. See `selection.rs`.
153 selection: Option<Selection>,
154 /// Cursor state saved by DECSC (ESC 7), restored by DECRC (ESC 8). A slot
155 /// separate from `saved_cursor` (which is the alt-screen save). Defaults to
156 /// home/default so a DECRC with no prior DECSC restores a sane state.
157 decsc: SavedCursor,
158 /// SCS-designated character sets G0..G3 (#62). `gl` indexes the active (GL)
159 /// set, switched by SI (→G0) / SO (→G1). First cut uses G0/G1.
160 charsets: [Charset; 4],
161 gl: usize,
162}
163
164/// A character set designated by SCS (#62). First cut: ASCII (default), DEC
165/// Special Graphics (line-drawing), and UK. G2/G3 and the GR half are later.
166#[derive(Clone, Copy, PartialEq, Eq, Default)]
167enum Charset {
168 #[default]
169 Ascii,
170 DecSpecialGraphics,
171 Uk,
172}
173
174impl Charset {
175 /// Map one GL byte (a `char` in the 7-bit range) through this set. ASCII and
176 /// any out-of-range char pass through; UK swaps `#`→£; DEC Special Graphics
177 /// translates `_`..`~` to the line-drawing / symbol glyphs.
178 fn map(self, c: char) -> char {
179 match self {
180 Charset::Ascii => c,
181 Charset::Uk if c == '#' => '£',
182 Charset::Uk => c,
183 Charset::DecSpecialGraphics => dec_special_graphics(c),
184 }
185 }
186}
187
188/// The VT100 DEC Special Graphics set: bytes `_`..`~` (0x5F..0x7E) map to the
189/// box-drawing and symbol glyphs. Matches xterm/alacritty; anything outside the
190/// range passes through unchanged.
191fn dec_special_graphics(c: char) -> char {
192 // Keys ``..`~` only — `_` (0x5F) is deliberately absent, matching xterm.js /
193 // alacritty (it passes through as a literal underscore), not the strict-DEC
194 // "0x5F = blank" reading.
195 match c {
196 '`' => '◆',
197 'a' => '▒',
198 'b' => '␉',
199 'c' => '␌',
200 'd' => '␍',
201 'e' => '␊',
202 'f' => '°',
203 'g' => '±',
204 'h' => '',
205 'i' => '␋',
206 'j' => '┘',
207 'k' => '┐',
208 'l' => '┌',
209 'm' => '└',
210 'n' => '┼',
211 'o' => '⎺',
212 'p' => '⎻',
213 'q' => '─',
214 'r' => '⎼',
215 's' => '⎽',
216 't' => '├',
217 'u' => '┤',
218 'v' => '┴',
219 'w' => '┬',
220 'x' => '│',
221 'y' => '≤',
222 'z' => '≥',
223 '{' => 'π',
224 '|' => '≠',
225 '}' => '£',
226 '~' => '·',
227 other => other,
228 }
229}
230
231/// Default scrollback retention when not specified.
232const DEFAULT_SCROLLBACK: usize = 10_000;
233
234/// The state DECSC (ESC 7) saves and DECRC (ESC 8) restores: position, pen/SGR,
235/// pending-wrap, and origin mode (per ADR-0004 — DECRC restores origin mode,
236/// which Alacritty omits). Cursor *visibility* is deliberately not part of this
237/// (DECTCEM is separate from DECSC).
238#[derive(Clone, Copy, Default)]
239struct SavedCursor {
240 row: usize,
241 col: usize,
242 pen: Pen,
243 pending_wrap: bool,
244 origin_mode: bool,
245 /// SCS charset state at save time — DECSC/DECRC round-trip the designated
246 /// sets and the active GL shift (#62).
247 charsets: [Charset; 4],
248 gl: usize,
249}
250
251/// A selection resolved to absolute-coordinate bounds, ready for text extraction
252/// or viewport-span projection. Columns are half-open (`from..to`).
253enum Resolved {
254 /// Char/Word/Line: a run that joins soft-wrapped rows. Columns apply to the
255 /// first/last line; middle lines are whole.
256 Linear {
257 start_line: usize,
258 from: usize,
259 end_line: usize,
260 to: usize,
261 },
262 /// Block: a rectangle — the same `from..to` columns on every row.
263 Block {
264 line0: usize,
265 line1: usize,
266 from: usize,
267 to: usize,
268 },
269}
270
271/// Collect per-line damage bounds into damaged `LineDamage` spans (undamaged
272/// lines dropped). Shared by `damage` (content-only) and `frame_damage`
273/// (content + cursor cells).
274fn bounds_to_lines(bounds: &[LineBounds]) -> Vec<LineDamage> {
275 bounds
276 .iter()
277 .enumerate()
278 .filter(|(_, b)| b.is_damaged())
279 .map(|(line, b)| {
280 let (left, right) = b.span();
281 LineDamage { line, left, right }
282 })
283 .collect()
284}
285
286impl Term {
287 pub fn new(cols: usize, rows: usize) -> Self {
288 Self::with_scrollback(cols, rows, DEFAULT_SCROLLBACK)
289 }
290
291 pub fn with_scrollback(cols: usize, rows: usize, scrollback_limit: usize) -> Self {
292 Term {
293 grid: Grid::new(cols, rows),
294 alt_grid: Grid::new(cols, rows),
295 cursor: Cursor::default(),
296 saved_cursor: Cursor::default(),
297 on_alt: false,
298 origin_mode: false,
299 autowrap: true,
300 insert_mode: false,
301 newline_mode: false,
302 reverse_wraparound: false,
303 bracketed_paste: false,
304 synchronized_output: false,
305 color_scheme_updates: false,
306 win32_input_mode: false,
307 app_cursor_keys: false,
308 application_keypad: false,
309 vt52_mode: false,
310 vt52_y_pending: 0,
311 vt52_y_row: 0,
312 mouse_protocol: MouseProtocol::Off,
313 mouse_encoding: MouseEncoding::Default,
314 focus_events: false,
315 kitty_flags: 0,
316 kitty_stack: Vec::new(),
317 events: Vec::new(),
318 replies: Vec::new(),
319 hyperlink_pool: Vec::new(),
320 current_link: None,
321 tabs: default_tabs(cols),
322 scroll_top: 0,
323 scroll_bottom: rows - 1,
324 scrollback: VecDeque::new(),
325 display_offset: 0,
326 scrollback_limit,
327 recycled_row: None,
328 line_damage: vec![LineBounds::undamaged(cols); rows],
329 scroll: None,
330 full_damage: false,
331 prev_cursor: (0, 0), // matches the default cursor's home position
332 selection: None,
333 decsc: SavedCursor::default(),
334 charsets: [Charset::Ascii; 4],
335 gl: 0,
336 }
337 }
338
339 /// What changed since the last `reset_damage()` — line ranges, each with a
340 /// changed column span. See ADR-0003.
341 pub fn damage(&self) -> TermDamage {
342 if self.full_damage {
343 return TermDamage::Full;
344 }
345 // Scrolled up under follow-bottom "stay": the viewport is frozen, so
346 // screen changes below it are not visible — report nothing. (A user
347 // scroll that moves the viewport sets full_damage above.)
348 if self.display_offset > 0 {
349 return TermDamage::Partial(Vec::new());
350 }
351 TermDamage::Partial(bounds_to_lines(&self.line_damage))
352 }
353
354 /// Render damage: content damage plus the cursor cells, for [`Term::frame`].
355 ///
356 /// A pure cursor move changes no cell *content*, so [`Term::damage`] (which
357 /// stays content-only, the cadence/flow-control primitive) would miss it —
358 /// yet a cell-invert caret must clear its old spot and ink the new one. So
359 /// the frame producer folds the old (last-acked) + current cursor cells in,
360 /// but only when the cursor actually moved: a still cursor needs no redraw,
361 /// keeping an idle frame empty. Mirrors Alacritty's `last_cursor`. #38.
362 fn frame_damage(&self) -> TermDamage {
363 if self.full_damage {
364 return TermDamage::Full;
365 }
366 if self.display_offset > 0 {
367 return TermDamage::Partial(Vec::new());
368 }
369 let cur = self.cursor.point();
370 if cur == self.prev_cursor {
371 return TermDamage::Partial(bounds_to_lines(&self.line_damage));
372 }
373 let mut bounds = self.line_damage.clone();
374 bounds[cur.0].expand(cur.1, cur.1);
375 let pr = self.prev_cursor.0.min(self.grid.rows() - 1);
376 let pc = self.prev_cursor.1.min(self.grid.cols() - 1);
377 bounds[pr].expand(pc, pc);
378 TermDamage::Partial(bounds_to_lines(&bounds))
379 }
380
381 /// Clear accumulated damage. The consumer calls this after applying a frame
382 /// (the ack); the next `damage()` reflects only changes since.
383 pub fn reset_damage(&mut self) {
384 for b in &mut self.line_damage {
385 b.reset();
386 }
387 self.scroll = None;
388 self.full_damage = false;
389 // The consumer has now seen the caret at the current position; the next
390 // frame's cursor-move damage is measured from here (#38).
391 self.prev_cursor = self.cursor.point();
392 }
393
394 /// Mark the whole screen damaged (alt switch / clear / flood, and a consumer
395 /// reattach that needs a full re-sync — see [`crate::Engine::mark_fully_damaged`]).
396 pub fn mark_fully_damaged(&mut self) {
397 self.full_damage = true;
398 }
399
400 /// Record that columns `[left, right]` of `row` changed.
401 fn damage_span(&mut self, row: usize, left: usize, right: usize) {
402 self.line_damage[row].expand(left, right);
403 }
404
405 /// The first-class scroll recorded since the last `reset_damage`, if any.
406 /// Suppressed while scrolled up — a content scroll must not shift the frozen
407 /// viewport.
408 pub fn scroll_delta(&self) -> Option<ScrollOp> {
409 if self.display_offset > 0 {
410 return None;
411 }
412 self.scroll
413 }
414
415 /// Build a serializable [`Frame`] from the current damage + grid + grapheme
416 /// pool (#6). `Full` ships every row; `Partial` ships the damaged spans. The
417 /// global side-table is remapped to **frame-local** indices — the engine pool
418 /// is append-only and leaky, so a frame carries only the clusters its cells
419 /// reference, renumbered, with each cell's `extra` rewritten to the local id.
420 pub fn frame(&self) -> Frame {
421 let cols = self.grid.cols();
422 let rows = self.grid.rows();
423 let (kind, line_spans): (FrameKind, Vec<(usize, usize, usize)>) = match self.frame_damage()
424 {
425 TermDamage::Full => (
426 FrameKind::Full,
427 (0..rows).map(|l| (l, 0, cols - 1)).collect(),
428 ),
429 TermDamage::Partial(lines) => (
430 FrameKind::Partial,
431 lines
432 .into_iter()
433 .map(|d| (d.line, d.left, d.right))
434 .collect(),
435 ),
436 };
437
438 let mut side_table: Vec<Vec<char>> = Vec::new();
439 // Same frame-local renumber for the hyperlink side-table (#26).
440 let mut link_table: Vec<String> = Vec::new();
441 let mut link_remap = vec![0u16; self.hyperlink_pool.len() + 1];
442 // Cells come from the viewport at `display_offset`, not the live grid:
443 // viewport row `line` is absolute buffer line `top + line` (scrollback
444 // when scrolled up, the live grid when `display_offset == 0`, where
445 // `top == scrollback.len()` and this is identical to reading the grid).
446 // Without this, a wire consumer — cells reach it only through `frame()` —
447 // could never display scrollback (#48).
448 let top = self.scrollback.len() - self.display_offset;
449 let mut spans = Vec::with_capacity(line_spans.len());
450 for (line, left, right) in line_spans {
451 let mut cells = Vec::with_capacity(right - left + 1);
452 let mut combining = std::collections::BTreeMap::new();
453 let mut links = std::collections::BTreeMap::new();
454 let row = self.abs_row(top + line);
455 for col in left..=right {
456 let cell = row[col];
457 // Combining clusters and hyperlinks live in the row's maps; each
458 // tagged cell contributes its reference to the frame, recorded on
459 // the span by span-relative column (the cell holds only the bit).
460 if let Some(marks) = row.combining_at(col) {
461 side_table.push(marks.to_vec());
462 let idx = core::num::NonZeroU32::new(side_table.len() as u32)
463 .expect("side_table just pushed, len >= 1");
464 combining.insert(col - left, idx);
465 }
466 if let Some(lidx) = row.link_at(col) {
467 // Renumber the global pool index to a contiguous frame-local
468 // one (only referenced URIs ship), same as the old per-cell link.
469 let l = lidx.get() as usize;
470 if link_remap[l] == 0 {
471 link_table.push(self.hyperlink_pool[l - 1].clone());
472 link_remap[l] = link_table.len() as u16;
473 }
474 let fidx = core::num::NonZeroU32::new(link_remap[l] as u32)
475 .expect("link_remap just set, nonzero");
476 links.insert(col - left, fidx);
477 }
478 cells.push(cell);
479 }
480 spans.push(Span {
481 line: line as u16,
482 left: left as u16,
483 right: right as u16,
484 cells,
485 combining,
486 links,
487 });
488 }
489
490 Frame {
491 cols: cols as u16,
492 rows: rows as u16,
493 kind,
494 // The live cursor: position in screen coords + DECTCEM visibility.
495 // Reported, not drawn — the consumer renders the caret (#38).
496 cursor_row: self.cursor.row as u16,
497 cursor_col: self.cursor.col as u16,
498 // Hidden while scrolled up: the live cursor is off the frozen
499 // viewport, and a cell-invert caret would otherwise ink over
500 // scrollback. Consistent with the frozen-damage policy (no cursor
501 // damage is emitted while scrolled) and with xterm.js / alacritty,
502 // which hide the caret when it falls outside the visible rows (#48).
503 cursor_visible: self.cursor.visible && self.display_offset == 0,
504 cursor_shape: self.cursor.shape,
505 cursor_blink: self.cursor.blink,
506 scroll: self.scroll_delta(),
507 spans,
508 side_table,
509 link_table,
510 }
511 }
512
513 /// Record a scroll of rows `[top, bottom]` by `count` (positive = up).
514 ///
515 /// Damage is indexed by row position, so it must follow the content the
516 /// scroll just moved: rotate the bounds the same way and mark the newly
517 /// exposed line fully damaged (it is new blank content for the consumer).
518 fn record_scroll(&mut self, top: usize, bottom: usize, count: isize) {
519 let cols = self.grid.cols();
520 match count {
521 1 => {
522 self.line_damage[top..=bottom].rotate_left(1);
523 self.line_damage[bottom] = LineBounds::fully_damaged(cols);
524 }
525 -1 => {
526 self.line_damage[top..=bottom].rotate_right(1);
527 self.line_damage[top] = LineBounds::fully_damaged(cols);
528 }
529 _ => {}
530 }
531 // Accumulate repeated scrolls of the same region into one op (flow
532 // control). A *different* region cannot be expressed as one op, so
533 // degrade to full rather than silently dropping the earlier scroll.
534 match self.scroll {
535 Some(op) if op.top == top && op.bottom == bottom => {
536 self.scroll = Some(ScrollOp {
537 top,
538 bottom,
539 count: op.count + count,
540 });
541 }
542 None => self.scroll = Some(ScrollOp { top, bottom, count }),
543 Some(_) => {
544 self.scroll = None;
545 self.mark_fully_damaged();
546 }
547 }
548 }
549
550 /// Number of lines currently held in scrollback history.
551 pub fn scrollback_len(&self) -> usize {
552 self.scrollback.len()
553 }
554
555 /// Whether the app has an open synchronized-output block (DEC ?2026, #73).
556 pub fn synchronized_output(&self) -> bool {
557 self.synchronized_output
558 }
559
560 /// Whether the app enabled color-scheme-update notifications (DEC ?2031, #85).
561 pub fn color_scheme_updates(&self) -> bool {
562 self.color_scheme_updates
563 }
564
565 /// Whether the app enabled win32-input-mode (DEC ?9001, #86). The engine does
566 /// not encode the raw key-records itself (a non-goal); a ConPTY consumer reads
567 /// this to decide whether to emit them.
568 pub fn win32_input_mode(&self) -> bool {
569 self.win32_input_mode
570 }
571
572 /// Queue a color-scheme report (`CSI ? 997 ; 1 n` dark / `; 2 n` light) on the
573 /// reply channel. The consumer calls this to answer a `ColorSchemeQuery` event
574 /// or, when its scheme changes and `color_scheme_updates()` is set, to send the
575 /// unsolicited notification. The engine never stores or interprets the scheme
576 /// (#85).
577 pub fn report_color_scheme(&mut self, dark: bool) {
578 let ps = if dark { 1 } else { 2 };
579 self.replies
580 .extend_from_slice(format!("\x1b[?997;{ps}n").as_bytes());
581 }
582
583 /// The cells of visible row `i` (0..rows) at the current scroll position.
584 /// The viewport windows into `[history.. ; screen..]`: rows above
585 /// `scrollback.len()` come from history, the rest from the live screen.
586 pub fn viewport_line(&self, i: usize) -> &[Cell] {
587 let top = self.scrollback.len() - self.display_offset;
588 let idx = top + i;
589 if idx < self.scrollback.len() {
590 &self.scrollback[idx]
591 } else {
592 self.grid.row(idx - self.scrollback.len())
593 }
594 }
595
596 /// Scroll the viewport up by `n` lines into history (clamped to the oldest).
597 pub fn scroll_up(&mut self, n: usize) {
598 let target = (self.display_offset + n).min(self.scrollback.len());
599 self.set_display_offset(target);
600 }
601
602 /// Scroll the viewport down by `n` lines toward the live screen.
603 pub fn scroll_down(&mut self, n: usize) {
604 let target = self.display_offset.saturating_sub(n);
605 self.set_display_offset(target);
606 }
607
608 /// Jump the viewport back to the live screen (follow the bottom).
609 pub fn scroll_to_bottom(&mut self) {
610 self.set_display_offset(0);
611 }
612
613 /// Move the viewport. A user scroll changes which lines are visible, so the
614 /// whole viewport is repainted (full damage) when the offset actually moves.
615 fn set_display_offset(&mut self, offset: usize) {
616 // The alt screen has no scrollback to view; scroll intents are no-ops.
617 if self.on_alt {
618 return;
619 }
620 if offset != self.display_offset {
621 self.display_offset = offset;
622 self.mark_fully_damaged();
623 }
624 }
625
626 // ---- selection -----------------------------------------------------------
627
628 /// Map a viewport cell `(row, col)` to an absolute buffer point. The top
629 /// visible row is `scrollback.len() - display_offset`, so viewport row `i`
630 /// is that plus `i`.
631 fn viewport_to_abs(&self, row: usize, col: usize) -> BufferPoint {
632 let top = self.scrollback.len() - self.display_offset;
633 BufferPoint {
634 line: top + row,
635 col,
636 }
637 }
638
639 /// The cells of absolute buffer line `line` (`[scrollback ++ screen]`).
640 fn abs_line(&self, line: usize) -> &[Cell] {
641 if line < self.scrollback.len() {
642 &self.scrollback[line]
643 } else {
644 self.grid.row(line - self.scrollback.len())
645 }
646 }
647
648 /// The whole row (cells + combining map) of absolute buffer line `line`.
649 fn abs_row(&self, line: usize) -> &Row {
650 if line < self.scrollback.len() {
651 &self.scrollback[line]
652 } else {
653 self.grid.row_ref(line - self.scrollback.len())
654 }
655 }
656
657 /// The combining marks at absolute `(line, col)`, or `None` — flag-gated
658 /// through the row's map, so a stale entry is never surfaced.
659 fn combining_at(&self, line: usize, col: usize) -> Option<&[char]> {
660 self.abs_row(line).combining_at(col)
661 }
662
663 /// The hyperlink-pool index at **screen** `(row, col)` (the live grid), or
664 /// `None` — flag-gated through the row's link map. Resolve to the URI with
665 /// [`Term::hyperlink`]. Mirrors `grid().cell(row, col)`.
666 pub(crate) fn screen_link_at(&self, row: usize, col: usize) -> Option<core::num::NonZeroU32> {
667 self.grid.row_ref(row).link_at(col)
668 }
669
670 /// The hyperlink-pool index at **viewport** `(row, col)` (visible window,
671 /// history included at the current scroll), or `None`. Mirrors
672 /// `viewport_line(row)`.
673 pub(crate) fn viewport_link_at(&self, row: usize, col: usize) -> Option<core::num::NonZeroU32> {
674 let idx = self.scrollback.len() - self.display_offset + row;
675 self.abs_row(idx).link_at(col)
676 }
677
678 /// Literal search over the whole buffer (`[scrollback ++ screen]`), returning
679 /// every non-overlapping match top-to-bottom in absolute coordinates. Matches
680 /// cross soft-wrapped rows (one logical line) and skip wide-char spacers.
681 /// Smart-case: a query with no uppercase matches case-insensitively.
682 pub fn search(&self, query: &str) -> Vec<Match> {
683 let q: Vec<char> = query.chars().collect();
684 if q.is_empty() {
685 return Vec::new();
686 }
687 let ci = !q.iter().any(|c| c.is_uppercase());
688 // Fold to a single representative char so the haystack stays 1:1 with its
689 // positions (rare multi-char case expansions take their first char).
690 let fold = |c: char| {
691 if ci {
692 c.to_lowercase().next().unwrap_or(c)
693 } else {
694 c
695 }
696 };
697 let needle: Vec<char> = q.iter().map(|&c| fold(c)).collect();
698 let total = self.scrollback.len() + self.grid.rows();
699
700 let mut matches = Vec::new();
701 let mut r = 0;
702 while r < total {
703 // Build the logical line at `r`: join soft-wrapped rows, recording
704 // each char's source position and skipping wide-char spacers.
705 let mut hay: Vec<char> = Vec::new();
706 let mut pos: Vec<(usize, usize)> = Vec::new();
707 let mut line = r;
708 loop {
709 let cells = self.abs_line(line);
710 for (col, cell) in cells.iter().enumerate() {
711 if cell.is_wide_spacer() {
712 continue;
713 }
714 hay.push(fold(cell.c()));
715 pos.push((line, col));
716 }
717 let soft = cells.last().is_some_and(|c| c.is_wrapline());
718 if soft && line + 1 < total {
719 line += 1;
720 } else {
721 break;
722 }
723 }
724
725 // Slide the needle over the logical line (non-overlapping).
726 let mut i = 0;
727 while needle.len() <= hay.len() && i + needle.len() <= hay.len() {
728 if hay[i..i + needle.len()] == needle[..] {
729 let (start_line, start_col) = pos[i];
730 let (end_line, end_col) = pos[i + needle.len() - 1];
731 matches.push(Match {
732 start_line,
733 start_col,
734 end_line,
735 end_col,
736 });
737 i += needle.len();
738 } else {
739 i += 1;
740 }
741 }
742 r = line + 1;
743 }
744 matches
745 }
746
747 /// Scroll the viewport so a match's start line is visible (placed at the top
748 /// when it sits in history; the live view when it is already on screen).
749 pub fn search_scroll_to(&mut self, m: &Match) {
750 let target = self.scrollback.len().saturating_sub(m.start_line);
751 self.set_display_offset(target);
752 }
753
754 /// Project a match onto the current viewport as inclusive-column spans, one
755 /// per visible row (off-screen parts dropped) — for the renderer to
756 /// highlight, like `selection_range`.
757 pub fn match_spans(&self, m: &Match) -> Vec<SelectionSpan> {
758 let rows = self.grid.rows();
759 let top = self.scrollback.len() - self.display_offset;
760 let mut spans = Vec::new();
761 for line in m.start_line..=m.end_line {
762 if line < top {
763 continue;
764 }
765 let row = line - top;
766 if row >= rows {
767 break;
768 }
769 let last = self.abs_line(line).len().saturating_sub(1);
770 let left = if line == m.start_line { m.start_col } else { 0 };
771 let right = if line == m.end_line {
772 m.end_col.min(last)
773 } else {
774 last
775 };
776 if right >= left {
777 spans.push(SelectionSpan { row, left, right });
778 }
779 }
780 spans
781 }
782
783 /// Begin a selection of `ty` at viewport `(row, col)`, `side`.
784 pub fn selection_begin(&mut self, row: usize, col: usize, side: Side, ty: SelectionType) {
785 let anchor = Anchor {
786 point: self.viewport_to_abs(row, col),
787 side,
788 };
789 self.selection = Some(Selection {
790 ty,
791 anchor,
792 focus: anchor,
793 });
794 }
795
796 /// Extend the live selection's focus to viewport `(row, col)`, `side`.
797 pub fn selection_extend(&mut self, row: usize, col: usize, side: Side) {
798 let focus = Anchor {
799 point: self.viewport_to_abs(row, col),
800 side,
801 };
802 if let Some(sel) = &mut self.selection {
803 sel.focus = focus;
804 }
805 }
806
807 /// Clear the selection.
808 pub fn selection_clear(&mut self) {
809 self.selection = None;
810 }
811
812 /// Shift the selection up by one absolute line after the oldest history line
813 /// is evicted by the scrollback cap. An endpoint clamps to the new top; if
814 /// the whole selection was on the evicted line, it is cleared.
815 fn selection_evict_oldest(&mut self) {
816 let Some((a, f)) = self
817 .selection
818 .as_ref()
819 .map(|s| (s.anchor.point.line, s.focus.point.line))
820 else {
821 return;
822 };
823 if a == 0 && f == 0 {
824 self.selection = None;
825 return;
826 }
827 if let Some(sel) = &mut self.selection {
828 sel.anchor.point.line = a.saturating_sub(1);
829 sel.focus.point.line = f.saturating_sub(1);
830 }
831 }
832
833 /// Rotate the selection within an in-screen scroll of absolute lines
834 /// `[top, bottom]`. `up` = content scrolled up (a line dropped at `top`);
835 /// otherwise down (dropped at `bottom`). Lines outside the region are
836 /// untouched; an endpoint on the dropped line scrolls out, so the whole
837 /// selection is cleared rather than copy stale content.
838 fn selection_rotate_region(&mut self, top: usize, bottom: usize, up: bool) {
839 let rotate = |line: usize| -> Option<usize> {
840 if line < top || line > bottom {
841 return Some(line); // outside the region — unchanged
842 }
843 if up {
844 (line != top).then(|| line - 1)
845 } else {
846 (line != bottom).then_some(line + 1)
847 }
848 };
849 let Some((a, f)) = self
850 .selection
851 .as_ref()
852 .map(|s| (s.anchor.point.line, s.focus.point.line))
853 else {
854 return;
855 };
856 match (rotate(a), rotate(f)) {
857 (Some(al), Some(fl)) => {
858 if let Some(sel) = &mut self.selection {
859 sel.anchor.point.line = al;
860 sel.focus.point.line = fl;
861 }
862 }
863 _ => self.selection = None,
864 }
865 }
866
867 /// The selection projected onto the current viewport: one inclusive-column
868 /// span per visible row. Rows scrolled off-screen (above or below) are
869 /// dropped. Empty when nothing is selected. See `SelectionSpan`.
870 pub fn selection_range(&self) -> Vec<SelectionSpan> {
871 let Some(resolved) = self.resolve() else {
872 return Vec::new();
873 };
874 let rows = self.grid.rows();
875 // Absolute index of viewport row 0.
876 let top = self.scrollback.len() - self.display_offset;
877 let mut spans = Vec::new();
878
879 // Add a span for absolute `line` with inclusive cols `left..=right`, if
880 // the line is currently visible.
881 let mut push = |line: usize, left: usize, right: usize| {
882 if line >= top {
883 let row = line - top;
884 if row < rows {
885 spans.push(SelectionSpan { row, left, right });
886 }
887 }
888 };
889
890 match resolved {
891 Resolved::Linear {
892 start_line,
893 from,
894 end_line,
895 to,
896 } => {
897 for line in start_line..=end_line {
898 let len = self.abs_line(line).len();
899 let left = if line == start_line { from } else { 0 };
900 let right_excl = if line == end_line { to.min(len) } else { len };
901 if right_excl > left {
902 push(line, left, right_excl - 1);
903 }
904 }
905 }
906 Resolved::Block {
907 line0,
908 line1,
909 from,
910 to,
911 } => {
912 if to > from {
913 for line in line0..=line1 {
914 push(line, from, to - 1);
915 }
916 }
917 }
918 }
919 spans
920 }
921
922 /// Resolve the live selection into absolute-coordinate bounds per type:
923 /// a `Linear` run (char/word/line, which join soft wraps) or a `Block`
924 /// rectangle. `None` when nothing is selected. Columns are half-open
925 /// (`from..to`). Shared by `selection_text` and `selection_range`.
926 fn resolve(&self) -> Option<Resolved> {
927 let sel = self.selection.as_ref()?;
928 let (start, end) = sel.ordered();
929 Some(match sel.ty {
930 SelectionType::Char => {
931 // Half-open columns: each side decides if its own cell is in.
932 let from = match start.side {
933 Side::Left => start.point.col,
934 Side::Right => start.point.col + 1,
935 };
936 let to = match end.side {
937 Side::Left => end.point.col,
938 Side::Right => end.point.col + 1,
939 };
940 Resolved::Linear {
941 start_line: start.point.line,
942 from,
943 end_line: end.point.line,
944 to,
945 }
946 }
947 SelectionType::Word => {
948 // Snap both ends to word boundaries (side is ignored).
949 let ws = self.word_start(start.point);
950 let we = self.word_end(end.point);
951 Resolved::Linear {
952 start_line: ws.line,
953 from: ws.col,
954 end_line: we.line,
955 to: we.col + 1,
956 }
957 }
958 SelectionType::Line => Resolved::Linear {
959 start_line: start.point.line,
960 from: 0,
961 end_line: end.point.line,
962 to: self.grid.cols(),
963 },
964 SelectionType::Block => {
965 // Rectangular: the same column range on every row. Columns come
966 // from the two anchors (min/max, with each edge's side).
967 let cols = self.grid.cols();
968 let (a, b) = (sel.anchor, sel.focus);
969 let (lcol, lside, rcol, rside) = if a.point.col <= b.point.col {
970 (a.point.col, a.side, b.point.col, b.side)
971 } else {
972 (b.point.col, b.side, a.point.col, a.side)
973 };
974 let from = match lside {
975 Side::Left => lcol,
976 Side::Right => lcol + 1,
977 };
978 let to = match rside {
979 Side::Left => rcol,
980 Side::Right => rcol + 1,
981 };
982 Resolved::Block {
983 line0: a.point.line.min(b.point.line),
984 line1: a.point.line.max(b.point.line),
985 from,
986 to: to.min(cols).max(from),
987 }
988 }
989 })
990 }
991
992 /// The selected text (for copy), or `None` when nothing is selected.
993 pub fn selection_text(&self) -> Option<String> {
994 match self.resolve()? {
995 Resolved::Linear {
996 start_line,
997 from,
998 end_line,
999 to,
1000 } => Some(self.extract_lines(start_line, from, end_line, to)),
1001 Resolved::Block {
1002 line0,
1003 line1,
1004 from,
1005 to,
1006 } => {
1007 // Each row independently — no soft-wrap joining.
1008 let mut out = String::new();
1009 for line in line0..=line1 {
1010 let hi = to.min(self.abs_line(line).len());
1011 let mut seg = String::new();
1012 for col in from..hi {
1013 self.append_cell(&mut seg, line, col);
1014 }
1015 out.push_str(seg.trim_end());
1016 if line != line1 {
1017 out.push('\n');
1018 }
1019 }
1020 Some(out)
1021 }
1022 }
1023 }
1024
1025 /// Append the text at absolute `(line, col)` — its base glyph plus any
1026 /// combining marks from the row's map — to `out`. Wide-char spacers
1027 /// contribute nothing.
1028 fn append_cell(&self, out: &mut String, line: usize, col: usize) {
1029 let cell = &self.abs_line(line)[col];
1030 if cell.is_wide_spacer() {
1031 return;
1032 }
1033 out.push(cell.c());
1034 if let Some(marks) = self.combining_at(line, col) {
1035 out.extend(marks);
1036 }
1037 }
1038
1039 /// Concatenate the selected cells from `(start_line, from)` to
1040 /// `(end_line, to_end)` (half-open columns on the first/last line, whole
1041 /// lines between). Soft-wrapped rows (WRAPLINE) accumulate into one *logical*
1042 /// line so trailing-blank trimming happens only at the logical end — spaces
1043 /// at a wrap boundary are real content. A hard line-end flushes with `\n`.
1044 fn extract_lines(
1045 &self,
1046 start_line: usize,
1047 from: usize,
1048 end_line: usize,
1049 to_end: usize,
1050 ) -> String {
1051 let mut out = String::new();
1052 let mut current = String::new();
1053 for line in start_line..=end_line {
1054 let cells = self.abs_line(line);
1055 let left = if line == start_line { from } else { 0 };
1056 let right = if line == end_line {
1057 to_end.min(cells.len())
1058 } else {
1059 cells.len()
1060 };
1061 // A degenerate range (sides inverting one cell) gives left > right;
1062 // clamp to empty rather than panic on the slice.
1063 let right = right.max(left);
1064 for col in left..right {
1065 self.append_cell(&mut current, line, col);
1066 }
1067
1068 let is_last = line == end_line;
1069 let soft = cells.last().is_some_and(|c| c.is_wrapline());
1070 if is_last || !soft {
1071 out.push_str(current.trim_end());
1072 current.clear();
1073 if !is_last {
1074 out.push('\n');
1075 }
1076 }
1077 }
1078 out
1079 }
1080
1081 /// The cell position before `(line, col)` in the *logical* line — the column
1082 /// to the left, or the end of the previous row if it soft-wrapped into this
1083 /// one. `None` at the buffer start or across a hard line-end.
1084 fn prev_pos(&self, line: usize, col: usize) -> Option<(usize, usize)> {
1085 if col > 0 {
1086 return Some((line, col - 1));
1087 }
1088 if line > 0 {
1089 let prev = self.abs_line(line - 1);
1090 if prev.last().is_some_and(|c| c.is_wrapline()) {
1091 return Some((line - 1, prev.len() - 1));
1092 }
1093 }
1094 None
1095 }
1096
1097 /// The cell position after `(line, col)` in the *logical* line — the column
1098 /// to the right, or the start of the next row if this row soft-wrapped.
1099 /// `None` at the buffer end or across a hard line-end.
1100 fn next_pos(&self, line: usize, col: usize) -> Option<(usize, usize)> {
1101 let cells = self.abs_line(line);
1102 if col + 1 < cells.len() {
1103 return Some((line, col + 1));
1104 }
1105 let total = self.scrollback.len() + self.grid.rows();
1106 if line + 1 < total && cells.last().is_some_and(|c| c.is_wrapline()) {
1107 return Some((line + 1, 0));
1108 }
1109 None
1110 }
1111
1112 /// Walk left to the first cell of `p`'s word (a maximal run of non-boundary
1113 /// chars), following a soft wrap into the previous row.
1114 fn word_start(&self, p: BufferPoint) -> BufferPoint {
1115 let cells = self.abs_line(p.line);
1116 let (mut line, mut col) = (p.line, p.col.min(cells.len().saturating_sub(1)));
1117 while let Some((pl, pc)) = self.prev_pos(line, col) {
1118 if is_word_boundary(self.abs_line(pl)[pc].c()) {
1119 break;
1120 }
1121 line = pl;
1122 col = pc;
1123 }
1124 BufferPoint { line, col }
1125 }
1126
1127 /// Walk right to the last cell of `p`'s word, following a soft wrap into the
1128 /// next row.
1129 fn word_end(&self, p: BufferPoint) -> BufferPoint {
1130 let cells = self.abs_line(p.line);
1131 let (mut line, mut col) = (p.line, p.col.min(cells.len().saturating_sub(1)));
1132 while let Some((nl, nc)) = self.next_pos(line, col) {
1133 if is_word_boundary(self.abs_line(nl)[nc].c()) {
1134 break;
1135 }
1136 line = nl;
1137 col = nc;
1138 }
1139 BufferPoint { line, col }
1140 }
1141
1142 /// Resize the screen to `cols` x `rows`. Rows dropped off the top (on shrink)
1143 /// enter scrollback. Column reflow of soft-wrapped lines is layered on top
1144 /// separately (#7). The whole screen is damaged.
1145 pub fn resize(&mut self, cols: usize, rows: usize) {
1146 // A terminal is never 0-wide/0-tall; clamp so the math below (rows - 1,
1147 // chunking by cols) can't underflow or divide by zero.
1148 let cols = cols.max(1);
1149 let rows = rows.max(1);
1150 let old_cols = self.grid.cols();
1151 let limit = self.scrollback_limit;
1152
1153 // Both screens are resized. Scrollback pairs with the PRIMARY screen
1154 // (whichever is active) — the alt screen has no history of its own.
1155 let dims = ReflowDims {
1156 old_cols,
1157 cols,
1158 rows,
1159 limit,
1160 };
1161 let scrollback = std::mem::take(&mut self.scrollback);
1162 if self.on_alt {
1163 // Active = alt (cursor, no scrollback); inactive = primary. Selection
1164 // is primary-only and cleared on alt enter, so no anchors to track.
1165 let alt = self.grid.take_lines();
1166 let r = reflow_pane(alt, VecDeque::new(), self.cursor.point(), &[], dims);
1167 self.grid.set_screen(r.screen, cols, rows);
1168 self.cursor.set_point(r.cursor, rows, cols);
1169
1170 let primary = self.alt_grid.take_lines();
1171 let r = reflow_pane(primary, scrollback, self.saved_cursor.point(), &[], dims);
1172 self.alt_grid.set_screen(r.screen, cols, rows);
1173 self.scrollback = r.scrollback;
1174 self.saved_cursor.set_point(r.cursor, rows, cols);
1175 } else {
1176 // Active = primary (cursor, scrollback); inactive = alt. The selection
1177 // anchors (absolute) reflow alongside the cursor so they keep their
1178 // content across a column change.
1179 let sel_pts: Vec<(usize, usize)> = self
1180 .selection
1181 .as_ref()
1182 .map(|s| {
1183 vec![
1184 (s.anchor.point.line, s.anchor.point.col),
1185 (s.focus.point.line, s.focus.point.col),
1186 ]
1187 })
1188 .unwrap_or_default();
1189
1190 let primary = self.grid.take_lines();
1191 let r = reflow_pane(primary, scrollback, self.cursor.point(), &sel_pts, dims);
1192 self.grid.set_screen(r.screen, cols, rows);
1193 self.scrollback = r.scrollback;
1194 self.cursor.set_point(r.cursor, rows, cols);
1195 if let Some(sel) = &mut self.selection {
1196 sel.anchor.point = BufferPoint {
1197 line: r.extras[0].0,
1198 col: r.extras[0].1,
1199 };
1200 sel.focus.point = BufferPoint {
1201 line: r.extras[1].0,
1202 col: r.extras[1].1,
1203 };
1204 }
1205
1206 let alt = self.alt_grid.take_lines();
1207 let r = reflow_pane(alt, VecDeque::new(), (0, 0), &[], dims);
1208 self.alt_grid.set_screen(r.screen, cols, rows);
1209 }
1210
1211 // Margins reset to the full screen; tab stops reset to the default grid.
1212 self.cursor.pending_wrap = false;
1213 self.scroll_top = 0;
1214 self.scroll_bottom = rows - 1;
1215 self.tabs = default_tabs(cols);
1216 self.display_offset = self.display_offset.min(self.scrollback.len());
1217
1218 // Damage tracking is sized to the screen; a resize repaints everything,
1219 // so drop any pending scroll op (it points at the old rows).
1220 self.line_damage = vec![LineBounds::undamaged(cols); rows];
1221 self.scroll = None;
1222 self.mark_fully_damaged();
1223 }
1224
1225 pub fn grid(&self) -> &Grid {
1226 &self.grid
1227 }
1228
1229 pub fn cursor(&self) -> &Cursor {
1230 &self.cursor
1231 }
1232
1233 /// Whether bracketed-paste mode (DEC ?2004) is enabled. The input encoder
1234 /// (#11) reads this to decide whether to wrap pasted text in markers.
1235 pub fn bracketed_paste(&self) -> bool {
1236 self.bracketed_paste
1237 }
1238
1239 // ---- input encoding (#11) ------------------------------------------------
1240
1241 /// Encode a key event to bytes using the active cursor-key mode (DECCKM)
1242 /// and the kitty keyboard-protocol flags (`encode_key` consults both).
1243 pub fn encode_key(&self, ev: KeyEvent) -> Option<Vec<u8>> {
1244 encode_key(
1245 &ev,
1246 self.app_cursor_keys,
1247 self.application_keypad,
1248 self.kitty_flags,
1249 )
1250 }
1251
1252 /// Encode a mouse event using the active tracking mode + encoding. `None`
1253 /// when reporting is off or the event is filtered by the mode.
1254 pub fn encode_mouse(&self, ev: MouseEvent) -> Option<Vec<u8>> {
1255 encode_mouse(&ev, self.mouse_protocol, self.mouse_encoding)
1256 }
1257
1258 /// Encode pasted text, wrapping it in bracketed-paste markers when ?2004 is
1259 /// on.
1260 pub fn encode_paste(&self, text: &str) -> Vec<u8> {
1261 encode_paste(text, self.bracketed_paste)
1262 }
1263
1264 /// Encode a focus change (`CSI I`/`CSI O`), or `None` when focus reporting
1265 /// (?1004) is off.
1266 pub fn encode_focus(&self, focused: bool) -> Option<Vec<u8>> {
1267 encode_focus(focused, self.focus_events)
1268 }
1269
1270 /// Take the consumer events queued since the last drain, emptying the queue.
1271 pub fn drain_events(&mut self) -> Vec<TermEvent> {
1272 std::mem::take(&mut self.events)
1273 }
1274
1275 /// Take the reply bytes queued since the last drain (DA/DSR/DECRQM answers),
1276 /// emptying the buffer. The consumer writes them back to the PTY.
1277 pub fn drain_replies(&mut self) -> Vec<u8> {
1278 std::mem::take(&mut self.replies)
1279 }
1280
1281 /// Device Status Report (CSI Ps n): 6 = cursor position, 5 = operating
1282 /// status. Queues the reply for `drain_replies` (#27).
1283 fn device_status_report(&mut self, param: u16) {
1284 match param {
1285 6 => {
1286 // CSI row;col R, 1-based — region-relative under origin mode
1287 // (the coordinate system the app is addressing in).
1288 let row = if self.origin_mode {
1289 self.cursor.row.saturating_sub(self.scroll_top)
1290 } else {
1291 self.cursor.row
1292 } + 1;
1293 let col = self.cursor.col + 1;
1294 self.replies
1295 .extend_from_slice(format!("\x1b[{row};{col}R").as_bytes());
1296 }
1297 5 => self.replies.extend_from_slice(b"\x1b[0n"), // status: OK
1298 _ => {}
1299 }
1300 }
1301
1302 /// Kitty keyboard-protocol negotiation (#23). `lead` is the leading CSI
1303 /// intermediate: `?` query, `>` push, `=` set, `<` pop.
1304 fn kitty_dispatch(&mut self, lead: u8, params: &Params) {
1305 match lead {
1306 // Query → report the current flags as `CSI ? flags u` (#27 channel).
1307 b'?' => self
1308 .replies
1309 .extend_from_slice(format!("\x1b[?{}u", self.kitty_flags).as_bytes()),
1310 // Push: save the current flags, then set the new ones (default 0).
1311 b'>' => {
1312 const KITTY_STACK_CAP: usize = 16;
1313 if self.kitty_stack.len() >= KITTY_STACK_CAP {
1314 self.kitty_stack.remove(0); // drop the oldest on overflow
1315 }
1316 self.kitty_stack.push(self.kitty_flags);
1317 self.kitty_flags = param_or(params, 0, 0) as u8;
1318 }
1319 // Pop `n` (default 1): restore from the stack, 0 once empty.
1320 b'<' => {
1321 for _ in 0..param_or(params, 0, 1) {
1322 self.kitty_flags = self.kitty_stack.pop().unwrap_or(0);
1323 }
1324 }
1325 // Set in place (no push): mode 1 replace, 2 or-in, 3 and-not.
1326 b'=' => {
1327 let flags = param_or(params, 0, 0) as u8;
1328 self.kitty_flags = match param_or(params, 1, 1) {
1329 1 => flags,
1330 2 => self.kitty_flags | flags,
1331 3 => self.kitty_flags & !flags,
1332 _ => self.kitty_flags,
1333 };
1334 }
1335 _ => {}
1336 }
1337 }
1338
1339 /// DECRQM (CSI ? Ps $ p): report whether DEC private mode `Ps` is set —
1340 /// `CSI ? Ps ; val $ y` with val 1=set, 2=reset, 0=not recognized (#27).
1341 fn decrqm(&mut self, mode: u16) {
1342 let state = match mode {
1343 1 => Some(self.app_cursor_keys),
1344 // DECANM (#84): set = ANSI mode (the normal state), reset = VT52.
1345 2 => Some(!self.vt52_mode),
1346 6 => Some(self.origin_mode),
1347 // DECCOLM: derived from the actual width, never a tracked flag — a
1348 // flag would lie if the consumer ignored the resize request (#82).
1349 3 => Some(self.grid.cols() == 132),
1350 7 => Some(self.autowrap),
1351 45 => Some(self.reverse_wraparound),
1352 9 => Some(self.mouse_protocol == MouseProtocol::X10),
1353 66 => Some(self.application_keypad),
1354 12 => Some(self.cursor.blink),
1355 25 => Some(self.cursor.visible),
1356 // Mouse tracking is a single-state enum (the levels are mutually
1357 // exclusive — an app enables one), so querying ?1000 while ?1002 is
1358 // active reports "reset". Faithful to that model.
1359 1000 => Some(self.mouse_protocol == MouseProtocol::Normal),
1360 1002 => Some(self.mouse_protocol == MouseProtocol::ButtonEvent),
1361 1003 => Some(self.mouse_protocol == MouseProtocol::AnyEvent),
1362 1004 => Some(self.focus_events),
1363 1006 => Some(self.mouse_encoding == MouseEncoding::Sgr),
1364 1015 => Some(self.mouse_encoding == MouseEncoding::Urxvt),
1365 1005 => Some(self.mouse_encoding == MouseEncoding::Utf8),
1366 1016 => Some(self.mouse_encoding == MouseEncoding::SgrPixels),
1367 47 | 1047 | 1049 => Some(self.on_alt),
1368 2004 => Some(self.bracketed_paste),
1369 2026 => Some(self.synchronized_output),
1370 2031 => Some(self.color_scheme_updates),
1371 9001 => Some(self.win32_input_mode),
1372 _ => None,
1373 };
1374 let val = match state {
1375 Some(true) => 1,
1376 Some(false) => 2,
1377 None => 0,
1378 };
1379 self.replies
1380 .extend_from_slice(format!("\x1b[?{mode};{val}$y").as_bytes());
1381 }
1382
1383 /// Resolve a cell's `link` index (OSC 8) to its URI, or `None` if the index
1384 /// is out of range. The renderer reads `Cell.link`, then this, to make a
1385 /// cell clickable (#26).
1386 pub fn hyperlink(&self, link: core::num::NonZeroU32) -> Option<&str> {
1387 self.hyperlink_pool
1388 .get(link.get() as usize - 1)
1389 .map(String::as_str)
1390 }
1391
1392 // ---- cursor / scroll primitives ------------------------------------------
1393
1394 /// Move down one line. At the bottom margin, scroll the region instead;
1395 /// below the region, just descend (no scroll). Column is unchanged (raw LF;
1396 /// CR is what returns to column 0).
1397 fn linefeed(&mut self) {
1398 // New-line mode (LNM ?20): a line feed also returns to column 0 (#71).
1399 if self.newline_mode {
1400 self.carriage_return();
1401 }
1402 if self.cursor.row == self.scroll_bottom {
1403 // A top-anchored primary-screen scroll pushes the evicted top line
1404 // into scrollback history.
1405 if self.scroll_top == 0 && !self.on_alt {
1406 // Scrollback accrues whenever the scroll is top-anchored on the
1407 // primary screen (`scroll_top == 0`) — but the O(1) ring handshake
1408 // only applies to a *full-screen* scroll (`scroll_bottom` at the
1409 // last row). A top-anchored *sub-region* (`[0..k]`, k < rows-1)
1410 // still accrues, yet must scroll only its region, so it keeps the
1411 // copy + region scroll. These are distinct predicates (ADR-0009).
1412 let evicted = if self.scroll_bottom == self.grid.rows() - 1 {
1413 // Full-screen hot path: move the evicted top row out, install
1414 // a recycled blank as the new bottom (zero-alloc steady state).
1415 let blank = self
1416 .recycled_row
1417 .take()
1418 .unwrap_or_else(|| Row::from_cells(Vec::with_capacity(self.grid.cols())));
1419 self.grid.scroll_up_recycle(blank)
1420 } else {
1421 // Top-anchored sub-region: copy row 0, then region-scroll
1422 // `[0..=scroll_bottom]` (rows below stay fixed).
1423 let evicted = self.grid.row_owned(0);
1424 self.grid
1425 .scroll_up_region(self.scroll_top, self.scroll_bottom);
1426 evicted
1427 };
1428 self.scrollback.push_back(evicted);
1429 // Follow-bottom = stay: if the user is scrolled up, bump the
1430 // offset so the same lines stay in view instead of being yanked
1431 // to the bottom.
1432 if self.display_offset > 0 {
1433 self.display_offset = (self.display_offset + 1).min(self.scrollback.len());
1434 }
1435 // Cap: evict the oldest line past the limit. The view is anchored
1436 // to history, so dropping the front shifts the offset down too
1437 // (xterm.js trims ybase and ydisp together) — also keeps the
1438 // offset within `[0, len]`. The evicted row is parked for reuse.
1439 if self.scrollback.len() > self.scrollback_limit {
1440 self.recycled_row = self.scrollback.pop_front();
1441 // Every absolute index just shifted down by one; move the
1442 // selection with it so its anchors keep their content.
1443 self.selection_evict_oldest();
1444 if self.display_offset > 0 {
1445 // Scrolled up: evicting the oldest line advanced the
1446 // viewport, so it must be repainted (the "frozen while
1447 // scrolled" rule does not apply when the view itself moved).
1448 self.display_offset -= 1;
1449 self.mark_fully_damaged();
1450 }
1451 }
1452 } else {
1453 // Region (top margin > 0) or alt-screen scroll: the evicted line
1454 // does NOT enter scrollback, so content moves *within* the screen
1455 // and absolute indices in the region shift. Rotate the selection
1456 // up so it follows; an endpoint on the dropped line clears it.
1457 let base = self.scrollback.len();
1458 self.selection_rotate_region(
1459 base + self.scroll_top,
1460 base + self.scroll_bottom,
1461 true,
1462 );
1463 self.grid
1464 .scroll_up_region(self.scroll_top, self.scroll_bottom);
1465 }
1466 self.record_scroll(self.scroll_top, self.scroll_bottom, 1);
1467 } else if self.cursor.row + 1 < self.grid.rows() {
1468 self.cursor.row += 1;
1469 }
1470 }
1471
1472 /// DECSTBM (CSI r): set the top/bottom scroll margins (1-based inclusive).
1473 /// An invalid region (top ≥ bottom) is ignored.
1474 fn set_scroll_region(&mut self, top: usize, bottom: usize) {
1475 let bottom = bottom.min(self.grid.rows());
1476 if top >= bottom {
1477 return;
1478 }
1479 self.scroll_top = top - 1;
1480 self.scroll_bottom = bottom - 1;
1481 self.goto(0, 0); // DECSTBM homes the cursor (absolute)
1482 }
1483
1484 // ---- alt screen (DEC 1049) -----------------------------------------------
1485
1486 /// Enter the alternate screen: save the cursor, swap in the other grid, and
1487 /// clear it.
1488 /// Save the cursor into the alt-screen slot — `?1048` set, and the first
1489 /// half of `?1049` enter (#72).
1490 fn save_alt_cursor(&mut self) {
1491 self.saved_cursor = self.cursor;
1492 }
1493
1494 /// Restore the cursor from the alt-screen slot — `?1048` reset, and the
1495 /// second half of `?1049` leave. DECTCEM visibility is a standalone mode, not
1496 /// part of the save, so preserve it across the restore (#38/#72).
1497 fn restore_alt_cursor(&mut self) {
1498 let visible = self.cursor.visible;
1499 self.cursor = self.saved_cursor;
1500 self.cursor.visible = visible;
1501 }
1502
1503 /// Switch to the (cleared) alternate buffer without touching the cursor —
1504 /// `?47`/`?1047` set, and the second half of `?1049` enter (#72).
1505 fn switch_to_alt(&mut self) {
1506 if self.on_alt {
1507 return;
1508 }
1509 std::mem::swap(&mut self.grid, &mut self.alt_grid);
1510 self.grid.clear();
1511 self.on_alt = true;
1512 self.display_offset = 0; // the alt screen has no scrollback to view
1513 self.selection = None; // a selection cannot survive a screen swap
1514 self.mark_fully_damaged();
1515 }
1516
1517 /// Switch back to the primary buffer without touching the cursor —
1518 /// `?47`/`?1047` reset, and the first half of `?1049` leave (#72).
1519 fn switch_to_primary(&mut self) {
1520 if !self.on_alt {
1521 return;
1522 }
1523 std::mem::swap(&mut self.grid, &mut self.alt_grid);
1524 self.on_alt = false;
1525 self.display_offset = 0; // return to the primary at its bottom
1526 self.selection = None; // a selection cannot survive a screen swap
1527 self.mark_fully_damaged();
1528 }
1529
1530 fn enter_alt_screen(&mut self) {
1531 if self.on_alt {
1532 return;
1533 }
1534 self.save_alt_cursor();
1535 self.switch_to_alt();
1536 }
1537
1538 /// Leave the alternate screen: swap the primary grid back in and restore the
1539 /// saved cursor.
1540 fn leave_alt_screen(&mut self) {
1541 if !self.on_alt {
1542 return;
1543 }
1544 self.switch_to_primary();
1545 self.restore_alt_cursor();
1546 }
1547
1548 /// RI (ESC M): move up one line. At the top margin, scroll the region down
1549 /// instead.
1550 fn reverse_index(&mut self) {
1551 if self.cursor.row == self.scroll_top {
1552 // RI never enters scrollback; the region scrolls down within the
1553 // screen, so absolute indices in it shift down. Rotate the selection.
1554 let base = self.scrollback.len();
1555 self.selection_rotate_region(base + self.scroll_top, base + self.scroll_bottom, false);
1556 self.grid
1557 .scroll_down_region(self.scroll_top, self.scroll_bottom);
1558 self.record_scroll(self.scroll_top, self.scroll_bottom, -1);
1559 } else if self.cursor.row > 0 {
1560 self.cursor.row -= 1;
1561 }
1562 }
1563
1564 // ---- cursor save/restore (DECSC / DECRC) ---------------------------------
1565
1566 /// DECSC (ESC 7): save the cursor position, pen, pending-wrap, and origin
1567 /// mode. Visibility is not saved (DECTCEM is separate).
1568 fn save_cursor(&mut self) {
1569 self.decsc = SavedCursor {
1570 row: self.cursor.row,
1571 col: self.cursor.col,
1572 pen: self.cursor.pen,
1573 pending_wrap: self.cursor.pending_wrap,
1574 origin_mode: self.origin_mode,
1575 charsets: self.charsets,
1576 gl: self.gl,
1577 };
1578 }
1579
1580 /// DECRC (ESC 8): restore what DECSC saved. Origin mode is restored (per
1581 /// ADR-0004); visibility is left as-is. The position is clamped to the
1582 /// current screen in case it shrank since the save.
1583 fn restore_cursor(&mut self) {
1584 let s = self.decsc;
1585 self.cursor.row = s.row.min(self.grid.rows() - 1);
1586 self.cursor.col = s.col.min(self.grid.cols() - 1);
1587 self.cursor.pen = s.pen;
1588 self.cursor.pending_wrap = s.pending_wrap;
1589 self.origin_mode = s.origin_mode;
1590 self.charsets = s.charsets;
1591 self.gl = s.gl;
1592 }
1593
1594 /// RIS (ESC c) — full reset to the power-on state (#53). Reconstruct every
1595 /// screen/mode field to its construction default (preserving only the
1596 /// dimensions and the scrollback cap), but keep the consumer-bound output
1597 /// queues (`replies`/`events`) that accrued earlier in this `feed`, and
1598 /// signal a full repaint. The vte parser lives outside `Term`, so replacing
1599 /// `self` does not disturb in-progress parsing. Mirrors xterm.js fullReset.
1600 fn full_reset(&mut self) {
1601 let replies = std::mem::take(&mut self.replies);
1602 let events = std::mem::take(&mut self.events);
1603 let (cols, rows) = (self.grid.cols(), self.grid.rows());
1604 *self = Term::with_scrollback(cols, rows, self.scrollback_limit);
1605 self.replies = replies;
1606 self.events = events;
1607 self.mark_fully_damaged();
1608 }
1609
1610 /// DECSTR (CSI ! p) — soft reset (#53). Resets a defined subset of modes to
1611 /// their defaults *without* destroying screen content or scrollback, moving
1612 /// the active cursor, or touching the mouse/focus reporting subsystem. Per
1613 /// xterm.js softReset, autowrap returns to ON (the xterm default), not off.
1614 fn soft_reset(&mut self) {
1615 self.cursor.visible = true;
1616 self.cursor.pen = Pen::default();
1617 self.scroll_top = 0;
1618 self.scroll_bottom = self.grid.rows() - 1;
1619 self.origin_mode = false;
1620 self.app_cursor_keys = false;
1621 self.bracketed_paste = false;
1622 self.autowrap = true; // xterm default is ON (not the VT100 "off")
1623 self.insert_mode = false;
1624 self.charsets = [Charset::Ascii; 4];
1625 self.gl = 0;
1626 self.decsc = SavedCursor::default();
1627 }
1628
1629 fn carriage_return(&mut self) {
1630 self.cursor.col = 0;
1631 self.cursor.pending_wrap = false;
1632 }
1633
1634 /// DECSCUSR (CSI Ps SP q): set the caret shape + blink (#89). 0/2 = steady
1635 /// block, 1 = blinking block; 3/4 = blinking/steady underline; 5/6 =
1636 /// blinking/steady bar (odd = blink). 0 resets to the default (steady block).
1637 /// An unknown param leaves the style unchanged. Mirrors xterm.js.
1638 fn set_cursor_style(&mut self, param: u16) {
1639 let (shape, blink) = match param {
1640 0 | 2 => (CursorShape::Block, false),
1641 1 => (CursorShape::Block, true),
1642 3 => (CursorShape::Underline, true),
1643 4 => (CursorShape::Underline, false),
1644 5 => (CursorShape::Bar, true),
1645 6 => (CursorShape::Bar, false),
1646 _ => return,
1647 };
1648 self.cursor.shape = shape;
1649 self.cursor.blink = blink;
1650 }
1651
1652 /// Backspace (BS, 0x08): move the cursor one column left. With reverse
1653 /// wraparound (?45) a backspace at column 0 of a *soft-wrapped* row moves
1654 /// back to the last column of the previous row — undoing one autowrap. Only
1655 /// soft wraps reverse (the previous row carries `WRAPLINE`); a hard CR/LF
1656 /// line does not. BS only (not cursor-left), matching xterm.js (#80).
1657 fn backspace(&mut self) {
1658 self.cursor.pending_wrap = false;
1659 if self.cursor.col > 0 {
1660 self.cursor.col -= 1;
1661 return;
1662 }
1663 if self.reverse_wraparound
1664 && self.cursor.row > self.scroll_top
1665 && self.cursor.row <= self.scroll_bottom
1666 {
1667 let prev = self.cursor.row - 1;
1668 let last = self.grid.cols() - 1;
1669 if self.grid.cell(prev, last).is_wrapline() {
1670 self.grid
1671 .cell_mut(prev, last)
1672 .remove_flags(CellFlags::WRAPLINE);
1673 self.cursor.row = prev;
1674 self.cursor.col = last;
1675 }
1676 }
1677 }
1678
1679 /// Auto-wrap at end of line: line-feed then return to column 0.
1680 fn wrapline(&mut self) {
1681 self.linefeed();
1682 self.cursor.col = 0;
1683 self.cursor.pending_wrap = false;
1684 }
1685
1686 // ---- tab stops (HT / HTS / TBC) ------------------------------------------
1687
1688 /// HT: advance to the next set tab stop, or the last column if none remain
1689 /// (no wrap).
1690 fn put_tab(&mut self) {
1691 let cols = self.grid.cols();
1692 let mut col = self.cursor.col;
1693 while col + 1 < cols {
1694 col += 1;
1695 if self.tabs[col] {
1696 break;
1697 }
1698 }
1699 self.cursor.col = col;
1700 self.cursor.pending_wrap = false;
1701 }
1702
1703 /// HTS (ESC H): set a tab stop at the cursor column.
1704 fn set_tab_stop(&mut self) {
1705 let col = self.cursor.col;
1706 self.tabs[col] = true;
1707 }
1708
1709 /// TBC (CSI g): clear the tab stop at the cursor (mode 0) or all stops
1710 /// (mode 3).
1711 fn clear_tab_stop(&mut self, mode: u16) {
1712 match mode {
1713 0 => {
1714 let col = self.cursor.col;
1715 self.tabs[col] = false;
1716 }
1717 3 => self.tabs.iter_mut().for_each(|t| *t = false),
1718 _ => {}
1719 }
1720 }
1721
1722 // ---- printing ------------------------------------------------------------
1723
1724 /// Write one glyph at the cursor, handling deferred wrap and the wide-char
1725 /// spacer, then advance the cursor (deferring the wrap if it hits the edge).
1726 fn write_glyph(&mut self, c: char, width: usize) {
1727 let cols = self.grid.cols();
1728
1729 // Resolve a deferred last-column wrap before placing the next glyph.
1730 // The row being left soft-wrapped: mark its last cell so reflow (#7) can
1731 // tell it from a hard CR/LF line-end.
1732 if self.cursor.pending_wrap {
1733 let row = self.cursor.row;
1734 self.grid
1735 .cell_mut(row, cols - 1)
1736 .insert_flags(CellFlags::WRAPLINE);
1737 self.wrapline();
1738 }
1739
1740 // A width-2 glyph that cannot fit in the last column wraps first — unless
1741 // autowrap is off, in which case it is dropped (xterm.js `continue`), not
1742 // squeezed or wrapped. TODO: xterm leaves a LEADING_WIDE_CHAR_SPACER in
1743 // the vacated column; common-90% just wraps and leaves it blank.
1744 if width == 2 && self.cursor.col + 1 >= cols {
1745 if !self.autowrap {
1746 return;
1747 }
1748 self.wrapline();
1749 }
1750
1751 // Insert mode (IRM): open a `width`-wide gap at the cursor first, shifting
1752 // the row's tail right (off-edge cells discarded, wide halves repaired),
1753 // then write into the gap — mirrors xterm.js's insertCells (#64).
1754 if self.insert_mode {
1755 self.insert_chars(width);
1756 }
1757
1758 let (row, col) = (self.cursor.row, self.cursor.col);
1759
1760 // Overwriting one half of an existing wide glyph orphans the other —
1761 // clear it so no stray lead/spacer is left behind.
1762 let last = col + width - 1;
1763 if col > 0 && self.grid.cell(row, col).is_wide_spacer() {
1764 self.grid.cell_mut(row, col - 1).reset();
1765 }
1766 if last + 1 < cols && self.grid.cell(row, last).is_wide() {
1767 self.grid.cell_mut(row, last + 1).reset();
1768 }
1769
1770 let mut cell = self.cursor.pen.cell(c);
1771 if width == 2 {
1772 cell.insert_flags(CellFlags::WIDE_CHAR);
1773 }
1774 *self.grid.cell_mut(row, col) = cell;
1775 // Stamp the open hyperlink, if any, into the row's link map (#26/#46).
1776 if let Some(link) = self.current_link {
1777 self.grid.row_mut(row).set_link(col, link);
1778 }
1779
1780 // The trailing column of a wide glyph carries a distinct spacer marker —
1781 // and the same link, so a hover/selection over either half agrees.
1782 if width == 2 && col + 1 < cols {
1783 let mut spacer = self.cursor.pen.cell(' ');
1784 spacer.insert_flags(CellFlags::WIDE_CHAR_SPACER);
1785 *self.grid.cell_mut(row, col + 1) = spacer;
1786 if let Some(link) = self.current_link {
1787 self.grid.row_mut(row).set_link(col + 1, link);
1788 }
1789 }
1790
1791 // Record damage for the cell(s) just written.
1792 self.damage_span(row, col, col + width - 1);
1793
1794 // Advance. Reaching/passing the last column sets pending-wrap instead of
1795 // wrapping eagerly — the cursor parks on the last column.
1796 let new_col = col + width;
1797 if new_col >= cols {
1798 self.cursor.col = cols - 1;
1799 // With autowrap off (DECAWM ?7l) the cursor pins to the last column
1800 // and the next glyph overwrites in place — no deferred wrap (#63).
1801 self.cursor.pending_wrap = self.autowrap;
1802 } else {
1803 self.cursor.col = new_col;
1804 }
1805 }
1806
1807 /// Attach a combining mark (width-0 code point) to the grapheme it modifies —
1808 /// the cell the cursor just left. With pending-wrap the cursor still sits on
1809 /// the just-written last-column glyph, so attach in place (no back-up, no
1810 /// deferred wrap); otherwise step back one column, and once more over a
1811 /// wide-char spacer to reach its lead. Stored in the grapheme side-table.
1812 fn push_combining(&mut self, c: char) {
1813 let row = self.cursor.row;
1814 let mut col = if self.cursor.pending_wrap {
1815 self.cursor.col
1816 } else {
1817 self.cursor.col.saturating_sub(1)
1818 };
1819 if self.grid.cell(row, col).is_wide_spacer() {
1820 col = col.saturating_sub(1);
1821 }
1822 // Append the mark to the row's combining map at this column (setting the
1823 // cell's combining bit). No global pool — the cluster rides the row.
1824 self.grid.row_mut(row).push_combining(col, c);
1825 self.damage_span(row, col, col);
1826 }
1827
1828 // ---- cursor movement (CSI A/B/C/D/G/d/H/f) -------------------------------
1829
1830 fn move_up(&mut self, n: usize) {
1831 self.cursor.row = self.cursor.row.saturating_sub(n);
1832 self.cursor.pending_wrap = false;
1833 }
1834
1835 fn move_down(&mut self, n: usize) {
1836 self.cursor.row = (self.cursor.row + n).min(self.grid.rows() - 1);
1837 self.cursor.pending_wrap = false;
1838 }
1839
1840 fn move_forward(&mut self, n: usize) {
1841 self.cursor.col = (self.cursor.col + n).min(self.grid.cols() - 1);
1842 self.cursor.pending_wrap = false;
1843 }
1844
1845 fn move_back(&mut self, n: usize) {
1846 self.cursor.col = self.cursor.col.saturating_sub(n);
1847 self.cursor.pending_wrap = false;
1848 }
1849
1850 fn set_col(&mut self, col: usize) {
1851 self.cursor.col = col.min(self.grid.cols() - 1);
1852 self.cursor.pending_wrap = false;
1853 }
1854
1855 fn set_row(&mut self, row: usize) {
1856 self.cursor.row = row.min(self.grid.rows() - 1);
1857 self.cursor.pending_wrap = false;
1858 }
1859
1860 fn goto(&mut self, row: usize, col: usize) {
1861 // Origin mode addresses rows relative to the scroll region's top margin
1862 // and clamps to its bottom; otherwise rows are absolute to the screen.
1863 let (offset, max_row) = if self.origin_mode {
1864 (self.scroll_top, self.scroll_bottom)
1865 } else {
1866 (0, self.grid.rows() - 1)
1867 };
1868 self.cursor.row = (row + offset).min(max_row);
1869 self.cursor.col = col.min(self.grid.cols() - 1);
1870 self.cursor.pending_wrap = false;
1871 }
1872
1873 // ---- erase (CSI J / K) ---------------------------------------------------
1874
1875 /// Clear cells `from..to` on `row`.
1876 ///
1877 /// Background Color Erase (BCE): erased cells carry the current SGR
1878 /// background only — fg and text attributes reset to default (matches
1879 /// xterm/alacritty, where the fill is `cursor.template.bg.into()`).
1880 fn clear_cells(&mut self, row: usize, from: usize, to: usize) {
1881 let cols = self.grid.cols();
1882 // Don't orphan a wide char straddling the erase boundary.
1883 if from > 0 && self.grid.cell(row, from).is_wide_spacer() {
1884 self.grid.cell_mut(row, from - 1).reset();
1885 }
1886 if to > from && to < cols && self.grid.cell(row, to - 1).is_wide() {
1887 self.grid.cell_mut(row, to).reset();
1888 }
1889
1890 let bg = self.cursor.pen.bg;
1891 for col in from..to {
1892 let cell = self.grid.cell_mut(row, col);
1893 cell.reset();
1894 cell.set_bg(bg);
1895 }
1896 if to > from {
1897 self.damage_span(row, from, to - 1);
1898 }
1899 }
1900
1901 /// Erase in display (ED): 0 = cursor→end, 1 = start→cursor, 2 = all.
1902 fn erase_display(&mut self, mode: u16) {
1903 let (cols, rows) = (self.grid.cols(), self.grid.rows());
1904 let (cr, cc) = (self.cursor.row, self.cursor.col);
1905 match mode {
1906 0 => {
1907 self.clear_cells(cr, cc, cols);
1908 for row in (cr + 1)..rows {
1909 self.clear_cells(row, 0, cols);
1910 }
1911 }
1912 1 => {
1913 for row in 0..cr {
1914 self.clear_cells(row, 0, cols);
1915 }
1916 self.clear_cells(cr, 0, cc + 1);
1917 }
1918 2 => {
1919 for row in 0..rows {
1920 self.clear_cells(row, 0, cols);
1921 }
1922 }
1923 _ => {}
1924 }
1925 }
1926
1927 /// Erase in line (EL): 0 = cursor→end, 1 = start→cursor, 2 = whole line.
1928 fn erase_line(&mut self, mode: u16) {
1929 let cols = self.grid.cols();
1930 let (cr, cc) = (self.cursor.row, self.cursor.col);
1931 match mode {
1932 0 => self.clear_cells(cr, cc, cols),
1933 1 => self.clear_cells(cr, 0, cc + 1),
1934 2 => self.clear_cells(cr, 0, cols),
1935 _ => {}
1936 }
1937 }
1938
1939 // ---- intra-line editing (ICH / DCH / ECH) --------------------------------
1940
1941 /// ECH (CSI Pn X): erase `n` cells in place from the cursor — no shift.
1942 /// BCE-filled (via `clear_cells`); pending-wrap is left untouched.
1943 fn erase_chars(&mut self, n: usize) {
1944 let cols = self.grid.cols();
1945 let (row, col) = (self.cursor.row, self.cursor.col);
1946 let to = (col + n).min(cols);
1947 self.clear_cells(row, col, to);
1948 }
1949
1950 /// ICH (CSI Pn @): insert `n` blanks at the cursor, shifting the rest of the
1951 /// line right; cells pushed past the right edge are lost. The opened gap is
1952 /// BCE-filled; pending-wrap is left untouched.
1953 fn insert_chars(&mut self, n: usize) {
1954 let cols = self.grid.cols();
1955 let (r, col) = (self.cursor.row, self.cursor.col);
1956 let n = n.min(cols - col);
1957 if n == 0 {
1958 return;
1959 }
1960 let bg = self.cursor.pen.bg;
1961 let row = self.grid.row_mut(r);
1962 // Shift [col .. cols-n) right by n; the tail falls off the edge. The
1963 // combining map follows the moved cells (the bit travels with the raw
1964 // copy, the cluster data must too).
1965 row.copy_within(col..cols - n, col + n);
1966 row.move_maps(col..cols - n, col + n);
1967 for cell in &mut row[col..col + n] {
1968 cell.reset();
1969 cell.set_bg(bg);
1970 }
1971 // Repair wide-char halves split at the seams (no-orphan invariant):
1972 // a lead just before the gap lost its spacer; the first shifted cell may
1973 // be a spacer whose lead did not move.
1974 if col > 0 && self.grid.cell(r, col - 1).is_wide() {
1975 self.grid.cell_mut(r, col - 1).reset();
1976 }
1977 if col + n < cols && self.grid.cell(r, col + n).is_wide_spacer() {
1978 self.grid.cell_mut(r, col + n).reset();
1979 }
1980 // A lead shifted to the last column lost its spacer off the edge.
1981 if self.grid.cell(r, cols - 1).is_wide() {
1982 self.grid.cell_mut(r, cols - 1).reset();
1983 }
1984 self.damage_span(r, col, cols - 1);
1985 }
1986
1987 /// DCH (CSI Pn P): delete `n` cells at the cursor, shifting the tail left; the
1988 /// vacated cells at the right are BCE-blanked. Pending-wrap is left untouched.
1989 fn delete_chars(&mut self, n: usize) {
1990 let cols = self.grid.cols();
1991 let (r, col) = (self.cursor.row, self.cursor.col);
1992 let n = n.min(cols - col);
1993 if n == 0 {
1994 return;
1995 }
1996 let bg = self.cursor.pen.bg;
1997 let row = self.grid.row_mut(r);
1998 // Shift [col+n .. cols) left to [col ..); BCE-fill the vacated tail. The
1999 // combining map follows the moved cells.
2000 row.copy_within(col + n..cols, col);
2001 row.move_maps(col + n..cols, col);
2002 for cell in &mut row[cols - n..cols] {
2003 cell.reset();
2004 cell.set_bg(bg);
2005 }
2006 // Repair wide-char halves split by the deletion (no-orphan invariant):
2007 // a lead just before the cut lost its spacer; the cell now at the cursor
2008 // may be a spacer whose lead was deleted.
2009 if col > 0 && self.grid.cell(r, col - 1).is_wide() {
2010 self.grid.cell_mut(r, col - 1).reset();
2011 }
2012 if self.grid.cell(r, col).is_wide_spacer() {
2013 self.grid.cell_mut(r, col).reset();
2014 }
2015 self.damage_span(r, col, cols - 1);
2016 }
2017
2018 // ---- line/region editing (IL / DL / SU / SD) -----------------------------
2019
2020 /// Scroll rows `[top..=bottom]` by `n` lines, BCE-filling the exposed lines.
2021 /// `down` inserts blanks at the top (content moves down); otherwise content
2022 /// moves up and blanks appear at the bottom. Reuses the one-line region scroll
2023 /// primitives (so damage + scroll-op accumulation come for free), then fills
2024 /// the exposed lines with the current SGR background.
2025 fn scroll_region_lines(&mut self, top: usize, bottom: usize, n: usize, down: bool) {
2026 let height = bottom - top + 1;
2027 let n = n.min(height);
2028 if n == 0 {
2029 return;
2030 }
2031 for _ in 0..n {
2032 if down {
2033 self.grid.scroll_down_region(top, bottom);
2034 self.record_scroll(top, bottom, -1);
2035 } else {
2036 self.grid.scroll_up_region(top, bottom);
2037 self.record_scroll(top, bottom, 1);
2038 }
2039 }
2040 // BCE-fill the n exposed lines (the primitives blank to default).
2041 let bg = self.cursor.pen.bg;
2042 let (fill_top, fill_end) = if down {
2043 (top, top + n)
2044 } else {
2045 (bottom + 1 - n, bottom + 1)
2046 };
2047 let cols = self.grid.cols();
2048 for r in fill_top..fill_end {
2049 for c in 0..cols {
2050 let cell = self.grid.cell_mut(r, c);
2051 cell.reset();
2052 cell.set_bg(bg);
2053 }
2054 }
2055 }
2056
2057 /// SU (CSI Pn S): scroll the scroll region up by `n`.
2058 fn scroll_up_lines(&mut self, n: usize) {
2059 self.scroll_region_lines(self.scroll_top, self.scroll_bottom, n, false);
2060 }
2061
2062 /// SD (CSI Pn T): scroll the scroll region down by `n`.
2063 fn scroll_down_lines(&mut self, n: usize) {
2064 self.scroll_region_lines(self.scroll_top, self.scroll_bottom, n, true);
2065 }
2066
2067 /// IL (CSI Pn L): insert `n` blank lines at the cursor, scrolling
2068 /// `[cursor..=scroll_bottom]` down. A no-op when the cursor is outside the
2069 /// scroll region.
2070 fn insert_lines(&mut self, n: usize) {
2071 let cur = self.cursor.row;
2072 if cur < self.scroll_top || cur > self.scroll_bottom {
2073 return;
2074 }
2075 self.scroll_region_lines(cur, self.scroll_bottom, n, true);
2076 }
2077
2078 /// DL (CSI Pn M): delete `n` lines at the cursor, scrolling
2079 /// `[cursor..=scroll_bottom]` up. A no-op when the cursor is outside the
2080 /// scroll region.
2081 fn delete_lines(&mut self, n: usize) {
2082 let cur = self.cursor.row;
2083 if cur < self.scroll_top || cur > self.scroll_bottom {
2084 return;
2085 }
2086 self.scroll_region_lines(cur, self.scroll_bottom, n, false);
2087 }
2088
2089 // ---- SGR (CSI m) ---------------------------------------------------------
2090
2091 fn sgr(&mut self, params: &Params) {
2092 let pen = &mut self.cursor.pen;
2093 let mut iter = params.iter();
2094 while let Some(param) = iter.next() {
2095 let code = param.first().copied().unwrap_or(0);
2096 match code {
2097 0 => pen.reset(),
2098 1 => pen.flags.insert(CellFlags::BOLD),
2099 2 => pen.flags.insert(CellFlags::DIM),
2100 3 => pen.flags.insert(CellFlags::ITALIC),
2101 4 => pen.flags.insert(CellFlags::UNDERLINE),
2102 5 => pen.flags.insert(CellFlags::BLINK),
2103 7 => pen.flags.insert(CellFlags::INVERSE),
2104 8 => pen.flags.insert(CellFlags::HIDDEN),
2105 9 => pen.flags.insert(CellFlags::STRIKETHROUGH),
2106 22 => pen.flags.remove(CellFlags::BOLD | CellFlags::DIM),
2107 23 => pen.flags.remove(CellFlags::ITALIC),
2108 24 => pen.flags.remove(CellFlags::UNDERLINE),
2109 25 => pen.flags.remove(CellFlags::BLINK),
2110 27 => pen.flags.remove(CellFlags::INVERSE),
2111 28 => pen.flags.remove(CellFlags::HIDDEN),
2112 29 => pen.flags.remove(CellFlags::STRIKETHROUGH),
2113 30..=37 => pen.fg = Color::Indexed((code - 30) as u8),
2114 38 => {
2115 if let Some(c) = parse_extended_color(param, &mut iter) {
2116 pen.fg = c;
2117 }
2118 }
2119 39 => pen.fg = Color::Default,
2120 40..=47 => pen.bg = Color::Indexed((code - 40) as u8),
2121 48 => {
2122 if let Some(c) = parse_extended_color(param, &mut iter) {
2123 pen.bg = c;
2124 }
2125 }
2126 49 => pen.bg = Color::Default,
2127 // bright foreground/background (aixterm) → palette 8..=15.
2128 90..=97 => pen.fg = Color::Indexed((code - 90 + 8) as u8),
2129 100..=107 => pen.bg = Color::Indexed((code - 100 + 8) as u8),
2130 _ => {}
2131 }
2132 }
2133 }
2134}
2135
2136/// Parse `38`/`48` extended colour, in either form:
2137/// - sub-parameter (colon) form inline in `param`: `38:5:n`, `38:2:r:g:b`
2138/// (optionally `38:2:cs:r:g:b` with a colorspace id), or
2139/// - legacy (semicolon) form: pull the following top-level params from `iter`.
2140fn parse_extended_color<'a, I>(param: &[u16], iter: &mut I) -> Option<Color>
2141where
2142 I: Iterator<Item = &'a [u16]>,
2143{
2144 if param.len() > 1 {
2145 // Colon sub-parameter form: kind is param[1].
2146 match param[1] {
2147 2 => {
2148 // 38:2:r:g:b (len 5) or 38:2:cs:r:g:b (len 6, colorspace skipped).
2149 let off = if param.len() >= 6 { 3 } else { 2 };
2150 let r = *param.get(off)? as u8;
2151 let g = *param.get(off + 1)? as u8;
2152 let b = *param.get(off + 2)? as u8;
2153 Some(Color::Rgb(r, g, b))
2154 }
2155 5 => Some(Color::Indexed(*param.get(2)? as u8)),
2156 _ => None,
2157 }
2158 } else {
2159 // Legacy semicolon form: kind, then its operands, are separate params.
2160 match iter.next()?.first().copied()? {
2161 2 => {
2162 let r = iter.next()?.first().copied()? as u8;
2163 let g = iter.next()?.first().copied()? as u8;
2164 let b = iter.next()?.first().copied()? as u8;
2165 Some(Color::Rgb(r, g, b))
2166 }
2167 5 => Some(Color::Indexed(iter.next()?.first().copied()? as u8)),
2168 _ => None,
2169 }
2170 }
2171}
2172
2173/// Reflow one screen (joined with its `scrollback`) to `cols` x `rows`, tracking
2174/// `point` (a cursor in screen coordinates). Returns the new screen rows, the new
2175/// scrollback (capped to `limit`), and the new point. The alt screen passes an
2176/// empty scrollback and discards the returned one.
2177/// The fixed dimensions a resize reflows toward.
2178#[derive(Clone, Copy)]
2179struct ReflowDims {
2180 old_cols: usize,
2181 cols: usize,
2182 rows: usize,
2183 limit: usize,
2184}
2185
2186/// The result of reflowing one pane.
2187struct PaneReflow {
2188 screen: Vec<Row>,
2189 scrollback: VecDeque<Row>,
2190 /// The cursor's new screen-relative position.
2191 cursor: (usize, usize),
2192 /// Each tracked extra point's new **absolute** position, index-aligned with
2193 /// the `extra_abs` argument.
2194 extras: Vec<(usize, usize)>,
2195}
2196
2197/// Reflow one pane (its `scrollback` joined with `screen`) to `dims`, tracking
2198/// the screen-relative cursor `point` plus any `extra_abs` points given in
2199/// **absolute** `[scrollback ++ screen]` coordinates (selection anchors).
2200fn reflow_pane(
2201 screen: Vec<Row>,
2202 scrollback: VecDeque<Row>,
2203 point: (usize, usize),
2204 extra_abs: &[(usize, usize)],
2205 dims: ReflowDims,
2206) -> PaneReflow {
2207 let scroll_len = scrollback.len();
2208 let mut all: Vec<Row> = scrollback.into();
2209 all.extend(screen);
2210
2211 // The cursor is screen-relative; lift it to absolute, then track it together
2212 // with the already-absolute extras.
2213 let mut pts: Vec<(usize, usize)> = Vec::with_capacity(1 + extra_abs.len());
2214 pts.push((scroll_len + point.0, point.1));
2215 pts.extend_from_slice(extra_abs);
2216
2217 let pts = if dims.cols != dims.old_cols {
2218 let (reflowed, np) = crate::grid::reflow(all, dims.cols, &pts);
2219 all = reflowed;
2220 np
2221 } else {
2222 pts
2223 };
2224
2225 let split = all.len().saturating_sub(dims.rows);
2226 let history: Vec<Row> = all.drain(0..split).collect();
2227 let mut sb: VecDeque<Row> = history.into();
2228 let mut dropped = 0usize;
2229 while sb.len() > dims.limit {
2230 sb.pop_front();
2231 dropped += 1;
2232 }
2233
2234 // The cursor returns to screen-relative (its absolute index minus the
2235 // history split). The extras stay absolute, shifted down by any lines the
2236 // cap dropped from the front of history.
2237 PaneReflow {
2238 cursor: (pts[0].0.saturating_sub(split), pts[0].1),
2239 extras: pts[1..]
2240 .iter()
2241 .map(|&(l, c)| (l.saturating_sub(dropped), c))
2242 .collect(),
2243 screen: all,
2244 scrollback: sb,
2245 }
2246}
2247
2248/// Whether `c` ends a word for Word (semantic) selection. Whitespace plus a
2249/// punctuation set mirroring Alacritty's default `semantic_escape_chars`, so
2250/// path/URL-ish runs (`.`, `/`, `-`) stay one word.
2251fn is_word_boundary(c: char) -> bool {
2252 c.is_whitespace() || ",│`|:\"'()[]{}<>".contains(c)
2253}
2254
2255/// Default tab stops: one every 8 columns (incl. column 0), matching xterm.
2256fn default_tabs(cols: usize) -> Vec<bool> {
2257 (0..cols).map(|i| i % 8 == 0).collect()
2258}
2259
2260/// First sub-parameter of CSI param `idx`, or `default` when absent or zero
2261/// (a zero/omitted numeric param means "1" for cursor movement and "0" for
2262/// erase — callers pass the right default).
2263fn param_or(params: &Params, idx: usize, default: u16) -> u16 {
2264 match params.iter().nth(idx).and_then(|p| p.first().copied()) {
2265 Some(v) if v != 0 => v,
2266 _ => default,
2267 }
2268}
2269
2270impl Term {
2271 /// Apply one DEC private mode set (`'h'`) or reset (`'l'`). DECSET/DECRST
2272 /// carry a list of modes, so `csi_dispatch` folds this over every parameter
2273 /// (#56); each mode is an independent toggle, not a stack.
2274 fn set_dec_private_mode(&mut self, action: char, mode: u16) {
2275 match (action, mode) {
2276 ('h', 1049) => self.enter_alt_screen(),
2277 ('l', 1049) => self.leave_alt_screen(),
2278 // Legacy alt-screen variants (#72): ?47/?1047 switch the buffer
2279 // without saving the cursor; ?1048 saves/restores the cursor without
2280 // switching. ?1049 is the two combined.
2281 ('h', 47) | ('h', 1047) => self.switch_to_alt(),
2282 ('l', 47) | ('l', 1047) => self.switch_to_primary(),
2283 ('h', 1048) => self.save_alt_cursor(),
2284 ('l', 1048) => self.restore_alt_cursor(),
2285 ('h', 6) => {
2286 // DECOM: set homes the cursor to the region top.
2287 self.origin_mode = true;
2288 self.goto(0, 0);
2289 }
2290 ('l', 6) => self.origin_mode = false, // unset leaves the cursor put
2291 ('h', 7) => self.autowrap = true, // DECAWM
2292 ('l', 7) => self.autowrap = false,
2293 ('h', 45) => self.reverse_wraparound = true, // reverse wraparound (#80)
2294 ('l', 45) => self.reverse_wraparound = false,
2295 // DECCOLM (#82): the engine is dimension-free, so emit a request the
2296 // consumer may honor by resizing — no screen/cursor/margin change here.
2297 ('h', 3) => self.events.push(TermEvent::ColumnMode { cols: 132 }),
2298 ('l', 3) => self.events.push(TermEvent::ColumnMode { cols: 80 }),
2299 ('h', 25) => self.cursor.visible = true, // DECTCEM show
2300 ('l', 25) => self.cursor.visible = false, // DECTCEM hide
2301 ('h', 12) => self.cursor.blink = true, // att610 cursor blink (#81)
2302 ('l', 12) => self.cursor.blink = false,
2303 ('h', 2004) => self.bracketed_paste = true,
2304 ('l', 2004) => self.bracketed_paste = false,
2305 ('h', 2026) => self.synchronized_output = true, // synchronized output (#73)
2306 ('l', 2026) => self.synchronized_output = false,
2307 ('h', 2031) => self.color_scheme_updates = true, // color-scheme notifications (#85)
2308 ('l', 2031) => self.color_scheme_updates = false,
2309 ('h', 9001) => self.win32_input_mode = true, // win32-input-mode (#86)
2310 ('l', 9001) => self.win32_input_mode = false,
2311
2312 // Input-encoding modes (#11): DECCKM, mouse tracking + encoding,
2313 // focus reporting. Each set assigns the level; each reset clears
2314 // it (apps enable/disable the same mode, not a stack).
2315 ('h', 1) => self.app_cursor_keys = true, // DECCKM
2316 ('l', 1) => self.app_cursor_keys = false,
2317 ('h', 66) => self.application_keypad = true, // DECNKM (#74)
2318 ('l', 66) => self.application_keypad = false,
2319 // DECANM (#84): set = ANSI (the normal state); reset enters VT52. Only
2320 // the reset is meaningful — `?2h` is a no-op (already ANSI).
2321 ('l', 2) => self.vt52_mode = true,
2322 ('h', 9) => self.mouse_protocol = MouseProtocol::X10, // X10 mouse (#70)
2323 ('h', 1000) => self.mouse_protocol = MouseProtocol::Normal,
2324 ('h', 1002) => self.mouse_protocol = MouseProtocol::ButtonEvent,
2325 ('h', 1003) => self.mouse_protocol = MouseProtocol::AnyEvent,
2326 ('l', 9) | ('l', 1000) | ('l', 1002) | ('l', 1003) => {
2327 self.mouse_protocol = MouseProtocol::Off
2328 }
2329 ('h', 1006) => self.mouse_encoding = MouseEncoding::Sgr,
2330 ('l', 1006) => self.mouse_encoding = MouseEncoding::Default,
2331 ('h', 1015) => self.mouse_encoding = MouseEncoding::Urxvt,
2332 ('l', 1015) => self.mouse_encoding = MouseEncoding::Default,
2333 ('h', 1005) => self.mouse_encoding = MouseEncoding::Utf8,
2334 ('l', 1005) => self.mouse_encoding = MouseEncoding::Default,
2335 ('h', 1016) => self.mouse_encoding = MouseEncoding::SgrPixels,
2336 ('l', 1016) => self.mouse_encoding = MouseEncoding::Default,
2337 ('h', 1004) => self.focus_events = true,
2338 ('l', 1004) => self.focus_events = false,
2339
2340 _ => {} // other DEC modes are later slices
2341 }
2342 }
2343
2344 /// Dispatch one VT52 escape sequence (`ESC <final>`), reached only while
2345 /// `vt52_mode` is set (#84). VT52 is a pre-ANSI dialect: the cursor/erase
2346 /// finals map to the same `Term` primitives the ANSI path uses. `ESC <`
2347 /// returns to ANSI. Unknown finals are ignored.
2348 fn vt52_dispatch(&mut self, byte: u8) {
2349 match byte {
2350 b'A' => self.move_up(1), // cursor up
2351 b'B' => self.move_down(1), // cursor down
2352 b'C' => self.move_forward(1), // cursor right
2353 b'D' => self.move_back(1), // cursor left
2354 b'H' => self.goto(0, 0), // cursor home
2355 b'I' => self.reverse_index(), // reverse line feed
2356 b'J' => self.erase_display(0), // erase cursor → end of screen
2357 b'K' => self.erase_line(0), // erase cursor → end of line
2358 b'Y' => self.vt52_y_pending = 2, // direct address: two coord bytes follow
2359 // Identify (DECID): reply `ESC / Z` — "I am a VT52".
2360 b'Z' => self.replies.extend_from_slice(b"\x1b/Z"),
2361 b'=' => self.application_keypad = true, // enter alternate keypad
2362 b'>' => self.application_keypad = false, // exit alternate keypad
2363 b'<' => self.vt52_mode = false, // exit VT52, return to ANSI
2364 // RIS (`ESC c`) is honored even here: it is a hard "recover from any
2365 // state" reset, and `full_reset` rebuilds `Term` with `vt52_mode`
2366 // cleared, so RIS always escapes VT52 back to ANSI. VT52 defines no
2367 // other meaning for `ESC c`.
2368 b'c' => self.full_reset(),
2369 // Graphics mode (`ESC F`/`ESC G`) is a documented non-goal: the VT52
2370 // graphics glyph set differs from DEC Special Graphics, so reusing that
2371 // charset would render the wrong glyphs. No-op rather than approximate.
2372 b'F' | b'G' => {}
2373 _ => {} // unknown VT52 finals are ignored
2374 }
2375 }
2376
2377 /// Consume one `ESC Y` coordinate byte (#84). The first byte is the row, the
2378 /// second the column; each decodes as `value - 0x20`. On the second byte the
2379 /// cursor is addressed (`goto` clamps out-of-range coordinates). Reached only
2380 /// from `print` while `vt52_y_pending > 0`.
2381 fn vt52_take_coord(&mut self, c: char) {
2382 let coord = (c as usize).saturating_sub(0x20);
2383 if self.vt52_y_pending == 2 {
2384 self.vt52_y_row = coord;
2385 self.vt52_y_pending = 1;
2386 } else {
2387 self.vt52_y_pending = 0;
2388 self.goto(self.vt52_y_row, coord);
2389 }
2390 }
2391}
2392
2393impl Perform for Term {
2394 fn print(&mut self, c: char) {
2395 // VT52 `ESC Y` direct addressing (#84): vte delivers the two coordinate
2396 // bytes here (it returned to ground after the `Y` final), so intercept
2397 // them before they would be written as glyphs.
2398 if self.vt52_y_pending > 0 {
2399 self.vt52_take_coord(c);
2400 return;
2401 }
2402 // Translate through the active (GL) character set first (#62): under DEC
2403 // Special Graphics a printable byte becomes a line-drawing glyph.
2404 let c = self.charsets[self.gl].map(c);
2405 match c.width() {
2406 // Zero-width (combining marks): the grapheme-cluster side-table is a
2407 // later slice; drop for now rather than mis-place it as its own cell.
2408 // A zero-width code point is a combining mark — attach it to the
2409 // previous base glyph rather than dropping it.
2410 Some(0) => self.push_combining(c),
2411 None => {}
2412 Some(width) => self.write_glyph(c, width),
2413 }
2414 }
2415
2416 fn execute(&mut self, byte: u8) {
2417 match byte {
2418 // LF, VT, FF all line-feed.
2419 b'\n' | 0x0b | 0x0c => self.linefeed(),
2420 b'\r' => self.carriage_return(),
2421 0x08 => self.backspace(),
2422 b'\t' => self.put_tab(),
2423 0x07 => self.events.push(TermEvent::Bell), // BEL (#12)
2424 0x0e => self.gl = 1, // SO (LS1): GL = G1 (#62)
2425 0x0f => self.gl = 0, // SI (LS0): GL = G0
2426 _ => {}
2427 }
2428 }
2429
2430 fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, action: char) {
2431 // Kitty keyboard-protocol negotiation: CSI > / = / < / ? ... u. The
2432 // leading intermediate distinguishes it from plain `CSI u` (SCORC) (#23).
2433 if action == 'u'
2434 && let Some(&lead) = intermediates.first()
2435 && matches!(lead, b'>' | b'<' | b'=' | b'?')
2436 {
2437 self.kitty_dispatch(lead, params);
2438 return;
2439 }
2440 // DEC private modes arrive with a '?' intermediate.
2441 if intermediates.first() == Some(&b'?') {
2442 // DECRQM (CSI ? Ps $ p) — report whether mode Ps is set. The '$'
2443 // intermediate distinguishes it from a plain `?...p`. It queries a
2444 // single mode, so it keys off the first parameter only.
2445 if action == 'p' && intermediates.contains(&b'$') {
2446 self.decrqm(param_or(params, 0, 0));
2447 return;
2448 }
2449 // Private DSR (CSI ? Ps n): ?996 = color-scheme query (#85). The
2450 // theme-agnostic engine relays it as an event for the consumer.
2451 if action == 'n' {
2452 if param_or(params, 0, 0) == 996 {
2453 self.events.push(TermEvent::ColorSchemeQuery);
2454 }
2455 return;
2456 }
2457 // DECSET/DECRST carry a *list* of modes; apply set/reset to EVERY
2458 // parameter, not just the first — htop batches `?1006;1000h` into one
2459 // CSI, so folding only params[0] dropped the 1000 (#56).
2460 for mode in params.iter().filter_map(|p| p.first().copied()) {
2461 self.set_dec_private_mode(action, mode);
2462 }
2463 return;
2464 }
2465 // DECSTR soft reset: CSI ! p (#53).
2466 if intermediates.first() == Some(&b'!') && action == 'p' {
2467 self.soft_reset();
2468 return;
2469 }
2470 // DECSCUSR set cursor style: CSI Ps SP q (space intermediate) (#89). An
2471 // absent param means 1 (block blink); an explicit 0 means reset — so the
2472 // raw value matters and `param_or` (which folds 0 to its default) is wrong.
2473 if intermediates.first() == Some(&b' ') && action == 'q' {
2474 let param = params.iter().next().and_then(|p| p.first().copied());
2475 self.set_cursor_style(param.unwrap_or(1));
2476 return;
2477 }
2478 // Other private/intermediate sequences are later slices; ignore them
2479 // rather than misinterpret.
2480 if !intermediates.is_empty() {
2481 return;
2482 }
2483 match action {
2484 'A' => self.move_up(param_or(params, 0, 1) as usize),
2485 'B' | 'e' => self.move_down(param_or(params, 0, 1) as usize),
2486 'C' | 'a' => self.move_forward(param_or(params, 0, 1) as usize),
2487 'D' => self.move_back(param_or(params, 0, 1) as usize),
2488 'G' | '`' => self.set_col(param_or(params, 0, 1) as usize - 1),
2489 'd' => self.set_row(param_or(params, 0, 1) as usize - 1),
2490 'H' | 'f' => {
2491 let row = param_or(params, 0, 1) as usize - 1;
2492 let col = param_or(params, 1, 1) as usize - 1;
2493 self.goto(row, col);
2494 }
2495 'J' => self.erase_display(param_or(params, 0, 0)),
2496 'K' => self.erase_line(param_or(params, 0, 0)),
2497 'X' => self.erase_chars(param_or(params, 0, 1) as usize),
2498 '@' => self.insert_chars(param_or(params, 0, 1) as usize),
2499 'P' => self.delete_chars(param_or(params, 0, 1) as usize),
2500 'S' => self.scroll_up_lines(param_or(params, 0, 1) as usize),
2501 'T' => self.scroll_down_lines(param_or(params, 0, 1) as usize),
2502 'L' => self.insert_lines(param_or(params, 0, 1) as usize),
2503 'M' => self.delete_lines(param_or(params, 0, 1) as usize),
2504 'g' => self.clear_tab_stop(param_or(params, 0, 0)),
2505 'r' => {
2506 let rows = self.grid.rows() as u16;
2507 let top = param_or(params, 0, 1) as usize;
2508 let bottom = param_or(params, 1, rows) as usize;
2509 self.set_scroll_region(top, bottom);
2510 }
2511 'm' => self.sgr(params),
2512 's' => self.save_cursor(), // SCOSC (CSI s) — alias of DECSC
2513 'u' => self.restore_cursor(), // SCORC (CSI u) — alias of DECRC
2514 // DA1 (primary device attributes, CSI c): advertise VT220 + ANSI
2515 // colour — the levels justerm actually implements (#27).
2516 'c' => self.replies.extend_from_slice(b"\x1b[?62;22c"),
2517 'n' => self.device_status_report(param_or(params, 0, 0)),
2518 // Non-private SM/RM. Folded over every parameter (modes can batch,
2519 // like the private path #56). IRM (4) and LNM (20) so far.
2520 'h' => {
2521 for m in params.iter().filter_map(|p| p.first().copied()) {
2522 match m {
2523 4 => self.insert_mode = true,
2524 20 => self.newline_mode = true,
2525 _ => {}
2526 }
2527 }
2528 }
2529 'l' => {
2530 for m in params.iter().filter_map(|p| p.first().copied()) {
2531 match m {
2532 4 => self.insert_mode = false,
2533 20 => self.newline_mode = false,
2534 _ => {}
2535 }
2536 }
2537 }
2538 _ => {}
2539 }
2540 }
2541
2542 fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, byte: u8) {
2543 // VT52 mode (#84): the pre-ANSI dialect reuses the same `ESC <final>`
2544 // tokens vte already produces, but with different meanings, so it is a
2545 // mode-gated branch here rather than a separate parser. All VT52 sequences
2546 // are intermediate-free; anything with an intermediate is not VT52.
2547 if self.vt52_mode && intermediates.is_empty() {
2548 self.vt52_dispatch(byte);
2549 return;
2550 }
2551 if let Some(&i) = intermediates.first() {
2552 // SCS: designate a charset to G0 (`ESC ( F`) or G1 (`ESC ) F`) (#62).
2553 if matches!(i, b'(' | b')') {
2554 let set = match byte {
2555 b'0' => Charset::DecSpecialGraphics,
2556 b'A' => Charset::Uk,
2557 b'B' => Charset::Ascii,
2558 _ => return, // other sets are later slices
2559 };
2560 self.charsets[if i == b'(' { 0 } else { 1 }] = set;
2561 }
2562 // Other intermediates (G2/G3 designators, etc.) are later slices.
2563 return;
2564 }
2565 match byte {
2566 b'D' => self.linefeed(), // IND (line-feed without CR)
2567 b'E' => {
2568 // NEL (next line): carriage return + line-feed.
2569 self.carriage_return();
2570 self.linefeed();
2571 }
2572 b'H' => self.set_tab_stop(), // HTS
2573 b'M' => self.reverse_index(), // RI
2574 b'7' => self.save_cursor(), // DECSC
2575 b'8' => self.restore_cursor(), // DECRC
2576 b'c' => self.full_reset(), // RIS (#53)
2577 b'=' => self.application_keypad = true, // DECKPAM (#74)
2578 b'>' => self.application_keypad = false, // DECKPNM
2579 _ => {}
2580 }
2581 }
2582
2583 /// OSC dispatch (#12 event surface): title (0/2), cwd (7). OSC 8 hyperlink
2584 /// is per-cell state, handled in its own slice (#26), not here.
2585 fn osc_dispatch(&mut self, params: &[&[u8]], _bell_terminated: bool) {
2586 // params[0] is the OSC number; params[1..] the payload fields.
2587 let Some(&number) = params.first() else {
2588 return;
2589 };
2590 match number {
2591 // OSC 0 = icon + window title, OSC 2 = window title. Both set title.
2592 b"0" | b"2" => {
2593 if let Some(&title) = params.get(1) {
2594 self.events.push(TermEvent::Title(
2595 String::from_utf8_lossy(title).into_owned(),
2596 ));
2597 }
2598 }
2599 // OSC 7 = current working directory (a file:// URI).
2600 b"7" => {
2601 if let Some(&cwd) = params.get(1) {
2602 self.events
2603 .push(TermEvent::Cwd(String::from_utf8_lossy(cwd).into_owned()));
2604 }
2605 }
2606 // OSC 8 = hyperlink: `OSC 8 ; params ; URI`. A non-empty URI opens a
2607 // link (interned + made current); an empty URI closes it. `params`
2608 // (e.g. `id=…`) is ignored for now — id-grouping is a later refinement.
2609 b"8" => {
2610 let uri = params.get(2).copied().unwrap_or(b"");
2611 if uri.is_empty() {
2612 self.current_link = None;
2613 } else {
2614 self.hyperlink_pool
2615 .push(String::from_utf8_lossy(uri).into_owned());
2616 self.current_link =
2617 core::num::NonZeroU32::new(self.hyperlink_pool.len() as u32);
2618 }
2619 }
2620 _ => {} // other OSCs are later slices
2621 }
2622 }
2623}