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