Skip to main content

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}