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