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::{ExtAttrs, 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 narrowest screen the engine represents: **two columns**.
270///
271/// A width-2 glyph occupies a `WIDE_CHAR` lead *and* the `WIDE_CHAR_SPACER` that
272/// stands for its second half, so one column cannot hold one — and a pair with only
273/// one half written is the malformed state every repair path in this crate keys off
274/// (ADR-0025 D4). `Term::with_scrollback` and [`Term::resize`] clamp `cols` up to
275/// this, which is what makes D4 (*both halves of a pair move together*)
276/// unconditionally satisfiable rather than true only above some unstated width.
277///
278/// Both references that a terminal *engine* can be compared to forbid one column for
279/// exactly this reason — alacritty's `MIN_COLUMNS = 2` and xterm.js's
280/// `MINIMUM_COLS = 2` — and the third (ghostty) permits it only by destroying the
281/// glyph.
282///
283/// The clamp is **silent and pull-only**: a `resize(1, rows)` during a pane drag is
284/// widened rather than rejected, and no event reports it. Both references instead
285/// make the clamped size the one that travels outward — alacritty derives its
286/// `WindowSize` from the clamped `SizeInfo`, xterm.js fires `onResize` with the
287/// clamped pair — so a justerm consumer must do that correlation itself: read the
288/// width back from [`Term::grid`] / the frame header and size the PTY from *that*,
289/// never from the value it requested. Sizing a PTY to one column leaves the
290/// application rendering for a width the buffer does not have (#547).
291pub const MIN_COLUMNS: usize = 2;
292
293/// The state DECSC (ESC 7) saves and DECRC (ESC 8) restores: position, pen/SGR,
294/// pending-wrap, and origin mode (per ADR-0004 — DECRC restores origin mode,
295/// which Alacritty omits). Cursor *visibility* is deliberately not part of this
296/// (DECTCEM is separate from DECSC).
297#[derive(Clone, Copy, Default)]
298struct SavedCursor {
299    row: usize,
300    col: usize,
301    pen: Pen,
302    pending_wrap: bool,
303    origin_mode: bool,
304    /// SCS charset state at save time — DECSC/DECRC round-trip the designated
305    /// sets and the active GL shift (#62).
306    charsets: [Charset; 4],
307    gl: usize,
308}
309
310/// An engine-owned decoration marker (#118): a stable id bound to an absolute
311/// buffer line. The line shifts in lockstep with eviction/region scroll/reflow
312/// (the same coordinate moves the selection anchor tracks); the marker is
313/// dropped when its line leaves the buffer.
314struct Marker {
315    id: MarkerId,
316    line: usize,
317    /// The cursor column at emit time (#166). Meaningful for OSC-133 command
318    /// marks — CommandStart(B)/OutputStart(C) columns bound the *typed command*
319    /// (excluding the prompt), like VSCode's `commandStartX`/`commandExecutedX`.
320    /// Plain `add_marker` decorations are row-granular and carry `col = 0`.
321    ///
322    /// **Domain is `[0, cols]`, not `[0, cols - 1]` (#562)** — a bound, not a cell.
323    /// A command that exactly fills its row ends *one past* the last column, and
324    /// that value is what `extract_lines` wants: it clips `[b_col, c_col)`, so the
325    /// exclusive end absorbs it through `.min(cells.len())`. Storing `cursor.col`
326    /// alone (the cursor is held at `cols - 1` with `pending_wrap`) cost such a
327    /// command its last character with no resize involved. The **inclusive** side
328    /// cannot absorb it, so `extract_lines` steps a `from` of `cells.len()` to the
329    /// next line rather than selecting an empty run and flushing a `\n`.
330    col: usize,
331    /// Plain for a `add_marker` decoration; a command-boundary role for an
332    /// OSC 133 mark (#158). All kinds share the anchor/eviction machinery.
333    kind: MarkerKind,
334}
335
336/// One executed shell command recovered from OSC-133 marks (#166), for
337/// screen-reader command navigation. The consumer jumps prompt-to-prompt over
338/// these and announces `command` + a success/fail signal from `exit`.
339#[derive(Debug, Clone, PartialEq, Eq)]
340pub struct CommandLine {
341    /// The command's jump anchor as a *document* line — the logical-line index of
342    /// the CommandStart(B) mark within [`Term::accessible_text`], so the consumer
343    /// reveals the right row of the accessible view (soft-wrapped rows collapse to
344    /// one logical line). This is core's analog of VSCode's
345    /// `bufferToEditorLineMapping`; the frame-mode web side has no wrap info to
346    /// map it itself.
347    pub line: usize,
348    /// The typed command text, prompt- and output-excluded (B→C columns).
349    pub command: String,
350    /// The CommandFinished(D) exit code, if the shell reported one and the
351    /// command has finished.
352    pub exit: Option<i32>,
353}
354
355/// A selection resolved to absolute-coordinate bounds, ready for text extraction
356/// or viewport-span projection. Columns are half-open (`from..to`).
357enum Resolved {
358    /// Char/Word/Line: a run that joins soft-wrapped rows. Columns apply to the
359    /// first/last line; middle lines are whole.
360    Linear {
361        start_line: usize,
362        from: usize,
363        end_line: usize,
364        to: usize,
365    },
366    /// Block: a rectangle — the same `from..to` columns on every row.
367    Block {
368        line0: usize,
369        line1: usize,
370        from: usize,
371        to: usize,
372    },
373}
374
375/// Collect per-line damage bounds into damaged `LineDamage` spans (undamaged
376/// lines dropped). Shared by `damage` (content-only) and `frame_damage`
377/// (content + cursor cells).
378fn bounds_to_lines(bounds: &[LineBounds]) -> Vec<LineDamage> {
379    bounds
380        .iter()
381        .enumerate()
382        .filter(|(_, b)| b.is_damaged())
383        .map(|(line, b)| {
384            let (left, right) = b.span();
385            LineDamage { line, left, right }
386        })
387        .collect()
388}
389
390impl Term {
391    pub fn new(cols: usize, rows: usize) -> Self {
392        Self::with_scrollback(cols, rows, DEFAULT_SCROLLBACK)
393    }
394
395    pub fn with_scrollback(cols: usize, rows: usize, scrollback_limit: usize) -> Self {
396        // Both clamps mirror `resize` exactly, so a screen cannot be born at a size a
397        // resize would refuse. They are not the same *kind* of rule, though: the width
398        // floor is a published contract (#547 — one column was supported and no longer
399        // is), while the row floor is `resize`'s own long-standing "a terminal is never
400        // 0-tall" that this constructor merely failed to enforce while carrying the
401        // same `scroll_bottom: rows - 1` below. That gap was a subtract-overflow panic
402        // on `rows == 0`, not a degenerate screen.
403        let cols = cols.max(MIN_COLUMNS);
404        let rows = rows.max(1);
405        Term {
406            grid: Grid::new(cols, rows),
407            alt_grid: Grid::new(cols, rows),
408            cursor: Cursor::default(),
409            saved_cursor: Cursor::default(),
410            on_alt: false,
411            origin_mode: false,
412            autowrap: true,
413            insert_mode: false,
414            newline_mode: false,
415            reverse_wraparound: false,
416            bracketed_paste: false,
417            synchronized_output: false,
418            color_scheme_updates: false,
419            grapheme_clustering: false,
420            win32_input_mode: false,
421            app_cursor_keys: false,
422            application_keypad: false,
423            vt52_mode: false,
424            vt52_y_pending: 0,
425            vt52_y_row: 0,
426            mouse_protocol: MouseProtocol::Off,
427            mouse_encoding: MouseEncoding::Default,
428            focus_events: false,
429            kitty_flags: 0,
430            kitty_stack: Vec::new(),
431            events: Vec::new(),
432            replies: Vec::new(),
433            hyperlink_pool: Vec::new(),
434            current_link: None,
435            tabs: default_tabs(cols),
436            scroll_top: 0,
437            scroll_bottom: rows - 1,
438            scrollback: VecDeque::new(),
439            display_offset: 0,
440            scrollback_limit,
441            recycled_row: None,
442            line_damage: vec![LineBounds::undamaged(cols); rows],
443            scroll: None,
444            full_damage: false,
445            prev_cursor: (0, 0), // matches the default cursor's home position
446            selection: None,
447            search_highlights: Vec::new(),
448            active_search_highlight: None,
449            normal_markers: Vec::new(),
450            alt_markers: Vec::new(),
451            next_marker_id: 0,
452            decsc: SavedCursor::default(),
453            charsets: [Charset::Ascii; 4],
454            gl: 0,
455        }
456    }
457
458    /// What changed since the last `reset_damage()` — line ranges, each with a
459    /// changed column span. See ADR-0003.
460    pub fn damage(&self) -> TermDamage {
461        if self.full_damage {
462            return TermDamage::Full;
463        }
464        // Scrolled up under follow-bottom "stay": the viewport is frozen, so
465        // screen changes below it are not visible — report nothing. (A user
466        // scroll that moves the viewport sets full_damage above.)
467        if self.display_offset > 0 {
468            return TermDamage::Partial(Vec::new());
469        }
470        TermDamage::Partial(bounds_to_lines(&self.line_damage))
471    }
472
473    /// Render damage: content damage plus the cursor cells, for [`Term::frame`].
474    ///
475    /// A pure cursor move changes no cell *content*, so [`Term::damage`] (which
476    /// stays content-only, the cadence/flow-control primitive) would miss it —
477    /// yet a cell-invert caret must clear its old spot and ink the new one. So
478    /// the frame producer folds the old (last-acked) + current cursor cells in,
479    /// but only when the cursor actually moved: a still cursor needs no redraw,
480    /// keeping an idle frame empty. Mirrors Alacritty's `last_cursor`. #38.
481    fn frame_damage(&self) -> TermDamage {
482        if self.full_damage {
483            return TermDamage::Full;
484        }
485        if self.display_offset > 0 {
486            return TermDamage::Partial(Vec::new());
487        }
488        let cur = self.cursor.point();
489        if cur == self.prev_cursor {
490            return TermDamage::Partial(bounds_to_lines(&self.line_damage));
491        }
492        let mut bounds = self.line_damage.clone();
493        bounds[cur.0].expand(cur.1, cur.1);
494        let pr = self.prev_cursor.0.min(self.grid.rows() - 1);
495        let pc = self.prev_cursor.1.min(self.grid.cols() - 1);
496        bounds[pr].expand(pc, pc);
497        TermDamage::Partial(bounds_to_lines(&bounds))
498    }
499
500    /// Clear accumulated damage. The consumer calls this after applying a frame
501    /// (the ack); the next `damage()` reflects only changes since.
502    pub fn reset_damage(&mut self) {
503        for b in &mut self.line_damage {
504            b.reset();
505        }
506        self.scroll = None;
507        self.full_damage = false;
508        // The consumer has now seen the caret at the current position; the next
509        // frame's cursor-move damage is measured from here (#38).
510        self.prev_cursor = self.cursor.point();
511    }
512
513    /// Mark the whole screen damaged (alt switch / clear / flood, and a consumer
514    /// reattach that needs a full re-sync — see [`crate::Engine::mark_fully_damaged`]).
515    pub fn mark_fully_damaged(&mut self) {
516        self.full_damage = true;
517    }
518
519    /// Record that columns `[left, right]` of `row` changed.
520    ///
521    /// Both columns are **clamped to the last column, and asserted in debug** (#536).
522    ///
523    /// Ten of the fourteen call sites derive their bound from a cursor column or from `cols`.
524    /// **Four derive it from a wide pair's width**, and that is the shape worth centralising:
525    /// `write_glyph`'s `col + width - 1` (which had no guard — this issue), `promote_cluster_to_wide`'s
526    /// `col + 1` (guarded by its own `col + 1 >= cols` early return), `demote_cluster_to_narrow`'s
527    /// `(col + 1).min(cols - 1)` (self-clamped), and `relocate_cluster_wide`'s literal `(0, 1)`
528    /// (valid only because `MIN_COLUMNS = 2`, #547). Three carried a private guard and one did not.
529    ///
530    /// No reference has this shape to port a clamp from: alacritty computes damage ranges too
531    /// (`term/mod.rs:1406`, `:1649` @ `852e971`) but always from a column or `columns()` — its print
532    /// path records no damage at all, relying on the previous and current cursor *points* to bracket
533    /// the line — while xterm.js tracks whole rows (`markDirty(y)`) and ghostty a per-row
534    /// `dirty: bool`. alacritty's `LineDamageBounds::expand`, which this one is a copy of, is equally
535    /// unguarded.
536    ///
537    /// The two halves do different jobs:
538    ///
539    /// - the **`debug_assert` is the detector**. An out-of-range bound is stored silently and
540    ///   detonates later, when `frame()` slices the row, so the stack trace accuses the reader
541    ///   rather than the writer. That delay is what #536 was filed about, and the assert collapses
542    ///   it — a bad caller dies here, at the site that recorded it (measured: an injected off-by-one
543    ///   moved the panic from `frame()`'s slice to this line).
544    /// - the **clamp is the release backstop**, and it clamps *toward a false positive*. justerm is
545    ///   a library, so a panic crosses into the consumer's process; over-damaging repaints a cell
546    ///   that did not change, which costs nothing a consumer can see. ghostty states the asymmetry
547    ///   as a rule: *"Dirty tracking may have false positives but should never have false negatives.
548    ///   A false negative would result in a visual artifact on the screen."* (`page.zig:1993-1995`).
549    ///
550    /// **`left` is guarded for that reason, and it is the axis that can actually lose a cell.**
551    /// Clamping `right` cannot under-report — columns past the last do not exist. But `LineBounds`
552    /// marks a line undamaged with `left = cols, right = 0` and `is_damaged()` is `left <= right`,
553    /// so a single `expand` with `left > right` on an otherwise-clean line leaves the line reading
554    /// as undamaged and **drops its whole span silently**. Unreachable from the ten column-derived
555    /// sites today; guarded because that is precisely the failure ghostty's rule forbids.
556    ///
557    /// `row` is deliberately left to panic on the index, and that is **not** in tension with
558    /// `frame_damage` clamping a row fifty lines below (`prev_cursor.0.min(rows - 1)`). The two
559    /// rows are different kinds of thing under the same rule: `prev_cursor` is a *stale remembered*
560    /// coordinate that a shrinking resize may have put out of range, so clamping it repaints the
561    /// nearest surviving cell — a false positive. `row` here is a *live computed* index for the
562    /// mutation just made, so clamping it would damage a different line than the one that changed:
563    /// a false negative on the real line, which is the outcome the rule forbids.
564    fn damage_span(&mut self, row: usize, left: usize, right: usize) {
565        let last = self.grid.cols().saturating_sub(1);
566        debug_assert!(
567            left <= right && right <= last,
568            "damage_span({row}, {left}, {right}) is not a span inside [0, {last}]"
569        );
570        self.line_damage[row].expand(left.min(last), right.min(last));
571    }
572
573    /// The first-class scroll recorded since the last `reset_damage`, if any.
574    /// Suppressed while scrolled up — a content scroll must not shift the frozen
575    /// viewport.
576    pub fn scroll_delta(&self) -> Option<ScrollOp> {
577        if self.display_offset > 0 {
578            return None;
579        }
580        self.scroll
581    }
582
583    /// Build a serializable [`Frame`] from the current damage + grid + grapheme
584    /// pool (#6). `Full` ships every row; `Partial` ships the damaged spans. The
585    /// global side-table is remapped to **frame-local** indices — the engine pool
586    /// is append-only and leaky, so a frame carries only the clusters its cells
587    /// reference, renumbered, with each cell's `extra` rewritten to the local id.
588    pub fn frame(&self) -> Frame {
589        let cols = self.grid.cols();
590        let rows = self.grid.rows();
591        let (kind, line_spans): (FrameKind, Vec<(usize, usize, usize)>) = match self.frame_damage()
592        {
593            TermDamage::Full => (
594                FrameKind::Full,
595                (0..rows).map(|l| (l, 0, cols - 1)).collect(),
596            ),
597            TermDamage::Partial(lines) => (
598                FrameKind::Partial,
599                lines
600                    .into_iter()
601                    .map(|d| (d.line, d.left, d.right))
602                    .collect(),
603            ),
604        };
605
606        let mut side_table: Vec<Vec<char>> = Vec::new();
607        // Same frame-local renumber for the hyperlink side-table (#26).
608        let mut link_table: Vec<String> = Vec::new();
609        let mut link_remap = vec![0u16; self.hyperlink_pool.len() + 1];
610        // Cells come from the viewport at `display_offset`, not the live grid:
611        // viewport row `line` is absolute buffer line `top + line` (scrollback
612        // when scrolled up, the live grid when `display_offset == 0`, where
613        // `top == scrollback.len()` and this is identical to reading the grid).
614        // Without this, a wire consumer — cells reach it only through `frame()` —
615        // could never display scrollback (#48).
616        let top = self.scrollback.len() - self.display_offset;
617        let mut spans = Vec::with_capacity(line_spans.len());
618        for (line, left, right) in line_spans {
619            let mut cells = Vec::with_capacity(right - left + 1);
620            let mut combining = std::collections::BTreeMap::new();
621            let mut links = std::collections::BTreeMap::new();
622            let mut ucolors = std::collections::BTreeMap::new();
623            let row = self.abs_row(top + line);
624            let last_col = row.len().saturating_sub(1);
625            for col in left..=right {
626                let mut cell = row[col];
627                // Soft-wrap is a row property (#538), but the wire has no per-row slot — so it is
628                // *derived* back onto the last cell's WRAPLINE bit here, which keeps the format
629                // byte-identical and is why moving the storage needed no VERSION bump. The bit is
630                // therefore wire-only: on a live grid it is never set, and `Row::is_wrapped` is
631                // the question to ask.
632                if col == last_col && row.is_wrapped() {
633                    cell.insert_flags(CellFlags::WRAPLINE);
634                }
635                // Combining clusters and hyperlinks live in the row's maps; each
636                // tagged cell contributes its reference to the frame, recorded on
637                // the span by span-relative column (the cell holds only the bit).
638                if let Some(marks) = row.combining_at(col) {
639                    side_table.push(marks.to_vec());
640                    let idx = core::num::NonZeroU32::new(side_table.len() as u32)
641                        .expect("side_table just pushed, len >= 1");
642                    combining.insert(col - left, idx);
643                }
644                if let Some(lidx) = row.link_at(col) {
645                    // Renumber the global pool index to a contiguous frame-local
646                    // one (only referenced URIs ship), same as the old per-cell link.
647                    let l = lidx.get() as usize;
648                    if link_remap[l] == 0 {
649                        link_table.push(self.hyperlink_pool[l - 1].clone());
650                        link_remap[l] = link_table.len() as u16;
651                    }
652                    let fidx = core::num::NonZeroU32::new(link_remap[l] as u32)
653                        .expect("link_remap just set, nonzero");
654                    links.insert(col - left, fidx);
655                }
656                // Underline colour (SGR 58, #520): a colour reference, not a
657                // side-table index, so it rides the span inline. `ucolor_at` is
658                // flag-gated + already Default-filtered (the stamp only fires on an
659                // underlined cell), so a present entry is a real non-default colour.
660                if let Some(color) = row.ucolor_at(col) {
661                    ucolors.insert(col - left, color);
662                }
663                cells.push(cell);
664            }
665            spans.push(Span {
666                line: line as u16,
667                left: left as u16,
668                right: right as u16,
669                cells,
670                combining,
671                links,
672                ucolors,
673            });
674        }
675
676        Frame {
677            cols: cols as u16,
678            rows: rows as u16,
679            kind,
680            // The live cursor: position in screen coords + DECTCEM visibility.
681            // Reported, not drawn — the consumer renders the caret (#38).
682            cursor_row: self.cursor.row as u16,
683            cursor_col: self.cursor.col as u16,
684            // Hidden while scrolled up: the live cursor is off the frozen
685            // viewport, and a cell-invert caret would otherwise ink over
686            // scrollback. Consistent with the frozen-damage policy (no cursor
687            // damage is emitted while scrolled) and with xterm.js / alacritty,
688            // which hide the caret when it falls outside the visible rows (#48).
689            cursor_visible: self.cursor.visible && self.display_offset == 0,
690            cursor_shape: self.cursor.shape,
691            cursor_blink: self.cursor.blink,
692            // Viewport scroll position for the consumer's scrollbar (ADR-0013).
693            display_offset: self.display_offset as u32,
694            scrollback_len: self.scrollback.len() as u32,
695            // The mouse tracking mode as a routing mask (#129): which mouse events
696            // the app wants, derived from the protocol by the single source
697            // `encode_mouse` shares. The consumer routes app-vs-local on it.
698            mouse_events: self.mouse_protocol.wanted_events(),
699            // Alt-screen flag (#149): buffer-global state the consumer can't
700            // derive from viewport damage; the a11y announce policy gates on it.
701            alt_screen: self.on_alt,
702            scroll: self.scroll_delta(),
703            spans,
704            side_table,
705            link_table,
706            // Interaction overlays projected onto this viewport (#108): the
707            // engine-owned selection and the consumer-supplied search highlights,
708            // each re-projected here so the scroll offset is applied once, by the
709            // same authority that projects the cells.
710            overlay: Overlay {
711                selection: self.selection_range(),
712                matches: self
713                    .search_highlights
714                    .iter()
715                    .flat_map(|m| self.match_spans(m))
716                    .collect(),
717                // The consumer-designated active match (#428), projected through
718                // the same `match_spans` math — usually also present in `matches`
719                // above (the renderer's ranking resolves the overlap, #424), but
720                // a span designation may sit OUTSIDE a capped hand-over (#436).
721                active_match: self
722                    .active_search_highlight
723                    .as_ref()
724                    .map(|m| self.match_spans(m))
725                    .unwrap_or_default(),
726                markers: self.marker_positions(),
727                marker_lines: self.all_marker_lines(),
728            },
729        }
730    }
731
732    /// Record a scroll of rows `[top, bottom]` by `count` (positive = up).
733    ///
734    /// Damage is indexed by row position, so it must follow the content the
735    /// scroll just moved: rotate the bounds the same way and mark the newly
736    /// exposed line fully damaged (it is new blank content for the consumer).
737    fn record_scroll(&mut self, top: usize, bottom: usize, count: isize) {
738        let cols = self.grid.cols();
739        match count {
740            1 => {
741                self.line_damage[top..=bottom].rotate_left(1);
742                self.line_damage[bottom] = LineBounds::fully_damaged(cols);
743            }
744            -1 => {
745                self.line_damage[top..=bottom].rotate_right(1);
746                self.line_damage[top] = LineBounds::fully_damaged(cols);
747            }
748            _ => {}
749        }
750        // Accumulate repeated scrolls of the same region into one op (flow
751        // control). A *different* region cannot be expressed as one op, so
752        // degrade to full rather than silently dropping the earlier scroll.
753        match self.scroll {
754            Some(op) if op.top == top && op.bottom == bottom => {
755                self.scroll = Some(ScrollOp {
756                    top,
757                    bottom,
758                    count: op.count + count,
759                });
760            }
761            None => self.scroll = Some(ScrollOp { top, bottom, count }),
762            Some(_) => {
763                self.scroll = None;
764                self.mark_fully_damaged();
765            }
766        }
767    }
768
769    /// Number of lines currently held in scrollback history.
770    pub fn scrollback_len(&self) -> usize {
771        self.scrollback.len()
772    }
773
774    /// Whether the app has an open synchronized-output block (DEC ?2026, #73).
775    pub fn synchronized_output(&self) -> bool {
776        self.synchronized_output
777    }
778
779    /// Whether the app enabled color-scheme-update notifications (DEC ?2031, #85).
780    pub fn color_scheme_updates(&self) -> bool {
781        self.color_scheme_updates
782    }
783
784    /// Whether the app enabled grapheme-cluster mode (DEC ?2027, #295): emoji ZWJ / skin-tone /
785    /// flag / VS16 sequences are clustered into one cell. OFF (default) is per-char, wcwidth-compat.
786    pub fn grapheme_clustering(&self) -> bool {
787        self.grapheme_clustering
788    }
789
790    /// Whether the app enabled win32-input-mode (DEC ?9001, #86). The engine does
791    /// not encode the raw key-records itself (a non-goal); a ConPTY consumer reads
792    /// this to decide whether to emit them.
793    pub fn win32_input_mode(&self) -> bool {
794        self.win32_input_mode
795    }
796
797    /// Queue a color-scheme report (`CSI ? 997 ; 1 n` dark / `; 2 n` light) on the
798    /// reply channel. The consumer calls this to answer a `ColorSchemeQuery` event
799    /// or, when its scheme changes and `color_scheme_updates()` is set, to send the
800    /// unsolicited notification. The engine never stores or interprets the scheme
801    /// (#85).
802    pub fn report_color_scheme(&mut self, dark: bool) {
803        let ps = if dark { 1 } else { 2 };
804        self.replies
805            .extend_from_slice(format!("\x1b[?997;{ps}n").as_bytes());
806    }
807
808    /// OSC 10/11 set/query the default fg/bg, stacking the `;`-separated specs
809    /// across the `[foreground, background]` slots — xterm's
810    /// `_setOrReportSpecialColor` offset loop (#137). OSC 10 starts at slot 0
811    /// (fg → bg), OSC 11 at slot 1 (bg). A `?` spec is a query. xterm's 3rd slot
812    /// (cursor / OSC 12) is out of scope, so the stack caps at two slots — extra
813    /// specs are dropped.
814    fn special_color(&mut self, params: &[&[u8]], start: usize) {
815        for (i, &spec) in params[1..].iter().enumerate() {
816            let event = match start + i {
817                0 if spec == b"?" => TermEvent::QueryForeground,
818                0 => TermEvent::SetForeground(String::from_utf8_lossy(spec).into_owned()),
819                1 if spec == b"?" => TermEvent::QueryBackground,
820                1 => TermEvent::SetBackground(String::from_utf8_lossy(spec).into_owned()),
821                _ => break, // past [fg, bg] — cursor (OSC 12) unsupported
822            };
823            self.events.push(event);
824        }
825    }
826
827    /// Answer an OSC 4 palette query (#122): wrap the consumer-supplied spec for
828    /// `index` in the OSC 4 reply envelope, ST-terminated.
829    pub fn report_palette_color(&mut self, index: u8, spec: &str) {
830        self.replies
831            .extend_from_slice(format!("\x1b]4;{index};{spec}\x1b\\").as_bytes());
832    }
833
834    /// Answer an OSC 10 foreground query (#122): wrap the consumer-supplied spec
835    /// in the OSC 10 reply envelope, ST-terminated.
836    pub fn report_foreground(&mut self, spec: &str) {
837        self.replies
838            .extend_from_slice(format!("\x1b]10;{spec}\x1b\\").as_bytes());
839    }
840
841    /// Answer an OSC 11 background query (#122): wrap the consumer-supplied spec
842    /// (it knows its palette) in the OSC 11 reply envelope, ST-terminated. The
843    /// engine formats the envelope only — it never knows the colour.
844    pub fn report_background(&mut self, spec: &str) {
845        self.replies
846            .extend_from_slice(format!("\x1b]11;{spec}\x1b\\").as_bytes());
847    }
848
849    /// The cells of visible row `i` (0..rows) at the current scroll position.
850    /// The viewport windows into `[history.. ; screen..]`: rows above
851    /// `scrollback.len()` come from history, the rest from the live screen.
852    pub fn viewport_line(&self, i: usize) -> &[Cell] {
853        let top = self.scrollback.len() - self.display_offset;
854        let idx = top + i;
855        if idx < self.scrollback.len() {
856            &self.scrollback[idx]
857        } else {
858            self.grid.row(idx - self.scrollback.len())
859        }
860    }
861
862    /// Scroll the viewport up by `n` lines into history (clamped to the oldest).
863    pub fn scroll_up(&mut self, n: usize) {
864        let target = (self.display_offset + n).min(self.scrollback.len());
865        self.set_display_offset(target);
866    }
867
868    /// Scroll the viewport down by `n` lines toward the live screen.
869    pub fn scroll_down(&mut self, n: usize) {
870        let target = self.display_offset.saturating_sub(n);
871        self.set_display_offset(target);
872    }
873
874    /// Jump the viewport back to the live screen (follow the bottom).
875    pub fn scroll_to_bottom(&mut self) {
876        self.set_display_offset(0);
877    }
878
879    /// Move the viewport. A user scroll changes which lines are visible, so the
880    /// whole viewport is repainted (full damage) when the offset actually moves.
881    fn set_display_offset(&mut self, offset: usize) {
882        // The alt screen has no scrollback to view; scroll intents are no-ops.
883        if self.on_alt {
884            return;
885        }
886        if offset != self.display_offset {
887            self.display_offset = offset;
888            self.mark_fully_damaged();
889        }
890    }
891
892    // ---- selection -----------------------------------------------------------
893
894    /// Map a viewport cell `(row, col)` to an absolute buffer point. The top
895    /// visible row is `scrollback.len() - display_offset`, so viewport row `i`
896    /// is that plus `i`.
897    fn viewport_to_abs(&self, row: usize, col: usize) -> BufferPoint {
898        let top = self.scrollback.len() - self.display_offset;
899        BufferPoint {
900            line: top + row,
901            col,
902        }
903    }
904
905    /// The primary-screen grid, wherever it currently lives — swapped into
906    /// `alt_grid` while on the alt screen (#192). Command marks anchor *primary*
907    /// content, so extracting their text must read this, not the active grid.
908    fn primary_grid(&self) -> &Grid {
909        if self.on_alt {
910            &self.alt_grid
911        } else {
912            &self.grid
913        }
914    }
915
916    /// The cells of absolute buffer line `line`, reading the screen portion from
917    /// `grid` (scrollback is shared). Callers pick the active grid (`abs_line`) or
918    /// the primary grid (`primary_grid`, for command-mark text on the alt screen).
919    fn line_in<'a>(&'a self, grid: &'a Grid, line: usize) -> &'a [Cell] {
920        if line < self.scrollback.len() {
921            &self.scrollback[line]
922        } else {
923            grid.row(line - self.scrollback.len())
924        }
925    }
926
927    /// The whole row of absolute buffer line `line` from `grid` (see `line_in`).
928    fn row_in<'a>(&'a self, grid: &'a Grid, line: usize) -> &'a Row {
929        if line < self.scrollback.len() {
930            &self.scrollback[line]
931        } else {
932            grid.row_ref(line - self.scrollback.len())
933        }
934    }
935
936    /// The cells of absolute buffer line `line` on the *active* screen.
937    fn abs_line(&self, line: usize) -> &[Cell] {
938        self.line_in(&self.grid, line)
939    }
940
941    /// The whole row of absolute buffer line `line` on the *active* screen.
942    fn abs_row(&self, line: usize) -> &Row {
943        self.row_in(&self.grid, line)
944    }
945
946    /// The combining marks at absolute `(line, col)` reading `grid`, or `None` —
947    /// flag-gated through the row's map, so a stale entry is never surfaced.
948    fn combining_in<'a>(&'a self, grid: &'a Grid, line: usize, col: usize) -> Option<&'a [char]> {
949        self.row_in(grid, line).combining_at(col)
950    }
951
952    /// The combining marks at absolute `(line, col)` on the *active* screen.
953    fn combining_at(&self, line: usize, col: usize) -> Option<&[char]> {
954        self.combining_in(&self.grid, line, col)
955    }
956
957    /// The hyperlink-pool index at **screen** `(row, col)` (the live grid), or
958    /// `None` — flag-gated through the row's link map. Resolve to the URI with
959    /// [`Term::hyperlink`]. Mirrors `grid().cell(row, col)`.
960    pub(crate) fn screen_link_at(&self, row: usize, col: usize) -> Option<core::num::NonZeroU32> {
961        self.grid.row_ref(row).link_at(col)
962    }
963
964    /// The underline colour (SGR 58, #520) at screen `(row, col)`, as a theme-agnostic
965    /// reference. `Color::Default` means "follow the fg" — the common case, and what an
966    /// unset cell returns. Mirror of [`Term::screen_link_at`].
967    pub(crate) fn screen_underline_color_at(&self, row: usize, col: usize) -> Color {
968        self.grid.row_ref(row).ucolor_at(col).unwrap_or_default()
969    }
970
971    /// The hyperlink-pool index at **viewport** `(row, col)` (visible window,
972    /// history included at the current scroll), or `None`. Mirrors
973    /// `viewport_line(row)`.
974    pub(crate) fn viewport_link_at(&self, row: usize, col: usize) -> Option<core::num::NonZeroU32> {
975        let idx = self.scrollback.len() - self.display_offset + row;
976        self.abs_row(idx).link_at(col)
977    }
978
979    /// The viewport's logical lines (#113/ADR-0017): each line's text plus a
980    /// per-char map to its viewport `(row, col)`. Wide-char spacers are skipped
981    /// and trailing blanks trimmed (so the text is 1:1 with `cells`). Empty rows
982    /// are dropped. The cell-aware assembly the consumer can't do in frame mode.
983    pub fn viewport_logical_lines(&self) -> Vec<LogicalLine> {
984        let rows = self.grid.rows();
985        let total = self.scrollback.len() + rows;
986        let top = self.scrollback.len() - self.display_offset; // abs line of viewport row 0
987        let bottom = top + rows; // abs lines [top, bottom) are on screen
988
989        // If viewport row 0 is a wrap-continuation, walk up into scrollback to
990        // the logical line's true start so an edge-spanning URL still matches.
991        // On the alt screen the scrollback belongs to the *primary* buffer, so
992        // the walk must stop at the screen top (`scrollback.len()`) — the alt
993        // buffer is separate (selection clears on alt-swap for the same reason).
994        let floor = if self.on_alt {
995            self.scrollback.len()
996        } else {
997            0
998        };
999        let mut start = top;
1000        while start > floor && self.abs_row(start - 1).is_wrapped() {
1001            start -= 1;
1002        }
1003
1004        let mut out = Vec::new();
1005        let mut line = start;
1006        while line < bottom {
1007            // Accumulate one logical line forward while each row soft-wraps; the
1008            // tail may run past `bottom` (off-screen below) — included too.
1009            let mut text = String::new();
1010            let mut map: Vec<(i32, usize)> = Vec::new();
1011            let mut cur = line;
1012            loop {
1013                let cells = self.abs_line(cur);
1014                for (col, cell) in cells.iter().enumerate() {
1015                    if cell.is_spacer() {
1016                        continue;
1017                    }
1018                    // Signed viewport row: < 0 above the top, >= rows below.
1019                    let vrow = cur as i32 - top as i32;
1020                    text.push(cell.c());
1021                    map.push((vrow, col));
1022                    // Combining marks (#45) ride the same cell — append each and
1023                    // map it to that cell so `text` stays 1:1 with `cells`.
1024                    if let Some(marks) = self.combining_at(cur, col) {
1025                        for &m in marks {
1026                            text.push(m);
1027                            map.push((vrow, col));
1028                        }
1029                    }
1030                }
1031                let soft = self.abs_row(cur).is_wrapped();
1032                if soft && cur + 1 < total {
1033                    cur += 1;
1034                } else {
1035                    break;
1036                }
1037            }
1038            // Trim trailing blanks (only the last row can have them), keeping
1039            // `text` and `cells` in lockstep.
1040            let trimmed = text.trim_end();
1041            map.truncate(trimmed.chars().count());
1042            text.truncate(trimmed.len());
1043            if !text.is_empty() {
1044                out.push(LogicalLine { text, cells: map });
1045            }
1046            line = cur + 1;
1047        }
1048        out
1049    }
1050
1051    /// Literal search over the whole buffer (`[scrollback ++ screen]`), returning
1052    /// every non-overlapping match top-to-bottom in absolute coordinates. Matches
1053    /// cross soft-wrapped rows (one logical line) and skip wide-char spacers.
1054    /// Smart-case: a query with no uppercase matches case-insensitively.
1055    pub fn search(&self, query: &str) -> Vec<Match> {
1056        self.search_with(query, SearchOptions::default())
1057    }
1058
1059    /// Search with explicit [`SearchOptions`] — regex, whole-word, and a case-sensitivity override
1060    /// on top of the literal + smart-case [`search`](Self::search) (#314). Same coordinates,
1061    /// soft-wrap join, spacer skip, and grapheme-mark inclusion (#304) as `search`.
1062    pub fn search_with(&self, query: &str, opts: SearchOptions) -> Vec<Match> {
1063        let q: Vec<char> = query.chars().collect();
1064        if q.is_empty() {
1065            return Vec::new();
1066        }
1067        // Smart-case unless overridden: case-insensitive iff the query has no uppercase.
1068        let ci = opts
1069            .case_sensitive
1070            .map_or_else(|| !q.iter().any(|c| c.is_uppercase()), |cs| !cs);
1071        // Fold to a single representative char so the haystack stays 1:1 with its
1072        // positions (rare multi-char case expansions take their first char).
1073        let fold = |c: char| {
1074            if ci {
1075                c.to_lowercase().next().unwrap_or(c)
1076            } else {
1077                c
1078            }
1079        };
1080        let needle: Vec<char> = q.iter().map(|&c| fold(c)).collect();
1081        // Regex mode: build the pattern once (case-insensitivity from the same smart-case/override
1082        // decision). An invalid pattern yields no matches rather than erroring (#314).
1083        let re = if opts.regex {
1084            match regex::RegexBuilder::new(query).case_insensitive(ci).build() {
1085                Ok(re) => Some(re),
1086                Err(_) => return Vec::new(),
1087            }
1088        } else {
1089            None
1090        };
1091        let total = self.scrollback.len() + self.grid.rows();
1092
1093        // On the alt screen the scrollback belongs to the *primary* buffer, so the
1094        // walk must start at the screen top (`scrollback.len()`): primary matches are
1095        // unreachable on alt, and a primary WRAPLINE row would otherwise soft-wrap-join
1096        // into the alt grid and corrupt the haystack at the boundary. Mirrors the
1097        // `viewport_logical_lines` floor (#113) — the alt buffer is separate (selection
1098        // clears on alt-swap for the same reason). (#144)
1099        let floor = if self.on_alt {
1100            self.scrollback.len()
1101        } else {
1102            0
1103        };
1104        let mut matches = Vec::new();
1105        let mut r = floor;
1106        while r < total {
1107            // Build the logical line at `r`: join soft-wrapped rows, recording
1108            // each char's source position and skipping wide-char spacers.
1109            let mut hay: Vec<char> = Vec::new();
1110            let mut pos: Vec<(usize, usize)> = Vec::new();
1111            let mut line = r;
1112            loop {
1113                let cells = self.abs_line(line);
1114                for (col, cell) in cells.iter().enumerate() {
1115                    if cell.is_spacer() {
1116                        continue;
1117                    }
1118                    // Build the haystack UNFOLDED (regex needs the original text; its own
1119                    // case-insensitive flag handles case). The literal path folds at compare time.
1120                    hay.push(cell.c());
1121                    pos.push((line, col));
1122                    // Include the cell's grapheme side-table marks — combining marks, and under
1123                    // mode 2027 the joined emoji scalars (2nd RI, ZWJ-joined emoji, skin tone) —
1124                    // so a clustered scalar is findable, not just the base (#304). Each maps to the
1125                    // same cell column, mirroring `append_cell`'s base+marks extraction.
1126                    if let Some(marks) = self.combining_at(line, col) {
1127                        for &m in marks {
1128                            hay.push(m);
1129                            pos.push((line, col));
1130                        }
1131                    }
1132                }
1133                let soft = self.abs_row(line).is_wrapped();
1134                if soft && line + 1 < total {
1135                    line += 1;
1136                } else {
1137                    break;
1138                }
1139            }
1140            // Trim trailing blank padding (only a logical line's tail can be blank), so a regex `$`
1141            // anchor or a greedy `.*` doesn't run into the grid's blank cells — mirrors
1142            // `viewport_logical_lines`'s trim (#314 Lens 1). Keeps hay/pos in lockstep.
1143            while hay.last().is_some_and(|c| c.is_whitespace()) {
1144                hay.pop();
1145                pos.pop();
1146            }
1147
1148            // A match at char-index range [cs, ce) → a Match, whole-word-filtered and deduped.
1149            // (Marks map many hay entries to one column (#304), so a repeated in-cluster scalar can
1150            // yield consecutive identical Matches — collapse them.)
1151            let push_range = |cs: usize, ce: usize, matches: &mut Vec<Match>| {
1152                if opts.whole_word && !word_bounded(&hay, cs, ce - cs) {
1153                    return;
1154                }
1155                let m = Match {
1156                    start_line: pos[cs].0,
1157                    start_col: pos[cs].1,
1158                    end_line: pos[ce - 1].0,
1159                    end_col: pos[ce - 1].1,
1160                };
1161                if matches.last() != Some(&m) {
1162                    matches.push(m);
1163                }
1164            };
1165
1166            if let Some(re) = &re {
1167                // Regex over the (unfolded) logical line; map each match's byte range to char indices.
1168                let hay_str: String = hay.iter().collect();
1169                for mat in re.find_iter(&hay_str) {
1170                    if mat.start() == mat.end() {
1171                        continue; // skip empty matches (e.g. `a*` between chars)
1172                    }
1173                    let cs = hay_str[..mat.start()].chars().count();
1174                    let ce = hay_str[..mat.end()].chars().count();
1175                    push_range(cs, ce, &mut matches);
1176                }
1177            } else {
1178                // Slide the literal needle non-overlapping, folding each hay char at compare time.
1179                let mut i = 0;
1180                while needle.len() <= hay.len() && i + needle.len() <= hay.len() {
1181                    let hit = hay[i..i + needle.len()]
1182                        .iter()
1183                        .enumerate()
1184                        .all(|(k, &c)| fold(c) == needle[k]);
1185                    if hit {
1186                        let before = matches.len();
1187                        push_range(i, i + needle.len(), &mut matches);
1188                        // Advance past a real (accepted) match; a whole-word-rejected run advances by
1189                        // one so a later, word-bounded position at an overlapping offset is still tried.
1190                        i += if matches.len() > before {
1191                            needle.len()
1192                        } else {
1193                            1
1194                        };
1195                    } else {
1196                        i += 1;
1197                    }
1198                }
1199            }
1200            r = line + 1;
1201        }
1202        matches
1203    }
1204
1205    /// Scroll the viewport so a match's start line is visible (placed at the top
1206    /// when it sits in history; the live view when it is already on screen).
1207    pub fn search_scroll_to(&mut self, m: &Match) {
1208        let target = self.scrollback.len().saturating_sub(m.start_line);
1209        self.set_display_offset(target);
1210    }
1211
1212    /// Project a match onto the current viewport as inclusive-column spans, one
1213    /// per visible row (off-screen parts dropped) — for the renderer to
1214    /// highlight, like `selection_range`.
1215    pub fn match_spans(&self, m: &Match) -> Vec<SelectionSpan> {
1216        let rows = self.grid.rows();
1217        let top = self.scrollback.len() - self.display_offset;
1218        let mut spans = Vec::new();
1219        for line in m.start_line..=m.end_line {
1220            if line < top {
1221                continue;
1222            }
1223            let row = line - top;
1224            if row >= rows {
1225                break;
1226            }
1227            let last = self.abs_line(line).len().saturating_sub(1);
1228            let left = if line == m.start_line { m.start_col } else { 0 };
1229            let right = if line == m.end_line {
1230                m.end_col.min(last)
1231            } else {
1232                last
1233            };
1234            if right >= left {
1235                spans.push(SelectionSpan { row, left, right });
1236            }
1237        }
1238        spans
1239    }
1240
1241    /// Set the search highlights to paint (#108). The consumer owns the
1242    /// `Vec<Match>` (it drives next/prev); handing it back here lets `frame()`
1243    /// project the highlights onto the viewport. An empty vec clears them.
1244    pub fn set_search_highlights(&mut self, matches: Vec<Match>) {
1245        self.search_highlights = matches;
1246        // A new set voids the designation: a stale index could be accidentally
1247        // in range and light wrong content (#428). The consumer re-designates.
1248        self.active_search_highlight = None;
1249    }
1250
1251    /// Designate which member of the held highlight set is the *active* match
1252    /// (#428) — the one the consumer's next/prev navigation currently points at.
1253    /// `frame()` projects it into `overlay.active_match` (it also stays in
1254    /// `overlay.matches`; the renderer's ranking resolves the overlap, #424).
1255    /// `None` or an out-of-range index projects nothing; the designation resets
1256    /// whenever a new set is passed to [`set_search_highlights`](Self::set_search_highlights).
1257    /// The index resolves to its span at call time (#436) — both designation
1258    /// APIs converge on one stored representation.
1259    pub fn set_active_search_highlight(&mut self, index: Option<usize>) {
1260        self.active_search_highlight = index.and_then(|i| self.search_highlights.get(i)).copied();
1261    }
1262
1263    /// Designate the *active* match by its absolute span (#436), independent of
1264    /// the held highlight set — the past-cap path: a backend that caps its
1265    /// hand-over (the documented 1000, xterm's `highlightLimit`) can still give
1266    /// the current match its active emphasis, exactly as xterm creates the
1267    /// active decoration from the found result outside the capped list. The
1268    /// span projects through the same viewport math as any match (wrap-aware);
1269    /// it need not be a member of the held set, so past the cap the match
1270    /// paints the ACTIVE colour only (no plain highlight underneath). `None`
1271    /// clears. Same lifecycle as the index form: reset on every
1272    /// [`set_search_highlights`](Self::set_search_highlights) hand-over and on
1273    /// any coordinate-shifting invalidation.
1274    pub fn set_active_search_match(&mut self, m: Option<Match>) {
1275        self.active_search_highlight = m;
1276    }
1277
1278    /// Invalidate the held search highlights (#108). Called wherever a buffer
1279    /// mutation shifts the *line* coordinates the matches were found at — cap
1280    /// eviction, in-screen region/RI/SU/SD/IL/DL scroll, the accrual
1281    /// sub-region scroll (#449 — which also re-anchors selection/markers below
1282    /// the margin, `selection_shift_below_margin`), reflow, both alt swaps.
1283    /// In-line *column* shifts (ICH/DCH, insert-mode print) and
1284    /// in-place erases (ED/EL/ECH, overwrite) deliberately do NOT funnel — the
1285    /// set stales in place there exactly like the selection sibling and
1286    /// xterm's decorations, healed by the consumer's debounced re-search on
1287    /// output (which those mutations are). Search matches are query-derived
1288    /// (the engine holds matches, not the query, and the *set* itself may have
1289    /// changed), so unlike the user-authored selection they are dropped rather
1290    /// than re-anchored. Clearing avoids painting wrong content for the frame
1291    /// between the mutation and the consumer's refresh.
1292    fn invalidate_search_highlights(&mut self) {
1293        self.search_highlights.clear();
1294        // #436: the active designation is a stored SPAN, no longer structurally
1295        // tied to the set — clear it in the same funnel or it would keep
1296        // painting coordinates that now hold arbitrary other text.
1297        self.active_search_highlight = None;
1298    }
1299
1300    /// Register a decoration marker at viewport `row`, returning its stable id
1301    /// (#118). The row is resolved to an absolute buffer line (like a selection
1302    /// anchor), so the marker tracks that content through scroll/eviction/reflow.
1303    /// The active buffer's marker list (#177 S0) — alt while on the alt screen,
1304    /// else normal. Add/rotate/project operate on this; primary-scoped queries
1305    /// (`command_marks`/`command_lines`) and scrollback eviction read
1306    /// `normal_markers` directly.
1307    fn markers(&self) -> &Vec<Marker> {
1308        if self.on_alt {
1309            &self.alt_markers
1310        } else {
1311            &self.normal_markers
1312        }
1313    }
1314
1315    /// Mutable [`Self::markers`].
1316    fn markers_mut(&mut self) -> &mut Vec<Marker> {
1317        if self.on_alt {
1318            &mut self.alt_markers
1319        } else {
1320            &mut self.normal_markers
1321        }
1322    }
1323
1324    pub fn add_marker(&mut self, row: usize) -> MarkerId {
1325        // On the alt screen this anchors an *alt-scoped* marker (#187): per-buffer
1326        // storage (#186) keeps it out of the primary list, and it is disposed on
1327        // alt-leave — xterm's per-buffer `addMarker` + `clearAllMarkers`. No dead
1328        // sentinel is needed anymore; `markers_mut` routes to the active buffer.
1329        let line = self.viewport_to_abs(row, 0).line;
1330        self.push_marker(line, 0, MarkerKind::Plain)
1331    }
1332
1333    /// Push a marker anchored at absolute `(line, col)` with `kind`, returning its
1334    /// id. The shared core of `add_marker` (viewport row, `col = 0`) and OSC-133
1335    /// command marks (cursor line + column) — one place owns id allocation + the
1336    /// `markers` list.
1337    fn push_marker(&mut self, line: usize, col: usize, kind: MarkerKind) -> MarkerId {
1338        let id = MarkerId(self.next_marker_id);
1339        self.next_marker_id += 1;
1340        self.markers_mut().push(Marker {
1341            id,
1342            line,
1343            col,
1344            kind,
1345        });
1346        id
1347    }
1348
1349    /// Record an OSC 133 command-boundary mark at the cursor's current line
1350    /// (#158). Ignored on the alt screen: unlike the decoration guards that
1351    /// per-buffer storage retired (#187), this one stands on a *semantic* — OSC
1352    /// 133 is shell integration, which only runs on the primary screen, so an alt
1353    /// 133 is meaningless (there is no command to bound). Command nav/announce read
1354    /// the *normal* buffer's marks (`command_marks`/`command_lines`, primary-scoped
1355    /// since #186), so even a stray alt 133 could not reach them — but there is no
1356    /// value in creating an alt-scoped command mark nothing consumes (#188). The
1357    /// cursor line is `scrollback ++ screen`-absolute, independent of
1358    /// `display_offset` (the cursor is always in the grid, never scrollback).
1359    fn add_command_mark(&mut self, kind: MarkerKind) {
1360        if self.on_alt {
1361            return;
1362        }
1363        let line = self.scrollback.len() + self.cursor.row;
1364        // `cursor.col` alone is one column short whenever the command exactly filled the row: the
1365        // cursor that has just written the last cell is held *at* `cols - 1` with `pending_wrap`
1366        // set, because "one past the last column" is not a column (#562). A command mark's column
1367        // is an **exclusive** bound on the command text, so it wants precisely that unrepresentable
1368        // value — `extract_lines` clips `[b_col, c_col)` and `.min(cells.len())` absorbs it.
1369        //
1370        // Without this, `$ ` + `abcd` at 6 columns recorded `abc`: no resize involved, so this half
1371        // of #562 was reachable on a screen that never changed size.
1372        let col = self.cursor.col + usize::from(self.cursor.pending_wrap);
1373        self.push_marker(line, col, kind);
1374    }
1375
1376    /// The OSC 133 command-boundary marks in buffer order — `(id, absolute line,
1377    /// kind)` (#158). Plain decoration markers (#118) are excluded. The consumer
1378    /// pairs prompt/command/finished marks and drives navigation/announce policy
1379    /// (#160); core only parses and anchors them.
1380    pub fn command_marks(&self) -> Vec<(MarkerId, usize, MarkerKind)> {
1381        // Primary-scoped: OSC-133 shell integration marks live on the normal
1382        // buffer, so command nav/announce read it even while on the alt screen.
1383        self.normal_markers
1384            .iter()
1385            .filter(|m| m.kind != MarkerKind::Plain)
1386            .map(|m| (m.id, m.line, m.kind))
1387            .collect()
1388    }
1389
1390    /// The executed shell commands recovered from OSC-133 marks, in buffer order
1391    /// (#166) — the data behind screen-reader command navigation. Each
1392    /// [`CommandLine`] pairs a CommandStart(B) with the following OutputStart(C)
1393    /// to extract the *typed command* (the prompt before B and the output after C
1394    /// excluded via the captured columns, VSCode `extractCommandLine` parity), and
1395    /// attaches the trailing CommandFinished(D) exit. A command still being typed
1396    /// (B with no C yet) is not navigable — its text has no bound — so it is
1397    /// omitted until output starts.
1398    pub fn command_lines(&self) -> Vec<CommandLine> {
1399        let mut out: Vec<CommandLine> = Vec::new();
1400        // (B line, B col) awaiting its matching C. Marks arrive in buffer order.
1401        let mut pending: Option<(usize, usize)> = None;
1402        // Primary-scoped (see `command_marks`): the normal buffer's marks.
1403        for m in &self.normal_markers {
1404            match m.kind {
1405                MarkerKind::CommandStart => pending = Some((m.line, m.col)),
1406                MarkerKind::OutputStart => {
1407                    if let Some((b_line, b_col)) = pending.take() {
1408                        // Columns bound the command precisely even though output was
1409                        // written after C — `extract_lines` reads current cells but
1410                        // clips to `[b_col, c_col)`, excluding both prompt and output.
1411                        // Command marks anchor primary content — read the primary
1412                        // grid so the text is right even while on the alt screen (#192).
1413                        // Where the *typed* command begins, which is not always where B was
1414                        // emitted: a prompt that ends its row leaves B past that row's content, and
1415                        // the command really starts on the next line. Normalised **here** rather
1416                        // than inside `extract_lines`, because the two answers differ by caller —
1417                        // a selection that starts in a line's trailing blanks does contain the
1418                        // break that follows, a command does not — and because `doc_line_of` needs
1419                        // the same value. Feeding it the raw `b_line` reported the command one
1420                        // document line early, which is the a11y "jump to previous command" target.
1421                        let (b_line, b_col) =
1422                            self.command_start(self.primary_grid(), b_line, b_col, m.line);
1423                        let command =
1424                            self.extract_lines(self.primary_grid(), b_line, b_col, m.line, m.col);
1425                        out.push(CommandLine {
1426                            line: self.doc_line_of(self.primary_grid(), b_line),
1427                            command,
1428                            exit: None,
1429                        });
1430                    }
1431                }
1432                MarkerKind::CommandFinished(exit) => {
1433                    // The exit belongs to the most recent command not yet closed;
1434                    // the `is_none` guard stops a stray D from clobbering a code.
1435                    if let Some(last) = out.last_mut()
1436                        && last.exit.is_none()
1437                    {
1438                        last.exit = exit;
1439                    }
1440                }
1441                MarkerKind::Plain | MarkerKind::PromptStart => {}
1442            }
1443        }
1444        out
1445    }
1446
1447    /// Advance an OSC-133 `CommandStart` position past any hard-ended line that holds no command
1448    /// text at or after it, stopping before `end` (the matching `OutputStart`).
1449    ///
1450    /// B is emitted at the cursor, so a prompt that fills — or merely ends — its row leaves the mark
1451    /// in that row's trailing blanks (#562). Two things then go wrong if the raw position is used:
1452    /// `extract_lines` selects an empty run and, because the row is hard-ended, flushes it with a
1453    /// `\n` the command never contained; and `doc_line_of` names the prompt's line rather than the
1454    /// command's. Both were reachable **without any resize** — the row only has to end before its
1455    /// width, which an 8-column row holding a 6-column prompt does.
1456    ///
1457    /// Only hard-ended rows advance. On a soft-wrapped row the continuation is the same logical
1458    /// line, its trailing blanks are real content (a space at a wrap boundary was typed), and no
1459    /// `\n` is flushed there anyway.
1460    fn command_start(&self, grid: &Grid, line: usize, col: usize, end: usize) -> (usize, usize) {
1461        let (mut line, mut col) = (line, col);
1462        while line < end && !self.row_in(grid, line).is_wrapped() {
1463            let cells = self.line_in(grid, line);
1464            if col < cells.len() && !cells[col..].iter().all(Cell::is_blank) {
1465                break;
1466            }
1467            line += 1;
1468            col = 0;
1469        }
1470        (line, col)
1471    }
1472
1473    /// The document (logical) line index that absolute buffer line `abs` renders
1474    /// into within [`Term::accessible_text`] — the number of hard line-ends before
1475    /// it (soft-wrapped rows share one logical line). Primary-screen coordinates,
1476    /// matching `accessible_text`'s `start = 0` for the primary screen; command
1477    /// marks are primary-only. O(abs) per call — fine for an on-demand query over
1478    /// the handful of commands in a session.
1479    fn doc_line_of(&self, grid: &Grid, abs: usize) -> usize {
1480        (0..abs)
1481            .filter(|&l| !self.row_in(grid, l).is_wrapped())
1482            .count()
1483    }
1484
1485    /// Remove a marker by id (#118). Disposing it fires `MarkerDisposed` so the
1486    /// consumer's cleanup is one path whether the marker left by eviction or by
1487    /// this explicit call (xterm's `dispose()` likewise always fires onDispose).
1488    /// A no-op for an unknown/already-disposed id.
1489    pub fn remove_marker(&mut self, id: MarkerId) {
1490        // Id-based, buffer-agnostic: search both lists (ids are unique across
1491        // buffers) so a marker is removed whichever screen it lives on (#177 S0).
1492        let before = self.normal_markers.len() + self.alt_markers.len();
1493        self.normal_markers.retain(|m| m.id != id);
1494        self.alt_markers.retain(|m| m.id != id);
1495        if self.normal_markers.len() + self.alt_markers.len() != before {
1496            self.events.push(TermEvent::MarkerDisposed(id));
1497        }
1498    }
1499
1500    /// Shift markers down one absolute line after the oldest history line is
1501    /// evicted; a marker *on* that line (abs 0) has left the buffer, so it is
1502    /// disposed and announced (#118) — the marker analogue of
1503    /// `selection_evict_oldest`, but a list with per-marker disposal.
1504    /// The marker analogue of `selection_shift_below_margin` (#449) — primary
1505    /// only, because the accrual branch that needs it is primary-only.
1506    fn markers_shift_below_margin(&mut self, from: usize) {
1507        for m in &mut self.normal_markers {
1508            if m.line >= from {
1509                m.line += 1;
1510            }
1511        }
1512    }
1513
1514    fn markers_evict_oldest(&mut self) {
1515        // Scrollback eviction is primary-only (the alt screen has none).
1516        let mut disposed = Vec::new();
1517        self.normal_markers.retain_mut(|m| {
1518            if m.line == 0 {
1519                disposed.push(m.id);
1520                false
1521            } else {
1522                m.line -= 1;
1523                true
1524            }
1525        });
1526        for id in disposed {
1527            self.events.push(TermEvent::MarkerDisposed(id));
1528        }
1529    }
1530
1531    /// Rotate markers within an in-screen region scroll of absolute lines
1532    /// `[top, bottom]` (`up` = a line dropped at `top`, else at `bottom`) — the
1533    /// marker analogue of `selection_rotate_region`. A marker on the dropped edge
1534    /// has left the buffer, so it is disposed and announced (#118).
1535    fn markers_rotate_region(&mut self, top: usize, bottom: usize, up: bool) {
1536        let mut disposed = Vec::new();
1537        self.markers_mut().retain_mut(|m| {
1538            if m.line < top || m.line > bottom {
1539                return true; // outside the region — unchanged
1540            }
1541            let dropped_edge = if up { top } else { bottom };
1542            if m.line == dropped_edge {
1543                disposed.push(m.id);
1544                false
1545            } else {
1546                m.line = if up { m.line - 1 } else { m.line + 1 };
1547                true
1548            }
1549        });
1550        for id in disposed {
1551            self.events.push(TermEvent::MarkerDisposed(id));
1552        }
1553    }
1554
1555    /// The active buffer's markers projected onto the current viewport — one
1556    /// `MarkerPosition` per marker whose line is visible, off-screen markers
1557    /// omitted. The alt screen projects its own (alt-scoped) markers now (#187);
1558    /// they are disposed on alt-leave, so a primary frame never shows them.
1559    fn marker_positions(&self) -> Vec<MarkerPosition> {
1560        let top = self.scrollback.len() - self.display_offset;
1561        let rows = self.grid.rows();
1562        self.markers()
1563            .iter()
1564            .filter_map(|m| {
1565                let row = m.line.checked_sub(top)?;
1566                (row < rows).then_some(MarkerPosition {
1567                    id: m.id,
1568                    row,
1569                    kind: m.kind,
1570                })
1571            })
1572            .collect()
1573    }
1574
1575    /// Every live marker's absolute buffer line (#120 S3) — the off-viewport
1576    /// superset of `marker_positions`, for the overview ruler. No viewport filter:
1577    /// a marker scrolled out of view is still reported (that is the ruler's job),
1578    /// its `line` in the same `[0, scrollback + rows)` frame as the header's
1579    /// `scrollback_len`/`display_offset`.
1580    fn all_marker_lines(&self) -> Vec<MarkerLine> {
1581        self.markers()
1582            .iter()
1583            .map(|m| MarkerLine {
1584                id: m.id,
1585                line: m.line as u32,
1586            })
1587            .collect()
1588    }
1589
1590    /// Begin a selection of `ty` at viewport `(row, col)`, `side`.
1591    pub fn selection_begin(&mut self, row: usize, col: usize, side: Side, ty: SelectionType) {
1592        let anchor = Anchor {
1593            point: self.viewport_to_abs(row, col),
1594            side,
1595        };
1596        self.selection = Some(Selection {
1597            ty,
1598            anchor,
1599            focus: anchor,
1600        });
1601    }
1602
1603    /// Extend the live selection's focus to viewport `(row, col)`, `side`.
1604    pub fn selection_extend(&mut self, row: usize, col: usize, side: Side) {
1605        let focus = Anchor {
1606            point: self.viewport_to_abs(row, col),
1607            side,
1608        };
1609        if let Some(sel) = &mut self.selection {
1610            sel.focus = focus;
1611        }
1612    }
1613
1614    /// Clear the selection.
1615    pub fn selection_clear(&mut self) {
1616        self.selection = None;
1617    }
1618
1619    /// Shift the selection up by one absolute line after the oldest history line
1620    /// is evicted by the scrollback cap. An endpoint clamps to the new top; if
1621    /// the whole selection was on the evicted line, it is cleared.
1622    /// Shift selection endpoints anchored at absolute line `>= from` down by
1623    /// one (#449): a top-anchored sub-region scroll grew scrollback while the
1624    /// rows below the margin stayed fixed on screen, so their content's
1625    /// absolute index rose +1 and the anchors must follow it. Endpoints above
1626    /// `from` (in-region / scrollback content, whose indices are stable) are
1627    /// untouched — per endpoint, so a selection straddling the margin keeps
1628    /// both ends on their content.
1629    fn selection_shift_below_margin(&mut self, from: usize) {
1630        if let Some(sel) = &mut self.selection {
1631            if sel.anchor.point.line >= from {
1632                sel.anchor.point.line += 1;
1633            }
1634            if sel.focus.point.line >= from {
1635                sel.focus.point.line += 1;
1636            }
1637        }
1638    }
1639
1640    fn selection_evict_oldest(&mut self) {
1641        let Some((a, f)) = self
1642            .selection
1643            .as_ref()
1644            .map(|s| (s.anchor.point.line, s.focus.point.line))
1645        else {
1646            return;
1647        };
1648        if a == 0 && f == 0 {
1649            self.selection = None;
1650            return;
1651        }
1652        if let Some(sel) = &mut self.selection {
1653            sel.anchor.point.line = a.saturating_sub(1);
1654            sel.focus.point.line = f.saturating_sub(1);
1655        }
1656    }
1657
1658    /// Rotate the selection within an in-screen scroll of absolute lines
1659    /// `[top, bottom]`. `up` = content scrolled up (a line dropped at `top`);
1660    /// otherwise down (dropped at `bottom`). Called once per scrolled line (delta
1661    /// 1) by linefeed/RI/SU/SD/IL/DL.
1662    ///
1663    /// Mirrors alacritty `Selection::rotate`: an endpoint pushed past the region
1664    /// edge is *clamped* to that edge (upper → `top`/col 0/Left, lower →
1665    /// `bottom`/last col/Right; columns/side kept for Block), preserving the part
1666    /// of the selection still in the buffer. The whole selection clears only on a
1667    /// true *overtake* — the upper endpoint crossing the bottom while the lower
1668    /// stays inside, or the lower falling above the upper (a selection wholly on
1669    /// the dropped line). (#174: this replaced a policy that cleared on any
1670    /// endpoint touching the dropped edge, dropping still-valid content.)
1671    fn selection_rotate_region(&mut self, top: usize, bottom: usize, up: bool) {
1672        let (ty, anchor, focus) = match self.selection.as_ref() {
1673            Some(s) => (s.ty, s.anchor, s.focus),
1674            None => return,
1675        };
1676        let last_col = self.grid.cols().saturating_sub(1);
1677        // Order the endpoints by buffer position; the upper (`start`) clamps to
1678        // the region top, the lower (`end`) to the bottom. Remember which is the
1679        // anchor so the result writes back to the right field.
1680        let anchor_is_start = anchor.point <= focus.point;
1681        let (mut start, mut end) = if anchor_is_start {
1682            (anchor, focus)
1683        } else {
1684            (focus, anchor)
1685        };
1686
1687        let (top_i, bottom_i) = (top as isize, bottom as isize);
1688        // The endpoint's line after the one-line scroll, or `None` if it's outside
1689        // the region (untouched). The dropped-edge line shifts *past* the edge (to
1690        // be clamped/overtaken below), matching alacritty's `line - delta`.
1691        let shift = |line: usize| -> Option<isize> {
1692            if line < top || line > bottom {
1693                None
1694            } else if up {
1695                Some(line as isize - 1)
1696            } else {
1697                Some(line as isize + 1)
1698            }
1699        };
1700
1701        // Upper endpoint: clamp to the region top when pushed above it; clear if it
1702        // overtook the region bottom (down-scroll) while the lower stays inside.
1703        if let Some(nl) = shift(start.point.line) {
1704            if nl > bottom_i && (end.point.line as isize) <= bottom_i {
1705                self.selection = None;
1706                return;
1707            }
1708            if nl < top_i {
1709                start.point.line = top;
1710                if ty != SelectionType::Block {
1711                    start.point.col = 0;
1712                    start.side = Side::Left;
1713                }
1714            } else {
1715                start.point.line = nl as usize;
1716            }
1717        }
1718        // Lower endpoint: clear if it fell above the (rotated) upper endpoint;
1719        // else clamp to the region bottom when pushed below it.
1720        if let Some(nl) = shift(end.point.line) {
1721            if nl < start.point.line as isize {
1722                self.selection = None;
1723                return;
1724            }
1725            if nl > bottom_i {
1726                end.point.line = bottom;
1727                if ty != SelectionType::Block {
1728                    end.point.col = last_col;
1729                    end.side = Side::Right;
1730                }
1731            } else {
1732                end.point.line = nl as usize;
1733            }
1734        }
1735
1736        if let Some(sel) = &mut self.selection {
1737            if anchor_is_start {
1738                (sel.anchor, sel.focus) = (start, end);
1739            } else {
1740                (sel.anchor, sel.focus) = (end, start);
1741            }
1742        }
1743    }
1744
1745    /// The selection projected onto the current viewport: one inclusive-column
1746    /// span per visible row. Rows scrolled off-screen (above or below) are
1747    /// dropped. Empty when nothing is selected. See `SelectionSpan`.
1748    pub fn selection_range(&self) -> Vec<SelectionSpan> {
1749        let Some(resolved) = self.resolve() else {
1750            return Vec::new();
1751        };
1752        let rows = self.grid.rows();
1753        // Absolute index of viewport row 0.
1754        let top = self.scrollback.len() - self.display_offset;
1755        let mut spans = Vec::new();
1756
1757        // Add a span for absolute `line` with inclusive cols `left..=right`, if
1758        // the line is currently visible.
1759        let mut push = |line: usize, left: usize, right: usize| {
1760            if line >= top {
1761                let row = line - top;
1762                if row < rows {
1763                    spans.push(SelectionSpan { row, left, right });
1764                }
1765            }
1766        };
1767
1768        match resolved {
1769            Resolved::Linear {
1770                start_line,
1771                from,
1772                end_line,
1773                to,
1774            } => {
1775                for line in start_line..=end_line {
1776                    let len = self.abs_line(line).len();
1777                    let left = if line == start_line { from } else { 0 };
1778                    let right_excl = if line == end_line { to.min(len) } else { len };
1779                    if right_excl > left {
1780                        push(line, left, right_excl - 1);
1781                    }
1782                }
1783            }
1784            Resolved::Block {
1785                line0,
1786                line1,
1787                from,
1788                to,
1789            } => {
1790                if to > from {
1791                    for line in line0..=line1 {
1792                        push(line, from, to - 1);
1793                    }
1794                }
1795            }
1796        }
1797        spans
1798    }
1799
1800    /// Resolve the live selection into absolute-coordinate bounds per type:
1801    /// a `Linear` run (char/word/line, which join soft wraps) or a `Block`
1802    /// rectangle. `None` when nothing is selected. Columns are half-open
1803    /// (`from..to`). Shared by `selection_text` and `selection_range`.
1804    fn resolve(&self) -> Option<Resolved> {
1805        let sel = self.selection.as_ref()?;
1806        let (start, end) = sel.ordered();
1807        Some(match sel.ty {
1808            SelectionType::Char => {
1809                // Half-open columns: each side decides if its own cell is in.
1810                let from = match start.side {
1811                    Side::Left => start.point.col,
1812                    Side::Right => start.point.col + 1,
1813                };
1814                let to = match end.side {
1815                    Side::Left => end.point.col,
1816                    Side::Right => end.point.col + 1,
1817                };
1818                Resolved::Linear {
1819                    start_line: start.point.line,
1820                    from,
1821                    end_line: end.point.line,
1822                    to,
1823                }
1824            }
1825            SelectionType::Word => {
1826                // Snap both ends to word boundaries (side is ignored).
1827                let ws = self.word_start(start.point);
1828                let we = self.word_end(end.point);
1829                Resolved::Linear {
1830                    start_line: ws.line,
1831                    from: ws.col,
1832                    end_line: we.line,
1833                    to: we.col + 1,
1834                }
1835            }
1836            SelectionType::Line => Resolved::Linear {
1837                start_line: start.point.line,
1838                from: 0,
1839                end_line: end.point.line,
1840                to: self.grid.cols(),
1841            },
1842            SelectionType::Block => {
1843                // Rectangular: the same column range on every row. Columns come
1844                // from the two anchors (min/max, with each edge's side).
1845                let cols = self.grid.cols();
1846                let (a, b) = (sel.anchor, sel.focus);
1847                let (lcol, lside, rcol, rside) = if a.point.col <= b.point.col {
1848                    (a.point.col, a.side, b.point.col, b.side)
1849                } else {
1850                    (b.point.col, b.side, a.point.col, a.side)
1851                };
1852                let from = match lside {
1853                    Side::Left => lcol,
1854                    Side::Right => lcol + 1,
1855                };
1856                let to = match rside {
1857                    Side::Left => rcol,
1858                    Side::Right => rcol + 1,
1859                };
1860                Resolved::Block {
1861                    line0: a.point.line.min(b.point.line),
1862                    line1: a.point.line.max(b.point.line),
1863                    from,
1864                    to: to.min(cols).max(from),
1865                }
1866            }
1867        })
1868    }
1869
1870    /// The selected text (for copy), or `None` when nothing is selected.
1871    pub fn selection_text(&self) -> Option<String> {
1872        match self.resolve()? {
1873            Resolved::Linear {
1874                start_line,
1875                from,
1876                end_line,
1877                to,
1878            } => Some(self.extract_lines(&self.grid, start_line, from, end_line, to)),
1879            Resolved::Block {
1880                line0,
1881                line1,
1882                from,
1883                to,
1884            } => {
1885                // Each row independently — no soft-wrap joining.
1886                let mut out = String::new();
1887                for line in line0..=line1 {
1888                    let hi = to.min(self.abs_line(line).len());
1889                    let mut seg = String::new();
1890                    for col in from..hi {
1891                        self.append_cell(&self.grid, &mut seg, line, col);
1892                    }
1893                    out.push_str(seg.trim_end());
1894                    if line != line1 {
1895                        out.push('\n');
1896                    }
1897                }
1898                Some(out)
1899            }
1900        }
1901    }
1902
1903    /// Append the text at absolute `(line, col)` — its base glyph plus any
1904    /// combining marks from the row's map — to `out`. Wide-char spacers
1905    /// contribute nothing.
1906    fn append_cell(&self, grid: &Grid, out: &mut String, line: usize, col: usize) {
1907        let cell = &self.line_in(grid, line)[col];
1908        if cell.is_spacer() {
1909            return;
1910        }
1911        out.push(cell.c());
1912        if let Some(marks) = self.combining_in(grid, line, col) {
1913            out.extend(marks);
1914        }
1915    }
1916
1917    /// The whole buffer as one text document (#150): scrollback + screen assembled
1918    /// into logical lines (soft-wrap joined, wide-spacers skipped, trailing blanks
1919    /// trimmed at the logical end) — the accessible-view a screen reader reads as
1920    /// a document, distinct from the viewport row tree (#119). Reuses the
1921    /// selection extraction (`extract_lines`) over the full
1922    /// range. On the alt screen only the alt buffer is shown — its "scrollback" is
1923    /// the *primary* buffer's, not this app's — mirroring `viewport_logical_lines`'
1924    /// alt floor.
1925    pub fn accessible_text(&self) -> String {
1926        let total = self.scrollback.len() + self.grid.rows();
1927        if total == 0 {
1928            return String::new();
1929        }
1930        let start = if self.on_alt {
1931            self.scrollback.len()
1932        } else {
1933            0
1934        };
1935        let mut doc = self.extract_lines(&self.grid, start, 0, total - 1, usize::MAX);
1936        // Trim *trailing* empty lines (blank screen rows below the content) — pure
1937        // noise to a listener, and what a fresh screen would otherwise emit. Keep
1938        // *internal* blank lines (paragraph breaks between command outputs) — a
1939        // document wants those, unlike the viewport tree which drops all empties.
1940        doc.truncate(doc.trim_end_matches('\n').len());
1941        doc
1942    }
1943
1944    /// Concatenate the selected cells from `(start_line, from)` to
1945    /// `(end_line, to_end)` (half-open columns on the first/last line, whole
1946    /// lines between). Soft-wrapped rows (WRAPLINE) accumulate into one *logical*
1947    /// line so trailing-blank trimming happens only at the logical end — spaces
1948    /// at a wrap boundary are real content. A hard line-end flushes with `\n`.
1949    fn extract_lines(
1950        &self,
1951        grid: &Grid,
1952        start_line: usize,
1953        from: usize,
1954        end_line: usize,
1955        to_end: usize,
1956    ) -> String {
1957        // The two column bounds are **not** symmetric: `to_end` is exclusive and absorbs a
1958        // one-past column through `.min(cells.len())` below, but `from` is inclusive and cannot.
1959        // A `from` of `cells.len()` — a command-start mark on a row its prompt exactly filled
1960        // (#562) — names the first cell of the *next* row, so step there. Left in place it selects
1961        // an empty run on this row and then, because the row is hard-ended, flushes that run with a
1962        // `\n` the command never contained; `doc_line_of` reported the wrong document line beside
1963        // it, sending an a11y "jump to previous command" one row off.
1964        let (start_line, from) =
1965            if from >= self.line_in(grid, start_line).len() && start_line < end_line {
1966                (start_line + 1, 0)
1967            } else {
1968                (start_line, from)
1969            };
1970
1971        let mut out = String::new();
1972        let mut current = String::new();
1973        for line in start_line..=end_line {
1974            let cells = self.line_in(grid, line);
1975            let left = if line == start_line { from } else { 0 };
1976            let right = if line == end_line {
1977                to_end.min(cells.len())
1978            } else {
1979                cells.len()
1980            };
1981            // A degenerate range (sides inverting one cell) gives left > right;
1982            // clamp to empty rather than panic on the slice.
1983            let right = right.max(left);
1984            for col in left..right {
1985                self.append_cell(grid, &mut current, line, col);
1986            }
1987
1988            let is_last = line == end_line;
1989            let soft = self.row_in(grid, line).is_wrapped();
1990            if is_last || !soft {
1991                out.push_str(current.trim_end());
1992                current.clear();
1993                if !is_last {
1994                    out.push('\n');
1995                }
1996            }
1997        }
1998        out
1999    }
2000
2001    /// The lowest absolute line a soft-wrap buffer walk may reach. On the alt screen
2002    /// `scrollback` holds the *primary* buffer's history — a separate logical space —
2003    /// so a walk floors at `scrollback.len()` (the alt grid's first line) and must not
2004    /// join across it. Mirrors the `search()` (#144) and `viewport_logical_lines`
2005    /// (#113) floors: justerm's single `[scrollback ++ grid]` buffer reproduces the
2006    /// primary↔alt isolation xterm gets from separate `Buffer` objects.
2007    fn abs_floor(&self) -> usize {
2008        if self.on_alt {
2009            self.scrollback.len()
2010        } else {
2011            0
2012        }
2013    }
2014
2015    /// The cell position before `(line, col)` in the *logical* line — the column
2016    /// to the left, or the end of the previous row if it soft-wrapped into this
2017    /// one. `None` at the buffer start or across a hard line-end.
2018    fn prev_pos(&self, line: usize, col: usize) -> Option<(usize, usize)> {
2019        if col > 0 {
2020            return Some((line, col - 1));
2021        }
2022        // Only step up while the previous row is still on *this* buffer (>= floor):
2023        // on alt, row 0 (`line == scrollback.len()`) must not join the primary
2024        // scrollback row below it, even when that row carries WRAPLINE (#207).
2025        if line > self.abs_floor() {
2026            let prev = self.abs_line(line - 1);
2027            if self.abs_row(line - 1).is_wrapped() {
2028                return Some((line - 1, prev.len() - 1));
2029            }
2030        }
2031        None
2032    }
2033
2034    /// The cell position after `(line, col)` in the *logical* line — the column
2035    /// to the right, or the start of the next row if this row soft-wrapped.
2036    /// `None` at the buffer end or across a hard line-end.
2037    fn next_pos(&self, line: usize, col: usize) -> Option<(usize, usize)> {
2038        let cells = self.abs_line(line);
2039        if col + 1 < cells.len() {
2040            return Some((line, col + 1));
2041        }
2042        let total = self.scrollback.len() + self.grid.rows();
2043        // Symmetric floor guard (#207): a row below the floor (primary scrollback on
2044        // alt) must not soft-wrap-join down into the alt grid. `line >= floor` holds
2045        // for any position reachable on alt once `prev_pos` is floored; kept explicit
2046        // so no future caller can cross from a primary row.
2047        if line >= self.abs_floor() && line + 1 < total && self.abs_row(line).is_wrapped() {
2048            return Some((line + 1, 0));
2049        }
2050        None
2051    }
2052
2053    /// Is `(line, col)` the wide-wrap artefact — the blank a width-2 glyph left behind when it
2054    /// did not fit, which the text extractors drop entirely?
2055    ///
2056    /// Position is part of the definition, not a shortcut: the artefact is only ever the **last**
2057    /// column of a soft-wrapped row. The marker alone is not enough, because a row-shift verb
2058    /// moves whole cells and can carry it inward, where it describes nothing — and a stale
2059    /// marker mid-row must stay an ordinary blank, or two visually separate words silently
2060    /// become one in the clipboard (#528).
2061    ///
2062    /// Since #534 the write sites keep that from arising: `DCH` ends the wrap **before** its
2063    /// shift, so the marker is cleared while it is still at the last column, and `ICH` pushes it
2064    /// off the edge. So this clause is now defence in depth rather than the only defence —
2065    /// deliberately kept, because it is one comparison and it is the read side's own statement of
2066    /// ghostty's write-side page invariant (*"Spacer heads must be at the end"*,
2067    /// `terminal/page.zig:537` @ `e6e26e1`), which that engine asserts rather than tolerates.
2068    fn is_wrap_artefact(&self, line: usize, col: usize) -> bool {
2069        let cells = self.abs_line(line);
2070        cells[col].is_leading_spacer() && col + 1 == cells.len()
2071    }
2072
2073    /// A `' '` cell the word walk passes *through* rather than stopping on, because it
2074    /// belongs to a glyph and is not a gap between words. Two kinds, gated differently
2075    /// (ADR-0025 D3 — position is part of the definition):
2076    /// - a wide glyph's **trailing** spacer (`is_wide_spacer`) — transparent where it actually
2077    ///   continues a wide lead whose character is not itself a boundary. A trailing spacer
2078    ///   carries no character of its own; it *stands for its lead* (ADR-0025 D4 — the pair is
2079    ///   one unit), so both halves of that test read the lead, never the spacer;
2080    /// - the **leading**-spacer wrap artefact — transparent *only* at the last column of a
2081    ///   wrapped row (`is_wrap_artefact`); a marker a row-shift carried inward describes
2082    ///   nothing and must still end a word (#528).
2083    ///
2084    /// Reading the spacer cell alone was wrong twice, both caught by the two-lens pass and
2085    /// both measured: **U+3000** IDEOGRAPHIC SPACE is wide *and* `is_whitespace()`, so the walk
2086    /// stepped onto its second cell before breaking on the lead and started the highlight on
2087    /// half a glyph (` abc`, double-click `a` → `1..=4` instead of `2..=4`); and a **lead-less**
2088    /// trailing spacer (#529's orphan) claimed to continue a glyph that is not there, merging
2089    /// two words in the clipboard. Requiring a wide, non-boundary lead answers both. Note the
2090    /// boundary clause has exactly **one** live trigger under the current fixed set — U+3000;
2091    /// `│` is East-Asian-Ambiguous (width 1) and the rest of the set is ASCII — so it is a real
2092    /// case, not a class.
2093    ///
2094    /// Resolving a spacer through its lead is alacritty's idiom, in a walk:
2095    /// `term/search.rs:457-460` (`skip_wide`) *replaces* a `WIDE_CHAR_SPACER` cell with
2096    /// `iter.prev()` and matches on that. Ghostty states the same principle in its print path
2097    /// (`Terminal.zig:1132-1142`, *"the previous cell is a wide spacer tail, so we actually want
2098    /// the cell before that because that has the actual content"*), and its **write-side**
2099    /// integrity rule is this predicate clause for clause — `page.zig:514-534` rejects a spacer
2100    /// tail at `x == 0` and one whose `cells[x-1].wide != .wide`. What ghostty does *not* do is
2101    /// model this in its own word selection: `Screen.zig:3204` `selectWord` breaks on
2102    /// `!hasText()`, i.e. it classifies the spacer cell and stops there — the pre-#535 justerm
2103    /// behaviour. Do not read ghostty's selection code as precedent for this.
2104    ///
2105    /// Gating on the char alone cut every CJK word at its first glyph, because a trailing
2106    /// spacer's char is a space and read as a boundary (#535). For that **trailing** kind the
2107    /// walk was the crate's outlier — `append_cell`, `viewport_logical_lines` and `search`
2108    /// already skipped it. It now matches alacritty, which gates the flag in both inline walkers
2109    /// (`search.rs:556`, `:580`) plus a forward normalizer in `semantic_search_left` (`:521-525`)
2110    /// — `semantic_search_right` has no gate at all. xterm.js converges on the *transparency*
2111    /// only (`_isCharWordSeparator` returns early on a width-0 cell): its stated reason is that
2112    /// such cells *"are always to the right of wide characters"*, which is precisely the
2113    /// assumption the lead-less clause above exists to reject.
2114    ///
2115    /// For the **leading** kind the comparison used to invert, and not in this walk's favour:
2116    /// those same extractors gate on `is_spacer()` with *no* position test, so a marker stranded
2117    /// mid-row was dropped from the text and two visually separate words merged. Measured on a
2118    /// 6-column screen — `"cde"` + a DCH-stranded marker + `"XY"` rendered as `cde XY` and copied
2119    /// as `"cdeXY"`, with `search("cdeXY")` matching. The position rule here bounds the selection
2120    /// *range*; it never could fix that *text*, which is why the direction was to fix the write
2121    /// site rather than to widen the extractors. **#534 did that**, so the extractors' bare
2122    /// `is_spacer()` is now safe by construction: no path leaves a marker whose claim is false.
2123    /// Do not add a position test to them on this reasoning — the invariant is upstream.
2124    ///
2125    /// Note what that leaves the position clause here: a read-site echo of the invariant ghostty
2126    /// enforces when writing (a mid-row spacer head is a page-integrity violation there, because
2127    /// a row shift clears it). Alacritty, which gates leading-everywhere, still does *not* clear
2128    /// on `delete_chars` — so its unconditional gate carries the merge defect justerm no longer
2129    /// has.
2130    fn is_walk_transparent_spacer(&self, line: usize, col: usize) -> bool {
2131        if self.is_wrap_artefact(line, col) {
2132            return true;
2133        }
2134        let cells = self.abs_line(line);
2135        cells[col].is_wide_spacer()
2136            && col > 0
2137            && cells[col - 1].is_wide()
2138            && !is_word_boundary(cells[col - 1].c())
2139    }
2140
2141    /// Walk left to the first cell of `p`'s word (a maximal run of non-boundary
2142    /// chars), following a soft wrap into the previous row.
2143    fn word_start(&self, p: BufferPoint) -> BufferPoint {
2144        let cells = self.abs_line(p.line);
2145        let (mut line, mut col) = (p.line, p.col.min(cells.len().saturating_sub(1)));
2146        while let Some((pl, pc)) = self.prev_pos(line, col) {
2147            // The wrap artefact represents no column of text, so the walk passes *through* it
2148            // rather than stopping — else a word that wrapped only because a wide glyph did not
2149            // fit would be cut in half (#528).
2150            if !self.is_walk_transparent_spacer(pl, pc)
2151                && is_word_boundary(self.abs_line(pl)[pc].c())
2152            {
2153                break;
2154            }
2155            line = pl;
2156            col = pc;
2157        }
2158        BufferPoint { line, col }
2159    }
2160
2161    /// Walk right to the last cell of `p`'s word, following a soft wrap into the
2162    /// next row.
2163    fn word_end(&self, p: BufferPoint) -> BufferPoint {
2164        let cells = self.abs_line(p.line);
2165        let (mut line, mut col) = (p.line, p.col.min(cells.len().saturating_sub(1)));
2166        while let Some((nl, nc)) = self.next_pos(line, col) {
2167            if !self.is_walk_transparent_spacer(nl, nc)
2168                && is_word_boundary(self.abs_line(nl)[nc].c())
2169            {
2170                break; // (see `word_start`: a glyph's spacer is transparent to the walk)
2171            }
2172            line = nl;
2173            col = nc;
2174        }
2175        BufferPoint { line, col }
2176    }
2177
2178    /// Resize the screen to `cols` x `rows`. Rows dropped off the top (on shrink)
2179    /// enter scrollback. Column reflow of soft-wrapped lines is layered on top
2180    /// separately (#7). The whole screen is damaged.
2181    ///
2182    /// `cols` is widened to [`MIN_COLUMNS`] — a narrower screen cannot hold a
2183    /// width-2 glyph, so it is clamped rather than represented (#547).
2184    pub fn resize(&mut self, cols: usize, rows: usize) {
2185        // A terminal is never 0-tall; clamp so the math below (rows - 1) can't
2186        // underflow. Columns clamp to MIN_COLUMNS, not 1: chunking by cols needs a
2187        // non-zero width, but a *wide glyph* needs two (#547).
2188        let cols = cols.max(MIN_COLUMNS);
2189        let rows = rows.max(1);
2190        let old_cols = self.grid.cols();
2191        let limit = self.scrollback_limit;
2192
2193        // A reflow moves match coordinates (and can change the match set), so the
2194        // query-derived highlights are invalidated; the consumer re-searches at
2195        // the new width. The selection re-anchors below — it is user-authored.
2196        self.invalidate_search_highlights();
2197
2198        // Both screens are resized. Scrollback pairs with the PRIMARY screen
2199        // (whichever is active) — the alt screen has no history of its own.
2200        // `reflow: true` is the *primary* pane's setting and is deliberately a constant, **not**
2201        // `self.autowrap`. ghostty gates its equivalent on DECAWM — `.reflow =
2202        // self.modes.get(.wraparound)` (`terminal/Terminal.zig` `resize`) — and the reading that
2203        // makes that coherent is the one this file just accepted for the alt screen: an application
2204        // that turns autowrap off is placing lines itself, so its content is a layout rather than a
2205        // flow, and re-wrapping it changes what it drew.
2206        //
2207        // Not followed here, for three reasons, and they are recorded rather than filed because
2208        // nothing observable is known to break either way (measured: with DECAWM off, a full
2209        // 6-column row still re-splits into two rows at width 3 exactly as it does with DECAWM on;
2210        // the difference against ghostty is that ghostty truncates that row instead).
2211        //
2212        // - **The wrap flag is not a lie.** `Row::is_wrapped` means "this row continues into the
2213        //   next", which after a re-split is simply true. DECAWM governs the **write** path — where
2214        //   a glyph goes when the cursor is at the last column — not how stored content is laid out
2215        //   again later. Dropping the flag would make `"abcdef"` extract as `"abc\ndef"`.
2216        // - **The mode is global and momentary; the buffer is neither.** DECAWM is read at resize
2217        //   time and would decide the fate of history written under the opposite setting. A TUI that
2218        //   turns it off while drawing would, on a resize landing in that window, leave every
2219        //   properly wrapped line in scrollback un-reflowed.
2220        // - **It costs content.** Not reflowing truncates each row to the new width, so the tail of
2221        //   a long line leaves the grid. justerm keeps it.
2222        //
2223        // There is no per-row signal to be finer with: a row written under DECAWM off and a row that
2224        // merely ended early are both simply unwrapped. "Do not re-split an unwrapped logical line"
2225        // would break ordinary reflow, since a line that exactly fills its width carries no wrap
2226        // flag either.
2227        let dims = ReflowDims {
2228            old_cols,
2229            cols,
2230            rows,
2231            limit,
2232            reflow: true,
2233        };
2234        let scrollback = std::mem::take(&mut self.scrollback);
2235        if self.on_alt {
2236            // Active = alt (cursor, no scrollback); inactive = primary. Selection
2237            // is primary-only and cleared on alt enter, so no anchors to track.
2238            // Alt markers still ride this pane, but **not because it reflows** — since #567 it does
2239            // not, so a marker's content no longer moves under it and the old reason here ("justerm
2240            // column-reflows the alt grid, so a marker must follow its content") is retracted. What
2241            // they ride it for is the row *fit*: a shrink still drops rows off the top, and a marker
2242            // on one of those has left a screen with no history to hold it. Their stored line is
2243            // `base + alt_row` (base = primary scrollback len), so convert to alt-local rows here
2244            // and re-anchor on the new base afterward — the primary scrollback below may rewrap and
2245            // change length even when the alt grid does not move at all.
2246            let old_base = scrollback.len();
2247            let alt_pts: Vec<(usize, usize)> = self
2248                .alt_markers
2249                .iter()
2250                .map(|m| (m.line - old_base, m.col))
2251                .collect();
2252            let alt = self.grid.take_lines();
2253            let r_alt = reflow_pane(
2254                alt,
2255                VecDeque::new(),
2256                self.cursor.point(),
2257                &alt_pts,
2258                ReflowDims {
2259                    limit: 0,
2260                    reflow: false,
2261                    ..dims
2262                },
2263            );
2264            self.grid.set_screen(r_alt.screen, cols, rows);
2265            self.cursor.set_point(r_alt.cursor, rows, cols);
2266
2267            // Primary is inactive here, but markers anchor *primary* content, so
2268            // they reflow with it (the selection is already cleared on alt enter).
2269            // `(line, col)`, not `(line, 0)`: the column is what bounds OSC-133 command-text
2270            // extraction (#166), and discarding it here truncated the recorded command for any
2271            // resize taken while a full-screen app was up. The primary branch below has always
2272            // passed and restored both; this one is the sibling that did not.
2273            let marker_pts: Vec<(usize, usize)> = self
2274                .normal_markers
2275                .iter()
2276                .map(|m| (m.line, m.col))
2277                .collect();
2278            let primary = self.alt_grid.take_lines();
2279            let r = reflow_pane(
2280                primary,
2281                scrollback,
2282                self.saved_cursor.point(),
2283                &marker_pts,
2284                dims,
2285            );
2286            self.alt_grid.set_screen(r.screen, cols, rows);
2287            self.scrollback = r.scrollback;
2288            self.saved_cursor.set_point(r.cursor, rows, cols);
2289            for (i, m) in self.normal_markers.iter_mut().enumerate() {
2290                m.line = r.extras[i].0.saturating_sub(r.evicted);
2291                m.col = r.extras[i].1;
2292            }
2293            // The alt half lives in a different frame from the primary one above, and adding the
2294            // two was the defect: `extras` count from the top of the alt pane's own history, and
2295            // the alt screen **has** no history — every row the shrink pushed off the top is gone,
2296            // not archived. Passing the primary's scrollback limit made `reflow_pane` keep them,
2297            // so a rows-only resize (no reflow at all) reported a marker four lines past the end of
2298            // the buffer. The limit is `0` here because that is what an alt screen's history is.
2299            //
2300            // A marker whose row went with it is **disposed**, matching what the alt screen already
2301            // does when a row leaves by scrolling (`markers_rotate_region` fires `MarkerDisposed`
2302            // for the marker on the departing edge). Silently relocating it to row 0 would put a
2303            // decoration on content it was never attached to.
2304            let new_base = self.scrollback.len();
2305            let mut alt_disposed = Vec::new();
2306            let mut i = 0;
2307            self.alt_markers.retain_mut(|m| {
2308                let (line, col) = r_alt.extras[i];
2309                i += 1;
2310                match line.checked_sub(r_alt.evicted) {
2311                    Some(row) if row < rows => {
2312                        m.line = new_base + row;
2313                        // The column rides along for the same reason as the primary half, but
2314                        // **unpinned**: `add_marker` always passes column 0, and I could not get an
2315                        // OSC-133 mark (the only column-bearing kind) to appear in `alt_markers` at
2316                        // all. That is a gap in my knowledge, not evidence the column is
2317                        // structurally zero — `push_marker` takes a column and `markers_mut` routes
2318                        // by active buffer, so the field is reachable in principle. Carrying it
2319                        // keeps the two halves stating one invariant; if the alt path really is
2320                        // marker-column-free, this line is a no-op.
2321                        m.col = col;
2322                        true
2323                    }
2324                    _ => {
2325                        alt_disposed.push(m.id);
2326                        false
2327                    }
2328                }
2329            });
2330            for id in alt_disposed {
2331                self.events.push(TermEvent::MarkerDisposed(id));
2332            }
2333        } else {
2334            // Active = primary (cursor, scrollback); inactive = alt. The selection
2335            // anchors (absolute) reflow alongside the cursor so they keep their
2336            // content across a column change.
2337            let sel_pts: Vec<(usize, usize)> = self
2338                .selection
2339                .as_ref()
2340                .map(|s| {
2341                    vec![
2342                        (s.anchor.point.line, s.anchor.point.col),
2343                        (s.focus.point.line, s.focus.point.col),
2344                    ]
2345                })
2346                .unwrap_or_default();
2347            // Markers reflow on the same pane by (line, col) — the column matters
2348            // for OSC-133 command marks, whose B/C columns bound the extracted
2349            // command text (#166). They ride after the selection points so each
2350            // reads its own reflowed slot back from `extras` (#118).
2351            let mut pts = sel_pts.clone();
2352            pts.extend(self.normal_markers.iter().map(|m| (m.line, m.col)));
2353
2354            let primary = self.grid.take_lines();
2355            let r = reflow_pane(primary, scrollback, self.cursor.point(), &pts, dims);
2356            self.grid.set_screen(r.screen, cols, rows);
2357            self.scrollback = r.scrollback;
2358            self.cursor.set_point(r.cursor, rows, cols);
2359            if let Some(sel) = &mut self.selection {
2360                // A selection endpoint is **UI** state, so its reading of `col == cols` (#562) is
2361                // neither the cursor's nor a mark's: it is clamped into the grid. UI state may not
2362                // move the application's content to make room for itself — the criterion that
2363                // decided this, and the one ghostty encodes by clamping every non-cursor pin before
2364                // it can widen a row (`terminal/PageList.zig:1576-1585` @ `e6e26e1`) while leaving
2365                // the cursor pin unclamped (`:1602-1606`).
2366                sel.anchor.point = BufferPoint {
2367                    line: r.extras[0].0.saturating_sub(r.evicted),
2368                    col: r.extras[0].1.min(cols - 1),
2369                };
2370                sel.focus.point = BufferPoint {
2371                    line: r.extras[1].0.saturating_sub(r.evicted),
2372                    col: r.extras[1].1.min(cols - 1),
2373                };
2374            }
2375            let marker_off = sel_pts.len();
2376            for (i, m) in self.normal_markers.iter_mut().enumerate() {
2377                m.line = r.extras[marker_off + i].0.saturating_sub(r.evicted);
2378                m.col = r.extras[marker_off + i].1;
2379            }
2380
2381            let alt = self.alt_grid.take_lines();
2382            let r = reflow_pane(
2383                alt,
2384                VecDeque::new(),
2385                (0, 0),
2386                &[],
2387                ReflowDims {
2388                    limit: 0,
2389                    reflow: false,
2390                    ..dims
2391                },
2392            );
2393            self.alt_grid.set_screen(r.screen, cols, rows);
2394        }
2395
2396        // Carry the deferred wrap across the resize — it is *cursor* state, and it used to be
2397        // reset here alongside the margins and tab stops, which are screen configuration and do
2398        // legitimately reset. Losing it meant the next byte overwrote the last glyph instead of
2399        // wrapping past it, on a column resize *and* on a rows-only one where no reflow runs at
2400        // all.
2401        //
2402        // The flag means "the cursor is logically one past the column it sits on". Where the
2403        // reflow leaves it somewhere other than the last column that logical position **is**
2404        // representable, so the flag is cleared and the cursor takes it instead — ghostty's rule,
2405        // stated in its own words for the saved cursor: *"If we had pending wrap set and we're no
2406        // longer at the end of the line, we unset the pending wrap and move the cursor to reflect
2407        // the correct next position"* (`terminal/Screen.zig:2092-2098` @ `e6e26e1`). alacritty
2408        // reaches the same place from the other side, lifting the cursor outside the grid before
2409        // reflowing and clamping it back afterwards (`grid/resize.rs:113-116`, `:248-251`,
2410        // `:173-177` @ `852e971`); xterm.js needs no rule because `x === cols` is representable.
2411        //
2412        // `col + 1` cannot overflow the row: the branch requires `col != cols - 1`, and `col` is
2413        // already clamped below `cols` by `Cursor::set_point`.
2414        if self.cursor.pending_wrap && self.cursor.col != cols - 1 {
2415            self.cursor.pending_wrap = false;
2416            self.cursor.col += 1;
2417        }
2418        self.scroll_top = 0;
2419        self.scroll_bottom = rows - 1;
2420        self.tabs = default_tabs(cols);
2421        self.display_offset = self.display_offset.min(self.scrollback.len());
2422
2423        // Damage tracking is sized to the screen; a resize repaints everything,
2424        // so drop any pending scroll op (it points at the old rows).
2425        self.line_damage = vec![LineBounds::undamaged(cols); rows];
2426        self.scroll = None;
2427        self.mark_fully_damaged();
2428    }
2429
2430    pub fn grid(&self) -> &Grid {
2431        &self.grid
2432    }
2433
2434    pub fn cursor(&self) -> &Cursor {
2435        &self.cursor
2436    }
2437
2438    /// Whether bracketed-paste mode (DEC ?2004) is enabled. The input encoder
2439    /// (#11) reads this to decide whether to wrap pasted text in markers.
2440    pub fn bracketed_paste(&self) -> bool {
2441        self.bracketed_paste
2442    }
2443
2444    // ---- input encoding (#11) ------------------------------------------------
2445
2446    /// Encode a key event to bytes using the active cursor-key mode (DECCKM)
2447    /// and the kitty keyboard-protocol flags (`encode_key` consults both).
2448    pub fn encode_key(&self, ev: KeyEvent) -> Option<Vec<u8>> {
2449        encode_key(
2450            &ev,
2451            self.app_cursor_keys,
2452            self.application_keypad,
2453            self.kitty_flags,
2454        )
2455    }
2456
2457    /// Encode a mouse event using the active tracking mode + encoding. `None`
2458    /// when reporting is off or the event is filtered by the mode.
2459    pub fn encode_mouse(&self, ev: MouseEvent) -> Option<Vec<u8>> {
2460        encode_mouse(&ev, self.mouse_protocol, self.mouse_encoding)
2461    }
2462
2463    /// Encode pasted text, wrapping it in bracketed-paste markers when ?2004 is
2464    /// on.
2465    pub fn encode_paste(&self, text: &str) -> Vec<u8> {
2466        encode_paste(text, self.bracketed_paste)
2467    }
2468
2469    /// Encode a focus change (`CSI I`/`CSI O`), or `None` when focus reporting
2470    /// (?1004) is off.
2471    pub fn encode_focus(&self, focused: bool) -> Option<Vec<u8>> {
2472        encode_focus(focused, self.focus_events)
2473    }
2474
2475    /// Take the consumer events queued since the last drain, emptying the queue.
2476    pub fn drain_events(&mut self) -> Vec<TermEvent> {
2477        std::mem::take(&mut self.events)
2478    }
2479
2480    /// Take the reply bytes queued since the last drain (DA/DSR/DECRQM answers),
2481    /// emptying the buffer. The consumer writes them back to the PTY.
2482    pub fn drain_replies(&mut self) -> Vec<u8> {
2483        std::mem::take(&mut self.replies)
2484    }
2485
2486    /// Device Status Report (CSI Ps n): 6 = cursor position, 5 = operating
2487    /// status. Queues the reply for `drain_replies` (#27).
2488    fn device_status_report(&mut self, param: u16) {
2489        match param {
2490            6 => {
2491                // CSI row;col R, 1-based — region-relative under origin mode
2492                // (the coordinate system the app is addressing in).
2493                let row = if self.origin_mode {
2494                    self.cursor.row.saturating_sub(self.scroll_top)
2495                } else {
2496                    self.cursor.row
2497                } + 1;
2498                let col = self.cursor.col + 1;
2499                self.replies
2500                    .extend_from_slice(format!("\x1b[{row};{col}R").as_bytes());
2501            }
2502            5 => self.replies.extend_from_slice(b"\x1b[0n"), // status: OK
2503            _ => {}
2504        }
2505    }
2506
2507    /// Kitty keyboard-protocol negotiation (#23). `lead` is the leading CSI
2508    /// intermediate: `?` query, `>` push, `=` set, `<` pop.
2509    fn kitty_dispatch(&mut self, lead: u8, params: &Params) {
2510        match lead {
2511            // Query → report the current flags as `CSI ? flags u` (#27 channel).
2512            b'?' => self
2513                .replies
2514                .extend_from_slice(format!("\x1b[?{}u", self.kitty_flags).as_bytes()),
2515            // Push: save the current flags, then set the new ones (default 0).
2516            b'>' => {
2517                const KITTY_STACK_CAP: usize = 16;
2518                if self.kitty_stack.len() >= KITTY_STACK_CAP {
2519                    self.kitty_stack.remove(0); // drop the oldest on overflow
2520                }
2521                self.kitty_stack.push(self.kitty_flags);
2522                self.kitty_flags = param_or(params, 0, 0) as u8;
2523            }
2524            // Pop `n` (default 1): restore from the stack, 0 once empty.
2525            b'<' => {
2526                for _ in 0..param_or(params, 0, 1) {
2527                    self.kitty_flags = self.kitty_stack.pop().unwrap_or(0);
2528                }
2529            }
2530            // Set in place (no push): mode 1 replace, 2 or-in, 3 and-not.
2531            b'=' => {
2532                let flags = param_or(params, 0, 0) as u8;
2533                self.kitty_flags = match param_or(params, 1, 1) {
2534                    1 => flags,
2535                    2 => self.kitty_flags | flags,
2536                    3 => self.kitty_flags & !flags,
2537                    _ => self.kitty_flags,
2538                };
2539            }
2540            _ => {}
2541        }
2542    }
2543
2544    /// DECRQM (CSI ? Ps $ p): report whether DEC private mode `Ps` is set —
2545    /// `CSI ? Ps ; val $ y` with val 1=set, 2=reset, 0=not recognized (#27).
2546    fn decrqm(&mut self, mode: u16) {
2547        let state = match mode {
2548            1 => Some(self.app_cursor_keys),
2549            // DECANM (#84): set = ANSI mode (the normal state), reset = VT52.
2550            2 => Some(!self.vt52_mode),
2551            6 => Some(self.origin_mode),
2552            // DECCOLM: derived from the actual width, never a tracked flag — a
2553            // flag would lie if the consumer ignored the resize request (#82).
2554            3 => Some(self.grid.cols() == 132),
2555            7 => Some(self.autowrap),
2556            45 => Some(self.reverse_wraparound),
2557            9 => Some(self.mouse_protocol == MouseProtocol::X10),
2558            66 => Some(self.application_keypad),
2559            12 => Some(self.cursor.blink),
2560            25 => Some(self.cursor.visible),
2561            // Mouse tracking is a single-state enum (the levels are mutually
2562            // exclusive — an app enables one), so querying ?1000 while ?1002 is
2563            // active reports "reset". Faithful to that model.
2564            1000 => Some(self.mouse_protocol == MouseProtocol::Normal),
2565            1002 => Some(self.mouse_protocol == MouseProtocol::ButtonEvent),
2566            1003 => Some(self.mouse_protocol == MouseProtocol::AnyEvent),
2567            1004 => Some(self.focus_events),
2568            1006 => Some(self.mouse_encoding == MouseEncoding::Sgr),
2569            1015 => Some(self.mouse_encoding == MouseEncoding::Urxvt),
2570            1005 => Some(self.mouse_encoding == MouseEncoding::Utf8),
2571            1016 => Some(self.mouse_encoding == MouseEncoding::SgrPixels),
2572            47 | 1047 | 1049 => Some(self.on_alt),
2573            2004 => Some(self.bracketed_paste),
2574            2026 => Some(self.synchronized_output),
2575            2027 => Some(self.grapheme_clustering),
2576            2031 => Some(self.color_scheme_updates),
2577            9001 => Some(self.win32_input_mode),
2578            _ => None,
2579        };
2580        let val = match state {
2581            Some(true) => 1,
2582            Some(false) => 2,
2583            None => 0,
2584        };
2585        self.replies
2586            .extend_from_slice(format!("\x1b[?{mode};{val}$y").as_bytes());
2587    }
2588
2589    /// Resolve a cell's `link` index (OSC 8) to its URI, or `None` if the index
2590    /// is out of range. The renderer reads `Cell.link`, then this, to make a
2591    /// cell clickable (#26).
2592    pub fn hyperlink(&self, link: core::num::NonZeroU32) -> Option<&str> {
2593        self.hyperlink_pool
2594            .get(link.get() as usize - 1)
2595            .map(String::as_str)
2596    }
2597
2598    // ---- cursor / scroll primitives ------------------------------------------
2599
2600    /// Move down one line. At the bottom margin, scroll the region instead;
2601    /// below the region, just descend (no scroll). Column is unchanged (raw LF;
2602    /// CR is what returns to column 0).
2603    /// An ordinary line feed — `LF`/`VT`/`FF`, `IND` and `NEL`. None of them serves a wrap.
2604    fn linefeed(&mut self) {
2605        self.linefeed_inner(false);
2606    }
2607
2608    /// A line feed, carrying the one fact the shift itself cannot see: whether the auto-wrap asked
2609    /// for it.
2610    ///
2611    /// `serves_wrap` is the **bottom** seam's exemption in `shift_region`, the mirror of
2612    /// `evicts_to_scrollback` for the top one. When `wrapline` drives this, the blank that lands at
2613    /// the region's bottom *is* where the wrapped text is about to go — so the row that #540 would
2614    /// call "the one that lost its continuation to the blank" is in fact the row whose continuation
2615    /// that blank **is** (#557).
2616    ///
2617    /// xterm.js threads the identical fact through the identical seam, in the opposite direction:
2618    /// `BufferService.scroll(eraseAttr, isWrapped)` stamps the *destination* row
2619    /// (`common/services/BufferService.ts:68`/`:77` @ `699f553`), and exactly one of its four
2620    /// non-test callers passes `true` — the auto-wrap branch of `_print` (`InputHandler.ts:588`),
2621    /// not `lineFeed`, `index` or the ED-2 loop.
2622    fn linefeed_inner(&mut self, serves_wrap: bool) {
2623        // New-line mode (LNM ?20): a line feed also returns to column 0 (#71).
2624        if self.newline_mode {
2625            self.carriage_return();
2626        }
2627        if self.cursor.row == self.scroll_bottom {
2628            // A top-anchored primary-screen scroll pushes the evicted top line
2629            // into scrollback history.
2630            if self.scroll_top == 0 && !self.on_alt {
2631                // Scrollback accrues whenever the scroll is top-anchored on the
2632                // primary screen (`scroll_top == 0`) — but the O(1) ring handshake
2633                // only applies to a *full-screen* scroll (`scroll_bottom` at the
2634                // last row). A top-anchored *sub-region* (`[0..k]`, k < rows-1)
2635                // still accrues, yet must scroll only its region, so it keeps the
2636                // copy + region scroll. These are distinct predicates (ADR-0009).
2637                let evicted = if self.scroll_bottom == self.grid.rows() - 1 {
2638                    // Full-screen hot path: move the evicted top row out, install
2639                    // a recycled blank as the new bottom (zero-alloc steady state).
2640                    let blank = self
2641                        .recycled_row
2642                        .take()
2643                        .unwrap_or_else(|| Row::from_cells(Vec::with_capacity(self.grid.cols())));
2644                    let evicted = self.grid.scroll_up_recycle(blank);
2645                    // The one row-shifting path that does not route through `shift_region` (it
2646                    // needs the primitive that *returns* the evicted row), so it records its own
2647                    // scroll op — and owes no seam clear, which is now an argument rather than a
2648                    // coincidence: the top seam is exempt because this evicts into scrollback
2649                    // (adjacency is preserved one row back), and the bottom seam is exempt because
2650                    // this branch only runs at `scroll_bottom == rows - 1`, where a wrap on the
2651                    // last row is the state the scroll exists to serve.
2652                    self.record_scroll(self.scroll_top, self.scroll_bottom, 1);
2653                    evicted
2654                } else {
2655                    // Top-anchored sub-region: copy row 0, then region-scroll
2656                    // `[0..=scroll_bottom]` (rows below stay fixed).
2657                    //
2658                    // #449: the fixed rows keep their GRID position while
2659                    // scrollback grows, so their content's concatenated absolute
2660                    // index shifts +1 — re-anchor the content-tracking anchors
2661                    // (selection, markers; alacritty's swap-back of the fixed
2662                    // bottom lines is the screen-relative equivalent of this)
2663                    // and invalidate the query-derived highlights
2664                    // (drop-not-re-anchor policy, #108). In-region and
2665                    // scrollback content keeps stable indices — untouched.
2666                    let below = self.scrollback.len() + self.scroll_bottom + 1;
2667                    self.selection_shift_below_margin(below);
2668                    self.markers_shift_below_margin(below);
2669                    self.invalidate_search_highlights();
2670                    let evicted = self.grid.row_owned(0);
2671                    self.shift_region(
2672                        self.scroll_top,
2673                        self.scroll_bottom,
2674                        false,
2675                        true,
2676                        serves_wrap,
2677                    );
2678                    evicted
2679                };
2680                self.scrollback.push_back(evicted);
2681                // Follow-bottom = stay: if the user is scrolled up, bump the
2682                // offset so the same lines stay in view instead of being yanked
2683                // to the bottom.
2684                if self.display_offset > 0 {
2685                    self.display_offset = (self.display_offset + 1).min(self.scrollback.len());
2686                }
2687                // Cap: evict the oldest line past the limit. The view is anchored
2688                // to history, so dropping the front shifts the offset down too
2689                // (xterm.js trims ybase and ydisp together) — also keeps the
2690                // offset within `[0, len]`. The evicted row is parked for reuse.
2691                if self.scrollback.len() > self.scrollback_limit {
2692                    self.recycled_row = self.scrollback.pop_front();
2693                    // Every absolute index just shifted down by one; move the
2694                    // selection with it so its anchors keep their content.
2695                    self.selection_evict_oldest();
2696                    // Query-derived highlights can't survive the index shift (see
2697                    // the method doc); selection re-anchors, highlights invalidate.
2698                    self.invalidate_search_highlights();
2699                    // Markers are persistent anchors: shift them down with the
2700                    // index, disposing any whose line was the evicted one (#118).
2701                    self.markers_evict_oldest();
2702                    if self.display_offset > 0 {
2703                        // Scrolled up: evicting the oldest line advanced the
2704                        // viewport, so it must be repainted (the "frozen while
2705                        // scrolled" rule does not apply when the view itself moved).
2706                        self.display_offset -= 1;
2707                        self.mark_fully_damaged();
2708                    }
2709                }
2710            } else {
2711                // Region (top margin > 0) or alt-screen scroll: the evicted line
2712                // does NOT enter scrollback, so content moves *within* the screen
2713                // and absolute indices in the region shift. Rotate the selection
2714                // up so it follows; an endpoint on the dropped line clears it.
2715                let base = self.scrollback.len();
2716                self.selection_rotate_region(
2717                    base + self.scroll_top,
2718                    base + self.scroll_bottom,
2719                    true,
2720                );
2721                // Rotate the active buffer's markers with the content (#187):
2722                // per-buffer storage (#186) scopes them, so an alt scroll rotates
2723                // *alt* marks and leaves the frozen primary list untouched — no
2724                // guard needed. `markers_rotate_region` routes via `markers_mut`.
2725                self.markers_rotate_region(base + self.scroll_top, base + self.scroll_bottom, true);
2726                self.invalidate_search_highlights();
2727                self.shift_region(
2728                    self.scroll_top,
2729                    self.scroll_bottom,
2730                    false,
2731                    false,
2732                    serves_wrap,
2733                );
2734            }
2735        } else if self.cursor.row + 1 < self.grid.rows() {
2736            self.cursor.row += 1;
2737        }
2738    }
2739
2740    /// DECSTBM (CSI r): set the top/bottom scroll margins (1-based inclusive).
2741    /// An invalid region (top ≥ bottom) is ignored.
2742    fn set_scroll_region(&mut self, top: usize, bottom: usize) {
2743        let bottom = bottom.min(self.grid.rows());
2744        if top >= bottom {
2745            return;
2746        }
2747        self.scroll_top = top - 1;
2748        self.scroll_bottom = bottom - 1;
2749        self.goto(0, 0); // DECSTBM homes the cursor (absolute)
2750    }
2751
2752    // ---- alt screen (DEC 1049) -----------------------------------------------
2753
2754    /// Enter the alternate screen: save the cursor, swap in the other grid, and
2755    /// clear it.
2756    /// Save the cursor into the alt-screen slot — `?1048` set, and the first
2757    /// half of `?1049` enter (#72).
2758    fn save_alt_cursor(&mut self) {
2759        self.saved_cursor = self.cursor;
2760    }
2761
2762    /// Restore the cursor from the alt-screen slot — `?1048` reset, and the
2763    /// second half of `?1049` leave. DECTCEM visibility is a standalone mode, not
2764    /// part of the save, so preserve it across the restore (#38/#72).
2765    fn restore_alt_cursor(&mut self) {
2766        let visible = self.cursor.visible;
2767        self.cursor = self.saved_cursor;
2768        self.cursor.visible = visible;
2769    }
2770
2771    /// Switch to the (cleared) alternate buffer without touching the cursor —
2772    /// `?47`/`?1047` set, and the second half of `?1049` enter (#72).
2773    fn switch_to_alt(&mut self) {
2774        if self.on_alt {
2775            return;
2776        }
2777        std::mem::swap(&mut self.grid, &mut self.alt_grid);
2778        self.grid.clear();
2779        self.on_alt = true;
2780        self.display_offset = 0; // the alt screen has no scrollback to view
2781        self.selection = None; // a selection cannot survive a screen swap
2782        self.invalidate_search_highlights(); // matches index the primary buffer
2783        self.mark_fully_damaged();
2784    }
2785
2786    /// Switch back to the primary buffer without touching the cursor —
2787    /// `?47`/`?1047` reset, and the first half of `?1049` leave (#72).
2788    fn switch_to_primary(&mut self) {
2789        if !self.on_alt {
2790            return;
2791        }
2792        // Dispose the alt buffer's markers on leave — xterm `activateNormalBuffer`
2793        // → `clearAllMarkers` (#177 S0). Empty while the alt guards stand, so this
2794        // fires nothing today; it's the seam the alt-marker slices (#187) build on.
2795        for m in self.alt_markers.drain(..) {
2796            self.events.push(TermEvent::MarkerDisposed(m.id));
2797        }
2798        std::mem::swap(&mut self.grid, &mut self.alt_grid);
2799        self.on_alt = false;
2800        self.display_offset = 0; // return to the primary at its bottom
2801        self.selection = None; // a selection cannot survive a screen swap
2802        self.invalidate_search_highlights(); // matches index the swapped-out buffer
2803        self.mark_fully_damaged();
2804    }
2805
2806    fn enter_alt_screen(&mut self) {
2807        if self.on_alt {
2808            return;
2809        }
2810        self.save_alt_cursor();
2811        self.switch_to_alt();
2812    }
2813
2814    /// Leave the alternate screen: swap the primary grid back in and restore the
2815    /// saved cursor.
2816    fn leave_alt_screen(&mut self) {
2817        if !self.on_alt {
2818            return;
2819        }
2820        self.switch_to_primary();
2821        self.restore_alt_cursor();
2822    }
2823
2824    /// RI (ESC M): move up one line. At the top margin, scroll the region down
2825    /// instead.
2826    fn reverse_index(&mut self) {
2827        if self.cursor.row == self.scroll_top {
2828            // RI never enters scrollback; the region scrolls down within the
2829            // screen, so absolute indices in it shift down. Rotate the selection.
2830            let base = self.scrollback.len();
2831            self.selection_rotate_region(base + self.scroll_top, base + self.scroll_bottom, false);
2832            // Rotate the active buffer's markers (#187) — alt-scoped on the alt
2833            // screen, so no guard (see `linefeed`).
2834            self.markers_rotate_region(base + self.scroll_top, base + self.scroll_bottom, false);
2835            self.invalidate_search_highlights();
2836            self.shift_region(self.scroll_top, self.scroll_bottom, true, false, false);
2837        } else if self.cursor.row > 0 {
2838            self.cursor.row -= 1;
2839        }
2840    }
2841
2842    // ---- cursor save/restore (DECSC / DECRC) ---------------------------------
2843
2844    /// DECSC (ESC 7): save the cursor position, pen, pending-wrap, and origin
2845    /// mode. Visibility is not saved (DECTCEM is separate).
2846    fn save_cursor(&mut self) {
2847        self.decsc = SavedCursor {
2848            row: self.cursor.row,
2849            col: self.cursor.col,
2850            pen: self.cursor.pen,
2851            pending_wrap: self.cursor.pending_wrap,
2852            origin_mode: self.origin_mode,
2853            charsets: self.charsets,
2854            gl: self.gl,
2855        };
2856    }
2857
2858    /// DECRC (ESC 8): restore what DECSC saved. Origin mode is restored (per
2859    /// ADR-0004); visibility is left as-is. The position is clamped to the
2860    /// current screen in case it shrank since the save.
2861    fn restore_cursor(&mut self) {
2862        let s = self.decsc;
2863        self.cursor.row = s.row.min(self.grid.rows() - 1);
2864        self.cursor.col = s.col.min(self.grid.cols() - 1);
2865        self.cursor.pen = s.pen;
2866        self.cursor.pending_wrap = s.pending_wrap;
2867        self.origin_mode = s.origin_mode;
2868        self.charsets = s.charsets;
2869        self.gl = s.gl;
2870    }
2871
2872    /// RIS (ESC c) — full reset to the power-on state (#53). Reconstruct every
2873    /// screen/mode field to its construction default (preserving only the
2874    /// dimensions and the scrollback cap), but keep the consumer-bound output
2875    /// queues (`replies`/`events`) that accrued earlier in this `feed`, and
2876    /// signal a full repaint. The vte parser lives outside `Term`, so replacing
2877    /// `self` does not disturb in-progress parsing. Mirrors xterm.js fullReset.
2878    fn full_reset(&mut self) {
2879        let replies = std::mem::take(&mut self.replies);
2880        let mut events = std::mem::take(&mut self.events);
2881        // RIS wipes the buffer, so every marker's line is gone — announce each
2882        // disposal so the consumer drops its decorations (and isn't confused when
2883        // the reset id counter reissues the same ids). The events survive the
2884        // reset below (#118).
2885        events.extend(
2886            self.normal_markers
2887                .iter()
2888                .chain(&self.alt_markers)
2889                .map(|m| TermEvent::MarkerDisposed(m.id)),
2890        );
2891        let (cols, rows) = (self.grid.cols(), self.grid.rows());
2892        *self = Term::with_scrollback(cols, rows, self.scrollback_limit);
2893        self.replies = replies;
2894        self.events = events;
2895        self.mark_fully_damaged();
2896    }
2897
2898    /// DECSTR (CSI ! p) — soft reset (#53). Resets a defined subset of modes to
2899    /// their defaults *without* destroying screen content or scrollback, moving
2900    /// the active cursor, or touching the mouse/focus reporting subsystem. Per
2901    /// xterm.js softReset, autowrap returns to ON (the xterm default), not off.
2902    fn soft_reset(&mut self) {
2903        self.cursor.visible = true;
2904        self.cursor.pen = Pen::default();
2905        self.scroll_top = 0;
2906        self.scroll_bottom = self.grid.rows() - 1;
2907        self.origin_mode = false;
2908        self.app_cursor_keys = false;
2909        self.bracketed_paste = false;
2910        self.grapheme_clustering = false; // ?2027 back to the wcwidth-compat default (#295)
2911        self.autowrap = true; // xterm default is ON (not the VT100 "off")
2912        self.insert_mode = false;
2913        self.charsets = [Charset::Ascii; 4];
2914        self.gl = 0;
2915        self.decsc = SavedCursor::default();
2916    }
2917
2918    fn carriage_return(&mut self) {
2919        self.cursor.col = 0;
2920        self.cursor.pending_wrap = false;
2921    }
2922
2923    /// DECSCUSR (CSI Ps SP q): set the caret shape + blink (#89). 0/2 = steady
2924    /// block, 1 = blinking block; 3/4 = blinking/steady underline; 5/6 =
2925    /// blinking/steady bar (odd = blink). 0 resets to the default (steady block).
2926    /// An unknown param leaves the style unchanged. Mirrors xterm.js.
2927    fn set_cursor_style(&mut self, param: u16) {
2928        let (shape, blink) = match param {
2929            0 | 2 => (CursorShape::Block, false),
2930            1 => (CursorShape::Block, true),
2931            3 => (CursorShape::Underline, true),
2932            4 => (CursorShape::Underline, false),
2933            5 => (CursorShape::Bar, true),
2934            6 => (CursorShape::Bar, false),
2935            _ => return,
2936        };
2937        self.cursor.shape = shape;
2938        self.cursor.blink = blink;
2939    }
2940
2941    /// Backspace (BS, 0x08): move the cursor one column left. With reverse
2942    /// wraparound (?45) a backspace at column 0 of a *soft-wrapped* row moves
2943    /// back to the last column of the previous row — undoing one autowrap. Only
2944    /// soft wraps reverse (the previous row carries `WRAPLINE`); a hard CR/LF
2945    /// line does not. BS only (not cursor-left), matching xterm.js (#80).
2946    fn backspace(&mut self) {
2947        self.cursor.pending_wrap = false;
2948        if self.cursor.col > 0 {
2949            self.cursor.col -= 1;
2950            return;
2951        }
2952        if self.reverse_wraparound
2953            && self.cursor.row > self.scroll_top
2954            && self.cursor.row <= self.scroll_bottom
2955        {
2956            let prev = self.cursor.row - 1;
2957            let last = self.grid.cols() - 1;
2958            if self.grid.row_ref(prev).is_wrapped() {
2959                self.grid.row_mut(prev).set_wrapped(false);
2960                self.cursor.row = prev;
2961                self.cursor.col = last;
2962            }
2963        }
2964    }
2965
2966    /// Auto-wrap at end of line: line-feed then return to column 0.
2967    fn wrapline(&mut self) {
2968        self.linefeed_inner(true);
2969        self.cursor.col = 0;
2970        self.cursor.pending_wrap = false;
2971    }
2972
2973    // ---- tab stops (HT / HTS / TBC) ------------------------------------------
2974
2975    /// HT: advance to the next set tab stop, or the last column if none remain
2976    /// (no wrap).
2977    fn put_tab(&mut self) {
2978        let cols = self.grid.cols();
2979        let mut col = self.cursor.col;
2980        while col + 1 < cols {
2981            col += 1;
2982            if self.tabs[col] {
2983                break;
2984            }
2985        }
2986        self.cursor.col = col;
2987        self.cursor.pending_wrap = false;
2988    }
2989
2990    /// HTS (ESC H): set a tab stop at the cursor column.
2991    fn set_tab_stop(&mut self) {
2992        let col = self.cursor.col;
2993        self.tabs[col] = true;
2994    }
2995
2996    /// TBC (CSI g): clear the tab stop at the cursor (mode 0) or all stops
2997    /// (mode 3).
2998    fn clear_tab_stop(&mut self, mode: u16) {
2999        match mode {
3000            0 => {
3001                let col = self.cursor.col;
3002                self.tabs[col] = false;
3003            }
3004            3 => self.tabs.iter_mut().for_each(|t| *t = false),
3005            _ => {}
3006        }
3007    }
3008
3009    // ---- printing ------------------------------------------------------------
3010
3011    /// The extended attributes the pen currently stamps onto a cell it writes: the open OSC 8
3012    /// hyperlink (#26/#46) and a non-default underline colour (SGR 58, #520).
3013    ///
3014    /// The colour is gated on the UNDERLINE attribute — an underline colour is meaningless on a
3015    /// cell that draws no underline, and xterm likewise does not persist it there
3016    /// (`AttributeData isEmpty()` ignores the colour; `InputHandler.test.ts:2084`). That keeps
3017    /// it off the wire for cells that never draw it (ADR-0020: no inert per-cell payload). SGR 58
3018    /// is the *underline* colour, so STRIKETHROUGH alone does not arm it.
3019    ///
3020    /// One place, because there are three sites that write a pen-built cell (the glyph, its wide
3021    /// spacer, and the vacated wrap column) — mirroring the pen half of `Row::ext_attrs_at`, so a
3022    /// later rider is added here rather than at each of them (#521/#528).
3023    fn pen_ext_attrs(&self) -> ExtAttrs {
3024        let ucolor = self.cursor.pen.underline_color;
3025        let armed =
3026            ucolor != Color::Default && self.cursor.pen.flags.contains(CellFlags::UNDERLINE);
3027        ExtAttrs::from_pen(self.current_link, armed.then_some(ucolor))
3028    }
3029
3030    /// Free a cell that has stopped being part of a glyph — the *structural repair* every
3031    /// overwrite, erase and row-shift owes the no-orphan invariant when it destroys one half of
3032    /// a width-2 glyph, plus the spacer a mode-2027 demotion no longer needs.
3033    ///
3034    /// This is **not** an erase. The app asked for something at a *different* column; freeing
3035    /// this one is the engine keeping its own invariant. But it is still a mutation, so it
3036    /// **damages** — and that is the half every site used to forget, because each function
3037    /// damaged its own range and the repaired cell lies outside it by construction (that is what
3038    /// makes it a repair). A frame-mode consumer therefore kept painting the destroyed glyph.
3039    /// Bundling the reset with its damage is the point of this helper: a repair site added later
3040    /// cannot forget the half that has no compiler behind it (#530).
3041    ///
3042    /// The cell it leaves is a **blank carrying the current background** — the same rule
3043    /// `clear_cells` already applies to a BCE erase, extended to the repair, so one sentence
3044    /// covers both: *a blank cell carries the current background.* A bare `Cell::default()`
3045    /// would punch an uncoloured notch into a coloured run, which no reference implementation
3046    /// does.
3047    ///
3048    /// Deliberately the pen's **background only** — not its full attributes, and this is not a
3049    /// compromise between references: it is byte-for-byte xterm.js's `_eraseAttrData()`
3050    /// (`DEFAULT_ATTR_DATA` + `curAttr.bg & ~0xFC000000`, i.e. default everything plus the pen's
3051    /// background colour), which is what its `replaceCells` / `insertCells` / `deleteCells`
3052    /// repairs are handed — eight of the twelve sites here. Only xterm's *print* path uses the
3053    /// whole pen. Taking the whole pen
3054    /// (xterm.js `setCellFromCodepoint(x, 0, 1, curAttr)`) would plant the pen's hyperlink and,
3055    /// worse, its DECSCA protection onto a cell the app never wrote — a cell no later erase could
3056    /// clear. Taking the *cell's own* attributes (alacritty `clear_wide`, which keeps `extra`)
3057    /// would leave the destroyed glyph's hyperlink alive and clickable, the defect #529 is filed
3058    /// against. Both were considered and rejected; the maintainer chose this on 2026-07-24 and it
3059    /// is theirs to reverse (see #530 for what they were shown).
3060    ///
3061    /// What used to be recorded here as a known limitation is **resolved** (#538, ADR-0025 D1):
3062    /// `reset()` still clears the whole content word, but the soft-wrap link is no longer part of
3063    /// it. The live flag is on the `Row`, so freeing the last column — here or on the erase path —
3064    /// cannot break a wrap; `CellFlags::WRAPLINE` is wire-only, derived onto the last cell at
3065    /// encode time and never read back (`cell.rs`). Ending a wrap is now an explicit per-verb call
3066    /// (`end_wrap`), which is the shape both references already had and the reason the move was
3067    /// made: ghostty and xterm.js hold the flag on the row/line, and xterm.js takes `clearWrap` as
3068    /// an explicit argument on its erase helper (`_eraseInBufferLine`, `InputHandler.ts:1175`)
3069    /// rather than letting a cell clear decide it.
3070    ///
3071    /// Known cost, accepted rather than overlooked: with DECSCA the freed cell loses its
3072    /// protection. ghostty has the same hole and flags it in its own source; justerm does not
3073    /// implement DECSCA today, so revisit if it lands.
3074    fn free_cell(&mut self, row: usize, col: usize) {
3075        let bg = self.cursor.pen.bg;
3076        let cell = self.grid.cell_mut(row, col);
3077        cell.reset();
3078        cell.set_bg(bg);
3079        self.damage_span(row, col, col);
3080    }
3081
3082    /// Will the next `wrapline()` actually reach another row?
3083    ///
3084    /// `wrapline` → `linefeed` advances in exactly two cases: the cursor sits at the scroll
3085    /// region's bottom (so the region scrolls under it), or it has a row below it on screen.
3086    /// Parked *below* a DECSTBM region on the last row it does neither — it silently stays put.
3087    ///
3088    /// Both wide-at-boundary paths must ask before they commit anything, because both destroy
3089    /// content on the assumption that the row is about to change: `write_glyph` blanks the column
3090    /// it is leaving, and `relocate_cluster_wide` writes its cluster to `(cursor.row, 0..=1)`
3091    /// *after* the wrap — which is the same row when nothing advanced, so it lands on live cells.
3092    /// Reasoning only about the vacated source column misses that second case entirely.
3093    ///
3094    /// This mirrors `linefeed`'s own condition; the two must be read together.
3095    fn wrapline_advances(&self) -> bool {
3096        self.cursor.row == self.scroll_bottom || self.cursor.row + 1 < self.grid.rows()
3097    }
3098
3099    /// Blank the last column as the soft-wrap artefact it is, when a width-2 glyph could not fit
3100    /// there (#528). Shared by the two paths that reach this state: `write_glyph`'s wide-at-boundary
3101    /// wrap and `relocate_cluster_wide`'s promoted cluster.
3102    ///
3103    /// The column is **written**, not merely flagged: a blank built from the current pen, exactly
3104    /// as every reference does it — xterm.js `setCellFromCodepoint(col, 0, 1, curAttr)`
3105    /// (`InputHandler.ts:609-611`; `BufferLine.ts:244-251` takes the pen's fg/bg *and* its
3106    /// `extended` link/colour), ghostty `printCell(0, .spacer_head)` (`Terminal.zig:1410-1412`,
3107    /// whose `printCell` stamps the cursor's hyperlink), alacritty `write_at_cursor(' ')` under a
3108    /// `LEADING_WIDE_CHAR_SPACER` template (`mod.rs:1108-1113`, assigning `extra` from it).
3109    ///
3110    /// Flagging in place instead left the previous occupant's glyph, hyperlink and underline colour
3111    /// alive in a cell every text reader skips — so a renderer drew a character that could not be
3112    /// copied, searched or announced (#528). Building from `Pen::cell` also clears the presence
3113    /// bits, so no stale side-map entry can be read back through the new cell.
3114    ///
3115    /// WRAPLINE marks the row a continuation rather than a hard line-end (search, logical lines
3116    /// #113 and reflow #7 all read it); the leading-spacer marker makes the text extractors skip
3117    /// the blank instead of joining `"ab한"` → `"ab 한"`.
3118    ///
3119    /// The marker is **alacritty's** `LEADING_WIDE_CHAR_SPACER` (`term/cell.rs`) — ghostty calls
3120    /// the same thing `.spacer_head`. It is *not* xterm's: xterm.js has no marker at all, writing
3121    /// a bare null cell and re-inferring the artefact at reflow time from "ends in null and the
3122    /// following line starts with a wide char". That difference has a consequence here — in
3123    /// xterm.js a lost marker degrades to an empty cell that trimming drops anyway, whereas
3124    /// justerm writes `' '`, so the marker is the *only* thing keeping this column out of the
3125    /// extracted text.
3126    fn vacate_for_wrap(&mut self, row: usize, col: usize) {
3127        // Writing this column makes the vacate an overwrite like any other, so it inherits the
3128        // no-orphan obligation every other overwrite site carries (`write_glyph`,
3129        // `promote_cluster_to_wide`, `insert_chars`, `delete_chars`, the erase path): if the
3130        // column was the *spacer* of a wide glyph, blanking it destroys the spacer marker and
3131        // strands the lead. That is unrecoverable rather than merely untidy — every repair path
3132        // keys off `is_wide_spacer()`, so once the marker is gone no later write, ECH, EL, ICH
3133        // or DCH can ever clear the orphan.
3134        if col > 0 && self.grid.cell(row, col).is_wide_spacer() {
3135            self.free_cell(row, col - 1);
3136        }
3137        let mut vacated = self.cursor.pen.cell(' ');
3138        vacated.set_leading_spacer();
3139        *self.grid.cell_mut(row, col) = vacated;
3140        self.begin_wrap(row);
3141        let ext = self.pen_ext_attrs();
3142        self.grid.row_mut(row).set_ext_attrs(col, ext);
3143        // The cell's contents changed, so a frame-mode consumer must be told or it keeps painting
3144        // the old glyph (ADR-0003: every mutation site records damage). The *repaired* lead above
3145        // is damaged by `free_cell`, which owns that pairing for all twelve repair sites — this
3146        // site used to hand-roll it, and keeping both left neither able to discriminate.
3147        self.damage_span(row, col, col);
3148    }
3149
3150    /// Write one glyph at the cursor, handling deferred wrap and the wide-char
3151    /// spacer, then advance the cursor (deferring the wrap if it hits the edge).
3152    fn write_glyph(&mut self, c: char, width: usize) {
3153        let cols = self.grid.cols();
3154
3155        // Resolve a deferred last-column wrap before placing the next glyph.
3156        // The row being left soft-wrapped: mark its last cell so reflow (#7) can
3157        // tell it from a hard CR/LF line-end.
3158        if self.cursor.pending_wrap {
3159            let row = self.cursor.row;
3160            // Claim the wrap only if there will *be* a next row to continue into. Parked below a
3161            // DECSTBM region on the last row, `wrapline` → `linefeed` advances nothing and the
3162            // glyph overwrites this same row from column 0 — so the wrap never happened, and a
3163            // flag set here is permanently false: the cursor never leaves, nothing clears it, and
3164            // it survives into `backspace`'s reverse-wraparound, reflow, and every text reader.
3165            //
3166            // The predicate is not new and neither is its rationale: `wrapline_advances` was
3167            // written for exactly this state and is already asked by both wide-at-boundary paths.
3168            // This narrow path was the one caller that committed without asking. (Surfaced by the
3169            // #540 completeness pass, which found a row-shift verb inheriting the bogus flag and
3170            // merging two unrelated logical lines.)
3171            if self.wrapline_advances() {
3172                self.begin_wrap(row);
3173            }
3174            self.wrapline();
3175        }
3176
3177        // A width-2 glyph that cannot fit in the last column wraps first — unless
3178        // autowrap is off, in which case it is dropped (xterm.js `continue`), not
3179        // squeezed or wrapped.
3180        if width == 2 && self.cursor.col + 1 >= cols {
3181            if !self.autowrap {
3182                return;
3183            }
3184            // …but only if the wrap actually happens: vacating for a wrap that never occurs
3185            // blanks a column holding a live glyph.
3186            if self.wrapline_advances() {
3187                self.vacate_for_wrap(self.cursor.row, cols - 1);
3188            }
3189            self.wrapline();
3190        }
3191
3192        // Insert mode (IRM): open a `width`-wide gap at the cursor first, shifting
3193        // the row's tail right (off-edge cells discarded, wide halves repaired),
3194        // then write into the gap — mirrors xterm.js's insertCells (#64).
3195        if self.insert_mode {
3196            self.insert_chars(width);
3197        }
3198
3199        let (row, col) = (self.cursor.row, self.cursor.col);
3200
3201        // Overwriting either half of a pair that wrapped from the row above ends that pair, so the
3202        // row above's artefact record is void (#534). The one exception is the in-place same-width
3203        // overwrite — a wide lead replaced by another wide lead at the same column — which is
3204        // ghostty's `if (cell.wide != wide)` escape and the reason this is asked *before* the
3205        // write rather than after it. Note IRM has already run its own check inside `insert_chars`
3206        // by the time this would fire, on the pre-shift state, which is the correct one.
3207        if col <= 1 && self.wrapped_pair_at_row_start(row) && !(col == 0 && width == 2) {
3208            self.void_wrap_artefact_above(row);
3209        }
3210
3211        // Overwriting one half of an existing wide glyph orphans the other —
3212        // clear it so no stray lead/spacer is left behind.
3213        let last = col + width - 1;
3214        if col > 0 && self.grid.cell(row, col).is_wide_spacer() {
3215            self.free_cell(row, col - 1);
3216        }
3217        if last + 1 < cols && self.grid.cell(row, last).is_wide() {
3218            self.free_cell(row, last + 1);
3219        }
3220
3221        let mut cell = self.cursor.pen.cell(c);
3222        if width == 2 {
3223            cell.insert_flags(CellFlags::WIDE_CHAR);
3224        }
3225        *self.grid.cell_mut(row, col) = cell;
3226        // Stamp the pen's extended attrs — the open hyperlink (#26/#46) and a non-default
3227        // underline colour (#520) — into the row's side maps.
3228        let ext = self.pen_ext_attrs();
3229        self.grid.row_mut(row).set_ext_attrs(col, ext);
3230
3231        // The trailing column of a wide glyph carries a distinct spacer marker —
3232        // and the same link + underline colour, so a hover/selection/underline over
3233        // either half agrees.
3234        if width == 2 && col + 1 < cols {
3235            let mut spacer = self.cursor.pen.cell(' ');
3236            spacer.insert_flags(CellFlags::WIDE_CHAR_SPACER);
3237            *self.grid.cell_mut(row, col + 1) = spacer;
3238            self.grid.row_mut(row).set_ext_attrs(col + 1, ext);
3239        }
3240
3241        // Record damage for the cell(s) just written.
3242        self.damage_span(row, col, col + width - 1);
3243
3244        // Advance. Reaching/passing the last column sets pending-wrap instead of
3245        // wrapping eagerly — the cursor parks on the last column.
3246        let new_col = col + width;
3247        if new_col >= cols {
3248            self.cursor.col = cols - 1;
3249            // With autowrap off (DECAWM ?7l) the cursor pins to the last column
3250            // and the next glyph overwrites in place — no deferred wrap (#63).
3251            self.cursor.pending_wrap = self.autowrap;
3252        } else {
3253            self.cursor.col = new_col;
3254        }
3255    }
3256
3257    /// Attach a combining mark (width-0 code point) to the grapheme it modifies —
3258    /// the cell the cursor just left. With pending-wrap the cursor still sits on
3259    /// the just-written last-column glyph, so attach in place (no back-up, no
3260    /// deferred wrap); otherwise step back one column, and once more over a
3261    /// wide-char spacer to reach its lead. Stored in the grapheme side-table.
3262    fn push_combining(&mut self, c: char) {
3263        let row = self.cursor.row;
3264        let mut col = if self.cursor.pending_wrap {
3265            self.cursor.col
3266        } else {
3267            self.cursor.col.saturating_sub(1)
3268        };
3269        if self.grid.cell(row, col).is_wide_spacer() {
3270            col = col.saturating_sub(1);
3271        }
3272        // Append the mark to the row's combining map at this column (setting the
3273        // cell's combining bit). No global pool — the cluster rides the row.
3274        self.grid.row_mut(row).push_combining(col, c);
3275        self.damage_span(row, col, col);
3276    }
3277
3278    /// Mode 2027 (#295): if `c` **extends** the previous cell's grapheme cluster (UAX #29), append
3279    /// it to that cell's side-table — no new cell, no cursor advance — and return `true`. Otherwise
3280    /// return `false` so `print` takes the normal per-scalar path (a break starts a new cell).
3281    ///
3282    /// The break state is reconstructed fresh from the previous cell's stored cluster (base scalar +
3283    /// side-table marks) rather than persisted across calls, so cursor moves / CR-LF can't corrupt
3284    /// it (mirrors ghostty). Width promotion for a narrow base (a flag's second RI, a text-base +
3285    /// VS16) is handled by the caller in a later step; here the base's existing width holds.
3286    fn try_grapheme_join(&mut self, c: char) -> bool {
3287        let row = self.cursor.row;
3288        // Locate the previous cluster's base cell, exactly as `push_combining`: with pending-wrap
3289        // the cursor still sits on the last glyph; else step back one, and over a wide spacer.
3290        let col = if self.cursor.pending_wrap {
3291            self.cursor.col
3292        } else if self.cursor.col == 0 {
3293            return false; // nothing precedes on this row
3294        } else {
3295            self.cursor.col - 1
3296        };
3297        let col = if self.grid.cell(row, col).is_wide_spacer() {
3298            col.saturating_sub(1)
3299        } else {
3300            col
3301        };
3302        // Reconstruct the previous cluster's text: base scalar + any already-joined scalars.
3303        let mut prev = String::new();
3304        prev.push(self.grid.cell(row, col).c());
3305        if let Some(marks) = self.grid.row_ref(row).combining_at(col) {
3306            prev.extend(marks.iter().copied());
3307        }
3308        if !crate::grapheme::grapheme_extends(&prev, c) {
3309            return false;
3310        }
3311        // Join: ride the side-table (no new cell).
3312        self.grid.row_mut(row).push_combining(col, c);
3313        // Width promotion: a flag's second regional indicator, or a text-base + VS16, grows the
3314        // cluster to width 2. `UnicodeWidthStr` gives the cluster width (RI-pair → 2, VS16 → 2). If
3315        // the base cell is still narrow, widen it in place.
3316        let cluster_w = {
3317            prev.push(c);
3318            UnicodeWidthStr::width(prev.as_str())
3319        };
3320        if cluster_w == 2 && !self.grid.cell(row, col).is_wide() {
3321            self.promote_cluster_to_wide(row, col);
3322        } else if cluster_w == 1 && self.grid.cell(row, col).is_wide() {
3323            // The mirror case: a default-wide emoji + VS15 (text selector) shrinks to width 1.
3324            self.demote_cluster_to_narrow(row, col);
3325        }
3326        self.damage_span(row, col, col);
3327        true
3328    }
3329
3330    /// Shrink a wide cluster cell back to a single-width cell (#295): a default-wide emoji joined by
3331    /// VS15 (U+FE0E, the text selector) requests text presentation → width 1. Remove `WIDE_CHAR`,
3332    /// free the spacer, and back the cursor up over it (the inverse of `promote_cluster_to_wide`).
3333    fn demote_cluster_to_narrow(&mut self, row: usize, col: usize) {
3334        let cols = self.grid.cols();
3335        self.grid
3336            .cell_mut(row, col)
3337            .remove_flags(CellFlags::WIDE_CHAR);
3338        if col + 1 < cols {
3339            self.free_cell(row, col + 1); // free the now-unused spacer
3340        }
3341        // The cluster shrank 2→1: the cursor sat just past the wide cell (col+2, or pending-wrap on
3342        // the last column); it now sits just past the single-width cell at col+1.
3343        self.cursor.pending_wrap = false;
3344        self.cursor.col = (col + 1).min(cols - 1);
3345        self.damage_span(row, col, (col + 1).min(cols - 1));
3346    }
3347
3348    /// Widen a narrow base cell to a double-width cluster in place (#295): set `WIDE_CHAR`, write
3349    /// its spacer, and step the cursor over it. Only reached when a joining scalar (flag's 2nd RI,
3350    /// VS16) promotes the cluster to width 2. A base pinned at the last column has no room for a
3351    /// spacer — relocation is a later step; until then it stays narrow (rare, renders single-width).
3352    fn promote_cluster_to_wide(&mut self, row: usize, col: usize) {
3353        let cols = self.grid.cols();
3354        if col + 1 >= cols {
3355            // No spacer room at the last column: relocate the whole cluster to the next line as a
3356            // wide cell (the row soft-wraps), mirroring write_glyph's wide-at-boundary wrap (#303).
3357            self.relocate_cluster_wide(row, col);
3358            return;
3359        }
3360        // Overwriting col+1 with the spacer can orphan the far half of a WIDE glyph standing there
3361        // (the cursor may have been repositioned before the joining scalar arrived). Reset that
3362        // orphan, exactly as write_glyph does (2462-2470), so no dangling spacer survives.
3363        if self.grid.cell(row, col + 1).is_wide() && col + 2 < cols {
3364            self.free_cell(row, col + 2);
3365        }
3366        self.grid
3367            .cell_mut(row, col)
3368            .insert_flags(CellFlags::WIDE_CHAR);
3369        // The spacer is the lead's second half, so it takes the LEAD's extended attrs — the
3370        // hyperlink and underline colour riding the row's side maps — exactly as write_glyph
3371        // stamps both halves of a wide write. `pen.cell(' ')` carries neither (and the pen may
3372        // have moved on since the base was printed), so they are re-attached here; a base with
3373        // none clears whatever the overwritten column held (#521).
3374        let ext = self.grid.row_ref(row).ext_attrs_at(col);
3375        let mut spacer = self.cursor.pen.cell(' ');
3376        spacer.insert_flags(CellFlags::WIDE_CHAR_SPACER);
3377        *self.grid.cell_mut(row, col + 1) = spacer;
3378        self.grid.row_mut(row).set_ext_attrs(col + 1, ext);
3379        // The cursor sat at col+1 (just past the narrow base); move it over the new spacer, applying
3380        // the same last-column pending-wrap rule as a wide write.
3381        let new_col = col + 2;
3382        if new_col >= cols {
3383            self.cursor.col = cols - 1;
3384            self.cursor.pending_wrap = self.autowrap;
3385        } else {
3386            self.cursor.col = new_col;
3387        }
3388        self.damage_span(row, col, col + 1);
3389    }
3390
3391    /// Relocate a last-column narrow cluster to the next line as a wide cell (#303): its base +
3392    /// side-table marks move to `(next_row, 0..=1)` and the vacated last column becomes a soft-wrap
3393    /// (WRAPLINE + leading spacer), exactly as `write_glyph` wraps a wide glyph that can't fit. With
3394    /// autowrap off it stays narrow.
3395    ///
3396    /// The destination is an **overwrite**, so it owes the no-orphan repair every other overwrite
3397    /// site owes (#529, ADR-0025 D4) — see the comment at that site for why justerm restates it
3398    /// once per wide-writing path where the references get it structurally.
3399    ///
3400    /// The `cols < 2` arm is **unreachable since #547** —
3401    /// `MIN_COLUMNS = 2` is the floor on every path that sets a width — and is kept only as a
3402    /// bounds guard for the `col + 1` writes below, not as a described behaviour.
3403    fn relocate_cluster_wide(&mut self, row: usize, col: usize) {
3404        let cols = self.grid.cols();
3405        if cols < 2 || !self.autowrap || !self.wrapline_advances() {
3406            // Nowhere to place a wide cell — leave it narrow. `!wrapline_advances()` joins the
3407            // other two for the same reason: with no next row, the relocation would write the
3408            // cluster over columns 0-1 of the *current* row and destroy whatever is there.
3409            return;
3410        }
3411        // Capture the base cell (glyph + attrs), its marks, and its extended attrs before
3412        // vacating. The extended attrs (hyperlink, underline colour) must be read HERE and not
3413        // after the move: they live in the *source row's* side maps, and `wrapline()` below may
3414        // scroll — after which that row is a different (or recycled) `Row` (#521).
3415        let base = *self.grid.cell(row, col);
3416        let marks: Vec<char> = self
3417            .combining_at(row, col)
3418            .map(<[char]>::to_vec)
3419            .unwrap_or_default();
3420        let ext = self.grid.row_ref(row).ext_attrs_at(col);
3421        // Vacate the last column as a soft-wrap artefact — the same step `write_glyph` takes for a
3422        // wide glyph that cannot fit, and now literally the same code, so the two cannot drift
3423        // apart again (#528; they held opposite behaviours until then).
3424        self.vacate_for_wrap(row, col);
3425        // Advance to the next line (scrolls if at the bottom); cursor lands at col 0.
3426        self.wrapline();
3427        let nr = self.cursor.row;
3428        // The destination is an overwrite like any other, so it owes the same no-orphan repair
3429        // `write_glyph` performs for its own trailing column (#529, D4): the spacer about to land
3430        // on `(nr, 1)` half-destroys a wide glyph standing there, stranding its far half at
3431        // `(nr, 2)` — a `WIDE_CHAR_SPACER` with no lead to its left, still carrying the destroyed
3432        // glyph's hyperlink and underline colour. Asked *before* the writes, on the pre-write
3433        // state, exactly as `write_glyph`'s `last + 1` check is.
3434        //
3435        // Two of the three references have this exact site, and both repair it without a rule of
3436        // their own, because they write a pair as two *separate* cell writes and the repair lives
3437        // in the write:
3438        //   - xterm.js names the case outright — *"Combining character widens 1 column to 2. Move
3439        //     old character to next line."* (`InputHandler.ts:583-611` @ 699f553,
3440        //     `copyCellsFrom(oldRow, oldCol, 0, oldWidth, false)` at `:605-607`). The relocation
3441        //     leaves `x == 2`, so its once-per-run right-edge repair (`:668-669`) lands on exactly
3442        //     the orphaned column.
3443        //   - ghostty relocates in `Terminal.zig:1188-1252` @ e6e26e1 and reaches the repair
3444        //     through `cursorRight(1); printCell(0, .spacer_tail)` (`:1251-1252`) — that second
3445        //     `printCell` runs the `cell.wide != wide` switch (`:1484`) whose `.wide` arm clears
3446        //     the neighbouring lead's tail (`:1489-1499`).
3447        //   - alacritty has **no** counterpart: a width-0 codepoint returns early through
3448        //     `push_zerowidth` (`term/mod.rs:1069-1085` @ 852e971), so a cluster never changes
3449        //     width and nothing is ever relocated. Its orphan repair (`:994-1008`) is still the
3450        //     mechanism reference, reached the same way — one repair per `write_at_cursor`.
3451        // justerm writes both halves in one step, so the repair is not structural here and each
3452        // wide-writing path restates it — this is the third (`write_glyph`,
3453        // `promote_cluster_to_wide`, and now the relocation).
3454        //
3455        // What justerm does **not** copy is ghostty's reach-back at this site: its `.wide` arm
3456        // also clears the previous row's `.spacer_head` (`:1504-1506`, gated `cursor.y > 0 and
3457        // cursor.x <= 1`) — the very marker this relocation set seven statements earlier
3458        // (`:1200`). Derived from source, not executed. Suppressing it here is #534's rule
3459        // verbatim: a repair keyed on a state predicate must not fire while that state is
3460        // mid-construction.
3461        //
3462        // The other two obligations `write_glyph` carries are N/A here, recorded because an
3463        // unexplained omission is what gets re-litigated:
3464        //   - the *left*-orphan repair asks `col > 0`, and the lead lands at column 0.
3465        //   - `void_wrap_artefact_above(nr)` would clear a record that `vacate_for_wrap` **just
3466        //     set**, in both the advance case (`nr == row + 1`, so its target `nr - 1` is `row`)
3467        //     and the scroll case (`nr == row`, the source rotated up to `row - 1`). Firing it
3468        //     would be self-clobbering, not merely redundant — the same shape as #534's
3469        //     mid-construction rule. Measured after a repairing relocation: `is_row_wrapped(0)`
3470        //     and `(0, cols-1).is_leading_spacer()` both hold.
3471        //
3472        // `2 < cols` is a live bound, not defence in depth. The print paths cannot leave a
3473        // `WIDE_CHAR` lead in the last column — `write_glyph` wraps rather than write one there
3474        // and `promote_cluster_to_wide` relocates rather than promote in place — but `Row::resize`
3475        // can: the alt screen resizes without reflowing (#567), so truncating a row through a pair
3476        // strands its lead in the final column. The relocation then meets `is_wide() == true` at
3477        // `cols == 2`, and without the bound reads `(nr, 2)` on a two-column grid — an
3478        // out-of-bounds panic in a library, inside a consumer's process, reachable by shrinking a
3479        // window over a CJK glyph. Pinned by `min_columns.rs::
3480        // a_relocation_beside_a_truncated_wide_lead_does_not_index_past_the_row`.
3481        if 2 < cols && self.grid.cell(nr, 1).is_wide() {
3482            self.free_cell(nr, 2);
3483        }
3484        // Re-place the base as a wide lead + spacer, re-attaching the marks fresh (drop the combining
3485        // bit so push_combining starts a clean cluster at the new column).
3486        let mut lead = base;
3487        lead.set_combined(false);
3488        lead.insert_flags(CellFlags::WIDE_CHAR);
3489        *self.grid.cell_mut(nr, 0) = lead;
3490        for m in marks {
3491            self.grid.row_mut(nr).push_combining(0, m);
3492        }
3493        // Re-attach the extended attrs to BOTH halves at the new row. `lead` copied the base's
3494        // presence bits but not its map entries, so without this the bit is set with nothing
3495        // behind it — the read is gated and silently returns the default, and the frame stops
3496        // round-tripping (the cell encodes as linked with no index).
3497        self.grid.row_mut(nr).set_ext_attrs(0, ext);
3498        let mut spacer = self.cursor.pen.cell(' ');
3499        spacer.insert_flags(CellFlags::WIDE_CHAR_SPACER);
3500        *self.grid.cell_mut(nr, 1) = spacer;
3501        self.grid.row_mut(nr).set_ext_attrs(1, ext);
3502        // Cursor just past the wide cell (pending-wrap if it fills a 2-column row).
3503        if cols <= 2 {
3504            self.cursor.col = cols - 1;
3505            self.cursor.pending_wrap = self.autowrap;
3506        } else {
3507            self.cursor.col = 2;
3508            self.cursor.pending_wrap = false;
3509        }
3510        self.damage_span(nr, 0, 1);
3511    }
3512
3513    // ---- cursor movement (CSI A/B/C/D/G/d/H/f) -------------------------------
3514
3515    fn move_up(&mut self, n: usize) {
3516        self.cursor.row = self.cursor.row.saturating_sub(n);
3517        self.cursor.pending_wrap = false;
3518    }
3519
3520    fn move_down(&mut self, n: usize) {
3521        self.cursor.row = (self.cursor.row + n).min(self.grid.rows() - 1);
3522        self.cursor.pending_wrap = false;
3523    }
3524
3525    fn move_forward(&mut self, n: usize) {
3526        self.cursor.col = (self.cursor.col + n).min(self.grid.cols() - 1);
3527        self.cursor.pending_wrap = false;
3528    }
3529
3530    fn move_back(&mut self, n: usize) {
3531        self.cursor.col = self.cursor.col.saturating_sub(n);
3532        self.cursor.pending_wrap = false;
3533    }
3534
3535    fn set_col(&mut self, col: usize) {
3536        self.cursor.col = col.min(self.grid.cols() - 1);
3537        self.cursor.pending_wrap = false;
3538    }
3539
3540    fn set_row(&mut self, row: usize) {
3541        self.cursor.row = row.min(self.grid.rows() - 1);
3542        self.cursor.pending_wrap = false;
3543    }
3544
3545    fn goto(&mut self, row: usize, col: usize) {
3546        // Origin mode addresses rows relative to the scroll region's top margin
3547        // and clamps to its bottom; otherwise rows are absolute to the screen.
3548        let (offset, max_row) = if self.origin_mode {
3549            (self.scroll_top, self.scroll_bottom)
3550        } else {
3551            (0, self.grid.rows() - 1)
3552        };
3553        self.cursor.row = (row + offset).min(max_row);
3554        self.cursor.col = col.min(self.grid.cols() - 1);
3555        self.cursor.pending_wrap = false;
3556    }
3557
3558    // ---- erase (CSI J / K) ---------------------------------------------------
3559
3560    /// Clear cells `from..to` on `row`.
3561    ///
3562    /// Background Color Erase (BCE): erased cells carry the current SGR
3563    /// background only — fg and text attributes reset to default (matches
3564    /// xterm/alacritty, where the fill is `cursor.template.bg.into()`).
3565    ///
3566    /// **Cleared concern, with its validity condition — an empty range would break the pair
3567    /// invariant.** With `from == to` the first guard below still frees the lead at `from - 1`
3568    /// while the second is skipped (`to > from` is false) and the fill loop does nothing, so the
3569    /// spacer at `from` would survive its lead — an ADR-0025 D4 break, and the exact lead-less
3570    /// orphan the word walk must then treat as opaque. This is unreachable **as long as every
3571    /// caller passes a non-empty range**, which holds today: `ECH` clamps to
3572    /// `(col + n).min(cols)` with `n >= 1` (both `CSI X` and `CSI 0 X` erase one cell), and every
3573    /// `EL`/`ED` site passes `0..cols` or `0..=cursor`. A future caller that can pass an empty
3574    /// range must guard here first.
3575    fn clear_cells(&mut self, row: usize, from: usize, to: usize) {
3576        let cols = self.grid.cols();
3577        // Erasing either half of a pair that wrapped from the row above ends it, so that row's
3578        // artefact record is void (#534). `from <= 1` rather than `from == 0` because erasing from
3579        // column 1 destroys the spacer and the no-orphan repair below then frees the lead. ghostty
3580        // reaches the same row from its erase path — `Screen.splitCellBoundary`'s `x == 0 or x ==
3581        // 1` branch (`Screen.zig:1873` @ `e6e26e1`), called from `eraseChars` (`Terminal.zig:3159`).
3582        if from <= 1 && to > from && self.wrapped_pair_at_row_start(row) {
3583            self.void_wrap_artefact_above(row);
3584        }
3585        // Don't orphan a wide char straddling the erase boundary.
3586        if from > 0 && self.grid.cell(row, from).is_wide_spacer() {
3587            self.free_cell(row, from - 1);
3588        }
3589        if to > from && to < cols && self.grid.cell(row, to - 1).is_wide() {
3590            self.free_cell(row, to);
3591        }
3592
3593        let bg = self.cursor.pen.bg;
3594        for col in from..to {
3595            let cell = self.grid.cell_mut(row, col);
3596            cell.reset();
3597            cell.set_bg(bg);
3598        }
3599        if to > from {
3600            self.damage_span(row, from, to - 1);
3601        }
3602    }
3603
3604    /// End `row`'s soft wrap, because something just destroyed the content that was continuing
3605    /// onto the next row.
3606    ///
3607    /// Which verbs owe this is **not** derivable from the erased range — it is a per-verb rule,
3608    /// and both references spell it out call site by call site rather than inferring it:
3609    ///
3610    /// | verb | ends the wrap? | xterm | ghostty |
3611    /// |---|---|---|---|
3612    /// | `EL 0` (erase right) | **yes**, at any column | `ClearRight` → `LineClrWrapped` unconditionally (`util.c:1871`) | `cursorResetWrap()` in `eraseLine(.right)` |
3613    /// | `ECH` | **yes**, at any column | same `ClearRight` (`util.c:1961`) | `cursorResetWrap()` in `eraseChars` |
3614    /// | `DCH` | **yes** | `screen.c` | `cursorResetWrap()` — *"Our row's soft-wrap is always reset"* |
3615    /// | `EL 1` (erase left) | no | `ClearLeft`, no clear | no |
3616    /// | `ICH` | no | no | no |
3617    ///
3618    /// The shape behind the three that do: each destroys content **from the cursor rightward**, so
3619    /// "this row continues past its last column" can no longer be asserted. Erasing leftward or
3620    /// inserting blanks leaves the tail — and whatever it flowed into — intact.
3621    ///
3622    /// **`EL 2` is a deliberate divergence.** justerm ends the wrap; xterm does not (`ClearLine`,
3623    /// `util.c:1905`, has no `LineClrWrapped`) and ghostty copies that with a comment naming it —
3624    /// *"it seems like complete should reset the soft-wrap state of the line but in xterm it does
3625    /// not."* justerm differs because it *joins* logical lines for `accessible_text` / `search` /
3626    /// selection text, so a blanked-but-still-wrapped row visibly merges two lines in copy — a
3627    /// consequence xterm does not carry. Recorded rather than silently matched or silently
3628    /// Mark `row` as soft-wrapping into the next one — and damage the cell the bit rides on.
3629    ///
3630    /// The exact mirror of [`Term::end_wrap`], and it exists for the mirror of that function's
3631    /// reason. The flag lives on the `Row` (#538) and reaches a consumer only as the last cell's
3632    /// `WRAPLINE`, derived at encode time. Every other cell-carried fact changes when that cell is
3633    /// written, so damage covers it for free; this one does not, and a `Partial` frame would never
3634    /// ship the bit — a frame-mode consumer rebuilding logical lines from cells then keeps the two
3635    /// rows *split* forever, the exact dual of the "joined forever" that `end_wrap` guards.
3636    ///
3637    /// `end_wrap` took that obligation in #540; the set side never did. It stayed invisible because
3638    /// a wrap normally moves the cursor to the next row, and `frame_damage` tops the frame up with
3639    /// the old cursor cell. When a **scroll serves the wrap** the cursor keeps its row index, so
3640    /// nothing tops it up — which is how #557 surfaced it.
3641    ///
3642    /// Damaging here rather than at each caller is what keeps this true for set sites added later,
3643    /// the same argument `end_wrap`'s comment makes.
3644    fn begin_wrap(&mut self, row: usize) {
3645        self.grid.row_mut(row).set_wrapped(true);
3646        let last = self.grid.cols() - 1;
3647        self.damage_span(row, last, last);
3648    }
3649
3650    /// diverged; see #538.
3651    fn end_wrap(&mut self, row: usize) {
3652        self.grid.row_mut(row).set_wrapped(false);
3653        // The flag is stored on the `Row` but rides the wire on the row's **last cell**, derived
3654        // at encode time. Every other cell-carried fact changes only when that cell is written,
3655        // so damage covers it for free; this one does not, and a `Partial` frame would never
3656        // re-ship the bit — leaving a frame-mode consumer with two rows joined forever. Damaging
3657        // here rather than at each caller is what keeps that true for call sites added later.
3658        let last = self.grid.cols() - 1;
3659        self.damage_span(row, last, last);
3660        // The wrap artefact goes with the wrap. The marker's claim is "the last column is the
3661        // blank a width-2 glyph vacated **because this row continues onto the next**", so a row
3662        // that stops continuing cannot hold one (ADR-0025 D3 — position is part of the test, and
3663        // so is the wrap it is positioned in). Coupling the two here is what makes the row-shift
3664        // seams and every wrap-ending erase a single rule instead of a clear per verb: ghostty
3665        // couples them in one function the same way — `Screen.cursorResetWrap`
3666        // (`terminal/Screen.zig:1524` @ `e6e26e1`, spacer-head clear at `:1539-1545`), reached from
3667        // `deleteChars` / `eraseChars` / `eraseLine`. It early-returns on `if (!page_row.wrap)`;
3668        // this one clears unconditionally, which is strictly safer.
3669        //
3670        // Most callers erase through this column anyway, so the clear is redundant for them; the
3671        // ones it is *not* redundant for are the row-shift seams (#540's `shift_region`, which
3672        // ends a wrap without touching a cell) and `delete_chars`, whose marker rides the shift.
3673        // The leftward erases are the mirror case — they blank this column while the wrap
3674        // legitimately survives — and go through `drop_artefact_if_erased` instead.
3675        //
3676        // One wrap-ending path deliberately does *not* reach here: `shift_region`'s `top == 0`
3677        // seam, whose row is in scrollback rather than the grid. It couples the same two clears
3678        // inline; see the comment there.
3679        self.grid.cell_mut(row, last).clear_leading_spacer();
3680    }
3681
3682    /// The pair that wrapped into `row` is about to be destroyed or moved, so the artefact record
3683    /// on the row **above** it is void — drop it. **Call before the mutation.**
3684    ///
3685    /// The marker makes a claim with two clauses: this row soft-wraps (owned by `end_wrap`), and
3686    /// its last column is the blank *that specific pair* vacated. This is the second clause, and
3687    /// the rule behind every call site is one sentence: **the record survives only an in-place
3688    /// same-width overwrite.** Anything else that reaches columns 0/1 of the continuation — a
3689    /// narrow write, an erase, a shift in either direction — ends the pair the record was about,
3690    /// and a wide lead that arrives afterwards by some other route did not *wrap* from anywhere.
3691    ///
3692    /// Both references gate on that, and both gate on the state **before** the write rather than
3693    /// after it:
3694    ///
3695    /// - ghostty `Terminal.zig:1484` @ `e6e26e1` — the whole wide-repair `switch` sits under
3696    ///   `if (cell.wide != wide)`, so a wide glyph overwritten by another wide glyph skips it; the
3697    ///   reach-back stanza then appears in the `.wide` (`:1501-1506`) and `.spacer_tail`
3698    ///   (`:1529-1532`) arms only.
3699    /// - alacritty `term/mod.rs:994` @ `852e971` — the reach-back at `:1004-1008` is inside
3700    ///   `if cursor_cell.flags.intersects(WIDE_CHAR | WIDE_CHAR_SPACER)`, but with no
3701    ///   width-unchanged escape, so it drops a record that is still true. Alacritty is the outlier
3702    ///   of the two and justerm follows ghostty.
3703    ///
3704    /// Asking *after* the mutation instead looks equivalent and is not: it answers "is some wide
3705    /// lead standing at column 0", which a `DCH` that pulls the *next* wide glyph left also
3706    /// satisfies, and which a two-step placement (a narrow base promoted to wide by VS16 under
3707    /// mode 2027, or IRM's insert-then-write) satisfies only at the end. Both were measured
3708    /// disagreeing with the rule above before this took its current form.
3709    ///
3710    /// The erase and intra-row-shift call sites are **ported, not derived**: ghostty's
3711    /// `Screen.splitCellBoundary` (`Screen.zig:1831`, the `x == 0 or x == 1` branch at `:1873`)
3712    /// reaches up one row and clears the previous row's spacer head, and it is called from
3713    /// `deleteChars` (`Terminal.zig:3107-3109`) and `eraseChars` (`:3159-3160`). Only justerm's
3714    /// `ICH` site has no counterpart — ghostty's `insertBlanks` (`:2988`) calls it nowhere.
3715    ///
3716    /// `row == 0` does not mean "no row above": on the primary screen the text readers walk
3717    /// `[scrollback ++ grid]` as one buffer (`abs_floor() == 0`), so the row above grid row 0 is
3718    /// the last **scrollback** row and it can carry the marker. Alacritty reaches the same row for
3719    /// the same reason — its `topmost_line()` is `Line(-history_size)` (`grid/mod.rs:504`), so
3720    /// `point.line - 1` indexes into history; ghostty is the one that stops at the viewport
3721    /// (`cursor.y > 0`). On the alt screen `abs_floor()` is the screen top, so no join crosses the
3722    /// boundary and there is nothing to repair.
3723    ///
3724    /// No damage is owed by either branch, and for a stronger reason than #540's: the marker is a
3725    /// `content` bit outside `CONTENT_MARKER_MASK`, so `Cell::flags()` never sees it and it does
3726    /// not cross the wire at all. The `damage_span` below is defensive, not load-bearing.
3727    fn void_wrap_artefact_above(&mut self, row: usize) {
3728        if row > 0 {
3729            let last = self.grid.cols() - 1;
3730            if self.grid.cell(row - 1, last).is_leading_spacer() {
3731                self.grid.cell_mut(row - 1, last).clear_leading_spacer();
3732                self.damage_span(row - 1, last, last);
3733            }
3734        } else if !self.on_alt
3735            && let Some(cell) = self.scrollback.back_mut().and_then(|r| r.last_mut())
3736        {
3737            cell.clear_leading_spacer();
3738        }
3739    }
3740
3741    /// Is a wide pair standing at columns 0..=1 of `row` — i.e. is there a record for
3742    /// `void_wrap_artefact_above` to void? A cheap pre-mutation test the four call sites share, so
3743    /// the rule lives in one place rather than being re-derived per verb (ADR-0025 D2).
3744    fn wrapped_pair_at_row_start(&self, row: usize) -> bool {
3745        self.grid.cell(row, 0).is_wide()
3746    }
3747
3748    /// Drop a wide-wrap artefact marker that has outlived the wrap it belonged to, without
3749    /// touching the wrap itself.
3750    ///
3751    /// The mirror of the marker clean-up inside `end_wrap`, for the verbs that erase *leftward*:
3752    /// `EL 1` and `ED 1` correctly leave the wrap alone (the row's tail still flows onward), but
3753    /// they can still clear the last column, and then the artefact's blank turns into visible
3754    /// text that a reflow bakes in permanently. Only the marker goes; the wrap is the caller's
3755    /// business.
3756    fn drop_artefact_if_erased(&mut self, row: usize, from: usize, to: usize) {
3757        let last = self.grid.cols() - 1;
3758        if from <= last && to > last {
3759            self.grid.cell_mut(row, last).clear_leading_spacer();
3760        }
3761    }
3762
3763    /// Shift `[top..=bottom]` by one line — up unless `down` — and end the wraps the shift
3764    /// falsified. Every row-shifting verb (IL/DL/SU/SD and the region paths in LF/RI) goes
3765    /// through here so the repair cannot be forgotten at a call site (ADR-0025 D2).
3766    ///
3767    /// The wrap flag claims "this row continues into the **next** row", so it is a statement about
3768    /// *adjacency*, and rotating whole `Row`s keeps it true for free: both halves of a pair inside
3769    /// the region move by the same line, so the claim still describes the same neighbour. Only the
3770    /// two seams falsify it, where a row's next neighbour changed underneath it:
3771    ///
3772    /// - **`top - 1`**, just outside the region. Its continuation rotated away (up-shift) or was
3773    ///   pushed down (down-shift), so whatever now sits at `top` is a stranger. This is the seam
3774    ///   that merges two unrelated logical lines in copy/search/accessible text (#540's repro).
3775    /// - **the row that lost its continuation to the blank** — `bottom - 1` after an up-shift (the
3776    ///   blank lands at `bottom`), `bottom` after a down-shift (its continuation rotated up to
3777    ///   `top` and was blanked there). The down-shift form is the one that reaches *outside* the
3778    ///   region: the stale claim points at `bottom + 1`, a row the verb never touched.
3779    ///
3780    /// Damaging matters as much as clearing, and `end_wrap` does both: `top - 1` is outside the
3781    /// region, so the scroll op the caller records does not cover it and a `Partial` frame would
3782    /// never re-ship the derived `WRAPLINE` bit.
3783    ///
3784    /// **Each seam has exactly one exemption, and both are facts about the caller that this
3785    /// function cannot see** — which is why they are parameters rather than tests:
3786    ///
3787    /// - `evicts_to_scrollback` exempts the **top** seam: a linefeed pushes row 0 into scrollback,
3788    ///   so the readers' `[scrollback ++ grid]` walk finds the continuation one row further back
3789    ///   and adjacency survives.
3790    /// - `serves_wrap` exempts the **bottom** seam: the shift was asked for by `wrapline`, so the
3791    ///   blank it exposes at `bottom` is not a stranger that displaced a continuation — it *is*
3792    ///   the continuation, about to be written into (#557).
3793    ///
3794    /// Both are one-sided on purpose. A wrap-serving scroll still falsifies the top seam, and a
3795    /// scrollback-evicting linefeed still falsifies the bottom one when no wrap asked for it.
3796    ///
3797    /// **No reference implements this rule**, so it is derived rather than ported — ADR-0004, the
3798    /// spec is the authority for VT semantics, above any implementation:
3799    ///
3800    /// - **ghostty** clears the wrap on *every* row a full-width IL/DL touches
3801    ///   (`terminal/Terminal.zig:2746-2752`, `:2906-2912` @ `e6e26e1`). The clear runs *before* the
3802    ///   row swap at `:2936-2939`, so both ends stay false: an interior pair is split, not
3803    ///   preserved. It still never reaches the row above the shifted range.
3804    /// - **alacritty** has no `WRAPLINE` clear on any scroll path (@ `852e971`).
3805    /// - **xterm.js** splices whole line objects and never touches `isWrapped`
3806    ///   (`common/InputHandler.ts:1345-1402` @ `699f553`). Its opposite polarity — "I continue the
3807    ///   *previous* row" (`common/buffer/Buffer.ts:566-570`) — moves the exposure to the mirrored
3808    ///   seam rather than removing it: a spliced-in line keeps a continuation claim about a
3809    ///   predecessor it never met.
3810    ///
3811    /// The seam row's wide-wrap *marker* is the same shift's other half, and it now rides along:
3812    /// `end_wrap` clears both (#534), and the `top == 0` branch below — the one seam whose row is
3813    /// not a grid row — couples them inline for the same reason.
3814    ///
3815    /// **Validity condition for clearing at the seams rather than everywhere.** ghostty clears the
3816    /// wrap and the spacer head on *every* row a full-width IL/DL touches, and its own comment
3817    /// gives two reasons: it splits interior pairs, **and** it supports left/right margins
3818    /// (DECSLRM), where a partial-row shift can break an interior pair without moving its
3819    /// neighbour. justerm rotates whole `Row`s and implements no DECSLRM, so an interior pair and
3820    /// its continuation always move together and seam-only is sound. If left/right margins ever
3821    /// land, this rule and #534's marker rule break at the same time — neither is safe under a
3822    /// shift that moves part of a row.
3823    fn shift_region(
3824        &mut self,
3825        top: usize,
3826        bottom: usize,
3827        down: bool,
3828        evicts_to_scrollback: bool,
3829        serves_wrap: bool,
3830    ) {
3831        if down {
3832            self.grid.scroll_down_region(top, bottom);
3833        } else {
3834            self.grid.scroll_up_region(top, bottom);
3835        }
3836        // Recording the scroll op is part of shifting, not a step a caller adds after: damage is
3837        // indexed by row position, so `record_scroll` rotates `line_damage` with the content. A
3838        // seam clear damaged *before* that rotation is carried to the wrong row — and on a
3839        // down-shift it lands on `top`, which `record_scroll` immediately overwrites with
3840        // `fully_damaged`. The clear then never reaches the wire at all: the model splits the
3841        // rows, a `Partial` frame does not say so, and the consumer keeps them joined forever.
3842        // Ordering it here is what makes that unrepeatable at a sixth call site.
3843        self.record_scroll(top, bottom, if down { -1 } else { 1 });
3844        if top > 0 {
3845            self.end_wrap(top - 1);
3846        } else if !evicts_to_scrollback && !self.on_alt {
3847            // `top == 0` does not mean "no row above": on the primary the text readers walk
3848            // `[scrollback ++ grid]` as one buffer (`abs_floor() == 0`), so the row above grid row
3849            // 0 is the last *scrollback* row and it can wrap into the screen. A full-screen SU /
3850            // DL / RI therefore leaves this issue's defect one row higher, outside the grid.
3851            //
3852            // `evicts_to_scrollback` is what keeps `linefeed` out: it pushes grid row 0 into
3853            // scrollback, so the continuation is re-attached one row further back and the claim
3854            // stays true — clearing there would split a line the scroll preserved. On the alt
3855            // screen `abs_floor()` is the screen top, so no join crosses the boundary at all.
3856            //
3857            // No damage is owed with the clear, unlike `end_wrap`'s grid form: a scrollback row
3858            // only reaches the wire while `display_offset > 0`, and there `damage()` returns an
3859            // empty `Partial` (`term.rs`, the frozen-viewport short-circuit) while any scroll that
3860            // *moves* the viewport marks full damage. Valid as long as that short-circuit holds.
3861            //
3862            // The artefact marker goes with the wrap here exactly as it does in `end_wrap`, and
3863            // this branch is the reason that coupling cannot simply live in `end_wrap`: it is the
3864            // one wrap-ending path whose row is not a grid row, so it does not call it. Leaving it
3865            // out left #534's defect alive one row above the grid — reachable from every
3866            // `scroll_region_lines` verb, since all of them pass `evicts_to_scrollback: false`,
3867            // and visible as a word selection one cell too wide plus a reflow that bakes the
3868            // stranded marker mid-row.
3869            if let Some(row) = self.scrollback.back_mut() {
3870                row.set_wrapped(false);
3871                if let Some(cell) = row.last_mut() {
3872                    cell.clear_leading_spacer();
3873                }
3874            }
3875        }
3876        // The blank lands at `bottom` going up and at `top` going down, so the row that lost its
3877        // continuation is the one just above it. Going down that is `top - 1`, already cleared
3878        // above; going up it is `bottom - 1`, which for a one-row region is that same row.
3879        //
3880        // The up-shift form needs the `bottom + 1` guard, and it is not defensive — without it the
3881        // clear destroys a **live** wrap. A row at the screen's bottom edge that wraps is the
3882        // ordinary soft-wrap-at-the-last-row state: `wrapline` sets the flag and the linefeed
3883        // scrolls precisely so the continuation has somewhere to land, which is the *next* row
3884        // after this shift. Its claim is about a row that does not exist yet, so the shift makes it
3885        // true rather than false.
3886        //
3887        // **The rest of that guard's original rationale was too narrow, and #557 is what it cost.**
3888        // It read: *"the link is only broken when there is a stationary row below the region
3889        // (`bottom + 1 < rows`): then the continuation stayed put while its lead moved up."* A
3890        // stationary row below is **necessary but not sufficient**. At a *region's* bottom the same
3891        // wrapline-asked-for scroll happens with `bottom + 1 < rows` perfectly true, and the clear
3892        // then split the logical line the scroll existed to continue. The geometry was never the
3893        // discriminator; **why the shift is happening** is — which is what `serves_wrap` carries.
3894        //
3895        // The guard stays anyway: it is the screen-bottom case of the same fact, and it also holds
3896        // for a *non*-wrap-serving linefeed at the screen edge.
3897        //
3898        // One invariant is still worth naming, because it was not true when this guard was first
3899        // written: **a row only claims a wrap if a next row will exist for it**. A row parked below
3900        // a DECSTBM region kept a permanent false claim, and this guard preserved it — the #540
3901        // completeness pass merged two unrelated logical lines through exactly that hole. The claim
3902        // is now gated at its set site (`write_glyph` asks `wrapline_advances`), so the guard's
3903        // premise holds. Valid as long as that gate stays.
3904        let orphaned = if serves_wrap {
3905            // The blank this shift just exposed is the continuation the wrap is waiting for, so
3906            // there is nothing to falsify — see the `serves_wrap` note on `linefeed_inner` (#557).
3907            None
3908        } else if down {
3909            Some(bottom)
3910        } else if bottom + 1 < self.grid.rows() {
3911            bottom.checked_sub(1)
3912        } else {
3913            None
3914        };
3915        if let Some(row) = orphaned {
3916            self.end_wrap(row);
3917        }
3918    }
3919
3920    fn erase_display(&mut self, mode: u16) {
3921        let (cols, rows) = (self.grid.cols(), self.grid.rows());
3922        let (cr, cc) = (self.cursor.row, self.cursor.col);
3923        match mode {
3924            0 => {
3925                // Erases this row's tail and every row below, so nothing can continue from here
3926                // — and the rows below cannot continue either.
3927                self.clear_cells(cr, cc, cols);
3928                self.end_wrap(cr);
3929                for row in (cr + 1)..rows {
3930                    self.clear_cells(row, 0, cols);
3931                    self.end_wrap(row);
3932                }
3933            }
3934            1 => {
3935                // Leftward: this row's tail survives, so its own wrap does. The rows *above* are
3936                // gone entirely.
3937                for row in 0..cr {
3938                    self.clear_cells(row, 0, cols);
3939                    self.end_wrap(row);
3940                }
3941                self.clear_cells(cr, 0, cc + 1);
3942                self.drop_artefact_if_erased(cr, 0, cc + 1);
3943                // Covering the whole row means nothing continues from it. xterm.js has a
3944                // dedicated arm for exactly this case, in its own words: *"Deleted entire
3945                // previous line. This next line can no longer be wrapped."*
3946                // (`InputHandler.ts:1248-1252` — under its continuation polarity that assignment
3947                // is this engine's `end_wrap(cr)`.) `EL 1` has no such arm there, and none here.
3948                if cc + 1 == cols {
3949                    self.end_wrap(cr);
3950                }
3951            }
3952            2 => {
3953                for row in 0..rows {
3954                    self.clear_cells(row, 0, cols);
3955                    self.end_wrap(row);
3956                }
3957            }
3958            _ => {}
3959        }
3960    }
3961
3962    /// Erase in line (EL): 0 = cursor→end, 1 = start→cursor, 2 = whole line.
3963    fn erase_line(&mut self, mode: u16) {
3964        let cols = self.grid.cols();
3965        let (cr, cc) = (self.cursor.row, self.cursor.col);
3966        match mode {
3967            // Erase right — ends the wrap at any column (xterm's `ClearRight`).
3968            0 => {
3969                self.clear_cells(cr, cc, cols);
3970                self.end_wrap(cr);
3971            }
3972            // Erase left — the tail survives, so the wrap does. The artefact marker does not:
3973            // if the erase reached the last column it just blanked the cell the marker described.
3974            1 => {
3975                self.clear_cells(cr, 0, cc + 1);
3976                self.drop_artefact_if_erased(cr, 0, cc + 1);
3977            }
3978            // Erase the whole line — see `end_wrap`: a deliberate divergence from xterm.
3979            2 => {
3980                self.clear_cells(cr, 0, cols);
3981                self.end_wrap(cr);
3982            }
3983            _ => {}
3984        }
3985    }
3986
3987    // ---- intra-line editing (ICH / DCH / ECH) --------------------------------
3988
3989    /// ECH (CSI Pn X): erase `n` cells in place from the cursor — no shift.
3990    /// BCE-filled (via `clear_cells`); pending-wrap is left untouched.
3991    fn erase_chars(&mut self, n: usize) {
3992        let cols = self.grid.cols();
3993        let (row, col) = (self.cursor.row, self.cursor.col);
3994        let to = (col + n).min(cols);
3995        self.clear_cells(row, col, to);
3996        // Destroys content from the cursor rightward, so the row can no longer be continuing —
3997        // unconditionally, at any column and for any `n`. Both references do exactly this (see
3998        // `end_wrap`): xterm routes ECH through the same `ClearRight` as `EL 0`, ghostty calls
3999        // `cursorResetWrap()` in `eraseChars`.
4000        self.end_wrap(row);
4001    }
4002
4003    /// ICH (CSI Pn @): insert `n` blanks at the cursor, shifting the rest of the
4004    /// line right; cells pushed past the right edge are lost. The opened gap is
4005    /// BCE-filled; pending-wrap is left untouched.
4006    fn insert_chars(&mut self, n: usize) {
4007        let cols = self.grid.cols();
4008        let (r, col) = (self.cursor.row, self.cursor.col);
4009        let n = n.min(cols - col);
4010        if n == 0 {
4011            return;
4012        }
4013        // Shifting a wrapped pair out of columns 0/1 ends it, so the row above's artefact record
4014        // is void (#534). Asked **before** the shift, which is what keeps IRM correct: `write_glyph`
4015        // routes its wide-at-boundary insert through here *after* `vacate_for_wrap` has just set
4016        // the marker on the row above, and a post-shift test would see the freshly blanked gap and
4017        // clear the marker inside its own SET site's critical section. Pre-shift the question is
4018        // about the pair that was actually there, which is the one the record is about.
4019        if col <= 1 && self.wrapped_pair_at_row_start(r) {
4020            self.void_wrap_artefact_above(r);
4021        }
4022        let bg = self.cursor.pen.bg;
4023        let row = self.grid.row_mut(r);
4024        // Shift [col .. cols-n) right by n; the tail falls off the edge. The
4025        // combining map follows the moved cells (the bit travels with the raw
4026        // copy, the cluster data must too).
4027        row.copy_within(col..cols - n, col + n);
4028        row.move_maps(col..cols - n, col + n);
4029        for cell in &mut row[col..col + n] {
4030            cell.reset();
4031            cell.set_bg(bg);
4032        }
4033        // Repair wide-char halves split at the seams (no-orphan invariant):
4034        // a lead just before the gap lost its spacer; the first shifted cell may
4035        // be a spacer whose lead did not move.
4036        if col > 0 && self.grid.cell(r, col - 1).is_wide() {
4037            self.free_cell(r, col - 1);
4038        }
4039        if col + n < cols && self.grid.cell(r, col + n).is_wide_spacer() {
4040            self.free_cell(r, col + n);
4041        }
4042        // A lead shifted to the last column lost its spacer off the edge.
4043        if self.grid.cell(r, cols - 1).is_wide() {
4044            self.free_cell(r, cols - 1);
4045        }
4046        // Note ICH needs no repair to *this* row's marker: a right shift always pushes the last
4047        // column off the edge, so it discards a marker rather than carrying one inward —
4048        // measured, and pinned by `ich_discards_the_marker_off_the_edge`.
4049        self.damage_span(r, col, cols - 1);
4050    }
4051
4052    /// DCH (CSI Pn P): delete `n` cells at the cursor, shifting the tail left; the
4053    /// vacated cells at the right are BCE-blanked. Pending-wrap is left untouched.
4054    fn delete_chars(&mut self, n: usize) {
4055        let cols = self.grid.cols();
4056        let (r, col) = (self.cursor.row, self.cursor.col);
4057        let n = n.min(cols - col);
4058        if n == 0 {
4059            return;
4060        }
4061        // The shift pulls the tail left and blanks the far end, so the row stops continuing —
4062        // ghostty says it outright (*"Our row's soft-wrap is always reset"* in `deleteChars`,
4063        // `Terminal.zig:3133` @ `e6e26e1`).
4064        //
4065        // **Before the shift, not after** (#534): `end_wrap` clears the artefact marker at the
4066        // *last* column, and the marker is a cell bit that the shift carries inward with every
4067        // other cell. Ending the wrap afterwards would clear a column the marker has already left,
4068        // stranding it mid-row where it describes nothing (ADR-0025 D3) and silently swallows the
4069        // blank between two runs in copy, search and accessible text. Same shape as #540's
4070        // `record_scroll` ordering: the clear has to happen where the state still is.
4071        self.end_wrap(r);
4072        // Deleting a wrapped pair out of columns 0/1 ends it, so the row above's artefact record
4073        // is void — and this is where the "ask before, not after" rule earns its keep twice over:
4074        // a `DCH` can pull the *next* wide glyph left into column 0, which a post-shift "is a wide
4075        // lead standing here?" test happily accepts even though the pair the record was about has
4076        // been deleted. ghostty asks the same question at the same point:
4077        // `Screen.splitCellBoundary(cursor.x)` from `deleteChars` (`Terminal.zig:3107` @ `e6e26e1`),
4078        // whose `x == 0 or x == 1` branch reaches up a row and clears the spacer head.
4079        if col <= 1 && self.wrapped_pair_at_row_start(r) {
4080            self.void_wrap_artefact_above(r);
4081        }
4082        let bg = self.cursor.pen.bg;
4083        let row = self.grid.row_mut(r);
4084        // Shift [col+n .. cols) left to [col ..); BCE-fill the vacated tail. The
4085        // combining map follows the moved cells.
4086        row.copy_within(col + n..cols, col);
4087        row.move_maps(col + n..cols, col);
4088        for cell in &mut row[cols - n..cols] {
4089            cell.reset();
4090            cell.set_bg(bg);
4091        }
4092        // Repair wide-char halves split by the deletion (no-orphan invariant):
4093        // a lead just before the cut lost its spacer; the cell now at the cursor
4094        // may be a spacer whose lead was deleted.
4095        if col > 0 && self.grid.cell(r, col - 1).is_wide() {
4096            self.free_cell(r, col - 1);
4097        }
4098        if self.grid.cell(r, col).is_wide_spacer() {
4099            self.free_cell(r, col);
4100        }
4101        self.damage_span(r, col, cols - 1);
4102    }
4103
4104    // ---- line/region editing (IL / DL / SU / SD) -----------------------------
4105
4106    /// Scroll rows `[top..=bottom]` by `n` lines, BCE-filling the exposed lines.
4107    /// `down` inserts blanks at the top (content moves down); otherwise content
4108    /// moves up and blanks appear at the bottom. Reuses the one-line region scroll
4109    /// primitives (so damage + scroll-op accumulation come for free), then fills
4110    /// the exposed lines with the current SGR background.
4111    fn scroll_region_lines(&mut self, top: usize, bottom: usize, n: usize, down: bool) {
4112        let height = bottom - top + 1;
4113        let n = n.min(height);
4114        if n == 0 {
4115            return;
4116        }
4117        // Anchors (selection #3, markers #118/#158) live at absolute buffer lines;
4118        // SU/SD/IL/DL don't accrue scrollback, so `base` is stable across the loop.
4119        let base = self.scrollback.len();
4120        for _ in 0..n {
4121            self.shift_region(top, bottom, down, false, false);
4122            // Rotate anchors with the content, like `linefeed`/`reverse_index`
4123            // (#162). `up` = content moved up = the non-`down` case. Markers rotate
4124            // with the active buffer (#187) — alt-scoped on the alt screen, so no
4125            // guard; the selection is cleared on alt enter.
4126            self.selection_rotate_region(base + top, base + bottom, !down);
4127            self.markers_rotate_region(base + top, base + bottom, !down);
4128        }
4129        self.invalidate_search_highlights();
4130        // BCE-fill the n exposed lines (the primitives blank to default).
4131        let bg = self.cursor.pen.bg;
4132        let (fill_top, fill_end) = if down {
4133            (top, top + n)
4134        } else {
4135            (bottom + 1 - n, bottom + 1)
4136        };
4137        let cols = self.grid.cols();
4138        for r in fill_top..fill_end {
4139            for c in 0..cols {
4140                let cell = self.grid.cell_mut(r, c);
4141                cell.reset();
4142                cell.set_bg(bg);
4143            }
4144        }
4145    }
4146
4147    /// SU (CSI Pn S): scroll the scroll region up by `n`.
4148    fn scroll_up_lines(&mut self, n: usize) {
4149        self.scroll_region_lines(self.scroll_top, self.scroll_bottom, n, false);
4150    }
4151
4152    /// SD (CSI Pn T): scroll the scroll region down by `n`.
4153    fn scroll_down_lines(&mut self, n: usize) {
4154        self.scroll_region_lines(self.scroll_top, self.scroll_bottom, n, true);
4155    }
4156
4157    /// IL (CSI Pn L): insert `n` blank lines at the cursor, scrolling
4158    /// `[cursor..=scroll_bottom]` down. A no-op when the cursor is outside the
4159    /// scroll region.
4160    fn insert_lines(&mut self, n: usize) {
4161        let cur = self.cursor.row;
4162        if cur < self.scroll_top || cur > self.scroll_bottom {
4163            return;
4164        }
4165        self.scroll_region_lines(cur, self.scroll_bottom, n, true);
4166    }
4167
4168    /// DL (CSI Pn M): delete `n` lines at the cursor, scrolling
4169    /// `[cursor..=scroll_bottom]` up. A no-op when the cursor is outside the
4170    /// scroll region.
4171    fn delete_lines(&mut self, n: usize) {
4172        let cur = self.cursor.row;
4173        if cur < self.scroll_top || cur > self.scroll_bottom {
4174            return;
4175        }
4176        self.scroll_region_lines(cur, self.scroll_bottom, n, false);
4177    }
4178
4179    // ---- SGR (CSI m) ---------------------------------------------------------
4180
4181    fn sgr(&mut self, params: &Params) {
4182        let pen = &mut self.cursor.pen;
4183        let mut iter = params.iter();
4184        while let Some(param) = iter.next() {
4185            let code = param.first().copied().unwrap_or(0);
4186            match code {
4187                0 => pen.reset(),
4188                1 => pen.flags.insert(CellFlags::BOLD),
4189                2 => pen.flags.insert(CellFlags::DIM),
4190                3 => pen.flags.insert(CellFlags::ITALIC),
4191                4 => pen.flags.insert(CellFlags::UNDERLINE),
4192                5 => pen.flags.insert(CellFlags::BLINK),
4193                7 => pen.flags.insert(CellFlags::INVERSE),
4194                8 => pen.flags.insert(CellFlags::HIDDEN),
4195                9 => pen.flags.insert(CellFlags::STRIKETHROUGH),
4196                22 => pen.flags.remove(CellFlags::BOLD | CellFlags::DIM),
4197                23 => pen.flags.remove(CellFlags::ITALIC),
4198                24 => pen.flags.remove(CellFlags::UNDERLINE),
4199                25 => pen.flags.remove(CellFlags::BLINK),
4200                27 => pen.flags.remove(CellFlags::INVERSE),
4201                28 => pen.flags.remove(CellFlags::HIDDEN),
4202                29 => pen.flags.remove(CellFlags::STRIKETHROUGH),
4203                30..=37 => pen.fg = Color::Indexed((code - 30) as u8),
4204                38 => {
4205                    if let Some(c) = parse_extended_color(param, &mut iter) {
4206                        pen.fg = c;
4207                    }
4208                }
4209                39 => pen.fg = Color::Default,
4210                40..=47 => pen.bg = Color::Indexed((code - 40) as u8),
4211                48 => {
4212                    if let Some(c) = parse_extended_color(param, &mut iter) {
4213                        pen.bg = c;
4214                    }
4215                }
4216                49 => pen.bg = Color::Default,
4217                // Underline colour (SGR 58 / 59, #520) — same extended-colour grammar
4218                // as 38/48 (colon `58:2:r:g:b` / `58:5:n`, or legacy semicolon), so it
4219                // reuses `parse_extended_color` verbatim. 59 returns to "follow the fg".
4220                58 => {
4221                    if let Some(c) = parse_extended_color(param, &mut iter) {
4222                        pen.underline_color = c;
4223                    }
4224                }
4225                59 => pen.underline_color = Color::Default,
4226                // bright foreground/background (aixterm) → palette 8..=15.
4227                90..=97 => pen.fg = Color::Indexed((code - 90 + 8) as u8),
4228                100..=107 => pen.bg = Color::Indexed((code - 100 + 8) as u8),
4229                _ => {}
4230            }
4231        }
4232    }
4233}
4234
4235/// Parse `38`/`48`/`58` extended colour (foreground / background / underline colour, #520), in
4236/// either form:
4237/// - sub-parameter (colon) form inline in `param`: `38:5:n`, `38:2:r:g:b`
4238///   (optionally `38:2:cs:r:g:b` with a colorspace id), or
4239/// - legacy (semicolon) form: pull the following top-level params from `iter`.
4240///
4241/// The colon RGB form is **count-based** (`off = if param.len() >= 6 { 3 } else { 2 }`): a 5-param
4242/// `38:2:r:g:b` (no colorspace slot) reads RGB(r,g,b) directly, while a 6-param `38:2:cs:r:g:b` — or
4243/// `38:2::r:g:b` with an *empty* cs, the form kitty/nvim actually emit — skips the colorspace slot.
4244/// The short 5-param form is **non-conformant to T.416 / ISO-8613-6** (the de-jure standard always
4245/// carries a colorspace field), but tolerating it is the **ecosystem-dominant** behaviour, verified
4246/// against real source (2026-07, #520): VTE (`src/sgr.hh`, branches on `n > 4`), foot (`csi.c`,
4247/// `sub.idx >= 5`) and alacritty (`ansi.rs`, `params.len() > 4`) all count the sub-parameters and
4248/// decode the short form as RGB(r,g,b), exactly as here. VTE's own comment calls it a "common
4249/// misinterpretation of the standard" (foot: "bastard version") that it supports anyway; **only
4250/// xterm.js is strict** (always consumes a colorspace slot, so it misreads the short form). So a
4251/// difference from xterm here is deliberate leniency shared with the non-xterm ecosystem, not a
4252/// defect — the ADR-0004 spec-faithfulness is about not *omitting* behaviour, not about rejecting a
4253/// widely-emitted non-standard input.
4254fn parse_extended_color<'a, I>(param: &[u16], iter: &mut I) -> Option<Color>
4255where
4256    I: Iterator<Item = &'a [u16]>,
4257{
4258    if param.len() > 1 {
4259        // Colon sub-parameter form: kind is param[1].
4260        match param[1] {
4261            2 => {
4262                // 38:2:r:g:b (len 5) or 38:2:cs:r:g:b (len 6, colorspace skipped).
4263                let off = if param.len() >= 6 { 3 } else { 2 };
4264                let r = *param.get(off)? as u8;
4265                let g = *param.get(off + 1)? as u8;
4266                let b = *param.get(off + 2)? as u8;
4267                Some(Color::Rgb(r, g, b))
4268            }
4269            5 => Some(Color::Indexed(*param.get(2)? as u8)),
4270            _ => None,
4271        }
4272    } else {
4273        // Legacy semicolon form: kind, then its operands, are separate params.
4274        match iter.next()?.first().copied()? {
4275            2 => {
4276                let r = iter.next()?.first().copied()? as u8;
4277                let g = iter.next()?.first().copied()? as u8;
4278                let b = iter.next()?.first().copied()? as u8;
4279                Some(Color::Rgb(r, g, b))
4280            }
4281            5 => Some(Color::Indexed(iter.next()?.first().copied()? as u8)),
4282            _ => None,
4283        }
4284    }
4285}
4286
4287/// Reflow one screen (joined with its `scrollback`) to `cols` x `rows`, tracking
4288/// `point` (a cursor in screen coordinates). Returns the new screen rows, the new
4289/// scrollback (capped to `limit`), and the new point. The alt screen passes an
4290/// empty scrollback and discards the returned one.
4291/// The fixed dimensions a resize reflows toward.
4292#[derive(Clone, Copy)]
4293struct ReflowDims {
4294    old_cols: usize,
4295    cols: usize,
4296    rows: usize,
4297    limit: usize,
4298    /// Whether a column change may **re-split** this pane's content, or only re-fit its rows.
4299    ///
4300    /// False for the alt screen (#567). Reflow re-splits a long line so history stays readable at
4301    /// the new width — it assumes the content is text that *flows*. The alt screen has no history,
4302    /// its content is a **layout** rather than a paragraph (re-wrapping htop's columns means
4303    /// nothing), and the application already knows the new size and repaints. All three references
4304    /// take the same position with the same shape — one flag on the same resize function:
4305    /// ghostty `alt.resize(.{ .reflow = false })`, alacritty `grid.resize(!is_alt, …)`, xterm.js
4306    /// gating on `_hasScrollback` with the alt buffer built as `new Buffer(false, …)`.
4307    ///
4308    /// It is not merely wasted work: measured on a real `htop` recording taken across a live
4309    /// `SIGWINCH`, re-splitting leaves debris in the cells htop does not overwrite, because htop
4310    /// repaints **without** clearing. `vim` hides it by erasing first.
4311    reflow: bool,
4312}
4313
4314/// The result of reflowing one pane.
4315struct PaneReflow {
4316    screen: Vec<Row>,
4317    scrollback: VecDeque<Row>,
4318    /// The cursor's new screen-relative position.
4319    cursor: (usize, usize),
4320    /// Each tracked extra point's new position **in this pane's own `[history ++ screen]` frame**,
4321    /// index-aligned with the `extra_abs` argument — *before* any history the caller discards.
4322    ///
4323    /// Reported raw, with `evicted` beside it, because the two callers translate differently and
4324    /// doing it here silently picked the primary's answer for both: the primary keeps its history,
4325    /// so an extra's absolute line only moves by what the cap threw away, while the alt pane has no
4326    /// history at all and everything above the screen is *gone*. Adding the alt result to the
4327    /// primary's scrollback length then produced a line the buffer does not have — reachable
4328    /// without any reflow, on a rows-only resize.
4329    extras: Vec<(usize, usize)>,
4330    /// Rows that left the buffer entirely off the front of this pane's history. For the primary
4331    /// that is the scrollback cap's eviction; for the alt pane, whose limit is `0` because it has
4332    /// no history, it is every row the shrink pushed off the top. An extra whose raw line is below
4333    /// this **is not in the buffer any more** — the caller decides what that means for its kind.
4334    evicted: usize,
4335}
4336
4337/// Reflow one pane (its `scrollback` joined with `screen`) to `dims`, tracking
4338/// the screen-relative cursor `point` plus any `extra_abs` points given in
4339/// **absolute** `[scrollback ++ screen]` coordinates (selection anchors).
4340fn reflow_pane(
4341    screen: Vec<Row>,
4342    scrollback: VecDeque<Row>,
4343    point: (usize, usize),
4344    extra_abs: &[(usize, usize)],
4345    dims: ReflowDims,
4346) -> PaneReflow {
4347    let scroll_len = scrollback.len();
4348    let mut all: Vec<Row> = scrollback.into();
4349    all.extend(screen);
4350
4351    // The cursor is screen-relative; lift it to absolute, then track it together
4352    // with the already-absolute extras.
4353    let mut pts: Vec<(usize, usize)> = Vec::with_capacity(1 + extra_abs.len());
4354    pts.push((scroll_len + point.0, point.1));
4355    pts.extend_from_slice(extra_abs);
4356
4357    let pts = if dims.reflow && dims.cols != dims.old_cols {
4358        let (reflowed, np) = crate::grid::reflow(all, dims.cols, &pts);
4359        all = reflowed;
4360        np
4361    } else {
4362        pts
4363    };
4364
4365    // The cursor can land one row past everything the reflow emitted — "just after the content"
4366    // when the content ends on a full row (#562). That row is real, and while the pane is shorter
4367    // than the screen the caller's fit supplies it for free. When the content already fills the
4368    // pane it has to be bought, and the price is one row of history: the pane **scrolls**, which is
4369    // what a terminal does when content grows past the bottom. Without it the cursor was pulled
4370    // back onto the last glyph and the next byte destroyed a character — the ordinary shell shape,
4371    // a prompt at the bottom of a full screen.
4372    //
4373    // Five earlier designs made `reflow` itself materialise the row and were rejected on
4374    // measurements (a cursor at column 59 resized to width 4 emptied the buffer; a blank-line
4375    // exemption turned 22 alt lines into 21). `reflow` cannot see this pane's budget, so it spent
4376    // what it did not have. Here the budget is in scope, and it is the gate: a pane with no history
4377    // cannot pay — the displaced row would be destroyed rather than archived — so it keeps clamping.
4378    //
4379    // `limit > 0`, deliberately, and not "is this the alt screen": since #567 the alt panes pass
4380    // `limit: 0` because that is what an alt screen's history is, so they are excluded by the budget
4381    // rather than by a branch. That branch is what the design carrying this rule was rejected for
4382    // needing.
4383    //
4384    // This **amends** ADR-0025 rather than reading it narrowly: `reflow` does not create rows; the
4385    // seam may, when the pane can pay. What that record measured is that materialising
4386    // *unconditionally* destroys content.
4387    let cursor_abs = pts[0].0 + usize::from(pts[0].1 == dims.cols);
4388    if dims.limit > 0 {
4389        while all.len() <= cursor_abs {
4390            all.push(Row::blank(dims.cols));
4391        }
4392    }
4393
4394    let split = all.len().saturating_sub(dims.rows);
4395    let history: Vec<Row> = all.drain(0..split).collect();
4396    let mut sb: VecDeque<Row> = history.into();
4397    let mut dropped = 0usize;
4398    while sb.len() > dims.limit {
4399        sb.pop_front();
4400        dropped += 1;
4401    }
4402
4403    // `reflow` may answer `col == cols` — "just after the last cell", which is a real place in the
4404    // logical line and no place in the grid (#562). The **cursor's** reading of it is the next
4405    // *write* position, so a full row means the start of the row after; the caller's row fit
4406    // provides that row (`Grid::set_screen` pads at the bottom). A mark reads the same value the
4407    // opposite way and keeps it verbatim — see `Term::resize`.
4408    let cursor_row = pts[0].0.saturating_sub(split);
4409    let cursor = if pts[0].1 == dims.cols {
4410        (cursor_row + 1, 0)
4411    } else {
4412        (cursor_row, pts[0].1)
4413    };
4414
4415    // The bound on a tracked line belongs **here**, not inside `reflow`: this is where the final
4416    // geometry is known. The screen is padded to `dims.rows` whatever `reflow` emitted, so this
4417    // pane's last addressable line is `split + dims.rows - 1`. Bounding against `reflow`'s own row
4418    // count instead clamped away rows the fit was about to create (#562), while still being the
4419    // only thing standing between an out-of-range anchor and a panic in the consumer's process —
4420    // selection anchors and marks are written back raw, unlike the cursor (`Cursor::set_point`).
4421    // Expressed in this pane's own frame, so it is the same frame `extras` and `evicted` are in.
4422    let max_line = split + dims.rows - 1;
4423
4424    // The cursor returns to screen-relative (its absolute index minus the history split). The
4425    // extras stay in this pane's frame — see the field docs for why they are not shifted here.
4426    PaneReflow {
4427        cursor,
4428        extras: pts[1..]
4429            .iter()
4430            .map(|&(l, c)| (l.min(max_line), c))
4431            .collect(),
4432        evicted: dropped,
4433        screen: all,
4434        scrollback: sb,
4435    }
4436}
4437
4438/// Whether `c` ends a word for Word (semantic) selection. Whitespace plus a
4439/// punctuation set mirroring Alacritty's default `semantic_escape_chars`, so
4440/// path/URL-ish runs (`.`, `/`, `-`) stay one word.
4441fn is_word_boundary(c: char) -> bool {
4442    c.is_whitespace() || ",│`|:\"'()[]{}<>".contains(c)
4443}
4444
4445/// Whether the run `hay[i..i+len]` is bounded by non-word characters on both sides — the `\bword\b`
4446/// sense for whole-word search (#314). A word char is alphanumeric or `_` (the regex `\w` set),
4447/// deliberately distinct from `is_word_boundary`'s wider semantic-selection set.
4448fn word_bounded(hay: &[char], i: usize, len: usize) -> bool {
4449    // A word char is alphanumeric, `_`, OR a grapheme-extending mark (width 0: combining marks,
4450    // ZWJ, variation selectors) — so a mark attached to a base is never read as a word boundary,
4451    // matching the regex `\b` sense (`\w` includes `\p{M}`) and staying consistent across the
4452    // literal and regex paths on decomposed graphemes (#314 Lens 1).
4453    let is_word = |c: char| c.is_alphanumeric() || c == '_' || c.width() == Some(0);
4454    let left = i == 0 || !is_word(hay[i - 1]);
4455    let right = i + len == hay.len() || !is_word(hay[i + len]);
4456    left && right
4457}
4458
4459/// Default tab stops: one every 8 columns (incl. column 0), matching xterm.
4460fn default_tabs(cols: usize) -> Vec<bool> {
4461    (0..cols).map(|i| i % 8 == 0).collect()
4462}
4463
4464/// First sub-parameter of CSI param `idx`, or `default` when absent or zero
4465/// (a zero/omitted numeric param means "1" for cursor movement and "0" for
4466/// erase — callers pass the right default).
4467fn param_or(params: &Params, idx: usize, default: u16) -> u16 {
4468    match params.iter().nth(idx).and_then(|p| p.first().copied()) {
4469        Some(v) if v != 0 => v,
4470        _ => default,
4471    }
4472}
4473
4474impl Term {
4475    /// Apply one DEC private mode set (`'h'`) or reset (`'l'`). DECSET/DECRST
4476    /// carry a list of modes, so `csi_dispatch` folds this over every parameter
4477    /// (#56); each mode is an independent toggle, not a stack.
4478    fn set_dec_private_mode(&mut self, action: char, mode: u16) {
4479        match (action, mode) {
4480            ('h', 1049) => self.enter_alt_screen(),
4481            ('l', 1049) => self.leave_alt_screen(),
4482            // Legacy alt-screen variants (#72): ?47/?1047 switch the buffer
4483            // without saving the cursor; ?1048 saves/restores the cursor without
4484            // switching. ?1049 is the two combined.
4485            ('h', 47) | ('h', 1047) => self.switch_to_alt(),
4486            ('l', 47) | ('l', 1047) => self.switch_to_primary(),
4487            ('h', 1048) => self.save_alt_cursor(),
4488            ('l', 1048) => self.restore_alt_cursor(),
4489            ('h', 6) => {
4490                // DECOM: set homes the cursor to the region top.
4491                self.origin_mode = true;
4492                self.goto(0, 0);
4493            }
4494            ('l', 6) => self.origin_mode = false, // unset leaves the cursor put
4495            ('h', 7) => self.autowrap = true,     // DECAWM
4496            ('l', 7) => self.autowrap = false,
4497            ('h', 45) => self.reverse_wraparound = true, // reverse wraparound (#80)
4498            ('l', 45) => self.reverse_wraparound = false,
4499            // DECCOLM (#82): the engine is dimension-free, so emit a request the
4500            // consumer may honor by resizing — no screen/cursor/margin change here.
4501            ('h', 3) => self.events.push(TermEvent::ColumnMode { cols: 132 }),
4502            ('l', 3) => self.events.push(TermEvent::ColumnMode { cols: 80 }),
4503            ('h', 25) => self.cursor.visible = true, // DECTCEM show
4504            ('l', 25) => self.cursor.visible = false, // DECTCEM hide
4505            ('h', 12) => self.cursor.blink = true,   // att610 cursor blink (#81)
4506            ('l', 12) => self.cursor.blink = false,
4507            ('h', 2004) => self.bracketed_paste = true,
4508            ('l', 2004) => self.bracketed_paste = false,
4509            ('h', 2026) => self.synchronized_output = true, // synchronized output (#73)
4510            ('l', 2026) => self.synchronized_output = false,
4511            ('h', 2027) => self.grapheme_clustering = true, // grapheme-cluster mode (#295)
4512            ('l', 2027) => self.grapheme_clustering = false,
4513            ('h', 2031) => self.color_scheme_updates = true, // color-scheme notifications (#85)
4514            ('l', 2031) => self.color_scheme_updates = false,
4515            ('h', 9001) => self.win32_input_mode = true, // win32-input-mode (#86)
4516            ('l', 9001) => self.win32_input_mode = false,
4517
4518            // Input-encoding modes (#11): DECCKM, mouse tracking + encoding,
4519            // focus reporting. Each set assigns the level; each reset clears
4520            // it (apps enable/disable the same mode, not a stack).
4521            ('h', 1) => self.app_cursor_keys = true, // DECCKM
4522            ('l', 1) => self.app_cursor_keys = false,
4523            ('h', 66) => self.application_keypad = true, // DECNKM (#74)
4524            ('l', 66) => self.application_keypad = false,
4525            // DECANM (#84): set = ANSI (the normal state); reset enters VT52. Only
4526            // the reset is meaningful — `?2h` is a no-op (already ANSI).
4527            ('l', 2) => self.vt52_mode = true,
4528            ('h', 9) => self.mouse_protocol = MouseProtocol::X10, // X10 mouse (#70)
4529            ('h', 1000) => self.mouse_protocol = MouseProtocol::Normal,
4530            ('h', 1002) => self.mouse_protocol = MouseProtocol::ButtonEvent,
4531            ('h', 1003) => self.mouse_protocol = MouseProtocol::AnyEvent,
4532            ('l', 9) | ('l', 1000) | ('l', 1002) | ('l', 1003) => {
4533                self.mouse_protocol = MouseProtocol::Off
4534            }
4535            ('h', 1006) => self.mouse_encoding = MouseEncoding::Sgr,
4536            ('l', 1006) => self.mouse_encoding = MouseEncoding::Default,
4537            ('h', 1015) => self.mouse_encoding = MouseEncoding::Urxvt,
4538            ('l', 1015) => self.mouse_encoding = MouseEncoding::Default,
4539            ('h', 1005) => self.mouse_encoding = MouseEncoding::Utf8,
4540            ('l', 1005) => self.mouse_encoding = MouseEncoding::Default,
4541            ('h', 1016) => self.mouse_encoding = MouseEncoding::SgrPixels,
4542            ('l', 1016) => self.mouse_encoding = MouseEncoding::Default,
4543            ('h', 1004) => self.focus_events = true,
4544            ('l', 1004) => self.focus_events = false,
4545
4546            _ => {} // other DEC modes are later slices
4547        }
4548    }
4549
4550    /// Dispatch one VT52 escape sequence (`ESC <final>`), reached only while
4551    /// `vt52_mode` is set (#84). VT52 is a pre-ANSI dialect: the cursor/erase
4552    /// finals map to the same `Term` primitives the ANSI path uses. `ESC <`
4553    /// returns to ANSI. Unknown finals are ignored.
4554    fn vt52_dispatch(&mut self, byte: u8) {
4555        match byte {
4556            b'A' => self.move_up(1),         // cursor up
4557            b'B' => self.move_down(1),       // cursor down
4558            b'C' => self.move_forward(1),    // cursor right
4559            b'D' => self.move_back(1),       // cursor left
4560            b'H' => self.goto(0, 0),         // cursor home
4561            b'I' => self.reverse_index(),    // reverse line feed
4562            b'J' => self.erase_display(0),   // erase cursor → end of screen
4563            b'K' => self.erase_line(0),      // erase cursor → end of line
4564            b'Y' => self.vt52_y_pending = 2, // direct address: two coord bytes follow
4565            // Identify (DECID): reply `ESC / Z` — "I am a VT52".
4566            b'Z' => self.replies.extend_from_slice(b"\x1b/Z"),
4567            b'=' => self.application_keypad = true, // enter alternate keypad
4568            b'>' => self.application_keypad = false, // exit alternate keypad
4569            b'<' => self.vt52_mode = false,         // exit VT52, return to ANSI
4570            // RIS (`ESC c`) is honored even here: it is a hard "recover from any
4571            // state" reset, and `full_reset` rebuilds `Term` with `vt52_mode`
4572            // cleared, so RIS always escapes VT52 back to ANSI. VT52 defines no
4573            // other meaning for `ESC c`.
4574            b'c' => self.full_reset(),
4575            // Graphics mode (`ESC F`/`ESC G`) is a documented non-goal: the VT52
4576            // graphics glyph set differs from DEC Special Graphics, so reusing that
4577            // charset would render the wrong glyphs. No-op rather than approximate.
4578            b'F' | b'G' => {}
4579            _ => {} // unknown VT52 finals are ignored
4580        }
4581    }
4582
4583    /// Consume one `ESC Y` coordinate byte (#84). The first byte is the row, the
4584    /// second the column; each decodes as `value - 0x20`. On the second byte the
4585    /// cursor is addressed (`goto` clamps out-of-range coordinates). Reached only
4586    /// from `print` while `vt52_y_pending > 0`.
4587    fn vt52_take_coord(&mut self, c: char) {
4588        let coord = (c as usize).saturating_sub(0x20);
4589        if self.vt52_y_pending == 2 {
4590            self.vt52_y_row = coord;
4591            self.vt52_y_pending = 1;
4592        } else {
4593            self.vt52_y_pending = 0;
4594            self.goto(self.vt52_y_row, coord);
4595        }
4596    }
4597}
4598
4599impl Perform for Term {
4600    fn print(&mut self, c: char) {
4601        // VT52 `ESC Y` direct addressing (#84): vte delivers the two coordinate
4602        // bytes here (it returned to ground after the `Y` final), so intercept
4603        // them before they would be written as glyphs.
4604        if self.vt52_y_pending > 0 {
4605            self.vt52_take_coord(c);
4606            return;
4607        }
4608        // Translate through the active (GL) character set first (#62): under DEC
4609        // Special Graphics a printable byte becomes a line-drawing glyph.
4610        let c = self.charsets[self.gl].map(c);
4611        // Grapheme-cluster mode (DEC ?2027, #295): if `c` extends the previous cell's cluster,
4612        // join it there instead of placing a new cell. OFF → the per-char (wcwidth) path below.
4613        if self.grapheme_clustering && self.try_grapheme_join(c) {
4614            return;
4615        }
4616        match c.width() {
4617            // Zero-width (combining marks): the grapheme-cluster side-table is a
4618            // later slice; drop for now rather than mis-place it as its own cell.
4619            // A zero-width code point is a combining mark — attach it to the
4620            // previous base glyph rather than dropping it.
4621            Some(0) => self.push_combining(c),
4622            None => {}
4623            Some(width) => self.write_glyph(c, width),
4624        }
4625    }
4626
4627    fn execute(&mut self, byte: u8) {
4628        match byte {
4629            // LF, VT, FF all line-feed.
4630            b'\n' | 0x0b | 0x0c => self.linefeed(),
4631            b'\r' => self.carriage_return(),
4632            0x08 => self.backspace(),
4633            b'\t' => self.put_tab(),
4634            0x07 => self.events.push(TermEvent::Bell), // BEL (#12)
4635            0x0e => self.gl = 1,                       // SO (LS1): GL = G1 (#62)
4636            0x0f => self.gl = 0,                       // SI (LS0): GL = G0
4637            _ => {}
4638        }
4639    }
4640
4641    fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, action: char) {
4642        // Kitty keyboard-protocol negotiation: CSI > / = / < / ? ... u. The
4643        // leading intermediate distinguishes it from plain `CSI u` (SCORC) (#23).
4644        if action == 'u'
4645            && let Some(&lead) = intermediates.first()
4646            && matches!(lead, b'>' | b'<' | b'=' | b'?')
4647        {
4648            self.kitty_dispatch(lead, params);
4649            return;
4650        }
4651        // DEC private modes arrive with a '?' intermediate.
4652        if intermediates.first() == Some(&b'?') {
4653            // DECRQM (CSI ? Ps $ p) — report whether mode Ps is set. The '$'
4654            // intermediate distinguishes it from a plain `?...p`. It queries a
4655            // single mode, so it keys off the first parameter only.
4656            if action == 'p' && intermediates.contains(&b'$') {
4657                self.decrqm(param_or(params, 0, 0));
4658                return;
4659            }
4660            // Private DSR (CSI ? Ps n): ?996 = color-scheme query (#85). The
4661            // theme-agnostic engine relays it as an event for the consumer.
4662            if action == 'n' {
4663                if param_or(params, 0, 0) == 996 {
4664                    self.events.push(TermEvent::ColorSchemeQuery);
4665                }
4666                return;
4667            }
4668            // DECSET/DECRST carry a *list* of modes; apply set/reset to EVERY
4669            // parameter, not just the first — htop batches `?1006;1000h` into one
4670            // CSI, so folding only params[0] dropped the 1000 (#56).
4671            for mode in params.iter().filter_map(|p| p.first().copied()) {
4672                self.set_dec_private_mode(action, mode);
4673            }
4674            return;
4675        }
4676        // DECSTR soft reset: CSI ! p (#53).
4677        if intermediates.first() == Some(&b'!') && action == 'p' {
4678            self.soft_reset();
4679            return;
4680        }
4681        // DECSCUSR set cursor style: CSI Ps SP q (space intermediate) (#89). An
4682        // absent param means 1 (block blink); an explicit 0 means reset — so the
4683        // raw value matters and `param_or` (which folds 0 to its default) is wrong.
4684        if intermediates.first() == Some(&b' ') && action == 'q' {
4685            let param = params.iter().next().and_then(|p| p.first().copied());
4686            self.set_cursor_style(param.unwrap_or(1));
4687            return;
4688        }
4689        // Other private/intermediate sequences are later slices; ignore them
4690        // rather than misinterpret.
4691        if !intermediates.is_empty() {
4692            return;
4693        }
4694        match action {
4695            'A' => self.move_up(param_or(params, 0, 1) as usize),
4696            'B' | 'e' => self.move_down(param_or(params, 0, 1) as usize),
4697            'C' | 'a' => self.move_forward(param_or(params, 0, 1) as usize),
4698            'D' => self.move_back(param_or(params, 0, 1) as usize),
4699            'G' | '`' => self.set_col(param_or(params, 0, 1) as usize - 1),
4700            'd' => self.set_row(param_or(params, 0, 1) as usize - 1),
4701            'H' | 'f' => {
4702                let row = param_or(params, 0, 1) as usize - 1;
4703                let col = param_or(params, 1, 1) as usize - 1;
4704                self.goto(row, col);
4705            }
4706            'J' => self.erase_display(param_or(params, 0, 0)),
4707            'K' => self.erase_line(param_or(params, 0, 0)),
4708            'X' => self.erase_chars(param_or(params, 0, 1) as usize),
4709            '@' => self.insert_chars(param_or(params, 0, 1) as usize),
4710            'P' => self.delete_chars(param_or(params, 0, 1) as usize),
4711            'S' => self.scroll_up_lines(param_or(params, 0, 1) as usize),
4712            'T' => self.scroll_down_lines(param_or(params, 0, 1) as usize),
4713            'L' => self.insert_lines(param_or(params, 0, 1) as usize),
4714            'M' => self.delete_lines(param_or(params, 0, 1) as usize),
4715            'g' => self.clear_tab_stop(param_or(params, 0, 0)),
4716            'r' => {
4717                let rows = self.grid.rows() as u16;
4718                let top = param_or(params, 0, 1) as usize;
4719                let bottom = param_or(params, 1, rows) as usize;
4720                self.set_scroll_region(top, bottom);
4721            }
4722            'm' => self.sgr(params),
4723            's' => self.save_cursor(),    // SCOSC (CSI s) — alias of DECSC
4724            'u' => self.restore_cursor(), // SCORC (CSI u) — alias of DECRC
4725            // DA1 (primary device attributes, CSI c): advertise VT220 + ANSI
4726            // colour — the levels justerm actually implements (#27).
4727            'c' => self.replies.extend_from_slice(b"\x1b[?62;22c"),
4728            'n' => self.device_status_report(param_or(params, 0, 0)),
4729            // Non-private SM/RM. Folded over every parameter (modes can batch,
4730            // like the private path #56). IRM (4) and LNM (20) so far.
4731            'h' => {
4732                for m in params.iter().filter_map(|p| p.first().copied()) {
4733                    match m {
4734                        4 => self.insert_mode = true,
4735                        20 => self.newline_mode = true,
4736                        _ => {}
4737                    }
4738                }
4739            }
4740            'l' => {
4741                for m in params.iter().filter_map(|p| p.first().copied()) {
4742                    match m {
4743                        4 => self.insert_mode = false,
4744                        20 => self.newline_mode = false,
4745                        _ => {}
4746                    }
4747                }
4748            }
4749            _ => {}
4750        }
4751    }
4752
4753    fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, byte: u8) {
4754        // VT52 mode (#84): the pre-ANSI dialect reuses the same `ESC <final>`
4755        // tokens vte already produces, but with different meanings, so it is a
4756        // mode-gated branch here rather than a separate parser. All VT52 sequences
4757        // are intermediate-free; anything with an intermediate is not VT52.
4758        if self.vt52_mode && intermediates.is_empty() {
4759            self.vt52_dispatch(byte);
4760            return;
4761        }
4762        if let Some(&i) = intermediates.first() {
4763            // SCS: designate a charset to G0 (`ESC ( F`) or G1 (`ESC ) F`) (#62).
4764            if matches!(i, b'(' | b')') {
4765                let set = match byte {
4766                    b'0' => Charset::DecSpecialGraphics,
4767                    b'A' => Charset::Uk,
4768                    b'B' => Charset::Ascii,
4769                    _ => return, // other sets are later slices
4770                };
4771                self.charsets[if i == b'(' { 0 } else { 1 }] = set;
4772            }
4773            // Other intermediates (G2/G3 designators, etc.) are later slices.
4774            return;
4775        }
4776        match byte {
4777            b'D' => self.linefeed(), // IND (line-feed without CR)
4778            b'E' => {
4779                // NEL (next line): carriage return + line-feed.
4780                self.carriage_return();
4781                self.linefeed();
4782            }
4783            b'H' => self.set_tab_stop(),             // HTS
4784            b'M' => self.reverse_index(),            // RI
4785            b'7' => self.save_cursor(),              // DECSC
4786            b'8' => self.restore_cursor(),           // DECRC
4787            b'c' => self.full_reset(),               // RIS (#53)
4788            b'=' => self.application_keypad = true,  // DECKPAM (#74)
4789            b'>' => self.application_keypad = false, // DECKPNM
4790            _ => {}
4791        }
4792    }
4793
4794    /// OSC dispatch (#12 event surface): title (0/2), cwd (7). OSC 8 hyperlink
4795    /// is per-cell state, handled in its own slice (#26), not here.
4796    fn osc_dispatch(&mut self, params: &[&[u8]], _bell_terminated: bool) {
4797        // params[0] is the OSC number; params[1..] the payload fields.
4798        let Some(&number) = params.first() else {
4799            return;
4800        };
4801        match number {
4802            // OSC 0 = icon + window title, OSC 2 = window title. Both set title.
4803            b"0" | b"2" => {
4804                if let Some(&title) = params.get(1) {
4805                    self.events.push(TermEvent::Title(
4806                        String::from_utf8_lossy(title).into_owned(),
4807                    ));
4808                }
4809            }
4810            // OSC 7 = current working directory (a file:// URI).
4811            b"7" => {
4812                if let Some(&cwd) = params.get(1) {
4813                    self.events
4814                        .push(TermEvent::Cwd(String::from_utf8_lossy(cwd).into_owned()));
4815                }
4816            }
4817            // OSC 133 = FinalTerm/iTerm2 shell-integration command marks (#158):
4818            // `A` prompt start, `B` command start, `C` output start, `D[;exit]`
4819            // command finished. Each anchors a kinded marker at the cursor line;
4820            // pairing + navigation is consumer policy (#160). Unknown subcommands
4821            // (or none) are ignored. `D`'s exit field parses to `i32`, else None.
4822            b"133" => match params.get(1).copied() {
4823                Some(b"A") => self.add_command_mark(MarkerKind::PromptStart),
4824                Some(b"B") => self.add_command_mark(MarkerKind::CommandStart),
4825                Some(b"C") => self.add_command_mark(MarkerKind::OutputStart),
4826                Some(b"D") => {
4827                    let exit = params
4828                        .get(2)
4829                        .and_then(|p| core::str::from_utf8(p).ok())
4830                        .and_then(|s| s.parse::<i32>().ok());
4831                    self.add_command_mark(MarkerKind::CommandFinished(exit));
4832                }
4833                _ => {}
4834            },
4835            // OSC 8 = hyperlink: `OSC 8 ; params ; URI`. A non-empty URI opens a
4836            // link (interned + made current); an empty URI closes it. `params`
4837            // (e.g. `id=…`) is ignored for now — id-grouping is a later refinement.
4838            b"8" => {
4839                let uri = params.get(2).copied().unwrap_or(b"");
4840                if uri.is_empty() {
4841                    self.current_link = None;
4842                } else {
4843                    self.hyperlink_pool
4844                        .push(String::from_utf8_lossy(uri).into_owned());
4845                    self.current_link =
4846                        core::num::NonZeroU32::new(self.hyperlink_pool.len() as u32);
4847                }
4848            }
4849            // OSC 4 = set/query an ANSI palette entry: `OSC 4 ; index ; spec`
4850            // (#122). The engine forwards index + raw spec; the consumer applies
4851            // it to its palette (theme-agnostic — the cell keeps `Indexed`).
4852            b"4" => {
4853                // One event per `index ; spec` pair (xterm's `while slots > 1`).
4854                let mut rest = &params[1..];
4855                while let [idx, spec, tail @ ..] = rest {
4856                    rest = tail;
4857                    if let Ok(index) = String::from_utf8_lossy(idx).parse::<u8>() {
4858                        if *spec == b"?" {
4859                            self.events.push(TermEvent::QueryPaletteColor { index });
4860                        } else {
4861                            self.events.push(TermEvent::SetPaletteColor {
4862                                index,
4863                                spec: String::from_utf8_lossy(spec).into_owned(),
4864                            });
4865                        }
4866                    }
4867                }
4868            }
4869            // OSC 104 = reset palette entries (#122): no arg resets the whole
4870            // table, else one event per named index.
4871            b"104" => {
4872                if params.len() <= 1 {
4873                    self.events.push(TermEvent::ResetPaletteColor(None));
4874                } else {
4875                    for &idx in &params[1..] {
4876                        if let Ok(index) = String::from_utf8_lossy(idx).parse::<u8>() {
4877                            self.events.push(TermEvent::ResetPaletteColor(Some(index)));
4878                        }
4879                    }
4880                }
4881            }
4882            // OSC 10/11 = set/query the default foreground/background, stacking
4883            // specs across the [fg, bg] slots (#122, #137). OSC 10 starts at fg,
4884            // OSC 11 at bg. The engine forwards raw specs (theme-agnostic).
4885            b"10" => self.special_color(params, 0),
4886            b"11" => self.special_color(params, 1),
4887            // OSC 110 / 111 = reset the default foreground / background (#122).
4888            b"110" => self.events.push(TermEvent::ResetForeground),
4889            b"111" => self.events.push(TermEvent::ResetBackground),
4890            _ => {} // other OSCs are later slices
4891        }
4892    }
4893}