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