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::search::Match;
21use crate::selection::{BufferPoint, Selection};
22use crate::serialize::{Frame, FrameKind, MarkerId, MarkerKind, Overlay, Span};
23
24/// Buffer-walk primitives shared by every read surface (#585). A child module, so
25/// it reaches `Term`'s private fields directly — no field is widened for it.
26mod walk;
27
28/// The search query surface (#586) — finding matches, and the highlight set the
29/// consumer pushes back. Stands on `walk`, whose `pub(super)` reaches a sibling
30/// module because both are descendants of `term`.
31mod search;
32
33/// The selection surface (#587) — gestures, the anchor fixups the write path drives,
34/// and text extraction. Stands on `walk` like its siblings.
35mod selection;
36
37/// The decoration-marker surface (#588) — marks anchored to absolute buffer lines, the
38/// OSC 133 command queries over them, and the anchor fixups the write path drives.
39mod markers;
40
41/// Viewport logical lines (#601) — the soft-wrap-joined text a consumer needs for URL
42/// detection and the a11y mirror. The last read surface to leave this file under #584.
43mod logical;
44
45/// Owns the authoritative screen state and applies VT actions to it.
46pub struct Term {
47 grid: Grid,
48 /// The inactive screen. Swapped with `grid` on alt-screen enter/leave; holds
49 /// whichever of primary/alternate is not currently shown. The alt screen has
50 /// no scrollback (#3 only rings the primary).
51 alt_grid: Grid,
52 cursor: Cursor,
53 /// Cursor saved on alt-screen enter (DEC 1049), restored on leave.
54 saved_cursor: Cursor,
55 /// Whether the alternate screen is currently active. Guards enter/leave so a
56 /// double-enter or double-leave is a no-op.
57 on_alt: bool,
58 /// One flag per column: is there a tab stop here? Explicit per-column state
59 /// (HTS sets, TBC clears), not a fixed modulo. Default = every 8th column.
60 tabs: Vec<bool>,
61 /// Origin mode (DECOM ?6): when set, cursor addressing is relative to the
62 /// scroll region's top margin (and clamped to it).
63 origin_mode: bool,
64 /// Autowrap (DECAWM ?7): default on. When off, a glyph past the right margin
65 /// pins the cursor to the last column and overwrites in place instead of
66 /// wrapping to the next line (matches xterm.js) (#63).
67 autowrap: bool,
68 /// Insert mode (IRM, the non-private SM/RM mode 4): default off (replace).
69 /// When on, a printed glyph shifts the row's tail right first (#64).
70 insert_mode: bool,
71 /// New-line mode (LNM, the non-private SM/RM mode 20): default off. When on,
72 /// a line feed also carriage-returns (`convertEol`). Output-only — the Enter
73 /// key still encodes CR, matching xterm.js (#71).
74 newline_mode: bool,
75 /// Reverse wraparound (DEC ?45): default off. When on, a *backspace* at
76 /// column 0 of a soft-wrapped row moves back to the end of the previous row
77 /// (BS only, soft wraps only — matches xterm.js) (#80).
78 reverse_wraparound: bool,
79 /// Bracketed-paste mode (DEC ?2004). The engine owns the flag; the input
80 /// encoder (#11) reads it to decide whether to wrap pasted text in markers.
81 bracketed_paste: bool,
82 /// Synchronized output (DEC ?2026): the app brackets a frame of output so the
83 /// renderer can paint it atomically. The engine only *tracks* the flag — the
84 /// consumer owns the paint-hold and the spec-mandated timeout (#73).
85 synchronized_output: bool,
86 /// Color-scheme-update notifications (DEC ?2031): the app asked to be told
87 /// when the light/dark scheme changes. The engine is theme-agnostic — it only
88 /// tracks the flag; the consumer (which knows the scheme) drives the ?997
89 /// notification via `report_color_scheme` (#85).
90 color_scheme_updates: bool,
91 /// Grapheme-cluster mode (DEC ?2027, default OFF): the app opted into UAX #29 grapheme-cluster
92 /// width — a ZWJ / skin-tone / flag / emoji+VS16 sequence is clustered into ONE cell instead of
93 /// one cell per scalar (#295). OFF keeps the per-char (wcwidth-compatible) behaviour so the
94 /// cursor stays in sync with wcwidth apps — clustering is opt-in for exactly that reason (#301).
95 grapheme_clustering: bool,
96 /// win32-input-mode (DEC ?9001): the app asked for keys as raw Windows
97 /// key-records. The engine only *tracks* the flag — the raw record encoding
98 /// (`CSI Vk;Sc;Uc;Kd;Cs;Rc _`) is a non-goal (raw passthrough, no semantic
99 /// conversion), left to the ConPTY consumer; `encode_key` is unchanged (#86).
100 win32_input_mode: bool,
101 /// Application cursor keys (DECCKM ?1): when set, cursor keys / Home / End
102 /// encode as SS3 rather than CSI (see `input.rs`).
103 app_cursor_keys: bool,
104 /// Application keypad mode (DECNKM ?66 / DECKPAM `ESC =` / DECKPNM `ESC >`):
105 /// tracked for protocol completeness + DECRQM, but NOT yet acted on in key
106 /// encoding — xterm.js tracks it the same way and never reads it (#74).
107 application_keypad: bool,
108 /// VT52 compatibility mode (DECANM ?2 *reset*): when set, `esc_dispatch` is
109 /// re-routed into the pre-ANSI VT52 dialect (`ESC A`-style sequences) instead
110 /// of the ANSI meaning. `ESC <` clears it. Default off (ANSI). (#84)
111 vt52_mode: bool,
112 /// VT52 `ESC Y row col` direct-addressing state (#84). vte tokenizes `ESC Y`
113 /// as a final and returns to ground, so the two coordinate bytes arrive as
114 /// `print()` calls — not part of the escape sequence. This counts them down
115 /// (2 → 1 → 0; 0 = not addressing) and `vt52_y_row` parks the first (row)
116 /// until the second (col) lands. Each byte decodes as `value - 0x20`.
117 vt52_y_pending: u8,
118 vt52_y_row: usize,
119 /// Mouse tracking mode — what events the app asked to be reported
120 /// (?1000/?1002/?1003). `Off` by default.
121 mouse_protocol: MouseProtocol,
122 /// Mouse coordinate encoding (default X10 vs ?1006 SGR).
123 mouse_encoding: MouseEncoding,
124 /// Focus in/out reporting (?1004): emit `CSI I`/`CSI O` on focus change.
125 focus_events: bool,
126 /// Kitty keyboard-protocol progressive-enhancement flags currently in effect
127 /// (bit0 disambiguate, bit1 report-events, bit2 alt-keys, bit3 all-as-escape,
128 /// bit4 associated-text). 0 = legacy. `encode_key` consults these (#23).
129 kitty_flags: u8,
130 /// Saved `kitty_flags` for the protocol's push/pop stack (`CSI > u` pushes,
131 /// `CSI < u` pops). Capped depth — overflow drops the oldest entry.
132 kitty_stack: Vec<u8>,
133 /// Consumer events (title / bell / cwd) accumulated since the last
134 /// `drain_events` (#12). Pull, not push — see `event.rs`.
135 events: Vec<TermEvent>,
136 /// Outbound reply bytes (DA/DSR/DECRQM query answers, #27) accumulated
137 /// during `feed` for the consumer to write back to the PTY. Raw bytes →
138 /// PTY, kept separate from typed `events` → UI.
139 replies: Vec<u8>,
140 /// The hyperlink currently open (OSC 8 with a URI), stamped onto every glyph
141 /// written until closed (OSC 8 with empty URI). Ambient pen-like state — not
142 /// part of the pen/SGR, and *not* cleared by an SGR reset.
143 ///
144 /// The URI itself, not a pool index — there is no pool (#628). The lead paragraph
145 /// here used to describe one (*"Hyperlink side-table … referenced by `Cell.link`
146 /// (1-based). Append-only (#26)"*), left behind when its field was deleted.
147 current_link: Option<std::sync::Arc<str>>,
148 /// Live OSC 8 `id=` groups: `"id;;uri"` → the allocation that key already named,
149 /// held **weakly** (#635).
150 ///
151 /// `Weak`, not `Arc`, is the whole lifetime story. A strong entry here would make
152 /// every id'd link immortal for the life of the `Term` — precisely the leak #628
153 /// deleted, re-entering through the door grouping opens. A dangling key is the
154 /// correct answer rather than a hole: the link it named has left the buffer, so a
155 /// later open of the same id is genuinely a new link. xterm.js expresses the same
156 /// lifetime by *deleting* its `_entriesWithId` entry when the last line marker
157 /// referencing it is disposed (`OscLinkService.ts:98-100`); justerm has no disposal
158 /// hook by design, and `Weak` is that lifetime without one.
159 link_ids: std::collections::HashMap<String, std::sync::Weak<str>>,
160 /// Map length at which [`Self::link_ids`] is swept for dangling keys, doubling each
161 /// time so the sweep is amortised O(1) per open and dead keys stay O(live).
162 ///
163 /// A sweep is affordable here for the reason #628's rejected option (c) was not:
164 /// staleness is observable in O(1) (`Weak::strong_count`), where (c) had to decide
165 /// "is the pool oversized" by counting live references — the O(buffer) walk it was
166 /// trying to avoid.
167 link_ids_sweep_at: usize,
168 /// Scroll region top/bottom margins (DECSTBM), 0-based inclusive. A
169 /// line-feed at `scroll_bottom` scrolls only rows `[scroll_top..=scroll_bottom]`.
170 /// Default = the full screen.
171 scroll_top: usize,
172 scroll_bottom: usize,
173 /// Lines that have scrolled off the top of the primary screen, oldest at the
174 /// front. Accrues only on a top-anchored, primary-screen scroll.
175 scrollback: VecDeque<Row>,
176 /// How many lines the viewport is scrolled up from the bottom. 0 = following
177 /// the live screen; clamped to `[0, scrollback.len()]`.
178 display_offset: usize,
179 /// Maximum scrollback lines retained; the oldest are evicted past this.
180 scrollback_limit: usize,
181 /// A spare row buffer recycled across full-screen scrolls: the cap-evicted
182 /// oldest line is parked here and reused as the next scroll's blank bottom,
183 /// so a steady-state flood allocates nothing (ADR-0009).
184 recycled_row: Option<Row>,
185 /// Per-line damage bounds since the last `reset_damage` (ack), one per row.
186 line_damage: Vec<LineBounds>,
187 /// A first-class scroll recorded since the last `reset_damage`.
188 scroll: Option<ScrollOp>,
189 /// The whole screen changed (alt switch / clear / later resize+flood) — the
190 /// renderer must redraw everything.
191 full_damage: bool,
192 /// The cursor `(row, col)` at the last `reset_damage` (ack) — where the
193 /// consumer last saw the caret. A pure cursor move records no content
194 /// damage, so `damage()` folds this *old* cell plus the current one into the
195 /// frame; without it a cell-invert caret ghosts at the old spot (mirrors
196 /// Alacritty's `last_cursor`). #38.
197 prev_cursor: (usize, usize),
198 /// The live selection, in absolute buffer coordinates. `None` when nothing
199 /// is selected. See `selection.rs`.
200 selection: Option<Selection>,
201 /// The search highlights the consumer asked to paint (#108). Search
202 /// matches are consumer-owned (it drives next/prev), so the engine holds only
203 /// the set handed back via `set_search_highlights`, and `frame()` projects it
204 /// onto the viewport — the same anchoring path as the selection.
205 search_highlights: Vec<Match>,
206 /// The *active* (current) search match (#428), stored as its absolute span
207 /// (#436) — designated by the consumer (next/prev is its policy) either as
208 /// an index into `search_highlights` (resolved to the span at call time) or
209 /// directly by span, which a capping backend uses for a past-cap match
210 /// (xterm creates its active decoration from the found result, OUTSIDE the
211 /// capped highlight list). A span is NOT structurally tied to the set, so
212 /// every path that voids the set must void this too: `set_search_highlights`
213 /// (hand-over reset, #428) and `invalidate_search_highlights` (the single
214 /// funnel for eviction / every region scroll incl. the accrual sub-region
215 /// branch (#449) / reflow / both alt swaps) — a stale span would otherwise
216 /// keep painting coordinates that now hold other text.
217 active_search_highlight: Option<Match>,
218 /// Engine-owned decoration markers (#118), split per buffer like xterm's
219 /// `BufferSet` (#177 S0): each a stable id bound to an absolute buffer line
220 /// that re-anchors through eviction/scroll/reflow like a selection anchor. The
221 /// active buffer's list is selected by `on_alt` — `markers`/`markers_mut`.
222 /// `alt_markers` stays empty while the alt guards (#158/#164) are in place; it
223 /// is disposed on alt-leave (xterm `clearAllMarkers`). `next_marker_id` hands
224 /// out monotonic ids across both buffers so ids never alias.
225 normal_markers: Vec<Marker>,
226 alt_markers: Vec<Marker>,
227 next_marker_id: u32,
228 /// Cursor state saved by DECSC (ESC 7), restored by DECRC (ESC 8). A slot
229 /// separate from `saved_cursor` (which is the alt-screen save). Defaults to
230 /// home/default so a DECRC with no prior DECSC restores a sane state.
231 decsc: SavedCursor,
232 /// SCS-designated character sets G0..G3 (#62). `gl` indexes the active (GL)
233 /// set, switched by SI (→G0) / SO (→G1). First cut uses G0/G1.
234 charsets: [Charset; 4],
235 gl: usize,
236}
237
238/// A character set designated by SCS (#62). First cut: ASCII (default), DEC
239/// Special Graphics (line-drawing), and UK. G2/G3 and the GR half are later.
240#[derive(Clone, Copy, PartialEq, Eq, Default)]
241enum Charset {
242 #[default]
243 Ascii,
244 DecSpecialGraphics,
245 Uk,
246}
247
248impl Charset {
249 /// Map one GL byte (a `char` in the 7-bit range) through this set. ASCII and
250 /// any out-of-range char pass through; UK swaps `#`→£; DEC Special Graphics
251 /// translates `_`..`~` to the line-drawing / symbol glyphs.
252 fn map(self, c: char) -> char {
253 match self {
254 Charset::Ascii => c,
255 Charset::Uk if c == '#' => '£',
256 Charset::Uk => c,
257 Charset::DecSpecialGraphics => dec_special_graphics(c),
258 }
259 }
260}
261
262/// The VT100 DEC Special Graphics set: bytes `_`..`~` (0x5F..0x7E) map to the
263/// box-drawing and symbol glyphs. Matches xterm/alacritty; anything outside the
264/// range passes through unchanged.
265fn dec_special_graphics(c: char) -> char {
266 // Keys ``..`~` only — `_` (0x5F) is deliberately absent, matching xterm.js /
267 // alacritty (it passes through as a literal underscore), not the strict-DEC
268 // "0x5F = blank" reading.
269 match c {
270 '`' => '◆',
271 'a' => '▒',
272 'b' => '␉',
273 'c' => '␌',
274 'd' => '␍',
275 'e' => '␊',
276 'f' => '°',
277 'g' => '±',
278 'h' => '',
279 'i' => '␋',
280 'j' => '┘',
281 'k' => '┐',
282 'l' => '┌',
283 'm' => '└',
284 'n' => '┼',
285 'o' => '⎺',
286 'p' => '⎻',
287 'q' => '─',
288 'r' => '⎼',
289 's' => '⎽',
290 't' => '├',
291 'u' => '┤',
292 'v' => '┴',
293 'w' => '┬',
294 'x' => '│',
295 'y' => '≤',
296 'z' => '≥',
297 '{' => 'π',
298 '|' => '≠',
299 '}' => '£',
300 '~' => '·',
301 other => other,
302 }
303}
304
305/// Default scrollback retention when not specified.
306const DEFAULT_SCROLLBACK: usize = 10_000;
307
308/// The narrowest screen the engine represents: **two columns**.
309///
310/// A width-2 glyph occupies a `WIDE_CHAR` lead *and* the `WIDE_CHAR_SPACER` that
311/// stands for its second half, so one column cannot hold one — and a pair with only
312/// one half written is the malformed state every repair path in this crate keys off
313/// (ADR-0025 D4). `Term::with_scrollback` and [`Term::resize`] clamp `cols` up to
314/// this, which is what makes D4 (*both halves of a pair move together*)
315/// unconditionally satisfiable rather than true only above some unstated width.
316///
317/// Both references that a terminal *engine* can be compared to forbid one column for
318/// exactly this reason — alacritty's `MIN_COLUMNS = 2` and xterm.js's
319/// `MINIMUM_COLS = 2` — and the third (ghostty) permits it only by destroying the
320/// glyph.
321///
322/// The clamp is **silent and pull-only**: a `resize(1, rows)` during a pane drag is
323/// widened rather than rejected, and no event reports it. Both references instead
324/// make the clamped size the one that travels outward — alacritty derives its
325/// `WindowSize` from the clamped `SizeInfo`, xterm.js fires `onResize` with the
326/// clamped pair — so a justerm consumer must do that correlation itself: read the
327/// width back from [`Term::grid`] / the frame header and size the PTY from *that*,
328/// never from the value it requested. Sizing a PTY to one column leaves the
329/// application rendering for a width the buffer does not have (#547).
330pub const MIN_COLUMNS: usize = 2;
331
332/// A declared OSC 8 hyperlink, as handed to a consumer.
333///
334/// **Owned, not borrowed**, and that is the point: the URI lives in a row's side map, so
335/// a `&str` into it would be tied to `&Engine` and a caller could not hold the link
336/// across the next `feed()` — which is precisely what a hover handler does. Measured on
337/// the alternative: reading a borrow costs 0.75 ns, but keeping it *does not compile*, so
338/// the caller copies the string instead at 62.6 ns. Handing back this handle is 17.9 ns
339/// — cheaper than the workaround it removes, on a call made once per hover.
340///
341/// Cloning is a refcount bump; the allocation is shared with every cell of the same OSC 8
342/// open and released when the last row holding it dies (#628).
343///
344/// **A struct rather than a bare `Arc<str>`** for two reasons: it keeps `Arc` out of the
345/// published signature, and OSC 8's `id=` parameter (#635) lands here as a field without
346/// changing the return type again. Same shape as alacritty's `Hyperlink`, for the same
347/// reasons (`alacritty_terminal/src/term/cell.rs`).
348///
349/// **Link *identity* is deliberately not exposed yet.** Two OSC 8 opens of an identical
350/// URI are two links here, so `uri() == uri()` cannot answer "is this cell part of the
351/// same link as that one?" — an `Arc::ptr_eq` accessor would. It is left out because no
352/// consumer asks it today (nothing outside this crate's tests calls `link_at` at all),
353/// and unlike this type's *shape*, adding a method later is not a breaking change. The
354/// asymmetry decides it: shipping an accessor nobody uses is hard to undo, adding one
355/// when a caller appears is free.
356#[derive(Clone, PartialEq, Eq, Debug)]
357pub struct Hyperlink {
358 uri: std::sync::Arc<str>,
359}
360
361impl Hyperlink {
362 pub(crate) fn new(uri: std::sync::Arc<str>) -> Self {
363 Hyperlink { uri }
364 }
365
366 /// The link target, exactly as the application declared it — never validated,
367 /// never resolved. Whether it is a URL a consumer is willing to open is that
368 /// consumer's policy (ADR-0017), the same way colour resolution is.
369 pub fn uri(&self) -> &str {
370 &self.uri
371 }
372}
373
374/// Length at which the `id=` group map is first swept for dangling keys, doubling from
375/// there. Small enough that a session declaring a handful of ids never pays a sweep,
376/// large enough that the sweep is not the common path.
377const LINK_IDS_FIRST_SWEEP: usize = 16;
378
379/// The `id=` value out of an OSC 8 `params` field, or `None` when it is absent or empty.
380///
381/// Three rules, each taken from xterm.js's `_createHyperlink` verbatim rather than from
382/// the spec prose, because each is a place a reasonable reading goes wrong
383/// (`src/common/InputHandler.ts:3128-3131` at the pinned SHA `699f5537b023`):
384///
385/// - **`:`-separated**, not `;` — `params` is one OSC argument holding a key=value list
386/// (`id=xyz123:foo=bar:baz=quux`), so the split is on colons (`params.split(':')`).
387/// - **`id` may sit anywhere in it** (`findIndex(e => e.startsWith('id='))`), so matching
388/// only a leading `id=` is the wrong parse and passes a single-parameter test.
389/// - **an empty value is not an id** (`slice(3) || undefined`). This is the one with teeth:
390/// an empty key would group every `id=`-with-no-value link in a session into one link
391/// across unrelated URIs, and it is a wrong answer that grows with uptime.
392///
393/// Only the first `id=` is consulted, matching `findIndex` — an empty first one yields
394/// `None` rather than searching on for a non-empty sibling.
395fn osc8_link_id(params: &[u8]) -> Option<&[u8]> {
396 params
397 .split(|b| *b == b':')
398 .find_map(|kv| kv.strip_prefix(b"id="))
399 .filter(|value| !value.is_empty())
400}
401
402/// The widest grid the engine will hold, and the mirror of [`MIN_COLUMNS`] — but derived
403/// from a different kind of constraint, which is why the two are not symmetric.
404///
405/// The floor is **semantic**: a width-2 glyph needs two cells, so one column is a screen
406/// no correct grid can be. The ceiling is **representational**: the frame header stores
407/// `cols` and `rows` as `u16` each, so a grid wider than `u16::MAX` cannot be *described*
408/// to a consumer even though the engine could hold it. Without the clamp that mismatch was
409/// silent — measured, `Engine::new(70_000, 2)` built a 70 000-column grid whose frame
410/// declared `cols = 4464` and decoded `Ok`, so a consumer laid out 4464 columns of a
411/// 70 000-column screen with nothing reporting the difference (#621).
412///
413/// No reference bounds a grid this way, and that is expected rather than a divergence:
414/// none of them serializes a grid, so none has a header field to overflow. This is the
415/// one axis where justerm's own wire is the only authority.
416///
417/// **A backstop, not a policy.** A 4K display at a very small font is roughly 550 columns;
418/// this is two orders of magnitude past any real terminal, so it should never be reached
419/// by a consumer that is not already doing something wrong. The clamp is silent and
420/// pull-only on the same terms as [`MIN_COLUMNS`] — read the size back from
421/// [`Term::grid`] rather than trusting the value you passed in.
422pub const MAX_COLUMNS: usize = u16::MAX as usize;
423
424/// The tallest grid the engine will hold. The row half of [`MAX_COLUMNS`] — same
425/// `u16` header field, same reasoning, same silent-clamp contract.
426pub const MAX_ROWS: usize = u16::MAX as usize;
427
428/// The state DECSC (ESC 7) saves and DECRC (ESC 8) restores: position, pen/SGR,
429/// pending-wrap, and origin mode (per ADR-0004 — DECRC restores origin mode,
430/// which Alacritty omits). Cursor *visibility* is deliberately not part of this
431/// (DECTCEM is separate from DECSC).
432#[derive(Clone, Copy, Default)]
433struct SavedCursor {
434 row: usize,
435 col: usize,
436 pen: Pen,
437 pending_wrap: bool,
438 origin_mode: bool,
439 /// SCS charset state at save time — DECSC/DECRC round-trip the designated
440 /// sets and the active GL shift (#62).
441 charsets: [Charset; 4],
442 gl: usize,
443}
444
445/// An engine-owned decoration marker (#118): a stable id bound to an absolute
446/// buffer line. The line shifts in lockstep with eviction/region scroll/reflow
447/// (the same coordinate moves the selection anchor tracks); the marker is
448/// dropped when its line leaves the buffer.
449struct Marker {
450 id: MarkerId,
451 line: usize,
452 /// The cursor column at emit time (#166). Meaningful for OSC-133 command
453 /// marks — CommandStart(B)/OutputStart(C) columns bound the *typed command*
454 /// (excluding the prompt), like VSCode's `commandStartX`/`commandExecutedX`.
455 /// Plain `add_marker` decorations are row-granular and carry `col = 0`.
456 ///
457 /// **Domain is `[0, cols]`, not `[0, cols - 1]` (#562)** — a bound, not a cell.
458 /// A command that exactly fills its row ends *one past* the last column, and
459 /// that value is what `extract_lines` wants: it clips `[b_col, c_col)`, so the
460 /// exclusive end absorbs it through `.min(cells.len())`. Storing `cursor.col`
461 /// alone (the cursor is held at `cols - 1` with `pending_wrap`) cost such a
462 /// command its last character with no resize involved. The **inclusive** side
463 /// cannot absorb it, so `extract_lines` steps a `from` of `cells.len()` to the
464 /// next line rather than selecting an empty run and flushing a `\n`.
465 col: usize,
466 /// Plain for a `add_marker` decoration; a command-boundary role for an
467 /// OSC 133 mark (#158). All kinds share the anchor/eviction machinery.
468 kind: MarkerKind,
469}
470
471/// One executed shell command recovered from OSC-133 marks (#166), for
472/// screen-reader command navigation. The consumer jumps prompt-to-prompt over
473/// these and announces `command` + a success/fail signal from `exit`.
474#[derive(Debug, Clone, PartialEq, Eq)]
475pub struct CommandLine {
476 /// The command's jump anchor as a *document* line — the logical-line index of
477 /// the CommandStart(B) mark within [`Term::accessible_text`], so the consumer
478 /// reveals the right row of the accessible view (soft-wrapped rows collapse to
479 /// one logical line). This is core's analog of VSCode's
480 /// `bufferToEditorLineMapping`; the frame-mode web side has no wrap info to
481 /// map it itself.
482 pub line: usize,
483 /// The typed command text, prompt- and output-excluded (B→C columns).
484 pub command: String,
485 /// The CommandFinished(D) exit code, if the shell reported one and the
486 /// command has finished.
487 pub exit: Option<i32>,
488}
489
490/// Collect per-line damage bounds into damaged `LineDamage` spans (undamaged
491/// lines dropped). Shared by `damage` (content-only) and `frame_damage`
492/// (content + cursor cells).
493fn bounds_to_lines(bounds: &[LineBounds]) -> Vec<LineDamage> {
494 bounds
495 .iter()
496 .enumerate()
497 .filter(|(_, b)| b.is_damaged())
498 .map(|(line, b)| {
499 let (left, right) = b.span();
500 LineDamage { line, left, right }
501 })
502 .collect()
503}
504
505impl Term {
506 pub fn new(cols: usize, rows: usize) -> Self {
507 Self::with_scrollback(cols, rows, DEFAULT_SCROLLBACK)
508 }
509
510 pub fn with_scrollback(cols: usize, rows: usize, scrollback_limit: usize) -> Self {
511 // Both clamps mirror `resize` exactly, so a screen cannot be born at a size a
512 // resize would refuse. They are not the same *kind* of rule, though: the width
513 // floor is a published contract (#547 — one column was supported and no longer
514 // is), while the row floor is `resize`'s own long-standing "a terminal is never
515 // 0-tall" that this constructor merely failed to enforce while carrying the
516 // same `scroll_bottom: rows - 1` below. That gap was a subtract-overflow panic
517 // on `rows == 0`, not a degenerate screen.
518 // …and the ceiling is the header's, not the glyph's: `frame.cols`/`rows` are u16,
519 // so a wider grid would be built and then misdescribed on the wire (#621).
520 let cols = cols.clamp(MIN_COLUMNS, MAX_COLUMNS);
521 let rows = rows.clamp(1, MAX_ROWS);
522 Term {
523 grid: Grid::new(cols, rows),
524 alt_grid: Grid::new(cols, rows),
525 cursor: Cursor::default(),
526 saved_cursor: Cursor::default(),
527 on_alt: false,
528 origin_mode: false,
529 autowrap: true,
530 insert_mode: false,
531 newline_mode: false,
532 reverse_wraparound: false,
533 bracketed_paste: false,
534 synchronized_output: false,
535 color_scheme_updates: false,
536 grapheme_clustering: false,
537 win32_input_mode: false,
538 app_cursor_keys: false,
539 application_keypad: false,
540 vt52_mode: false,
541 vt52_y_pending: 0,
542 vt52_y_row: 0,
543 mouse_protocol: MouseProtocol::Off,
544 mouse_encoding: MouseEncoding::Default,
545 focus_events: false,
546 kitty_flags: 0,
547 kitty_stack: Vec::new(),
548 events: Vec::new(),
549 replies: Vec::new(),
550 current_link: None,
551 link_ids: std::collections::HashMap::new(),
552 link_ids_sweep_at: LINK_IDS_FIRST_SWEEP,
553 tabs: default_tabs(cols),
554 scroll_top: 0,
555 scroll_bottom: rows - 1,
556 scrollback: VecDeque::new(),
557 display_offset: 0,
558 scrollback_limit,
559 recycled_row: None,
560 line_damage: vec![LineBounds::undamaged(cols); rows],
561 scroll: None,
562 full_damage: false,
563 prev_cursor: (0, 0), // matches the default cursor's home position
564 selection: None,
565 search_highlights: Vec::new(),
566 active_search_highlight: None,
567 normal_markers: Vec::new(),
568 alt_markers: Vec::new(),
569 next_marker_id: 0,
570 decsc: SavedCursor::default(),
571 charsets: [Charset::Ascii; 4],
572 gl: 0,
573 }
574 }
575
576 /// What changed since the last `reset_damage()` — line ranges, each with a
577 /// changed column span. See ADR-0003.
578 pub fn damage(&self) -> TermDamage {
579 if self.full_damage {
580 return TermDamage::Full;
581 }
582 // Scrolled up under follow-bottom "stay": the viewport is frozen, so
583 // screen changes below it are not visible — report nothing. (A user
584 // scroll that moves the viewport sets full_damage above.)
585 if self.display_offset > 0 {
586 return TermDamage::Partial(Vec::new());
587 }
588 TermDamage::Partial(bounds_to_lines(&self.line_damage))
589 }
590
591 /// Render damage: content damage plus the cursor cells, for [`Term::frame`].
592 ///
593 /// A pure cursor move changes no cell *content*, so [`Term::damage`] (which
594 /// stays content-only, the cadence/flow-control primitive) would miss it —
595 /// yet a cell-invert caret must clear its old spot and ink the new one. So
596 /// the frame producer folds the old (last-acked) + current cursor cells in,
597 /// but only when the cursor actually moved: a still cursor needs no redraw,
598 /// keeping an idle frame empty. Mirrors Alacritty's `last_cursor`. #38.
599 fn frame_damage(&self) -> TermDamage {
600 if self.full_damage {
601 return TermDamage::Full;
602 }
603 if self.display_offset > 0 {
604 return TermDamage::Partial(Vec::new());
605 }
606 let cur = self.cursor.point();
607 if cur == self.prev_cursor {
608 return TermDamage::Partial(bounds_to_lines(&self.line_damage));
609 }
610 let mut bounds = self.line_damage.clone();
611 bounds[cur.0].expand(cur.1, cur.1);
612 let pr = self.prev_cursor.0.min(self.grid.rows() - 1);
613 let pc = self.prev_cursor.1.min(self.grid.cols() - 1);
614 bounds[pr].expand(pc, pc);
615 TermDamage::Partial(bounds_to_lines(&bounds))
616 }
617
618 /// Clear accumulated damage. The consumer calls this after applying a frame
619 /// (the ack); the next `damage()` reflects only changes since.
620 pub fn reset_damage(&mut self) {
621 for b in &mut self.line_damage {
622 b.reset();
623 }
624 self.scroll = None;
625 self.full_damage = false;
626 // The consumer has now seen the caret at the current position; the next
627 // frame's cursor-move damage is measured from here (#38).
628 self.prev_cursor = self.cursor.point();
629 }
630
631 /// Mark the whole screen damaged (alt switch / clear / flood, and a consumer
632 /// reattach that needs a full re-sync — see [`crate::Engine::mark_fully_damaged`]).
633 pub fn mark_fully_damaged(&mut self) {
634 self.full_damage = true;
635 }
636
637 /// Record that columns `[left, right]` of `row` changed.
638 ///
639 /// Both columns are **clamped to the last column, and asserted in debug** (#536).
640 ///
641 /// Ten of the fourteen call sites derive their bound from a cursor column or from `cols`.
642 /// **Four derive it from a wide pair's width**, and that is the shape worth centralising:
643 /// `write_glyph`'s `col + width - 1` (which had no guard — this issue), `promote_cluster_to_wide`'s
644 /// `col + 1` (guarded by its own `col + 1 >= cols` early return), `demote_cluster_to_narrow`'s
645 /// `(col + 1).min(cols - 1)` (self-clamped), and `relocate_cluster_wide`'s literal `(0, 1)`
646 /// (valid only because `MIN_COLUMNS = 2`, #547). Three carried a private guard and one did not.
647 ///
648 /// No reference has this shape to port a clamp from: alacritty computes damage ranges too
649 /// (`term/mod.rs:1406`, `:1649` @ `852e971`) but always from a column or `columns()` — its print
650 /// path records no damage at all, relying on the previous and current cursor *points* to bracket
651 /// the line — while xterm.js tracks whole rows (`markDirty(y)`) and ghostty a per-row
652 /// `dirty: bool`. alacritty's `LineDamageBounds::expand`, which this one is a copy of, is equally
653 /// unguarded.
654 ///
655 /// The two halves do different jobs:
656 ///
657 /// - the **`debug_assert` is the detector**. An out-of-range bound is stored silently and
658 /// detonates later, when `frame()` slices the row, so the stack trace accuses the reader
659 /// rather than the writer. That delay is what #536 was filed about, and the assert collapses
660 /// it — a bad caller dies here, at the site that recorded it (measured: an injected off-by-one
661 /// moved the panic from `frame()`'s slice to this line).
662 /// - the **clamp is the release backstop**, and it clamps *toward a false positive*. justerm is
663 /// a library, so a panic crosses into the consumer's process; over-damaging repaints a cell
664 /// that did not change, which costs nothing a consumer can see. ghostty states the asymmetry
665 /// as a rule: *"Dirty tracking may have false positives but should never have false negatives.
666 /// A false negative would result in a visual artifact on the screen."* (`page.zig:1993-1995`).
667 ///
668 /// **`left` is guarded for that reason, and it is the axis that can actually lose a cell.**
669 /// Clamping `right` cannot under-report — columns past the last do not exist. But `LineBounds`
670 /// marks a line undamaged with `left = cols, right = 0` and `is_damaged()` is `left <= right`,
671 /// so a single `expand` with `left > right` on an otherwise-clean line leaves the line reading
672 /// as undamaged and **drops its whole span silently**. Unreachable from the ten column-derived
673 /// sites today; guarded because that is precisely the failure ghostty's rule forbids.
674 ///
675 /// `row` is deliberately left to panic on the index, and that is **not** in tension with
676 /// `frame_damage` clamping a row fifty lines below (`prev_cursor.0.min(rows - 1)`). The two
677 /// rows are different kinds of thing under the same rule: `prev_cursor` is a *stale remembered*
678 /// coordinate that a shrinking resize may have put out of range, so clamping it repaints the
679 /// nearest surviving cell — a false positive. `row` here is a *live computed* index for the
680 /// mutation just made, so clamping it would damage a different line than the one that changed:
681 /// a false negative on the real line, which is the outcome the rule forbids.
682 fn damage_span(&mut self, row: usize, left: usize, right: usize) {
683 let last = self.grid.cols().saturating_sub(1);
684 debug_assert!(
685 left <= right && right <= last,
686 "damage_span({row}, {left}, {right}) is not a span inside [0, {last}]"
687 );
688 self.line_damage[row].expand(left.min(last), right.min(last));
689 }
690
691 /// The first-class scroll recorded since the last `reset_damage`, if any.
692 /// Suppressed while scrolled up — a content scroll must not shift the frozen
693 /// viewport.
694 pub fn scroll_delta(&self) -> Option<ScrollOp> {
695 if self.display_offset > 0 {
696 return None;
697 }
698 self.scroll
699 }
700
701 /// Build a serializable [`Frame`] from the current damage + grid + grapheme
702 /// pool (#6). `Full` ships every row; `Partial` ships the damaged spans. The
703 /// global side-table is remapped to **frame-local** indices — the engine pool
704 /// is append-only and leaky, so a frame carries only the clusters its cells
705 /// reference, renumbered, with each cell's `extra` rewritten to the local id.
706 pub fn frame(&self) -> Frame {
707 let cols = self.grid.cols();
708 let rows = self.grid.rows();
709 let (kind, line_spans): (FrameKind, Vec<(usize, usize, usize)>) = match self.frame_damage()
710 {
711 TermDamage::Full => (
712 FrameKind::Full,
713 (0..rows).map(|l| (l, 0, cols - 1)).collect(),
714 ),
715 TermDamage::Partial(lines) => (
716 FrameKind::Partial,
717 lines
718 .into_iter()
719 .map(|d| (d.line, d.left, d.right))
720 .collect(),
721 ),
722 };
723
724 // Frame-local numbering for the hyperlink table (#26). Keyed by the URI's
725 // *identity* — the `Arc` pointer — so two cells sharing one open share one entry
726 // and a distinct open gets its own, which is exactly the semantics the pool
727 // index used to carry.
728 //
729 // Sized by this frame, not by session history (#628). It was
730 // `vec![0u32; hyperlink_pool.len() + 1]`, allocated and zeroed on **every**
731 // frame against every OSC 8 the session had ever seen — measured at 10 µs per
732 // frame with 100 000 opens retained. With the pool gone there is nothing left to
733 // size it by, and the cost disappears rather than being reduced.
734 let mut link_table: Vec<String> = Vec::new();
735 let mut link_remap: std::collections::HashMap<*const u8, u32> =
736 std::collections::HashMap::new();
737 // Cells come from the viewport at `display_offset`, not the live grid:
738 // viewport row `line` is absolute buffer line `top + line` (scrollback
739 // when scrolled up, the live grid when `display_offset == 0`, where
740 // `top == scrollback.len()` and this is identical to reading the grid).
741 // Without this, a wire consumer — cells reach it only through `frame()` —
742 // could never display scrollback (#48).
743 let top = self.scrollback.len() - self.display_offset;
744 let mut spans = Vec::with_capacity(line_spans.len());
745 for (line, left, right) in line_spans {
746 let mut cells = Vec::with_capacity(right - left + 1);
747 let mut combining = std::collections::BTreeMap::new();
748 let mut links = std::collections::BTreeMap::new();
749 let mut ucolors = std::collections::BTreeMap::new();
750 let row = self.abs_row(top + line);
751 let last_col = row.len().saturating_sub(1);
752 for col in left..=right {
753 let mut cell = row[col];
754 // Soft-wrap is a row property (#538), but the wire has no per-row slot — so it is
755 // *derived* back onto the last cell's WRAPLINE bit here, which keeps the format
756 // byte-identical and is why moving the storage needed no VERSION bump. The bit is
757 // therefore wire-only: on a live grid it is never set, and `Row::is_wrapped` is
758 // the question to ask.
759 if col == last_col && row.is_wrapped() {
760 cell.insert_flags(CellFlags::WRAPLINE);
761 }
762 // Combining clusters and hyperlinks live in the row's maps; each
763 // tagged cell contributes its reference to the frame, recorded on
764 // the span by span-relative column (the cell holds only the bit).
765 if let Some(marks) = row.combining_at(col) {
766 // The cluster itself, at its column — no side table and no index
767 // since v14 (#621). Nothing interned these (this push was
768 // unconditional), so the index only ever bought indirection.
769 combining.insert(col - left, marks.to_vec());
770 }
771 if let Some(uri) = row.link_at(col) {
772 // Number each distinct open once per frame (only referenced URIs
773 // ship). The wire keeps its interning — #621 measured inlining a URI
774 // per linked cell at +171…403% — so this stays an index into
775 // `link_table`; only the *engine* side stopped being a table.
776 let key = std::sync::Arc::as_ptr(uri) as *const u8;
777 let next = link_table.len() as u32 + 1;
778 let fidx = *link_remap.entry(key).or_insert_with(|| {
779 link_table.push(uri.to_string());
780 next
781 });
782 let fidx = core::num::NonZeroU32::new(fidx)
783 .expect("frame-local link indices are 1-based");
784 links.insert(col - left, fidx);
785 }
786 // Underline colour (SGR 58, #520): a colour reference, not a
787 // side-table index, so it rides the span inline. `ucolor_at` is
788 // flag-gated + already Default-filtered (the stamp only fires on an
789 // underlined cell), so a present entry is a real non-default colour.
790 if let Some(color) = row.ucolor_at(col) {
791 ucolors.insert(col - left, color);
792 }
793 cells.push(cell);
794 }
795 spans.push(Span {
796 line: line as u16,
797 left: left as u16,
798 right: right as u16,
799 cells,
800 combining,
801 links,
802 ucolors,
803 });
804 }
805
806 Frame {
807 cols: cols as u16,
808 rows: rows as u16,
809 kind,
810 // The live cursor: position in screen coords + DECTCEM visibility.
811 // Reported, not drawn — the consumer renders the caret (#38).
812 cursor_row: self.cursor.row as u16,
813 cursor_col: self.cursor.col as u16,
814 // Hidden while scrolled up: the live cursor is off the frozen
815 // viewport, and a cell-invert caret would otherwise ink over
816 // scrollback. Consistent with the frozen-damage policy (no cursor
817 // damage is emitted while scrolled) and with xterm.js / alacritty,
818 // which hide the caret when it falls outside the visible rows (#48).
819 cursor_visible: self.cursor.visible && self.display_offset == 0,
820 cursor_shape: self.cursor.shape,
821 cursor_blink: self.cursor.blink,
822 // Viewport scroll position for the consumer's scrollbar (ADR-0013).
823 display_offset: self.display_offset as u32,
824 scrollback_len: self.scrollback.len() as u32,
825 // The mouse tracking mode as a routing mask (#129): which mouse events
826 // the app wants, derived from the protocol by the single source
827 // `encode_mouse` shares. The consumer routes app-vs-local on it.
828 mouse_events: self.mouse_protocol.wanted_events(),
829 // Alt-screen flag (#149): buffer-global state the consumer can't
830 // derive from viewport damage; the a11y announce policy gates on it.
831 alt_screen: self.on_alt,
832 scroll: self.scroll_delta(),
833 spans,
834 link_table,
835 // Interaction overlays projected onto this viewport (#108): the
836 // engine-owned selection and the consumer-supplied search highlights,
837 // each re-projected here so the scroll offset is applied once, by the
838 // same authority that projects the cells.
839 overlay: Overlay {
840 selection: self.selection_range(),
841 matches: self
842 .search_highlights
843 .iter()
844 .flat_map(|m| self.match_spans(m))
845 .collect(),
846 // The consumer-designated active match (#428), projected through
847 // the same `match_spans` math — usually also present in `matches`
848 // above (the renderer's ranking resolves the overlap, #424), but
849 // a span designation may sit OUTSIDE a capped hand-over (#436).
850 active_match: self
851 .active_search_highlight
852 .as_ref()
853 .map(|m| self.match_spans(m))
854 .unwrap_or_default(),
855 markers: self.marker_positions(),
856 marker_lines: self.all_marker_lines(),
857 },
858 }
859 }
860
861 /// Record a scroll of rows `[top, bottom]` by `count` (positive = up).
862 ///
863 /// Damage is indexed by row position, so it must follow the content the
864 /// scroll just moved: rotate the bounds the same way and mark the newly
865 /// exposed line fully damaged (it is new blank content for the consumer).
866 fn record_scroll(&mut self, top: usize, bottom: usize, count: isize) {
867 let cols = self.grid.cols();
868 match count {
869 1 => {
870 self.line_damage[top..=bottom].rotate_left(1);
871 self.line_damage[bottom] = LineBounds::fully_damaged(cols);
872 }
873 -1 => {
874 self.line_damage[top..=bottom].rotate_right(1);
875 self.line_damage[top] = LineBounds::fully_damaged(cols);
876 }
877 _ => {}
878 }
879 // Accumulate repeated scrolls of the same region into one op (flow
880 // control). A *different* region cannot be expressed as one op, so
881 // degrade to full rather than silently dropping the earlier scroll.
882 match self.scroll {
883 Some(op) if op.top == top && op.bottom == bottom => {
884 self.scroll = Some(ScrollOp {
885 top,
886 bottom,
887 count: op.count + count,
888 });
889 }
890 None => self.scroll = Some(ScrollOp { top, bottom, count }),
891 Some(_) => {
892 self.scroll = None;
893 self.mark_fully_damaged();
894 }
895 }
896 }
897
898 /// Number of lines currently held in scrollback history.
899 pub fn scrollback_len(&self) -> usize {
900 self.scrollback.len()
901 }
902
903 /// Whether the app has an open synchronized-output block (DEC ?2026, #73).
904 pub fn synchronized_output(&self) -> bool {
905 self.synchronized_output
906 }
907
908 /// Whether the app enabled color-scheme-update notifications (DEC ?2031, #85).
909 pub fn color_scheme_updates(&self) -> bool {
910 self.color_scheme_updates
911 }
912
913 /// Whether the app enabled grapheme-cluster mode (DEC ?2027, #295): emoji ZWJ / skin-tone /
914 /// flag / VS16 sequences are clustered into one cell. OFF (default) is per-char, wcwidth-compat.
915 pub fn grapheme_clustering(&self) -> bool {
916 self.grapheme_clustering
917 }
918
919 /// Whether the app enabled win32-input-mode (DEC ?9001, #86). The engine does
920 /// not encode the raw key-records itself (a non-goal); a ConPTY consumer reads
921 /// this to decide whether to emit them.
922 pub fn win32_input_mode(&self) -> bool {
923 self.win32_input_mode
924 }
925
926 /// Queue a color-scheme report (`CSI ? 997 ; 1 n` dark / `; 2 n` light) on the
927 /// reply channel. The consumer calls this to answer a `ColorSchemeQuery` event
928 /// or, when its scheme changes and `color_scheme_updates()` is set, to send the
929 /// unsolicited notification. The engine never stores or interprets the scheme
930 /// (#85).
931 pub fn report_color_scheme(&mut self, dark: bool) {
932 let ps = if dark { 1 } else { 2 };
933 self.replies
934 .extend_from_slice(format!("\x1b[?997;{ps}n").as_bytes());
935 }
936
937 /// OSC 10/11 set/query the default fg/bg, stacking the `;`-separated specs
938 /// across the `[foreground, background]` slots — xterm's
939 /// `_setOrReportSpecialColor` offset loop (#137). OSC 10 starts at slot 0
940 /// (fg → bg), OSC 11 at slot 1 (bg). A `?` spec is a query. xterm's 3rd slot
941 /// (cursor / OSC 12) is out of scope, so the stack caps at two slots — extra
942 /// specs are dropped.
943 fn special_color(&mut self, params: &[&[u8]], start: usize) {
944 for (i, &spec) in params[1..].iter().enumerate() {
945 let event = match start + i {
946 0 if spec == b"?" => TermEvent::QueryForeground,
947 0 => TermEvent::SetForeground(String::from_utf8_lossy(spec).into_owned()),
948 1 if spec == b"?" => TermEvent::QueryBackground,
949 1 => TermEvent::SetBackground(String::from_utf8_lossy(spec).into_owned()),
950 _ => break, // past [fg, bg] — cursor (OSC 12) unsupported
951 };
952 self.events.push(event);
953 }
954 }
955
956 /// Answer an OSC 4 palette query (#122): wrap the consumer-supplied spec for
957 /// `index` in the OSC 4 reply envelope, ST-terminated.
958 pub fn report_palette_color(&mut self, index: u8, spec: &str) {
959 self.replies
960 .extend_from_slice(format!("\x1b]4;{index};{spec}\x1b\\").as_bytes());
961 }
962
963 /// Answer an OSC 10 foreground query (#122): wrap the consumer-supplied spec
964 /// in the OSC 10 reply envelope, ST-terminated.
965 pub fn report_foreground(&mut self, spec: &str) {
966 self.replies
967 .extend_from_slice(format!("\x1b]10;{spec}\x1b\\").as_bytes());
968 }
969
970 /// Answer an OSC 11 background query (#122): wrap the consumer-supplied spec
971 /// (it knows its palette) in the OSC 11 reply envelope, ST-terminated. The
972 /// engine formats the envelope only — it never knows the colour.
973 pub fn report_background(&mut self, spec: &str) {
974 self.replies
975 .extend_from_slice(format!("\x1b]11;{spec}\x1b\\").as_bytes());
976 }
977
978 /// The cells of visible row `i` (0..rows) at the current scroll position.
979 /// The viewport windows into `[history.. ; screen..]`: rows above
980 /// `scrollback.len()` come from history, the rest from the live screen.
981 pub fn viewport_line(&self, i: usize) -> &[Cell] {
982 let top = self.scrollback.len() - self.display_offset;
983 let idx = top + i;
984 if idx < self.scrollback.len() {
985 &self.scrollback[idx]
986 } else {
987 self.grid.row(idx - self.scrollback.len())
988 }
989 }
990
991 /// Scroll the viewport up by `n` lines into history (clamped to the oldest).
992 pub fn scroll_up(&mut self, n: usize) {
993 let target = (self.display_offset + n).min(self.scrollback.len());
994 self.set_display_offset(target);
995 }
996
997 /// Scroll the viewport down by `n` lines toward the live screen.
998 pub fn scroll_down(&mut self, n: usize) {
999 let target = self.display_offset.saturating_sub(n);
1000 self.set_display_offset(target);
1001 }
1002
1003 /// Jump the viewport back to the live screen (follow the bottom).
1004 pub fn scroll_to_bottom(&mut self) {
1005 self.set_display_offset(0);
1006 }
1007
1008 /// Move the viewport. A user scroll changes which lines are visible, so the
1009 /// whole viewport is repainted (full damage) when the offset actually moves.
1010 fn set_display_offset(&mut self, offset: usize) {
1011 // The alt screen has no scrollback to view; scroll intents are no-ops.
1012 if self.on_alt {
1013 return;
1014 }
1015 if offset != self.display_offset {
1016 self.display_offset = offset;
1017 self.mark_fully_damaged();
1018 }
1019 }
1020
1021 // ---- selection -----------------------------------------------------------
1022
1023 /// Map a viewport cell `(row, col)` to an absolute buffer point. The top
1024 /// visible row is `scrollback.len() - display_offset`, so viewport row `i`
1025 /// is that plus `i`.
1026 fn viewport_to_abs(&self, row: usize, col: usize) -> BufferPoint {
1027 let top = self.scrollback.len() - self.display_offset;
1028 BufferPoint {
1029 line: top + row,
1030 col,
1031 }
1032 }
1033
1034 /// The hyperlink **URI** at **screen** `(row, col)` (the live grid), or `None` —
1035 /// flag-gated through the row's link map. Since #628 the map holds the URI itself,
1036 /// so there is no index and no second call to resolve one.
1037 /// Mirrors `grid().cell(row, col)`.
1038 pub(crate) fn screen_link_at(&self, row: usize, col: usize) -> Option<Hyperlink> {
1039 self.grid
1040 .row_ref(row)
1041 .link_at(col)
1042 .cloned()
1043 .map(Hyperlink::new)
1044 }
1045
1046 /// The underline colour (SGR 58, #520) at screen `(row, col)`, as a theme-agnostic
1047 /// reference. `Color::Default` means "follow the fg" — the common case, and what an
1048 /// unset cell returns. Mirror of [`Term::screen_link_at`].
1049 pub(crate) fn screen_underline_color_at(&self, row: usize, col: usize) -> Color {
1050 self.grid.row_ref(row).ucolor_at(col).unwrap_or_default()
1051 }
1052
1053 /// The hyperlink URI at **viewport** `(row, col)` (visible window, history
1054 /// included at the current scroll), or `None`. Mirrors `viewport_line(row)`.
1055 pub(crate) fn viewport_link_at(&self, row: usize, col: usize) -> Option<Hyperlink> {
1056 let idx = self.scrollback.len() - self.display_offset + row;
1057 self.abs_row(idx).link_at(col).cloned().map(Hyperlink::new)
1058 }
1059
1060 /// Resize the screen to `cols` x `rows`. Rows dropped off the top (on shrink)
1061 /// enter scrollback. Column reflow of soft-wrapped lines is layered on top
1062 /// separately (#7). The whole screen is damaged.
1063 ///
1064 /// `cols` is widened to [`MIN_COLUMNS`] — a narrower screen cannot hold a
1065 /// width-2 glyph, so it is clamped rather than represented (#547).
1066 pub fn resize(&mut self, cols: usize, rows: usize) {
1067 // A terminal is never 0-tall; clamp so the math below (rows - 1) can't
1068 // underflow. Columns clamp to MIN_COLUMNS, not 1: chunking by cols needs a
1069 // non-zero width, but a *wide glyph* needs two (#547). The ceiling is the frame
1070 // header's u16, clamped here as well as in the constructor — a ceiling that held
1071 // only until the first resize is the gap `new` had against this function's own row
1072 // floor before #547 (#621).
1073 let cols = cols.clamp(MIN_COLUMNS, MAX_COLUMNS);
1074 let rows = rows.clamp(1, MAX_ROWS);
1075 let old_cols = self.grid.cols();
1076 let limit = self.scrollback_limit;
1077
1078 // A reflow moves match coordinates (and can change the match set), so the
1079 // query-derived highlights are invalidated; the consumer re-searches at
1080 // the new width. The selection re-anchors below — it is user-authored.
1081 self.invalidate_search_highlights();
1082
1083 // Both screens are resized. Scrollback pairs with the PRIMARY screen
1084 // (whichever is active) — the alt screen has no history of its own.
1085 // `reflow: true` is the *primary* pane's setting and is deliberately a constant, **not**
1086 // `self.autowrap`. ghostty gates its equivalent on DECAWM — `.reflow =
1087 // self.modes.get(.wraparound)` (`terminal/Terminal.zig` `resize`) — and the reading that
1088 // makes that coherent is the one this file just accepted for the alt screen: an application
1089 // that turns autowrap off is placing lines itself, so its content is a layout rather than a
1090 // flow, and re-wrapping it changes what it drew.
1091 //
1092 // Not followed here, for three reasons, and they are recorded rather than filed because
1093 // nothing observable is known to break either way (measured: with DECAWM off, a full
1094 // 6-column row still re-splits into two rows at width 3 exactly as it does with DECAWM on;
1095 // the difference against ghostty is that ghostty truncates that row instead).
1096 //
1097 // - **The wrap flag is not a lie.** `Row::is_wrapped` means "this row continues into the
1098 // next", which after a re-split is simply true. DECAWM governs the **write** path — where
1099 // a glyph goes when the cursor is at the last column — not how stored content is laid out
1100 // again later. Dropping the flag would make `"abcdef"` extract as `"abc\ndef"`.
1101 // - **The mode is global and momentary; the buffer is neither.** DECAWM is read at resize
1102 // time and would decide the fate of history written under the opposite setting. A TUI that
1103 // turns it off while drawing would, on a resize landing in that window, leave every
1104 // properly wrapped line in scrollback un-reflowed.
1105 // - **It costs content.** Not reflowing truncates each row to the new width, so the tail of
1106 // a long line leaves the grid. justerm keeps it.
1107 //
1108 // There is no per-row signal to be finer with: a row written under DECAWM off and a row that
1109 // merely ended early are both simply unwrapped. "Do not re-split an unwrapped logical line"
1110 // would break ordinary reflow, since a line that exactly fills its width carries no wrap
1111 // flag either.
1112 let dims = ReflowDims {
1113 old_cols,
1114 cols,
1115 rows,
1116 limit,
1117 reflow: true,
1118 };
1119 let scrollback = std::mem::take(&mut self.scrollback);
1120 if self.on_alt {
1121 // Active = alt (cursor, no scrollback); inactive = primary. Selection
1122 // is primary-only and cleared on alt enter, so no anchors to track.
1123 // Alt markers still ride this pane, but **not because it reflows** — since #567 it does
1124 // not, so a marker's content no longer moves under it and the old reason here ("justerm
1125 // column-reflows the alt grid, so a marker must follow its content") is retracted. What
1126 // they ride it for is the row *fit*: a shrink still drops rows off the top, and a marker
1127 // on one of those has left a screen with no history to hold it. Their stored line is
1128 // `base + alt_row` (base = primary scrollback len), so convert to alt-local rows here
1129 // and re-anchor on the new base afterward — the primary scrollback below may rewrap and
1130 // change length even when the alt grid does not move at all.
1131 let old_base = scrollback.len();
1132 let alt_pts: Vec<(usize, usize)> = self
1133 .alt_markers
1134 .iter()
1135 .map(|m| (m.line - old_base, m.col))
1136 .collect();
1137 let alt = self.grid.take_lines();
1138 let r_alt = reflow_pane(
1139 alt,
1140 VecDeque::new(),
1141 self.cursor.point(),
1142 &alt_pts,
1143 ReflowDims {
1144 limit: 0,
1145 reflow: false,
1146 ..dims
1147 },
1148 );
1149 self.grid.set_screen(r_alt.screen, cols, rows);
1150 self.cursor.set_point(r_alt.cursor, rows, cols);
1151
1152 // Primary is inactive here, but markers anchor *primary* content, so
1153 // they reflow with it (the selection is already cleared on alt enter).
1154 // `(line, col)`, not `(line, 0)`: the column is what bounds OSC-133 command-text
1155 // extraction (#166), and discarding it here truncated the recorded command for any
1156 // resize taken while a full-screen app was up. The primary branch below has always
1157 // passed and restored both; this one is the sibling that did not.
1158 let marker_pts: Vec<(usize, usize)> = self
1159 .normal_markers
1160 .iter()
1161 .map(|m| (m.line, m.col))
1162 .collect();
1163 let primary = self.alt_grid.take_lines();
1164 let r = reflow_pane(
1165 primary,
1166 scrollback,
1167 self.saved_cursor.point(),
1168 &marker_pts,
1169 dims,
1170 );
1171 self.alt_grid.set_screen(r.screen, cols, rows);
1172 self.scrollback = r.scrollback;
1173 self.saved_cursor.set_point(r.cursor, rows, cols);
1174 for (i, m) in self.normal_markers.iter_mut().enumerate() {
1175 m.line = r.extras[i].0.saturating_sub(r.evicted);
1176 m.col = r.extras[i].1;
1177 }
1178 // The alt half lives in a different frame from the primary one above, and adding the
1179 // two was the defect: `extras` count from the top of the alt pane's own history, and
1180 // the alt screen **has** no history — every row the shrink pushed off the top is gone,
1181 // not archived. Passing the primary's scrollback limit made `reflow_pane` keep them,
1182 // so a rows-only resize (no reflow at all) reported a marker four lines past the end of
1183 // the buffer. The limit is `0` here because that is what an alt screen's history is.
1184 //
1185 // A marker whose row went with it is **disposed**, matching what the alt screen already
1186 // does when a row leaves by scrolling (`markers_rotate_region` fires `MarkerDisposed`
1187 // for the marker on the departing edge). Silently relocating it to row 0 would put a
1188 // decoration on content it was never attached to.
1189 let new_base = self.scrollback.len();
1190 let mut alt_disposed = Vec::new();
1191 let mut i = 0;
1192 self.alt_markers.retain_mut(|m| {
1193 let (line, col) = r_alt.extras[i];
1194 i += 1;
1195 match line.checked_sub(r_alt.evicted) {
1196 Some(row) if row < rows => {
1197 m.line = new_base + row;
1198 // The column rides along for the same reason as the primary half, but
1199 // **unpinned**: `add_marker` always passes column 0, and I could not get an
1200 // OSC-133 mark (the only column-bearing kind) to appear in `alt_markers` at
1201 // all. That is a gap in my knowledge, not evidence the column is
1202 // structurally zero — `push_marker` takes a column and `markers_mut` routes
1203 // by active buffer, so the field is reachable in principle. Carrying it
1204 // keeps the two halves stating one invariant; if the alt path really is
1205 // marker-column-free, this line is a no-op.
1206 m.col = col;
1207 true
1208 }
1209 _ => {
1210 alt_disposed.push(m.id);
1211 false
1212 }
1213 }
1214 });
1215 for id in alt_disposed {
1216 self.events.push(TermEvent::MarkerDisposed(id));
1217 }
1218 } else {
1219 // Active = primary (cursor, scrollback); inactive = alt. The selection
1220 // anchors (absolute) reflow alongside the cursor so they keep their
1221 // content across a column change.
1222 let sel_pts: Vec<(usize, usize)> = self
1223 .selection
1224 .as_ref()
1225 .map(|s| {
1226 vec![
1227 (s.anchor.point.line, s.anchor.point.col),
1228 (s.focus.point.line, s.focus.point.col),
1229 ]
1230 })
1231 .unwrap_or_default();
1232 // Markers reflow on the same pane by (line, col) — the column matters
1233 // for OSC-133 command marks, whose B/C columns bound the extracted
1234 // command text (#166). They ride after the selection points so each
1235 // reads its own reflowed slot back from `extras` (#118).
1236 let mut pts = sel_pts.clone();
1237 pts.extend(self.normal_markers.iter().map(|m| (m.line, m.col)));
1238
1239 let primary = self.grid.take_lines();
1240 let r = reflow_pane(primary, scrollback, self.cursor.point(), &pts, dims);
1241 self.grid.set_screen(r.screen, cols, rows);
1242 self.scrollback = r.scrollback;
1243 self.cursor.set_point(r.cursor, rows, cols);
1244 if let Some(sel) = &mut self.selection {
1245 // A selection endpoint is **UI** state, so its reading of `col == cols` (#562) is
1246 // neither the cursor's nor a mark's: it is clamped into the grid. UI state may not
1247 // move the application's content to make room for itself — the criterion that
1248 // decided this, and the one ghostty encodes by clamping every non-cursor pin before
1249 // it can widen a row (`terminal/PageList.zig:1576-1585` @ `e6e26e1`) while leaving
1250 // the cursor pin unclamped (`:1602-1606`).
1251 sel.anchor.point = BufferPoint {
1252 line: r.extras[0].0.saturating_sub(r.evicted),
1253 col: r.extras[0].1.min(cols - 1),
1254 };
1255 sel.focus.point = BufferPoint {
1256 line: r.extras[1].0.saturating_sub(r.evicted),
1257 col: r.extras[1].1.min(cols - 1),
1258 };
1259 }
1260 let marker_off = sel_pts.len();
1261 for (i, m) in self.normal_markers.iter_mut().enumerate() {
1262 m.line = r.extras[marker_off + i].0.saturating_sub(r.evicted);
1263 m.col = r.extras[marker_off + i].1;
1264 }
1265
1266 let alt = self.alt_grid.take_lines();
1267 let r = reflow_pane(
1268 alt,
1269 VecDeque::new(),
1270 (0, 0),
1271 &[],
1272 ReflowDims {
1273 limit: 0,
1274 reflow: false,
1275 ..dims
1276 },
1277 );
1278 self.alt_grid.set_screen(r.screen, cols, rows);
1279 }
1280
1281 // Carry the deferred wrap across the resize — it is *cursor* state, and it used to be
1282 // reset here alongside the margins and tab stops, which are screen configuration and do
1283 // legitimately reset. Losing it meant the next byte overwrote the last glyph instead of
1284 // wrapping past it, on a column resize *and* on a rows-only one where no reflow runs at
1285 // all.
1286 //
1287 // The flag means "the cursor is logically one past the column it sits on". Where the
1288 // reflow leaves it somewhere other than the last column that logical position **is**
1289 // representable, so the flag is cleared and the cursor takes it instead — ghostty's rule,
1290 // stated in its own words for the saved cursor: *"If we had pending wrap set and we're no
1291 // longer at the end of the line, we unset the pending wrap and move the cursor to reflect
1292 // the correct next position"* (`terminal/Screen.zig:2092-2098` @ `e6e26e1`). alacritty
1293 // reaches the same place from the other side, lifting the cursor outside the grid before
1294 // reflowing and clamping it back afterwards (`grid/resize.rs:113-116`, `:248-251`,
1295 // `:173-177` @ `852e971`); xterm.js needs no rule because `x === cols` is representable.
1296 //
1297 // `col + 1` cannot overflow the row: the branch requires `col != cols - 1`, and `col` is
1298 // already clamped below `cols` by `Cursor::set_point`.
1299 if self.cursor.pending_wrap && self.cursor.col != cols - 1 {
1300 self.cursor.pending_wrap = false;
1301 self.cursor.col += 1;
1302 }
1303 self.scroll_top = 0;
1304 self.scroll_bottom = rows - 1;
1305 self.tabs = default_tabs(cols);
1306 self.display_offset = self.display_offset.min(self.scrollback.len());
1307
1308 // Damage tracking is sized to the screen; a resize repaints everything,
1309 // so drop any pending scroll op (it points at the old rows).
1310 self.line_damage = vec![LineBounds::undamaged(cols); rows];
1311 self.scroll = None;
1312 self.mark_fully_damaged();
1313 }
1314
1315 pub fn grid(&self) -> &Grid {
1316 &self.grid
1317 }
1318
1319 pub fn cursor(&self) -> &Cursor {
1320 &self.cursor
1321 }
1322
1323 /// Whether bracketed-paste mode (DEC ?2004) is enabled. The input encoder
1324 /// (#11) reads this to decide whether to wrap pasted text in markers.
1325 pub fn bracketed_paste(&self) -> bool {
1326 self.bracketed_paste
1327 }
1328
1329 // ---- input encoding (#11) ------------------------------------------------
1330
1331 /// Encode a key event to bytes using the active cursor-key mode (DECCKM)
1332 /// and the kitty keyboard-protocol flags (`encode_key` consults both).
1333 pub fn encode_key(&self, ev: KeyEvent) -> Option<Vec<u8>> {
1334 encode_key(
1335 &ev,
1336 self.app_cursor_keys,
1337 self.application_keypad,
1338 self.kitty_flags,
1339 )
1340 }
1341
1342 /// Encode a mouse event using the active tracking mode + encoding. `None`
1343 /// when reporting is off or the event is filtered by the mode.
1344 pub fn encode_mouse(&self, ev: MouseEvent) -> Option<Vec<u8>> {
1345 encode_mouse(&ev, self.mouse_protocol, self.mouse_encoding)
1346 }
1347
1348 /// Encode pasted text, wrapping it in bracketed-paste markers when ?2004 is
1349 /// on.
1350 pub fn encode_paste(&self, text: &str) -> Vec<u8> {
1351 encode_paste(text, self.bracketed_paste)
1352 }
1353
1354 /// Encode a focus change (`CSI I`/`CSI O`), or `None` when focus reporting
1355 /// (?1004) is off.
1356 pub fn encode_focus(&self, focused: bool) -> Option<Vec<u8>> {
1357 encode_focus(focused, self.focus_events)
1358 }
1359
1360 /// Take the consumer events queued since the last drain, emptying the queue.
1361 pub fn drain_events(&mut self) -> Vec<TermEvent> {
1362 std::mem::take(&mut self.events)
1363 }
1364
1365 /// Take the reply bytes queued since the last drain (DA/DSR/DECRQM answers),
1366 /// emptying the buffer. The consumer writes them back to the PTY.
1367 pub fn drain_replies(&mut self) -> Vec<u8> {
1368 std::mem::take(&mut self.replies)
1369 }
1370
1371 /// Device Status Report (CSI Ps n): 6 = cursor position, 5 = operating
1372 /// status. Queues the reply for `drain_replies` (#27).
1373 fn device_status_report(&mut self, param: u16) {
1374 match param {
1375 6 => {
1376 // CSI row;col R, 1-based — region-relative under origin mode
1377 // (the coordinate system the app is addressing in).
1378 let row = if self.origin_mode {
1379 self.cursor.row.saturating_sub(self.scroll_top)
1380 } else {
1381 self.cursor.row
1382 } + 1;
1383 let col = self.cursor.col + 1;
1384 self.replies
1385 .extend_from_slice(format!("\x1b[{row};{col}R").as_bytes());
1386 }
1387 5 => self.replies.extend_from_slice(b"\x1b[0n"), // status: OK
1388 _ => {}
1389 }
1390 }
1391
1392 /// Kitty keyboard-protocol negotiation (#23). `lead` is the leading CSI
1393 /// intermediate: `?` query, `>` push, `=` set, `<` pop.
1394 fn kitty_dispatch(&mut self, lead: u8, params: &Params) {
1395 match lead {
1396 // Query → report the current flags as `CSI ? flags u` (#27 channel).
1397 b'?' => self
1398 .replies
1399 .extend_from_slice(format!("\x1b[?{}u", self.kitty_flags).as_bytes()),
1400 // Push: save the current flags, then set the new ones (default 0).
1401 b'>' => {
1402 const KITTY_STACK_CAP: usize = 16;
1403 if self.kitty_stack.len() >= KITTY_STACK_CAP {
1404 self.kitty_stack.remove(0); // drop the oldest on overflow
1405 }
1406 self.kitty_stack.push(self.kitty_flags);
1407 self.kitty_flags = param_or(params, 0, 0) as u8;
1408 }
1409 // Pop `n` (default 1): restore from the stack, 0 once empty.
1410 b'<' => {
1411 for _ in 0..param_or(params, 0, 1) {
1412 self.kitty_flags = self.kitty_stack.pop().unwrap_or(0);
1413 }
1414 }
1415 // Set in place (no push): mode 1 replace, 2 or-in, 3 and-not.
1416 b'=' => {
1417 let flags = param_or(params, 0, 0) as u8;
1418 self.kitty_flags = match param_or(params, 1, 1) {
1419 1 => flags,
1420 2 => self.kitty_flags | flags,
1421 3 => self.kitty_flags & !flags,
1422 _ => self.kitty_flags,
1423 };
1424 }
1425 _ => {}
1426 }
1427 }
1428
1429 /// DECRQM (CSI ? Ps $ p): report whether DEC private mode `Ps` is set —
1430 /// `CSI ? Ps ; val $ y` with val 1=set, 2=reset, 0=not recognized (#27).
1431 fn decrqm(&mut self, mode: u16) {
1432 let state = match mode {
1433 1 => Some(self.app_cursor_keys),
1434 // DECANM (#84): set = ANSI mode (the normal state), reset = VT52.
1435 2 => Some(!self.vt52_mode),
1436 6 => Some(self.origin_mode),
1437 // DECCOLM: derived from the actual width, never a tracked flag — a
1438 // flag would lie if the consumer ignored the resize request (#82).
1439 3 => Some(self.grid.cols() == 132),
1440 7 => Some(self.autowrap),
1441 45 => Some(self.reverse_wraparound),
1442 9 => Some(self.mouse_protocol == MouseProtocol::X10),
1443 66 => Some(self.application_keypad),
1444 12 => Some(self.cursor.blink),
1445 25 => Some(self.cursor.visible),
1446 // Mouse tracking is a single-state enum (the levels are mutually
1447 // exclusive — an app enables one), so querying ?1000 while ?1002 is
1448 // active reports "reset". Faithful to that model.
1449 1000 => Some(self.mouse_protocol == MouseProtocol::Normal),
1450 1002 => Some(self.mouse_protocol == MouseProtocol::ButtonEvent),
1451 1003 => Some(self.mouse_protocol == MouseProtocol::AnyEvent),
1452 1004 => Some(self.focus_events),
1453 1006 => Some(self.mouse_encoding == MouseEncoding::Sgr),
1454 1015 => Some(self.mouse_encoding == MouseEncoding::Urxvt),
1455 1005 => Some(self.mouse_encoding == MouseEncoding::Utf8),
1456 1016 => Some(self.mouse_encoding == MouseEncoding::SgrPixels),
1457 47 | 1047 | 1049 => Some(self.on_alt),
1458 2004 => Some(self.bracketed_paste),
1459 2026 => Some(self.synchronized_output),
1460 2027 => Some(self.grapheme_clustering),
1461 2031 => Some(self.color_scheme_updates),
1462 9001 => Some(self.win32_input_mode),
1463 _ => None,
1464 };
1465 let val = match state {
1466 Some(true) => 1,
1467 Some(false) => 2,
1468 None => 0,
1469 };
1470 self.replies
1471 .extend_from_slice(format!("\x1b[?{mode};{val}$y").as_bytes());
1472 }
1473
1474 // ---- cursor / scroll primitives ------------------------------------------
1475
1476 /// Move down one line. At the bottom margin, scroll the region instead;
1477 /// below the region, just descend (no scroll). Column is unchanged (raw LF;
1478 /// CR is what returns to column 0).
1479 /// An ordinary line feed — `LF`/`VT`/`FF`, `IND` and `NEL`. None of them serves a wrap.
1480 fn linefeed(&mut self) {
1481 self.linefeed_inner(false);
1482 }
1483
1484 /// A line feed, carrying the one fact the shift itself cannot see: whether the auto-wrap asked
1485 /// for it.
1486 ///
1487 /// `serves_wrap` is the **bottom** seam's exemption in `shift_region`, the mirror of
1488 /// `evicts_to_scrollback` for the top one. When `wrapline` drives this, the blank that lands at
1489 /// the region's bottom *is* where the wrapped text is about to go — so the row that #540 would
1490 /// call "the one that lost its continuation to the blank" is in fact the row whose continuation
1491 /// that blank **is** (#557).
1492 ///
1493 /// xterm.js threads the identical fact through the identical seam, in the opposite direction:
1494 /// `BufferService.scroll(eraseAttr, isWrapped)` stamps the *destination* row
1495 /// (`common/services/BufferService.ts:68`/`:77` @ `699f553`), and exactly one of its four
1496 /// non-test callers passes `true` — the auto-wrap branch of `_print` (`InputHandler.ts:588`),
1497 /// not `lineFeed`, `index` or the ED-2 loop.
1498 fn linefeed_inner(&mut self, serves_wrap: bool) {
1499 // New-line mode (LNM ?20): a line feed also returns to column 0 (#71).
1500 if self.newline_mode {
1501 self.carriage_return();
1502 }
1503 if self.cursor.row == self.scroll_bottom {
1504 // A top-anchored primary-screen scroll pushes the evicted top line
1505 // into scrollback history.
1506 if self.scroll_top == 0 && !self.on_alt {
1507 // Scrollback accrues whenever the scroll is top-anchored on the
1508 // primary screen (`scroll_top == 0`) — but the O(1) ring handshake
1509 // only applies to a *full-screen* scroll (`scroll_bottom` at the
1510 // last row). A top-anchored *sub-region* (`[0..k]`, k < rows-1)
1511 // still accrues, yet must scroll only its region, so it keeps the
1512 // copy + region scroll. These are distinct predicates (ADR-0009).
1513 let evicted = if self.scroll_bottom == self.grid.rows() - 1 {
1514 // Full-screen hot path: move the evicted top row out, install
1515 // a recycled blank as the new bottom (zero-alloc steady state).
1516 let blank = self
1517 .recycled_row
1518 .take()
1519 .unwrap_or_else(|| Row::from_cells(Vec::with_capacity(self.grid.cols())));
1520 let evicted = self.grid.scroll_up_recycle(blank);
1521 // The one row-shifting path that does not route through `shift_region` (it
1522 // needs the primitive that *returns* the evicted row), so it records its own
1523 // scroll op — and owes no seam clear, which is now an argument rather than a
1524 // coincidence: the top seam is exempt because this evicts into scrollback
1525 // (adjacency is preserved one row back), and the bottom seam is exempt because
1526 // this branch only runs at `scroll_bottom == rows - 1`, where a wrap on the
1527 // last row is the state the scroll exists to serve.
1528 self.record_scroll(self.scroll_top, self.scroll_bottom, 1);
1529 evicted
1530 } else {
1531 // Top-anchored sub-region: copy row 0, then region-scroll
1532 // `[0..=scroll_bottom]` (rows below stay fixed).
1533 //
1534 // #449: the fixed rows keep their GRID position while
1535 // scrollback grows, so their content's concatenated absolute
1536 // index shifts +1 — re-anchor the content-tracking anchors
1537 // (selection, markers; alacritty's swap-back of the fixed
1538 // bottom lines is the screen-relative equivalent of this)
1539 // and invalidate the query-derived highlights
1540 // (drop-not-re-anchor policy, #108). In-region and
1541 // scrollback content keeps stable indices — untouched.
1542 let below = self.scrollback.len() + self.scroll_bottom + 1;
1543 self.selection_shift_below_margin(below);
1544 self.markers_shift_below_margin(below);
1545 self.invalidate_search_highlights();
1546 let evicted = self.grid.row_owned(0);
1547 self.shift_region(
1548 self.scroll_top,
1549 self.scroll_bottom,
1550 false,
1551 true,
1552 serves_wrap,
1553 );
1554 evicted
1555 };
1556 self.scrollback.push_back(evicted);
1557 // Follow-bottom = stay: if the user is scrolled up, bump the
1558 // offset so the same lines stay in view instead of being yanked
1559 // to the bottom.
1560 if self.display_offset > 0 {
1561 self.display_offset = (self.display_offset + 1).min(self.scrollback.len());
1562 }
1563 // Cap: evict the oldest line past the limit. The view is anchored
1564 // to history, so dropping the front shifts the offset down too
1565 // (xterm.js trims ybase and ydisp together) — also keeps the
1566 // offset within `[0, len]`. The evicted row is parked for reuse.
1567 if self.scrollback.len() > self.scrollback_limit {
1568 self.recycled_row = self.scrollback.pop_front();
1569 // Every absolute index just shifted down by one; move the
1570 // selection with it so its anchors keep their content.
1571 self.selection_evict_oldest();
1572 // Query-derived highlights can't survive the index shift (see
1573 // the method doc); selection re-anchors, highlights invalidate.
1574 self.invalidate_search_highlights();
1575 // Markers are persistent anchors: shift them down with the
1576 // index, disposing any whose line was the evicted one (#118).
1577 self.markers_evict_oldest();
1578 if self.display_offset > 0 {
1579 // Scrolled up: evicting the oldest line advanced the
1580 // viewport, so it must be repainted (the "frozen while
1581 // scrolled" rule does not apply when the view itself moved).
1582 self.display_offset -= 1;
1583 self.mark_fully_damaged();
1584 }
1585 }
1586 } else {
1587 // Region (top margin > 0) or alt-screen scroll: the evicted line
1588 // does NOT enter scrollback, so content moves *within* the screen
1589 // and absolute indices in the region shift. Rotate the selection
1590 // up so it follows; an endpoint on the dropped line clears it.
1591 let base = self.scrollback.len();
1592 self.selection_rotate_region(
1593 base + self.scroll_top,
1594 base + self.scroll_bottom,
1595 true,
1596 );
1597 // Rotate the active buffer's markers with the content (#187):
1598 // per-buffer storage (#186) scopes them, so an alt scroll rotates
1599 // *alt* marks and leaves the frozen primary list untouched — no
1600 // guard needed. `markers_rotate_region` routes via `markers_mut`.
1601 self.markers_rotate_region(base + self.scroll_top, base + self.scroll_bottom, true);
1602 self.invalidate_search_highlights();
1603 self.shift_region(
1604 self.scroll_top,
1605 self.scroll_bottom,
1606 false,
1607 false,
1608 serves_wrap,
1609 );
1610 }
1611 } else if self.cursor.row + 1 < self.grid.rows() {
1612 self.cursor.row += 1;
1613 }
1614 }
1615
1616 /// DECSTBM (CSI r): set the top/bottom scroll margins (1-based inclusive).
1617 /// An invalid region (top ≥ bottom) is ignored.
1618 fn set_scroll_region(&mut self, top: usize, bottom: usize) {
1619 let bottom = bottom.min(self.grid.rows());
1620 if top >= bottom {
1621 return;
1622 }
1623 self.scroll_top = top - 1;
1624 self.scroll_bottom = bottom - 1;
1625 self.goto(0, 0); // DECSTBM homes the cursor (absolute)
1626 }
1627
1628 // ---- alt screen (DEC 1049) -----------------------------------------------
1629
1630 /// Enter the alternate screen: save the cursor, swap in the other grid, and
1631 /// clear it.
1632 /// Save the cursor into the alt-screen slot — `?1048` set, and the first
1633 /// half of `?1049` enter (#72).
1634 fn save_alt_cursor(&mut self) {
1635 self.saved_cursor = self.cursor;
1636 }
1637
1638 /// Restore the cursor from the alt-screen slot — `?1048` reset, and the
1639 /// second half of `?1049` leave. DECTCEM visibility is a standalone mode, not
1640 /// part of the save, so preserve it across the restore (#38/#72).
1641 fn restore_alt_cursor(&mut self) {
1642 let visible = self.cursor.visible;
1643 self.cursor = self.saved_cursor;
1644 self.cursor.visible = visible;
1645 }
1646
1647 /// Switch to the (cleared) alternate buffer without touching the cursor —
1648 /// `?47`/`?1047` set, and the second half of `?1049` enter (#72).
1649 fn switch_to_alt(&mut self) {
1650 if self.on_alt {
1651 return;
1652 }
1653 std::mem::swap(&mut self.grid, &mut self.alt_grid);
1654 self.grid.clear();
1655 self.on_alt = true;
1656 self.display_offset = 0; // the alt screen has no scrollback to view
1657 self.selection = None; // a selection cannot survive a screen swap
1658 self.invalidate_search_highlights(); // matches index the primary buffer
1659 self.mark_fully_damaged();
1660 }
1661
1662 /// Switch back to the primary buffer without touching the cursor —
1663 /// `?47`/`?1047` reset, and the first half of `?1049` leave (#72).
1664 fn switch_to_primary(&mut self) {
1665 if !self.on_alt {
1666 return;
1667 }
1668 // Dispose the alt buffer's markers on leave — xterm `activateNormalBuffer`
1669 // → `clearAllMarkers` (#177 S0). Empty while the alt guards stand, so this
1670 // fires nothing today; it's the seam the alt-marker slices (#187) build on.
1671 for m in self.alt_markers.drain(..) {
1672 self.events.push(TermEvent::MarkerDisposed(m.id));
1673 }
1674 std::mem::swap(&mut self.grid, &mut self.alt_grid);
1675 self.on_alt = false;
1676 self.display_offset = 0; // return to the primary at its bottom
1677 self.selection = None; // a selection cannot survive a screen swap
1678 self.invalidate_search_highlights(); // matches index the swapped-out buffer
1679 self.mark_fully_damaged();
1680 }
1681
1682 fn enter_alt_screen(&mut self) {
1683 if self.on_alt {
1684 return;
1685 }
1686 self.save_alt_cursor();
1687 self.switch_to_alt();
1688 }
1689
1690 /// Leave the alternate screen: swap the primary grid back in and restore the
1691 /// saved cursor.
1692 fn leave_alt_screen(&mut self) {
1693 if !self.on_alt {
1694 return;
1695 }
1696 self.switch_to_primary();
1697 self.restore_alt_cursor();
1698 }
1699
1700 /// RI (ESC M): move up one line. At the top margin, scroll the region down
1701 /// instead.
1702 fn reverse_index(&mut self) {
1703 if self.cursor.row == self.scroll_top {
1704 // RI never enters scrollback; the region scrolls down within the
1705 // screen, so absolute indices in it shift down. Rotate the selection.
1706 let base = self.scrollback.len();
1707 self.selection_rotate_region(base + self.scroll_top, base + self.scroll_bottom, false);
1708 // Rotate the active buffer's markers (#187) — alt-scoped on the alt
1709 // screen, so no guard (see `linefeed`).
1710 self.markers_rotate_region(base + self.scroll_top, base + self.scroll_bottom, false);
1711 self.invalidate_search_highlights();
1712 self.shift_region(self.scroll_top, self.scroll_bottom, true, false, false);
1713 } else if self.cursor.row > 0 {
1714 self.cursor.row -= 1;
1715 }
1716 }
1717
1718 // ---- cursor save/restore (DECSC / DECRC) ---------------------------------
1719
1720 /// DECSC (ESC 7): save the cursor position, pen, pending-wrap, and origin
1721 /// mode. Visibility is not saved (DECTCEM is separate).
1722 fn save_cursor(&mut self) {
1723 self.decsc = SavedCursor {
1724 row: self.cursor.row,
1725 col: self.cursor.col,
1726 pen: self.cursor.pen,
1727 pending_wrap: self.cursor.pending_wrap,
1728 origin_mode: self.origin_mode,
1729 charsets: self.charsets,
1730 gl: self.gl,
1731 };
1732 }
1733
1734 /// DECRC (ESC 8): restore what DECSC saved. Origin mode is restored (per
1735 /// ADR-0004); visibility is left as-is. The position is clamped to the
1736 /// current screen in case it shrank since the save.
1737 fn restore_cursor(&mut self) {
1738 let s = self.decsc;
1739 self.cursor.row = s.row.min(self.grid.rows() - 1);
1740 self.cursor.col = s.col.min(self.grid.cols() - 1);
1741 self.cursor.pen = s.pen;
1742 self.cursor.pending_wrap = s.pending_wrap;
1743 self.origin_mode = s.origin_mode;
1744 self.charsets = s.charsets;
1745 self.gl = s.gl;
1746 }
1747
1748 /// RIS (ESC c) — full reset to the power-on state (#53). Reconstruct every
1749 /// screen/mode field to its construction default (preserving only the
1750 /// dimensions and the scrollback cap), but keep the consumer-bound output
1751 /// queues (`replies`/`events`) that accrued earlier in this `feed`, and
1752 /// signal a full repaint. The vte parser lives outside `Term`, so replacing
1753 /// `self` does not disturb in-progress parsing. Mirrors xterm.js fullReset.
1754 fn full_reset(&mut self) {
1755 let replies = std::mem::take(&mut self.replies);
1756 let mut events = std::mem::take(&mut self.events);
1757 // RIS wipes the buffer, so every marker's line is gone — announce each
1758 // disposal so the consumer drops its decorations (and isn't confused when
1759 // the reset id counter reissues the same ids). The events survive the
1760 // reset below (#118).
1761 events.extend(
1762 self.normal_markers
1763 .iter()
1764 .chain(&self.alt_markers)
1765 .map(|m| TermEvent::MarkerDisposed(m.id)),
1766 );
1767 let (cols, rows) = (self.grid.cols(), self.grid.rows());
1768 *self = Term::with_scrollback(cols, rows, self.scrollback_limit);
1769 self.replies = replies;
1770 self.events = events;
1771 self.mark_fully_damaged();
1772 }
1773
1774 /// DECSTR (CSI ! p) — soft reset (#53). Resets a defined subset of modes to
1775 /// their defaults *without* destroying screen content or scrollback, moving
1776 /// the active cursor, or touching the mouse/focus reporting subsystem. Per
1777 /// xterm.js softReset, autowrap returns to ON (the xterm default), not off.
1778 fn soft_reset(&mut self) {
1779 self.cursor.visible = true;
1780 self.cursor.pen = Pen::default();
1781 self.scroll_top = 0;
1782 self.scroll_bottom = self.grid.rows() - 1;
1783 self.origin_mode = false;
1784 self.app_cursor_keys = false;
1785 self.bracketed_paste = false;
1786 self.grapheme_clustering = false; // ?2027 back to the wcwidth-compat default (#295)
1787 self.autowrap = true; // xterm default is ON (not the VT100 "off")
1788 self.insert_mode = false;
1789 self.charsets = [Charset::Ascii; 4];
1790 self.gl = 0;
1791 self.decsc = SavedCursor::default();
1792 }
1793
1794 fn carriage_return(&mut self) {
1795 self.cursor.col = 0;
1796 self.cursor.pending_wrap = false;
1797 }
1798
1799 /// DECSCUSR (CSI Ps SP q): set the caret shape + blink (#89). 0/2 = steady
1800 /// block, 1 = blinking block; 3/4 = blinking/steady underline; 5/6 =
1801 /// blinking/steady bar (odd = blink). 0 resets to the default (steady block).
1802 /// An unknown param leaves the style unchanged. Mirrors xterm.js.
1803 fn set_cursor_style(&mut self, param: u16) {
1804 let (shape, blink) = match param {
1805 0 | 2 => (CursorShape::Block, false),
1806 1 => (CursorShape::Block, true),
1807 3 => (CursorShape::Underline, true),
1808 4 => (CursorShape::Underline, false),
1809 5 => (CursorShape::Bar, true),
1810 6 => (CursorShape::Bar, false),
1811 _ => return,
1812 };
1813 self.cursor.shape = shape;
1814 self.cursor.blink = blink;
1815 }
1816
1817 /// Backspace (BS, 0x08): move the cursor one column left. With reverse
1818 /// wraparound (?45) a backspace at column 0 of a *soft-wrapped* row moves
1819 /// back to the last column of the previous row — undoing one autowrap. Only
1820 /// soft wraps reverse (the previous row carries `WRAPLINE`); a hard CR/LF
1821 /// line does not. BS only (not cursor-left), matching xterm.js (#80).
1822 fn backspace(&mut self) {
1823 self.cursor.pending_wrap = false;
1824 if self.cursor.col > 0 {
1825 self.cursor.col -= 1;
1826 return;
1827 }
1828 if self.reverse_wraparound
1829 && self.cursor.row > self.scroll_top
1830 && self.cursor.row <= self.scroll_bottom
1831 {
1832 let prev = self.cursor.row - 1;
1833 let last = self.grid.cols() - 1;
1834 if self.grid.row_ref(prev).is_wrapped() {
1835 self.grid.row_mut(prev).set_wrapped(false);
1836 self.cursor.row = prev;
1837 self.cursor.col = last;
1838 }
1839 }
1840 }
1841
1842 /// Auto-wrap at end of line: line-feed then return to column 0.
1843 fn wrapline(&mut self) {
1844 self.linefeed_inner(true);
1845 self.cursor.col = 0;
1846 self.cursor.pending_wrap = false;
1847 }
1848
1849 // ---- tab stops (HT / HTS / TBC) ------------------------------------------
1850
1851 /// HT: advance to the next set tab stop, or the last column if none remain
1852 /// (no wrap).
1853 fn put_tab(&mut self) {
1854 let cols = self.grid.cols();
1855 let mut col = self.cursor.col;
1856 while col + 1 < cols {
1857 col += 1;
1858 if self.tabs[col] {
1859 break;
1860 }
1861 }
1862 self.cursor.col = col;
1863 self.cursor.pending_wrap = false;
1864 }
1865
1866 /// HTS (ESC H): set a tab stop at the cursor column.
1867 fn set_tab_stop(&mut self) {
1868 let col = self.cursor.col;
1869 self.tabs[col] = true;
1870 }
1871
1872 /// TBC (CSI g): clear the tab stop at the cursor (mode 0) or all stops
1873 /// (mode 3).
1874 fn clear_tab_stop(&mut self, mode: u16) {
1875 match mode {
1876 0 => {
1877 let col = self.cursor.col;
1878 self.tabs[col] = false;
1879 }
1880 3 => self.tabs.iter_mut().for_each(|t| *t = false),
1881 _ => {}
1882 }
1883 }
1884
1885 // ---- printing ------------------------------------------------------------
1886
1887 /// The extended attributes the pen currently stamps onto a cell it writes: the open OSC 8
1888 /// hyperlink (#26/#46) and a non-default underline colour (SGR 58, #520).
1889 ///
1890 /// The colour is gated on the UNDERLINE attribute — an underline colour is meaningless on a
1891 /// cell that draws no underline, and xterm likewise does not persist it there
1892 /// (`AttributeData isEmpty()` ignores the colour; `InputHandler.test.ts:2084`). That keeps
1893 /// it off the wire for cells that never draw it (ADR-0020: no inert per-cell payload). SGR 58
1894 /// is the *underline* colour, so STRIKETHROUGH alone does not arm it.
1895 ///
1896 /// One place, because there are three sites that write a pen-built cell (the glyph, its wide
1897 /// spacer, and the vacated wrap column) — mirroring the pen half of `Row::ext_attrs_at`, so a
1898 /// later rider is added here rather than at each of them (#521/#528).
1899 fn pen_ext_attrs(&self) -> ExtAttrs {
1900 let ucolor = self.cursor.pen.underline_color;
1901 let armed =
1902 ucolor != Color::Default && self.cursor.pen.flags.contains(CellFlags::UNDERLINE);
1903 ExtAttrs::from_pen(self.current_link.clone(), armed.then_some(ucolor))
1904 }
1905
1906 /// Free a cell that has stopped being part of a glyph — the *structural repair* every
1907 /// overwrite, erase and row-shift owes the no-orphan invariant when it destroys one half of
1908 /// a width-2 glyph, plus the spacer a mode-2027 demotion no longer needs.
1909 ///
1910 /// This is **not** an erase. The app asked for something at a *different* column; freeing
1911 /// this one is the engine keeping its own invariant. But it is still a mutation, so it
1912 /// **damages** — and that is the half every site used to forget, because each function
1913 /// damaged its own range and the repaired cell lies outside it by construction (that is what
1914 /// makes it a repair). A frame-mode consumer therefore kept painting the destroyed glyph.
1915 /// Bundling the reset with its damage is the point of this helper: a repair site added later
1916 /// cannot forget the half that has no compiler behind it (#530).
1917 ///
1918 /// The cell it leaves is a **blank carrying the current background** — the same rule
1919 /// `clear_cells` already applies to a BCE erase, extended to the repair, so one sentence
1920 /// covers both: *a blank cell carries the current background.* A bare `Cell::default()`
1921 /// would punch an uncoloured notch into a coloured run, which no reference implementation
1922 /// does.
1923 ///
1924 /// Deliberately the pen's **background only** — not its full attributes, and this is not a
1925 /// compromise between references: it is byte-for-byte xterm.js's `_eraseAttrData()`
1926 /// (`DEFAULT_ATTR_DATA` + `curAttr.bg & ~0xFC000000`, i.e. default everything plus the pen's
1927 /// background colour), which is what its `replaceCells` / `insertCells` / `deleteCells`
1928 /// repairs are handed — eight of the twelve sites here. Only xterm's *print* path uses the
1929 /// whole pen. Taking the whole pen
1930 /// (xterm.js `setCellFromCodepoint(x, 0, 1, curAttr)`) would plant the pen's hyperlink and,
1931 /// worse, its DECSCA protection onto a cell the app never wrote — a cell no later erase could
1932 /// clear. Taking the *cell's own* attributes (alacritty `clear_wide`, which keeps `extra`)
1933 /// would leave the destroyed glyph's hyperlink alive and clickable, the defect #529 is filed
1934 /// against. Both were considered and rejected; the maintainer chose this on 2026-07-24 and it
1935 /// is theirs to reverse (see #530 for what they were shown).
1936 ///
1937 /// What used to be recorded here as a known limitation is **resolved** (#538, ADR-0025 D1):
1938 /// `reset()` still clears the whole content word, but the soft-wrap link is no longer part of
1939 /// it. The live flag is on the `Row`, so freeing the last column — here or on the erase path —
1940 /// cannot break a wrap; `CellFlags::WRAPLINE` is wire-only, derived onto the last cell at
1941 /// encode time and never read back (`cell.rs`). Ending a wrap is now an explicit per-verb call
1942 /// (`end_wrap`), which is the shape both references already had and the reason the move was
1943 /// made: ghostty and xterm.js hold the flag on the row/line, and xterm.js takes `clearWrap` as
1944 /// an explicit argument on its erase helper (`_eraseInBufferLine`, `InputHandler.ts:1175`)
1945 /// rather than letting a cell clear decide it.
1946 ///
1947 /// Known cost, accepted rather than overlooked: with DECSCA the freed cell loses its
1948 /// protection. ghostty has the same hole and flags it in its own source; justerm does not
1949 /// implement DECSCA today, so revisit if it lands.
1950 fn free_cell(&mut self, row: usize, col: usize) {
1951 let bg = self.cursor.pen.bg;
1952 let cell = self.grid.cell_mut(row, col);
1953 cell.reset();
1954 cell.set_bg(bg);
1955 // As in `clear_cells`: the bits are gone, so release what they gated (#628).
1956 self.grid.row_mut(row).purge_side_maps(col..col + 1);
1957 self.damage_span(row, col, col);
1958 }
1959
1960 /// Will the next `wrapline()` actually reach another row?
1961 ///
1962 /// `wrapline` → `linefeed` advances in exactly two cases: the cursor sits at the scroll
1963 /// region's bottom (so the region scrolls under it), or it has a row below it on screen.
1964 /// Parked *below* a DECSTBM region on the last row it does neither — it silently stays put.
1965 ///
1966 /// Both wide-at-boundary paths must ask before they commit anything, because both destroy
1967 /// content on the assumption that the row is about to change: `write_glyph` blanks the column
1968 /// it is leaving, and `relocate_cluster_wide` writes its cluster to `(cursor.row, 0..=1)`
1969 /// *after* the wrap — which is the same row when nothing advanced, so it lands on live cells.
1970 /// Reasoning only about the vacated source column misses that second case entirely.
1971 ///
1972 /// This mirrors `linefeed`'s own condition; the two must be read together.
1973 fn wrapline_advances(&self) -> bool {
1974 self.cursor.row == self.scroll_bottom || self.cursor.row + 1 < self.grid.rows()
1975 }
1976
1977 /// Blank the last column as the soft-wrap artefact it is, when a width-2 glyph could not fit
1978 /// there (#528). Shared by the two paths that reach this state: `write_glyph`'s wide-at-boundary
1979 /// wrap and `relocate_cluster_wide`'s promoted cluster.
1980 ///
1981 /// The column is **written**, not merely flagged: a blank built from the current pen, exactly
1982 /// as every reference does it — xterm.js `setCellFromCodepoint(col, 0, 1, curAttr)`
1983 /// (`InputHandler.ts:609-611`; `BufferLine.ts:244-251` takes the pen's fg/bg *and* its
1984 /// `extended` link/colour), ghostty `printCell(0, .spacer_head)` (`Terminal.zig:1410-1412`,
1985 /// whose `printCell` stamps the cursor's hyperlink), alacritty `write_at_cursor(' ')` under a
1986 /// `LEADING_WIDE_CHAR_SPACER` template (`mod.rs:1108-1113`, assigning `extra` from it).
1987 ///
1988 /// Flagging in place instead left the previous occupant's glyph, hyperlink and underline colour
1989 /// alive in a cell every text reader skips — so a renderer drew a character that could not be
1990 /// copied, searched or announced (#528). Building from `Pen::cell` also clears the presence
1991 /// bits, so no stale side-map entry can be read back through the new cell.
1992 ///
1993 /// WRAPLINE marks the row a continuation rather than a hard line-end (search, logical lines
1994 /// #113 and reflow #7 all read it); the leading-spacer marker makes the text extractors skip
1995 /// the blank instead of joining `"ab한"` → `"ab 한"`.
1996 ///
1997 /// The marker is **alacritty's** `LEADING_WIDE_CHAR_SPACER` (`term/cell.rs`) — ghostty calls
1998 /// the same thing `.spacer_head`. It is *not* xterm's: xterm.js has no marker at all, writing
1999 /// a bare null cell and re-inferring the artefact at reflow time from "ends in null and the
2000 /// following line starts with a wide char". That difference has a consequence here — in
2001 /// xterm.js a lost marker degrades to an empty cell that trimming drops anyway, whereas
2002 /// justerm writes `' '`, so the marker is the *only* thing keeping this column out of the
2003 /// extracted text.
2004 fn vacate_for_wrap(&mut self, row: usize, col: usize) {
2005 // Writing this column makes the vacate an overwrite like any other, so it inherits the
2006 // no-orphan obligation every other overwrite site carries (`write_glyph`,
2007 // `promote_cluster_to_wide`, `insert_chars`, `delete_chars`, the erase path): if the
2008 // column was the *spacer* of a wide glyph, blanking it destroys the spacer marker and
2009 // strands the lead. That is unrecoverable rather than merely untidy — every repair path
2010 // keys off `is_wide_spacer()`, so once the marker is gone no later write, ECH, EL, ICH
2011 // or DCH can ever clear the orphan.
2012 if col > 0 && self.grid.cell(row, col).is_wide_spacer() {
2013 self.free_cell(row, col - 1);
2014 }
2015 let mut vacated = self.cursor.pen.cell(' ');
2016 vacated.set_leading_spacer();
2017 *self.grid.cell_mut(row, col) = vacated;
2018 self.begin_wrap(row);
2019 let ext = self.pen_ext_attrs();
2020 self.grid.row_mut(row).set_ext_attrs(col, ext);
2021 // The cell's contents changed, so a frame-mode consumer must be told or it keeps painting
2022 // the old glyph (ADR-0003: every mutation site records damage). The *repaired* lead above
2023 // is damaged by `free_cell`, which owns that pairing for all twelve repair sites — this
2024 // site used to hand-roll it, and keeping both left neither able to discriminate.
2025 self.damage_span(row, col, col);
2026 }
2027
2028 /// Write one glyph at the cursor, handling deferred wrap and the wide-char
2029 /// spacer, then advance the cursor (deferring the wrap if it hits the edge).
2030 fn write_glyph(&mut self, c: char, width: usize) {
2031 // Every wide branch below is gated on `width == 2`, and the four unguarded uses
2032 // (`insert_chars`, `col + width - 1` twice, the cursor advance) assume the same bound.
2033 // The caller coerces (#595); this states the assumption at the site that holds it, so a
2034 // future second caller fails a test rather than writing an unmarked run of blanks.
2035 // Ghostty pairs its own source-side clamp with the same assertion for the same reason
2036 // (`Terminal.zig`, *"it is possible to have a width of 3 … assert(width <= 2)"*).
2037 debug_assert!(
2038 width <= 2,
2039 "write_glyph({c:?}, {width}) — the cell model represents at most a pair"
2040 );
2041 let cols = self.grid.cols();
2042
2043 // Resolve a deferred last-column wrap before placing the next glyph.
2044 // The row being left soft-wrapped: mark its last cell so reflow (#7) can
2045 // tell it from a hard CR/LF line-end.
2046 if self.cursor.pending_wrap {
2047 let row = self.cursor.row;
2048 // Claim the wrap only if there will *be* a next row to continue into. Parked below a
2049 // DECSTBM region on the last row, `wrapline` → `linefeed` advances nothing and the
2050 // glyph overwrites this same row from column 0 — so the wrap never happened, and a
2051 // flag set here is permanently false: the cursor never leaves, nothing clears it, and
2052 // it survives into `backspace`'s reverse-wraparound, reflow, and every text reader.
2053 //
2054 // The predicate is not new and neither is its rationale: `wrapline_advances` was
2055 // written for exactly this state and is already asked by both wide-at-boundary paths.
2056 // This narrow path was the one caller that committed without asking. (Surfaced by the
2057 // #540 completeness pass, which found a row-shift verb inheriting the bogus flag and
2058 // merging two unrelated logical lines.)
2059 if self.wrapline_advances() {
2060 self.begin_wrap(row);
2061 }
2062 self.wrapline();
2063 }
2064
2065 // A width-2 glyph that cannot fit in the last column wraps first — unless
2066 // autowrap is off, in which case it is dropped (xterm.js `continue`), not
2067 // squeezed or wrapped.
2068 if width == 2 && self.cursor.col + 1 >= cols {
2069 if !self.autowrap {
2070 return;
2071 }
2072 // …but only if the wrap actually happens: vacating for a wrap that never occurs
2073 // blanks a column holding a live glyph.
2074 if self.wrapline_advances() {
2075 self.vacate_for_wrap(self.cursor.row, cols - 1);
2076 }
2077 self.wrapline();
2078 }
2079
2080 // Insert mode (IRM): open a `width`-wide gap at the cursor first, shifting
2081 // the row's tail right (off-edge cells discarded, wide halves repaired),
2082 // then write into the gap — mirrors xterm.js's insertCells (#64).
2083 if self.insert_mode {
2084 self.insert_chars(width);
2085 }
2086
2087 let (row, col) = (self.cursor.row, self.cursor.col);
2088
2089 // Overwriting either half of a pair that wrapped from the row above ends that pair, so the
2090 // row above's artefact record is void (#534). The one exception is the in-place same-width
2091 // overwrite — a wide lead replaced by another wide lead at the same column — which is
2092 // ghostty's `if (cell.wide != wide)` escape and the reason this is asked *before* the
2093 // write rather than after it. Note IRM has already run its own check inside `insert_chars`
2094 // by the time this would fire, on the pre-shift state, which is the correct one.
2095 if col <= 1 && self.wrapped_pair_at_row_start(row) && !(col == 0 && width == 2) {
2096 self.void_wrap_artefact_above(row);
2097 }
2098
2099 // Overwriting one half of an existing wide glyph orphans the other —
2100 // clear it so no stray lead/spacer is left behind.
2101 let last = col + width - 1;
2102 if col > 0 && self.grid.cell(row, col).is_wide_spacer() {
2103 self.free_cell(row, col - 1);
2104 }
2105 if last + 1 < cols && self.grid.cell(row, last).is_wide() {
2106 self.free_cell(row, last + 1);
2107 }
2108
2109 let mut cell = self.cursor.pen.cell(c);
2110 if width == 2 {
2111 cell.insert_flags(CellFlags::WIDE_CHAR);
2112 }
2113 *self.grid.cell_mut(row, col) = cell;
2114 // Stamp the pen's extended attrs — the open hyperlink (#26/#46) and a non-default
2115 // underline colour (#520) — into the row's side maps.
2116 let ext = self.pen_ext_attrs();
2117 // `.clone()`: `ExtAttrs` stopped being `Copy` at #628 (the link rider is a shared
2118 // `Arc<str>`), and the spacer below stamps the same value — a refcount bump, not
2119 // a second string.
2120 self.grid.row_mut(row).set_ext_attrs(col, ext.clone());
2121
2122 // The trailing column of a wide glyph carries a distinct spacer marker —
2123 // and the same link + underline colour, so a hover/selection/underline over
2124 // either half agrees.
2125 if width == 2 && col + 1 < cols {
2126 let mut spacer = self.cursor.pen.cell(' ');
2127 spacer.insert_flags(CellFlags::WIDE_CHAR_SPACER);
2128 *self.grid.cell_mut(row, col + 1) = spacer;
2129 self.grid.row_mut(row).set_ext_attrs(col + 1, ext);
2130 }
2131
2132 // Record damage for the cell(s) just written.
2133 self.damage_span(row, col, col + width - 1);
2134
2135 // Advance. Reaching/passing the last column sets pending-wrap instead of
2136 // wrapping eagerly — the cursor parks on the last column.
2137 let new_col = col + width;
2138 if new_col >= cols {
2139 self.cursor.col = cols - 1;
2140 // With autowrap off (DECAWM ?7l) the cursor pins to the last column
2141 // and the next glyph overwrites in place — no deferred wrap (#63).
2142 self.cursor.pending_wrap = self.autowrap;
2143 } else {
2144 self.cursor.col = new_col;
2145 }
2146 }
2147
2148 /// Attach a combining mark (width-0 code point) to the grapheme it modifies —
2149 /// the cell the cursor just left. With pending-wrap the cursor still sits on
2150 /// the just-written last-column glyph, so attach in place (no back-up, no
2151 /// deferred wrap); otherwise step back one column, and once more over a
2152 /// wide-char spacer to reach its lead. Stored in the grapheme side-table.
2153 fn push_combining(&mut self, c: char) {
2154 let row = self.cursor.row;
2155 let mut col = if self.cursor.pending_wrap {
2156 self.cursor.col
2157 } else {
2158 self.cursor.col.saturating_sub(1)
2159 };
2160 if self.grid.cell(row, col).is_wide_spacer() {
2161 col = col.saturating_sub(1);
2162 }
2163 // Append the mark to the row's combining map at this column (setting the
2164 // cell's combining bit). No global pool — the cluster rides the row.
2165 self.grid.row_mut(row).push_combining(col, c);
2166 self.damage_span(row, col, col);
2167 }
2168
2169 /// Mode 2027 (#295): if `c` **extends** the previous cell's grapheme cluster (UAX #29), append
2170 /// it to that cell's side-table — no new cell, no cursor advance — and return `true`. Otherwise
2171 /// return `false` so `print` takes the normal per-scalar path (a break starts a new cell).
2172 ///
2173 /// The break state is reconstructed fresh from the previous cell's stored cluster (base scalar +
2174 /// side-table marks) rather than persisted across calls, so cursor moves / CR-LF can't corrupt
2175 /// it (mirrors ghostty). Width promotion for a narrow base (a flag's second RI, a text-base +
2176 /// VS16) is handled by the caller in a later step; here the base's existing width holds.
2177 fn try_grapheme_join(&mut self, c: char) -> bool {
2178 let row = self.cursor.row;
2179 // Locate the previous cluster's base cell, exactly as `push_combining`: with pending-wrap
2180 // the cursor still sits on the last glyph; else step back one, and over a wide spacer.
2181 let col = if self.cursor.pending_wrap {
2182 self.cursor.col
2183 } else if self.cursor.col == 0 {
2184 return false; // nothing precedes on this row
2185 } else {
2186 self.cursor.col - 1
2187 };
2188 let col = if self.grid.cell(row, col).is_wide_spacer() {
2189 col.saturating_sub(1)
2190 } else {
2191 col
2192 };
2193 // Reconstruct the previous cluster's text: base scalar + any already-joined scalars.
2194 let mut prev = String::new();
2195 prev.push(self.grid.cell(row, col).c());
2196 if let Some(marks) = self.grid.row_ref(row).combining_at(col) {
2197 prev.extend(marks.iter().copied());
2198 }
2199 if !crate::grapheme::grapheme_extends(&prev, c) {
2200 return false;
2201 }
2202 // Join: ride the side-table (no new cell).
2203 self.grid.row_mut(row).push_combining(col, c);
2204 // Width promotion: a flag's second regional indicator, or a text-base + VS16, grows the
2205 // cluster to width 2. `UnicodeWidthStr` gives the cluster width (RI-pair → 2, VS16 → 2). If
2206 // the base cell is still narrow, widen it in place.
2207 let cluster_w = {
2208 prev.push(c);
2209 UnicodeWidthStr::width(prev.as_str())
2210 };
2211 if cluster_w == 2 && !self.grid.cell(row, col).is_wide() {
2212 self.promote_cluster_to_wide(row, col);
2213 } else if cluster_w == 1 && self.grid.cell(row, col).is_wide() {
2214 // The mirror case: a default-wide emoji + VS15 (text selector) shrinks to width 1.
2215 self.demote_cluster_to_narrow(row, col);
2216 }
2217 self.damage_span(row, col, col);
2218 true
2219 }
2220
2221 /// Shrink a wide cluster cell back to a single-width cell (#295): a default-wide emoji joined by
2222 /// VS15 (U+FE0E, the text selector) requests text presentation → width 1. Remove `WIDE_CHAR`,
2223 /// free the spacer, and back the cursor up over it (the inverse of `promote_cluster_to_wide`).
2224 fn demote_cluster_to_narrow(&mut self, row: usize, col: usize) {
2225 let cols = self.grid.cols();
2226 self.grid
2227 .cell_mut(row, col)
2228 .remove_flags(CellFlags::WIDE_CHAR);
2229 if col + 1 < cols {
2230 self.free_cell(row, col + 1); // free the now-unused spacer
2231 }
2232 // The cluster shrank 2→1: the cursor sat just past the wide cell (col+2, or pending-wrap on
2233 // the last column); it now sits just past the single-width cell at col+1.
2234 self.cursor.pending_wrap = false;
2235 self.cursor.col = (col + 1).min(cols - 1);
2236 self.damage_span(row, col, (col + 1).min(cols - 1));
2237 }
2238
2239 /// Widen a narrow base cell to a double-width cluster in place (#295): set `WIDE_CHAR`, write
2240 /// its spacer, and step the cursor over it. Only reached when a joining scalar (flag's 2nd RI,
2241 /// VS16) promotes the cluster to width 2. A base pinned at the last column has no room for a
2242 /// spacer — relocation is a later step; until then it stays narrow (rare, renders single-width).
2243 fn promote_cluster_to_wide(&mut self, row: usize, col: usize) {
2244 let cols = self.grid.cols();
2245 if col + 1 >= cols {
2246 // No spacer room at the last column: relocate the whole cluster to the next line as a
2247 // wide cell (the row soft-wraps), mirroring write_glyph's wide-at-boundary wrap (#303).
2248 self.relocate_cluster_wide(row, col);
2249 return;
2250 }
2251 // Overwriting col+1 with the spacer can orphan the far half of a WIDE glyph standing there
2252 // (the cursor may have been repositioned before the joining scalar arrived). Reset that
2253 // orphan, exactly as write_glyph does (2462-2470), so no dangling spacer survives.
2254 if self.grid.cell(row, col + 1).is_wide() && col + 2 < cols {
2255 self.free_cell(row, col + 2);
2256 }
2257 self.grid
2258 .cell_mut(row, col)
2259 .insert_flags(CellFlags::WIDE_CHAR);
2260 // The spacer is the lead's second half, so it takes the LEAD's extended attrs — the
2261 // hyperlink and underline colour riding the row's side maps — exactly as write_glyph
2262 // stamps both halves of a wide write. `pen.cell(' ')` carries neither (and the pen may
2263 // have moved on since the base was printed), so they are re-attached here; a base with
2264 // none clears whatever the overwritten column held (#521).
2265 let ext = self.grid.row_ref(row).ext_attrs_at(col);
2266 let mut spacer = self.cursor.pen.cell(' ');
2267 spacer.insert_flags(CellFlags::WIDE_CHAR_SPACER);
2268 *self.grid.cell_mut(row, col + 1) = spacer;
2269 self.grid.row_mut(row).set_ext_attrs(col + 1, ext);
2270 // The cursor sat at col+1 (just past the narrow base); move it over the new spacer, applying
2271 // the same last-column pending-wrap rule as a wide write.
2272 let new_col = col + 2;
2273 if new_col >= cols {
2274 self.cursor.col = cols - 1;
2275 self.cursor.pending_wrap = self.autowrap;
2276 } else {
2277 self.cursor.col = new_col;
2278 }
2279 self.damage_span(row, col, col + 1);
2280 }
2281
2282 /// Relocate a last-column narrow cluster to the next line as a wide cell (#303): its base +
2283 /// side-table marks move to `(next_row, 0..=1)` and the vacated last column becomes a soft-wrap
2284 /// (WRAPLINE + leading spacer), exactly as `write_glyph` wraps a wide glyph that can't fit. With
2285 /// autowrap off it stays narrow.
2286 ///
2287 /// The destination is an **overwrite**, so it owes the no-orphan repair every other overwrite
2288 /// site owes (#529, ADR-0025 D4) — see the comment at that site for why justerm restates it
2289 /// once per wide-writing path where the references get it structurally.
2290 ///
2291 /// The `cols < 2` arm is **unreachable since #547** —
2292 /// `MIN_COLUMNS = 2` is the floor on every path that sets a width — and is kept only as a
2293 /// bounds guard for the `col + 1` writes below, not as a described behaviour.
2294 fn relocate_cluster_wide(&mut self, row: usize, col: usize) {
2295 let cols = self.grid.cols();
2296 if cols < 2 || !self.autowrap || !self.wrapline_advances() {
2297 // Nowhere to place a wide cell — leave it narrow. `!wrapline_advances()` joins the
2298 // other two for the same reason: with no next row, the relocation would write the
2299 // cluster over columns 0-1 of the *current* row and destroy whatever is there.
2300 return;
2301 }
2302 // Capture the base cell (glyph + attrs), its marks, and its extended attrs before
2303 // vacating. The extended attrs (hyperlink, underline colour) must be read HERE and not
2304 // after the move: they live in the *source row's* side maps, and `wrapline()` below may
2305 // scroll — after which that row is a different (or recycled) `Row` (#521).
2306 let base = *self.grid.cell(row, col);
2307 let marks: Vec<char> = self
2308 .combining_at(row, col)
2309 .map(<[char]>::to_vec)
2310 .unwrap_or_default();
2311 let ext = self.grid.row_ref(row).ext_attrs_at(col);
2312 // Vacate the last column as a soft-wrap artefact — the same step `write_glyph` takes for a
2313 // wide glyph that cannot fit, and now literally the same code, so the two cannot drift
2314 // apart again (#528; they held opposite behaviours until then).
2315 self.vacate_for_wrap(row, col);
2316 // Advance to the next line (scrolls if at the bottom); cursor lands at col 0.
2317 self.wrapline();
2318 let nr = self.cursor.row;
2319 // The destination is an overwrite like any other, so it owes the same no-orphan repair
2320 // `write_glyph` performs for its own trailing column (#529, D4): the spacer about to land
2321 // on `(nr, 1)` half-destroys a wide glyph standing there, stranding its far half at
2322 // `(nr, 2)` — a `WIDE_CHAR_SPACER` with no lead to its left, still carrying the destroyed
2323 // glyph's hyperlink and underline colour. Asked *before* the writes, on the pre-write
2324 // state, exactly as `write_glyph`'s `last + 1` check is.
2325 //
2326 // Two of the three references have this exact site, and both repair it without a rule of
2327 // their own, because they write a pair as two *separate* cell writes and the repair lives
2328 // in the write:
2329 // - xterm.js names the case outright — *"Combining character widens 1 column to 2. Move
2330 // old character to next line."* (`InputHandler.ts:583-611` @ 699f553,
2331 // `copyCellsFrom(oldRow, oldCol, 0, oldWidth, false)` at `:605-607`). The relocation
2332 // leaves `x == 2`, so its once-per-run right-edge repair (`:668-669`) lands on exactly
2333 // the orphaned column.
2334 // - ghostty relocates in `Terminal.zig:1188-1252` @ e6e26e1 and reaches the repair
2335 // through `cursorRight(1); printCell(0, .spacer_tail)` (`:1251-1252`) — that second
2336 // `printCell` runs the `cell.wide != wide` switch (`:1484`) whose `.wide` arm clears
2337 // the neighbouring lead's tail (`:1489-1499`).
2338 // - alacritty has **no** counterpart: a width-0 codepoint returns early through
2339 // `push_zerowidth` (`term/mod.rs:1069-1085` @ 852e971), so a cluster never changes
2340 // width and nothing is ever relocated. Its orphan repair (`:994-1008`) is still the
2341 // mechanism reference, reached the same way — one repair per `write_at_cursor`.
2342 // justerm writes both halves in one step, so the repair is not structural here and each
2343 // wide-writing path restates it — this is the third (`write_glyph`,
2344 // `promote_cluster_to_wide`, and now the relocation).
2345 //
2346 // What justerm does **not** copy is ghostty's reach-back at this site: its `.wide` arm
2347 // also clears the previous row's `.spacer_head` (`:1504-1506`, gated `cursor.y > 0 and
2348 // cursor.x <= 1`) — the very marker this relocation set seven statements earlier
2349 // (`:1200`). Derived from source, not executed. Suppressing it here is #534's rule
2350 // verbatim: a repair keyed on a state predicate must not fire while that state is
2351 // mid-construction.
2352 //
2353 // The other two obligations `write_glyph` carries are N/A here, recorded because an
2354 // unexplained omission is what gets re-litigated:
2355 // - the *left*-orphan repair asks `col > 0`, and the lead lands at column 0.
2356 // - `void_wrap_artefact_above(nr)` would clear a record that `vacate_for_wrap` **just
2357 // set**, in both the advance case (`nr == row + 1`, so its target `nr - 1` is `row`)
2358 // and the scroll case (`nr == row`, the source rotated up to `row - 1`). Firing it
2359 // would be self-clobbering, not merely redundant — the same shape as #534's
2360 // mid-construction rule. Measured after a repairing relocation: `is_row_wrapped(0)`
2361 // and `(0, cols-1).is_leading_spacer()` both hold.
2362 //
2363 // `2 < cols` is a live bound, not defence in depth. The print paths cannot leave a
2364 // `WIDE_CHAR` lead in the last column — `write_glyph` wraps rather than write one there
2365 // and `promote_cluster_to_wide` relocates rather than promote in place — but `Row::resize`
2366 // can: the alt screen resizes without reflowing (#567), so truncating a row through a pair
2367 // strands its lead in the final column. The relocation then meets `is_wide() == true` at
2368 // `cols == 2`, and without the bound reads `(nr, 2)` on a two-column grid — an
2369 // out-of-bounds panic in a library, inside a consumer's process, reachable by shrinking a
2370 // window over a CJK glyph. Pinned by `min_columns.rs::
2371 // a_relocation_beside_a_truncated_wide_lead_does_not_index_past_the_row`.
2372 if 2 < cols && self.grid.cell(nr, 1).is_wide() {
2373 self.free_cell(nr, 2);
2374 }
2375 // Re-place the base as a wide lead + spacer, re-attaching the marks fresh (drop the combining
2376 // bit so push_combining starts a clean cluster at the new column).
2377 let mut lead = base;
2378 lead.set_combined(false);
2379 lead.insert_flags(CellFlags::WIDE_CHAR);
2380 *self.grid.cell_mut(nr, 0) = lead;
2381 for m in marks {
2382 self.grid.row_mut(nr).push_combining(0, m);
2383 }
2384 // Re-attach the extended attrs to BOTH halves at the new row. `lead` copied the base's
2385 // presence bits but not its map entries, so without this the bit is set with nothing
2386 // behind it — the read is gated and silently returns the default, and the frame stops
2387 // round-tripping (the cell encodes as linked with no index).
2388 self.grid.row_mut(nr).set_ext_attrs(0, ext.clone());
2389 let mut spacer = self.cursor.pen.cell(' ');
2390 spacer.insert_flags(CellFlags::WIDE_CHAR_SPACER);
2391 *self.grid.cell_mut(nr, 1) = spacer;
2392 self.grid.row_mut(nr).set_ext_attrs(1, ext);
2393 // Cursor just past the wide cell (pending-wrap if it fills a 2-column row).
2394 if cols <= 2 {
2395 self.cursor.col = cols - 1;
2396 self.cursor.pending_wrap = self.autowrap;
2397 } else {
2398 self.cursor.col = 2;
2399 self.cursor.pending_wrap = false;
2400 }
2401 self.damage_span(nr, 0, 1);
2402 }
2403
2404 // ---- cursor movement (CSI A/B/C/D/G/d/H/f) -------------------------------
2405
2406 fn move_up(&mut self, n: usize) {
2407 self.cursor.row = self.cursor.row.saturating_sub(n);
2408 self.cursor.pending_wrap = false;
2409 }
2410
2411 fn move_down(&mut self, n: usize) {
2412 self.cursor.row = (self.cursor.row + n).min(self.grid.rows() - 1);
2413 self.cursor.pending_wrap = false;
2414 }
2415
2416 fn move_forward(&mut self, n: usize) {
2417 self.cursor.col = (self.cursor.col + n).min(self.grid.cols() - 1);
2418 self.cursor.pending_wrap = false;
2419 }
2420
2421 fn move_back(&mut self, n: usize) {
2422 self.cursor.col = self.cursor.col.saturating_sub(n);
2423 self.cursor.pending_wrap = false;
2424 }
2425
2426 fn set_col(&mut self, col: usize) {
2427 self.cursor.col = col.min(self.grid.cols() - 1);
2428 self.cursor.pending_wrap = false;
2429 }
2430
2431 fn set_row(&mut self, row: usize) {
2432 self.cursor.row = row.min(self.grid.rows() - 1);
2433 self.cursor.pending_wrap = false;
2434 }
2435
2436 fn goto(&mut self, row: usize, col: usize) {
2437 // Origin mode addresses rows relative to the scroll region's top margin
2438 // and clamps to its bottom; otherwise rows are absolute to the screen.
2439 let (offset, max_row) = if self.origin_mode {
2440 (self.scroll_top, self.scroll_bottom)
2441 } else {
2442 (0, self.grid.rows() - 1)
2443 };
2444 self.cursor.row = (row + offset).min(max_row);
2445 self.cursor.col = col.min(self.grid.cols() - 1);
2446 self.cursor.pending_wrap = false;
2447 }
2448
2449 // ---- erase (CSI J / K) ---------------------------------------------------
2450
2451 /// Clear cells `from..to` on `row`.
2452 ///
2453 /// Background Color Erase (BCE): erased cells carry the current SGR
2454 /// background only — fg and text attributes reset to default (matches
2455 /// xterm/alacritty, where the fill is `cursor.template.bg.into()`).
2456 ///
2457 /// **Cleared concern, with its validity condition — an empty range would break the pair
2458 /// invariant.** With `from == to` the first guard below still frees the lead at `from - 1`
2459 /// while the second is skipped (`to > from` is false) and the fill loop does nothing, so the
2460 /// spacer at `from` would survive its lead — an ADR-0025 D4 break, and the exact lead-less
2461 /// orphan the word walk must then treat as opaque. This is unreachable **as long as every
2462 /// caller passes a non-empty range**, which holds today: `ECH` clamps to
2463 /// `(col + n).min(cols)` with `n >= 1` (both `CSI X` and `CSI 0 X` erase one cell), and every
2464 /// `EL`/`ED` site passes `0..cols` or `0..=cursor`. A future caller that can pass an empty
2465 /// range must guard here first.
2466 fn clear_cells(&mut self, row: usize, from: usize, to: usize) {
2467 let cols = self.grid.cols();
2468 // Erasing either half of a pair that wrapped from the row above ends it, so that row's
2469 // artefact record is void (#534). `from <= 1` rather than `from == 0` because erasing from
2470 // column 1 destroys the spacer and the no-orphan repair below then frees the lead. ghostty
2471 // reaches the same row from its erase path — `Screen.splitCellBoundary`'s `x == 0 or x ==
2472 // 1` branch (`Screen.zig:1873` @ `e6e26e1`), called from `eraseChars` (`Terminal.zig:3159`).
2473 if from <= 1 && to > from && self.wrapped_pair_at_row_start(row) {
2474 self.void_wrap_artefact_above(row);
2475 }
2476 // Don't orphan a wide char straddling the erase boundary.
2477 if from > 0 && self.grid.cell(row, from).is_wide_spacer() {
2478 self.free_cell(row, from - 1);
2479 }
2480 if to > from && to < cols && self.grid.cell(row, to - 1).is_wide() {
2481 self.free_cell(row, to);
2482 }
2483
2484 let bg = self.cursor.pen.bg;
2485 for col in from..to {
2486 let cell = self.grid.cell_mut(row, col);
2487 cell.reset();
2488 cell.set_bg(bg);
2489 }
2490 // `reset` cleared the presence bits; this releases what they gated (#628).
2491 self.grid.row_mut(row).purge_side_maps(from..to);
2492 if to > from {
2493 self.damage_span(row, from, to - 1);
2494 }
2495 }
2496
2497 /// End `row`'s soft wrap, because something just destroyed the content that was continuing
2498 /// onto the next row.
2499 ///
2500 /// Which verbs owe this is **not** derivable from the erased range — it is a per-verb rule,
2501 /// and both references spell it out call site by call site rather than inferring it:
2502 ///
2503 /// | verb | ends the wrap? | xterm | ghostty |
2504 /// |---|---|---|---|
2505 /// | `EL 0` (erase right) | **yes**, at any column | `ClearRight` → `LineClrWrapped` unconditionally (`util.c:1871`) | `cursorResetWrap()` in `eraseLine(.right)` |
2506 /// | `ECH` | **yes**, at any column | same `ClearRight` (`util.c:1961`) | `cursorResetWrap()` in `eraseChars` |
2507 /// | `DCH` | **yes** | `screen.c` | `cursorResetWrap()` — *"Our row's soft-wrap is always reset"* |
2508 /// | `EL 1` (erase left) | no | `ClearLeft`, no clear | no |
2509 /// | `ICH` | no | no | no |
2510 ///
2511 /// The shape behind the three that do: each destroys content **from the cursor rightward**, so
2512 /// "this row continues past its last column" can no longer be asserted. Erasing leftward or
2513 /// inserting blanks leaves the tail — and whatever it flowed into — intact.
2514 ///
2515 /// **`EL 2` is a deliberate divergence.** justerm ends the wrap; xterm does not (`ClearLine`,
2516 /// `util.c:1905`, has no `LineClrWrapped`) and ghostty copies that with a comment naming it —
2517 /// *"it seems like complete should reset the soft-wrap state of the line but in xterm it does
2518 /// not."* justerm differs because it *joins* logical lines for `accessible_text` / `search` /
2519 /// selection text, so a blanked-but-still-wrapped row visibly merges two lines in copy — a
2520 /// consequence xterm does not carry. Recorded rather than silently matched or silently
2521 /// Mark `row` as soft-wrapping into the next one — and damage the cell the bit rides on.
2522 ///
2523 /// The exact mirror of [`Term::end_wrap`], and it exists for the mirror of that function's
2524 /// reason. The flag lives on the `Row` (#538) and reaches a consumer only as the last cell's
2525 /// `WRAPLINE`, derived at encode time. Every other cell-carried fact changes when that cell is
2526 /// written, so damage covers it for free; this one does not, and a `Partial` frame would never
2527 /// ship the bit — a frame-mode consumer rebuilding logical lines from cells then keeps the two
2528 /// rows *split* forever, the exact dual of the "joined forever" that `end_wrap` guards.
2529 ///
2530 /// `end_wrap` took that obligation in #540; the set side never did. It stayed invisible because
2531 /// a wrap normally moves the cursor to the next row, and `frame_damage` tops the frame up with
2532 /// the old cursor cell. When a **scroll serves the wrap** the cursor keeps its row index, so
2533 /// nothing tops it up — which is how #557 surfaced it.
2534 ///
2535 /// Damaging here rather than at each caller is what keeps this true for set sites added later,
2536 /// the same argument `end_wrap`'s comment makes.
2537 fn begin_wrap(&mut self, row: usize) {
2538 self.grid.row_mut(row).set_wrapped(true);
2539 let last = self.grid.cols() - 1;
2540 self.damage_span(row, last, last);
2541 }
2542
2543 /// diverged; see #538.
2544 fn end_wrap(&mut self, row: usize) {
2545 self.grid.row_mut(row).set_wrapped(false);
2546 // The flag is stored on the `Row` but rides the wire on the row's **last cell**, derived
2547 // at encode time. Every other cell-carried fact changes only when that cell is written,
2548 // so damage covers it for free; this one does not, and a `Partial` frame would never
2549 // re-ship the bit — leaving a frame-mode consumer with two rows joined forever. Damaging
2550 // here rather than at each caller is what keeps that true for call sites added later.
2551 let last = self.grid.cols() - 1;
2552 self.damage_span(row, last, last);
2553 // The wrap artefact goes with the wrap. The marker's claim is "the last column is the
2554 // blank a width-2 glyph vacated **because this row continues onto the next**", so a row
2555 // that stops continuing cannot hold one (ADR-0025 D3 — position is part of the test, and
2556 // so is the wrap it is positioned in). Coupling the two here is what makes the row-shift
2557 // seams and every wrap-ending erase a single rule instead of a clear per verb: ghostty
2558 // couples them in one function the same way — `Screen.cursorResetWrap`
2559 // (`terminal/Screen.zig:1524` @ `e6e26e1`, spacer-head clear at `:1539-1545`), reached from
2560 // `deleteChars` / `eraseChars` / `eraseLine`. It early-returns on `if (!page_row.wrap)`;
2561 // this one clears unconditionally, which is strictly safer.
2562 //
2563 // Most callers erase through this column anyway, so the clear is redundant for them; the
2564 // ones it is *not* redundant for are the row-shift seams (#540's `shift_region`, which
2565 // ends a wrap without touching a cell) and `delete_chars`, whose marker rides the shift.
2566 // The leftward erases are the mirror case — they blank this column while the wrap
2567 // legitimately survives — and go through `drop_artefact_if_erased` instead.
2568 //
2569 // One wrap-ending path deliberately does *not* reach here: `shift_region`'s `top == 0`
2570 // seam, whose row is in scrollback rather than the grid. It couples the same two clears
2571 // inline; see the comment there.
2572 self.grid.cell_mut(row, last).clear_leading_spacer();
2573 }
2574
2575 /// The pair that wrapped into `row` is about to be destroyed or moved, so the artefact record
2576 /// on the row **above** it is void — drop it. **Call before the mutation.**
2577 ///
2578 /// The marker makes a claim with two clauses: this row soft-wraps (owned by `end_wrap`), and
2579 /// its last column is the blank *that specific pair* vacated. This is the second clause, and
2580 /// the rule behind every call site is one sentence: **the record survives only an in-place
2581 /// same-width overwrite.** Anything else that reaches columns 0/1 of the continuation — a
2582 /// narrow write, an erase, a shift in either direction — ends the pair the record was about,
2583 /// and a wide lead that arrives afterwards by some other route did not *wrap* from anywhere.
2584 ///
2585 /// Both references gate on that, and both gate on the state **before** the write rather than
2586 /// after it:
2587 ///
2588 /// - ghostty `Terminal.zig:1484` @ `e6e26e1` — the whole wide-repair `switch` sits under
2589 /// `if (cell.wide != wide)`, so a wide glyph overwritten by another wide glyph skips it; the
2590 /// reach-back stanza then appears in the `.wide` (`:1501-1506`) and `.spacer_tail`
2591 /// (`:1529-1532`) arms only.
2592 /// - alacritty `term/mod.rs:994` @ `852e971` — the reach-back at `:1004-1008` is inside
2593 /// `if cursor_cell.flags.intersects(WIDE_CHAR | WIDE_CHAR_SPACER)`, but with no
2594 /// width-unchanged escape, so it drops a record that is still true. Alacritty is the outlier
2595 /// of the two and justerm follows ghostty.
2596 ///
2597 /// Asking *after* the mutation instead looks equivalent and is not: it answers "is some wide
2598 /// lead standing at column 0", which a `DCH` that pulls the *next* wide glyph left also
2599 /// satisfies, and which a two-step placement (a narrow base promoted to wide by VS16 under
2600 /// mode 2027, or IRM's insert-then-write) satisfies only at the end. Both were measured
2601 /// disagreeing with the rule above before this took its current form.
2602 ///
2603 /// The erase and intra-row-shift call sites are **ported, not derived**: ghostty's
2604 /// `Screen.splitCellBoundary` (`Screen.zig:1831`, the `x == 0 or x == 1` branch at `:1873`)
2605 /// reaches up one row and clears the previous row's spacer head, and it is called from
2606 /// `deleteChars` (`Terminal.zig:3107-3109`) and `eraseChars` (`:3159-3160`). Only justerm's
2607 /// `ICH` site has no counterpart — ghostty's `insertBlanks` (`:2988`) calls it nowhere.
2608 ///
2609 /// `row == 0` does not mean "no row above": on the primary screen the text readers walk
2610 /// `[scrollback ++ grid]` as one buffer (`abs_floor() == 0`), so the row above grid row 0 is
2611 /// the last **scrollback** row and it can carry the marker. Alacritty reaches the same row for
2612 /// the same reason — its `topmost_line()` is `Line(-history_size)` (`grid/mod.rs:504`), so
2613 /// `point.line - 1` indexes into history; ghostty is the one that stops at the viewport
2614 /// (`cursor.y > 0`). On the alt screen `abs_floor()` is the screen top, so no join crosses the
2615 /// boundary and there is nothing to repair.
2616 ///
2617 /// No damage is owed by either branch, and for a stronger reason than #540's: the marker is a
2618 /// `content` bit outside `CONTENT_MARKER_MASK`, so `Cell::flags()` never sees it and it does
2619 /// not cross the wire at all. The `damage_span` below is defensive, not load-bearing.
2620 fn void_wrap_artefact_above(&mut self, row: usize) {
2621 if row > 0 {
2622 let last = self.grid.cols() - 1;
2623 if self.grid.cell(row - 1, last).is_leading_spacer() {
2624 self.grid.cell_mut(row - 1, last).clear_leading_spacer();
2625 self.damage_span(row - 1, last, last);
2626 }
2627 } else if !self.on_alt
2628 && let Some(cell) = self.scrollback.back_mut().and_then(|r| r.last_mut())
2629 {
2630 cell.clear_leading_spacer();
2631 }
2632 }
2633
2634 /// Is a wide pair standing at columns 0..=1 of `row` — i.e. is there a record for
2635 /// `void_wrap_artefact_above` to void? A cheap pre-mutation test the four call sites share, so
2636 /// the rule lives in one place rather than being re-derived per verb (ADR-0025 D2).
2637 fn wrapped_pair_at_row_start(&self, row: usize) -> bool {
2638 self.grid.cell(row, 0).is_wide()
2639 }
2640
2641 /// Drop a wide-wrap artefact marker that has outlived the wrap it belonged to, without
2642 /// touching the wrap itself.
2643 ///
2644 /// The mirror of the marker clean-up inside `end_wrap`, for the verbs that erase *leftward*:
2645 /// `EL 1` and `ED 1` correctly leave the wrap alone (the row's tail still flows onward), but
2646 /// they can still clear the last column, and then the artefact's blank turns into visible
2647 /// text that a reflow bakes in permanently. Only the marker goes; the wrap is the caller's
2648 /// business.
2649 fn drop_artefact_if_erased(&mut self, row: usize, from: usize, to: usize) {
2650 let last = self.grid.cols() - 1;
2651 if from <= last && to > last {
2652 self.grid.cell_mut(row, last).clear_leading_spacer();
2653 }
2654 }
2655
2656 /// Shift `[top..=bottom]` by one line — up unless `down` — and end the wraps the shift
2657 /// falsified. Every row-shifting verb (IL/DL/SU/SD and the region paths in LF/RI) goes
2658 /// through here so the repair cannot be forgotten at a call site (ADR-0025 D2).
2659 ///
2660 /// The wrap flag claims "this row continues into the **next** row", so it is a statement about
2661 /// *adjacency*, and rotating whole `Row`s keeps it true for free: both halves of a pair inside
2662 /// the region move by the same line, so the claim still describes the same neighbour. Only the
2663 /// two seams falsify it, where a row's next neighbour changed underneath it:
2664 ///
2665 /// - **`top - 1`**, just outside the region. Its continuation rotated away (up-shift) or was
2666 /// pushed down (down-shift), so whatever now sits at `top` is a stranger. This is the seam
2667 /// that merges two unrelated logical lines in copy/search/accessible text (#540's repro).
2668 /// - **the row that lost its continuation to the blank** — `bottom - 1` after an up-shift (the
2669 /// blank lands at `bottom`), `bottom` after a down-shift (its continuation rotated up to
2670 /// `top` and was blanked there). The down-shift form is the one that reaches *outside* the
2671 /// region: the stale claim points at `bottom + 1`, a row the verb never touched.
2672 ///
2673 /// Damaging matters as much as clearing, and `end_wrap` does both: `top - 1` is outside the
2674 /// region, so the scroll op the caller records does not cover it and a `Partial` frame would
2675 /// never re-ship the derived `WRAPLINE` bit.
2676 ///
2677 /// **Each seam has exactly one exemption, and both are facts about the caller that this
2678 /// function cannot see** — which is why they are parameters rather than tests:
2679 ///
2680 /// - `evicts_to_scrollback` exempts the **top** seam: a linefeed pushes row 0 into scrollback,
2681 /// so the readers' `[scrollback ++ grid]` walk finds the continuation one row further back
2682 /// and adjacency survives.
2683 /// - `serves_wrap` exempts the **bottom** seam: the shift was asked for by `wrapline`, so the
2684 /// blank it exposes at `bottom` is not a stranger that displaced a continuation — it *is*
2685 /// the continuation, about to be written into (#557).
2686 ///
2687 /// Both are one-sided on purpose. A wrap-serving scroll still falsifies the top seam, and a
2688 /// scrollback-evicting linefeed still falsifies the bottom one when no wrap asked for it.
2689 ///
2690 /// **No reference implements this rule**, so it is derived rather than ported — ADR-0004, the
2691 /// spec is the authority for VT semantics, above any implementation:
2692 ///
2693 /// - **ghostty** clears the wrap on *every* row a full-width IL/DL touches
2694 /// (`terminal/Terminal.zig:2746-2752`, `:2906-2912` @ `e6e26e1`). The clear runs *before* the
2695 /// row swap at `:2936-2939`, so both ends stay false: an interior pair is split, not
2696 /// preserved. It still never reaches the row above the shifted range.
2697 /// - **alacritty** has no `WRAPLINE` clear on any scroll path (@ `852e971`).
2698 /// - **xterm.js** splices whole line objects and never touches `isWrapped`
2699 /// (`common/InputHandler.ts:1345-1402` @ `699f553`). Its opposite polarity — "I continue the
2700 /// *previous* row" (`common/buffer/Buffer.ts:566-570`) — moves the exposure to the mirrored
2701 /// seam rather than removing it: a spliced-in line keeps a continuation claim about a
2702 /// predecessor it never met.
2703 ///
2704 /// The seam row's wide-wrap *marker* is the same shift's other half, and it now rides along:
2705 /// `end_wrap` clears both (#534), and the `top == 0` branch below — the one seam whose row is
2706 /// not a grid row — couples them inline for the same reason.
2707 ///
2708 /// **Validity condition for clearing at the seams rather than everywhere.** ghostty clears the
2709 /// wrap and the spacer head on *every* row a full-width IL/DL touches, and its own comment
2710 /// gives two reasons: it splits interior pairs, **and** it supports left/right margins
2711 /// (DECSLRM), where a partial-row shift can break an interior pair without moving its
2712 /// neighbour. justerm rotates whole `Row`s and implements no DECSLRM, so an interior pair and
2713 /// its continuation always move together and seam-only is sound. If left/right margins ever
2714 /// land, this rule and #534's marker rule break at the same time — neither is safe under a
2715 /// shift that moves part of a row.
2716 fn shift_region(
2717 &mut self,
2718 top: usize,
2719 bottom: usize,
2720 down: bool,
2721 evicts_to_scrollback: bool,
2722 serves_wrap: bool,
2723 ) {
2724 if down {
2725 self.grid.scroll_down_region(top, bottom);
2726 } else {
2727 self.grid.scroll_up_region(top, bottom);
2728 }
2729 // Recording the scroll op is part of shifting, not a step a caller adds after: damage is
2730 // indexed by row position, so `record_scroll` rotates `line_damage` with the content. A
2731 // seam clear damaged *before* that rotation is carried to the wrong row — and on a
2732 // down-shift it lands on `top`, which `record_scroll` immediately overwrites with
2733 // `fully_damaged`. The clear then never reaches the wire at all: the model splits the
2734 // rows, a `Partial` frame does not say so, and the consumer keeps them joined forever.
2735 // Ordering it here is what makes that unrepeatable at a sixth call site.
2736 self.record_scroll(top, bottom, if down { -1 } else { 1 });
2737 if top > 0 {
2738 self.end_wrap(top - 1);
2739 } else if !evicts_to_scrollback && !self.on_alt {
2740 // `top == 0` does not mean "no row above": on the primary the text readers walk
2741 // `[scrollback ++ grid]` as one buffer (`abs_floor() == 0`), so the row above grid row
2742 // 0 is the last *scrollback* row and it can wrap into the screen. A full-screen SU /
2743 // DL / RI therefore leaves this issue's defect one row higher, outside the grid.
2744 //
2745 // `evicts_to_scrollback` is what keeps `linefeed` out: it pushes grid row 0 into
2746 // scrollback, so the continuation is re-attached one row further back and the claim
2747 // stays true — clearing there would split a line the scroll preserved. On the alt
2748 // screen `abs_floor()` is the screen top, so no join crosses the boundary at all.
2749 //
2750 // No damage is owed with the clear, unlike `end_wrap`'s grid form: a scrollback row
2751 // only reaches the wire while `display_offset > 0`, and there `damage()` returns an
2752 // empty `Partial` (`term.rs`, the frozen-viewport short-circuit) while any scroll that
2753 // *moves* the viewport marks full damage. Valid as long as that short-circuit holds.
2754 //
2755 // The artefact marker goes with the wrap here exactly as it does in `end_wrap`, and
2756 // this branch is the reason that coupling cannot simply live in `end_wrap`: it is the
2757 // one wrap-ending path whose row is not a grid row, so it does not call it. Leaving it
2758 // out left #534's defect alive one row above the grid — reachable from every
2759 // `scroll_region_lines` verb, since all of them pass `evicts_to_scrollback: false`,
2760 // and visible as a word selection one cell too wide plus a reflow that bakes the
2761 // stranded marker mid-row.
2762 if let Some(row) = self.scrollback.back_mut() {
2763 row.set_wrapped(false);
2764 if let Some(cell) = row.last_mut() {
2765 cell.clear_leading_spacer();
2766 }
2767 }
2768 }
2769 // The blank lands at `bottom` going up and at `top` going down, so the row that lost its
2770 // continuation is the one just above it. Going down that is `top - 1`, already cleared
2771 // above; going up it is `bottom - 1`, which for a one-row region is that same row.
2772 //
2773 // The up-shift form needs the `bottom + 1` guard, and it is not defensive — without it the
2774 // clear destroys a **live** wrap. A row at the screen's bottom edge that wraps is the
2775 // ordinary soft-wrap-at-the-last-row state: `wrapline` sets the flag and the linefeed
2776 // scrolls precisely so the continuation has somewhere to land, which is the *next* row
2777 // after this shift. Its claim is about a row that does not exist yet, so the shift makes it
2778 // true rather than false.
2779 //
2780 // **The rest of that guard's original rationale was too narrow, and #557 is what it cost.**
2781 // It read: *"the link is only broken when there is a stationary row below the region
2782 // (`bottom + 1 < rows`): then the continuation stayed put while its lead moved up."* A
2783 // stationary row below is **necessary but not sufficient**. At a *region's* bottom the same
2784 // wrapline-asked-for scroll happens with `bottom + 1 < rows` perfectly true, and the clear
2785 // then split the logical line the scroll existed to continue. The geometry was never the
2786 // discriminator; **why the shift is happening** is — which is what `serves_wrap` carries.
2787 //
2788 // The guard stays anyway: it is the screen-bottom case of the same fact, and it also holds
2789 // for a *non*-wrap-serving linefeed at the screen edge.
2790 //
2791 // One invariant is still worth naming, because it was not true when this guard was first
2792 // written: **a row only claims a wrap if a next row will exist for it**. A row parked below
2793 // a DECSTBM region kept a permanent false claim, and this guard preserved it — the #540
2794 // completeness pass merged two unrelated logical lines through exactly that hole. The claim
2795 // is now gated at its set site (`write_glyph` asks `wrapline_advances`), so the guard's
2796 // premise holds. Valid as long as that gate stays.
2797 let orphaned = if serves_wrap {
2798 // The blank this shift just exposed is the continuation the wrap is waiting for, so
2799 // there is nothing to falsify — see the `serves_wrap` note on `linefeed_inner` (#557).
2800 None
2801 } else if down {
2802 Some(bottom)
2803 } else if bottom + 1 < self.grid.rows() {
2804 bottom.checked_sub(1)
2805 } else {
2806 None
2807 };
2808 if let Some(row) = orphaned {
2809 self.end_wrap(row);
2810 }
2811 }
2812
2813 fn erase_display(&mut self, mode: u16) {
2814 let (cols, rows) = (self.grid.cols(), self.grid.rows());
2815 let (cr, cc) = (self.cursor.row, self.cursor.col);
2816 match mode {
2817 0 => {
2818 // Erases this row's tail and every row below, so nothing can continue from here
2819 // — and the rows below cannot continue either.
2820 self.clear_cells(cr, cc, cols);
2821 self.end_wrap(cr);
2822 for row in (cr + 1)..rows {
2823 self.clear_cells(row, 0, cols);
2824 self.end_wrap(row);
2825 }
2826 }
2827 1 => {
2828 // Leftward: this row's tail survives, so its own wrap does. The rows *above* are
2829 // gone entirely.
2830 for row in 0..cr {
2831 self.clear_cells(row, 0, cols);
2832 self.end_wrap(row);
2833 }
2834 self.clear_cells(cr, 0, cc + 1);
2835 self.drop_artefact_if_erased(cr, 0, cc + 1);
2836 // Covering the whole row means nothing continues from it. xterm.js has a
2837 // dedicated arm for exactly this case, in its own words: *"Deleted entire
2838 // previous line. This next line can no longer be wrapped."*
2839 // (`InputHandler.ts:1248-1252` — under its continuation polarity that assignment
2840 // is this engine's `end_wrap(cr)`.) `EL 1` has no such arm there, and none here.
2841 if cc + 1 == cols {
2842 self.end_wrap(cr);
2843 }
2844 }
2845 2 => {
2846 for row in 0..rows {
2847 self.clear_cells(row, 0, cols);
2848 self.end_wrap(row);
2849 }
2850 }
2851 _ => {}
2852 }
2853 }
2854
2855 /// Erase in line (EL): 0 = cursor→end, 1 = start→cursor, 2 = whole line.
2856 fn erase_line(&mut self, mode: u16) {
2857 let cols = self.grid.cols();
2858 let (cr, cc) = (self.cursor.row, self.cursor.col);
2859 match mode {
2860 // Erase right — ends the wrap at any column (xterm's `ClearRight`).
2861 0 => {
2862 self.clear_cells(cr, cc, cols);
2863 self.end_wrap(cr);
2864 }
2865 // Erase left — the tail survives, so the wrap does. The artefact marker does not:
2866 // if the erase reached the last column it just blanked the cell the marker described.
2867 1 => {
2868 self.clear_cells(cr, 0, cc + 1);
2869 self.drop_artefact_if_erased(cr, 0, cc + 1);
2870 }
2871 // Erase the whole line — see `end_wrap`: a deliberate divergence from xterm.
2872 2 => {
2873 self.clear_cells(cr, 0, cols);
2874 self.end_wrap(cr);
2875 }
2876 _ => {}
2877 }
2878 }
2879
2880 // ---- intra-line editing (ICH / DCH / ECH) --------------------------------
2881
2882 /// ECH (CSI Pn X): erase `n` cells in place from the cursor — no shift.
2883 /// BCE-filled (via `clear_cells`); pending-wrap is left untouched.
2884 fn erase_chars(&mut self, n: usize) {
2885 let cols = self.grid.cols();
2886 let (row, col) = (self.cursor.row, self.cursor.col);
2887 let to = (col + n).min(cols);
2888 self.clear_cells(row, col, to);
2889 // Destroys content from the cursor rightward, so the row can no longer be continuing —
2890 // unconditionally, at any column and for any `n`. Both references do exactly this (see
2891 // `end_wrap`): xterm routes ECH through the same `ClearRight` as `EL 0`, ghostty calls
2892 // `cursorResetWrap()` in `eraseChars`.
2893 self.end_wrap(row);
2894 }
2895
2896 /// ICH (CSI Pn @): insert `n` blanks at the cursor, shifting the rest of the
2897 /// line right; cells pushed past the right edge are lost. The opened gap is
2898 /// BCE-filled; pending-wrap is left untouched.
2899 fn insert_chars(&mut self, n: usize) {
2900 let cols = self.grid.cols();
2901 let (r, col) = (self.cursor.row, self.cursor.col);
2902 let n = n.min(cols - col);
2903 if n == 0 {
2904 return;
2905 }
2906 // Shifting a wrapped pair out of columns 0/1 ends it, so the row above's artefact record
2907 // is void (#534). Asked **before** the shift, which is what keeps IRM correct: `write_glyph`
2908 // routes its wide-at-boundary insert through here *after* `vacate_for_wrap` has just set
2909 // the marker on the row above, and a post-shift test would see the freshly blanked gap and
2910 // clear the marker inside its own SET site's critical section. Pre-shift the question is
2911 // about the pair that was actually there, which is the one the record is about.
2912 if col <= 1 && self.wrapped_pair_at_row_start(r) {
2913 self.void_wrap_artefact_above(r);
2914 }
2915 let bg = self.cursor.pen.bg;
2916 let row = self.grid.row_mut(r);
2917 // Shift [col .. cols-n) right by n; the tail falls off the edge. The
2918 // combining map follows the moved cells (the bit travels with the raw
2919 // copy, the cluster data must too).
2920 row.copy_within(col..cols - n, col + n);
2921 row.move_maps(col..cols - n, col + n);
2922 for cell in &mut row[col..col + n] {
2923 cell.reset();
2924 cell.set_bg(bg);
2925 }
2926 // Repair wide-char halves split at the seams (no-orphan invariant):
2927 // a lead just before the gap lost its spacer; the first shifted cell may
2928 // be a spacer whose lead did not move.
2929 if col > 0 && self.grid.cell(r, col - 1).is_wide() {
2930 self.free_cell(r, col - 1);
2931 }
2932 if col + n < cols && self.grid.cell(r, col + n).is_wide_spacer() {
2933 self.free_cell(r, col + n);
2934 }
2935 // A lead shifted to the last column lost its spacer off the edge.
2936 if self.grid.cell(r, cols - 1).is_wide() {
2937 self.free_cell(r, cols - 1);
2938 }
2939 // Note ICH needs no repair to *this* row's marker: a right shift always pushes the last
2940 // column off the edge, so it discards a marker rather than carrying one inward —
2941 // measured, and pinned by `ich_discards_the_marker_off_the_edge`.
2942 self.damage_span(r, col, cols - 1);
2943 }
2944
2945 /// DCH (CSI Pn P): delete `n` cells at the cursor, shifting the tail left; the
2946 /// vacated cells at the right are BCE-blanked. Pending-wrap is left untouched.
2947 fn delete_chars(&mut self, n: usize) {
2948 let cols = self.grid.cols();
2949 let (r, col) = (self.cursor.row, self.cursor.col);
2950 let n = n.min(cols - col);
2951 if n == 0 {
2952 return;
2953 }
2954 // The shift pulls the tail left and blanks the far end, so the row stops continuing —
2955 // ghostty says it outright (*"Our row's soft-wrap is always reset"* in `deleteChars`,
2956 // `Terminal.zig:3133` @ `e6e26e1`).
2957 //
2958 // **Before the shift, not after** (#534): `end_wrap` clears the artefact marker at the
2959 // *last* column, and the marker is a cell bit that the shift carries inward with every
2960 // other cell. Ending the wrap afterwards would clear a column the marker has already left,
2961 // stranding it mid-row where it describes nothing (ADR-0025 D3) and silently swallows the
2962 // blank between two runs in copy, search and accessible text. Same shape as #540's
2963 // `record_scroll` ordering: the clear has to happen where the state still is.
2964 self.end_wrap(r);
2965 // Deleting a wrapped pair out of columns 0/1 ends it, so the row above's artefact record
2966 // is void — and this is where the "ask before, not after" rule earns its keep twice over:
2967 // a `DCH` can pull the *next* wide glyph left into column 0, which a post-shift "is a wide
2968 // lead standing here?" test happily accepts even though the pair the record was about has
2969 // been deleted. ghostty asks the same question at the same point:
2970 // `Screen.splitCellBoundary(cursor.x)` from `deleteChars` (`Terminal.zig:3107` @ `e6e26e1`),
2971 // whose `x == 0 or x == 1` branch reaches up a row and clears the spacer head.
2972 if col <= 1 && self.wrapped_pair_at_row_start(r) {
2973 self.void_wrap_artefact_above(r);
2974 }
2975 let bg = self.cursor.pen.bg;
2976 let row = self.grid.row_mut(r);
2977 // Shift [col+n .. cols) left to [col ..); BCE-fill the vacated tail. The
2978 // combining map follows the moved cells.
2979 row.copy_within(col + n..cols, col);
2980 row.move_maps(col + n..cols, col);
2981 for cell in &mut row[cols - n..cols] {
2982 cell.reset();
2983 cell.set_bg(bg);
2984 }
2985 // Repair wide-char halves split by the deletion (no-orphan invariant):
2986 // a lead just before the cut lost its spacer; the cell now at the cursor
2987 // may be a spacer whose lead was deleted.
2988 if col > 0 && self.grid.cell(r, col - 1).is_wide() {
2989 self.free_cell(r, col - 1);
2990 }
2991 if self.grid.cell(r, col).is_wide_spacer() {
2992 self.free_cell(r, col);
2993 }
2994 self.damage_span(r, col, cols - 1);
2995 }
2996
2997 // ---- line/region editing (IL / DL / SU / SD) -----------------------------
2998
2999 /// Scroll rows `[top..=bottom]` by `n` lines, BCE-filling the exposed lines.
3000 /// `down` inserts blanks at the top (content moves down); otherwise content
3001 /// moves up and blanks appear at the bottom. Reuses the one-line region scroll
3002 /// primitives (so damage + scroll-op accumulation come for free), then fills
3003 /// the exposed lines with the current SGR background.
3004 fn scroll_region_lines(&mut self, top: usize, bottom: usize, n: usize, down: bool) {
3005 let height = bottom - top + 1;
3006 let n = n.min(height);
3007 if n == 0 {
3008 return;
3009 }
3010 // Anchors (selection #3, markers #118/#158) live at absolute buffer lines;
3011 // SU/SD/IL/DL don't accrue scrollback, so `base` is stable across the loop.
3012 let base = self.scrollback.len();
3013 for _ in 0..n {
3014 self.shift_region(top, bottom, down, false, false);
3015 // Rotate anchors with the content, like `linefeed`/`reverse_index`
3016 // (#162). `up` = content moved up = the non-`down` case. Markers rotate
3017 // with the active buffer (#187) — alt-scoped on the alt screen, so no
3018 // guard; the selection is cleared on alt enter.
3019 self.selection_rotate_region(base + top, base + bottom, !down);
3020 self.markers_rotate_region(base + top, base + bottom, !down);
3021 }
3022 self.invalidate_search_highlights();
3023 // BCE-fill the n exposed lines (the primitives blank to default).
3024 let bg = self.cursor.pen.bg;
3025 let (fill_top, fill_end) = if down {
3026 (top, top + n)
3027 } else {
3028 (bottom + 1 - n, bottom + 1)
3029 };
3030 let cols = self.grid.cols();
3031 for r in fill_top..fill_end {
3032 for c in 0..cols {
3033 let cell = self.grid.cell_mut(r, c);
3034 cell.reset();
3035 cell.set_bg(bg);
3036 }
3037 }
3038 }
3039
3040 /// SU (CSI Pn S): scroll the scroll region up by `n`.
3041 fn scroll_up_lines(&mut self, n: usize) {
3042 self.scroll_region_lines(self.scroll_top, self.scroll_bottom, n, false);
3043 }
3044
3045 /// SD (CSI Pn T): scroll the scroll region down by `n`.
3046 fn scroll_down_lines(&mut self, n: usize) {
3047 self.scroll_region_lines(self.scroll_top, self.scroll_bottom, n, true);
3048 }
3049
3050 /// IL (CSI Pn L): insert `n` blank lines at the cursor, scrolling
3051 /// `[cursor..=scroll_bottom]` down. A no-op when the cursor is outside the
3052 /// scroll region.
3053 fn insert_lines(&mut self, n: usize) {
3054 let cur = self.cursor.row;
3055 if cur < self.scroll_top || cur > self.scroll_bottom {
3056 return;
3057 }
3058 self.scroll_region_lines(cur, self.scroll_bottom, n, true);
3059 }
3060
3061 /// DL (CSI Pn M): delete `n` lines at the cursor, scrolling
3062 /// `[cursor..=scroll_bottom]` up. A no-op when the cursor is outside the
3063 /// scroll region.
3064 fn delete_lines(&mut self, n: usize) {
3065 let cur = self.cursor.row;
3066 if cur < self.scroll_top || cur > self.scroll_bottom {
3067 return;
3068 }
3069 self.scroll_region_lines(cur, self.scroll_bottom, n, false);
3070 }
3071
3072 // ---- SGR (CSI m) ---------------------------------------------------------
3073
3074 fn sgr(&mut self, params: &Params) {
3075 let pen = &mut self.cursor.pen;
3076 let mut iter = params.iter();
3077 while let Some(param) = iter.next() {
3078 let code = param.first().copied().unwrap_or(0);
3079 match code {
3080 0 => pen.reset(),
3081 1 => pen.flags.insert(CellFlags::BOLD),
3082 2 => pen.flags.insert(CellFlags::DIM),
3083 3 => pen.flags.insert(CellFlags::ITALIC),
3084 4 => pen.flags.insert(CellFlags::UNDERLINE),
3085 5 => pen.flags.insert(CellFlags::BLINK),
3086 7 => pen.flags.insert(CellFlags::INVERSE),
3087 8 => pen.flags.insert(CellFlags::HIDDEN),
3088 9 => pen.flags.insert(CellFlags::STRIKETHROUGH),
3089 22 => pen.flags.remove(CellFlags::BOLD | CellFlags::DIM),
3090 23 => pen.flags.remove(CellFlags::ITALIC),
3091 24 => pen.flags.remove(CellFlags::UNDERLINE),
3092 25 => pen.flags.remove(CellFlags::BLINK),
3093 27 => pen.flags.remove(CellFlags::INVERSE),
3094 28 => pen.flags.remove(CellFlags::HIDDEN),
3095 29 => pen.flags.remove(CellFlags::STRIKETHROUGH),
3096 30..=37 => pen.fg = Color::Indexed((code - 30) as u8),
3097 38 => {
3098 if let Some(c) = parse_extended_color(param, &mut iter) {
3099 pen.fg = c;
3100 }
3101 }
3102 39 => pen.fg = Color::Default,
3103 40..=47 => pen.bg = Color::Indexed((code - 40) as u8),
3104 48 => {
3105 if let Some(c) = parse_extended_color(param, &mut iter) {
3106 pen.bg = c;
3107 }
3108 }
3109 49 => pen.bg = Color::Default,
3110 // Underline colour (SGR 58 / 59, #520) — same extended-colour grammar
3111 // as 38/48 (colon `58:2:r:g:b` / `58:5:n`, or legacy semicolon), so it
3112 // reuses `parse_extended_color` verbatim. 59 returns to "follow the fg".
3113 58 => {
3114 if let Some(c) = parse_extended_color(param, &mut iter) {
3115 pen.underline_color = c;
3116 }
3117 }
3118 59 => pen.underline_color = Color::Default,
3119 // bright foreground/background (aixterm) → palette 8..=15.
3120 90..=97 => pen.fg = Color::Indexed((code - 90 + 8) as u8),
3121 100..=107 => pen.bg = Color::Indexed((code - 100 + 8) as u8),
3122 _ => {}
3123 }
3124 }
3125 }
3126}
3127
3128/// Parse `38`/`48`/`58` extended colour (foreground / background / underline colour, #520), in
3129/// either form:
3130/// - sub-parameter (colon) form inline in `param`: `38:5:n`, `38:2:r:g:b`
3131/// (optionally `38:2:cs:r:g:b` with a colorspace id), or
3132/// - legacy (semicolon) form: pull the following top-level params from `iter`.
3133///
3134/// The colon RGB form is **count-based** (`off = if param.len() >= 6 { 3 } else { 2 }`): a 5-param
3135/// `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
3136/// `38:2::r:g:b` with an *empty* cs, the form kitty/nvim actually emit — skips the colorspace slot.
3137/// The short 5-param form is **non-conformant to T.416 / ISO-8613-6** (the de-jure standard always
3138/// carries a colorspace field), but tolerating it is the **ecosystem-dominant** behaviour, verified
3139/// against real source (2026-07, #520): VTE (`src/sgr.hh`, branches on `n > 4`), foot (`csi.c`,
3140/// `sub.idx >= 5`) and alacritty (`ansi.rs`, `params.len() > 4`) all count the sub-parameters and
3141/// decode the short form as RGB(r,g,b), exactly as here. VTE's own comment calls it a "common
3142/// misinterpretation of the standard" (foot: "bastard version") that it supports anyway; **only
3143/// xterm.js is strict** (always consumes a colorspace slot, so it misreads the short form). So a
3144/// difference from xterm here is deliberate leniency shared with the non-xterm ecosystem, not a
3145/// defect — the ADR-0004 spec-faithfulness is about not *omitting* behaviour, not about rejecting a
3146/// widely-emitted non-standard input.
3147fn parse_extended_color<'a, I>(param: &[u16], iter: &mut I) -> Option<Color>
3148where
3149 I: Iterator<Item = &'a [u16]>,
3150{
3151 if param.len() > 1 {
3152 // Colon sub-parameter form: kind is param[1].
3153 match param[1] {
3154 2 => {
3155 // 38:2:r:g:b (len 5) or 38:2:cs:r:g:b (len 6, colorspace skipped).
3156 let off = if param.len() >= 6 { 3 } else { 2 };
3157 let r = *param.get(off)? as u8;
3158 let g = *param.get(off + 1)? as u8;
3159 let b = *param.get(off + 2)? as u8;
3160 Some(Color::Rgb(r, g, b))
3161 }
3162 5 => Some(Color::Indexed(*param.get(2)? as u8)),
3163 _ => None,
3164 }
3165 } else {
3166 // Legacy semicolon form: kind, then its operands, are separate params.
3167 match iter.next()?.first().copied()? {
3168 2 => {
3169 let r = iter.next()?.first().copied()? as u8;
3170 let g = iter.next()?.first().copied()? as u8;
3171 let b = iter.next()?.first().copied()? as u8;
3172 Some(Color::Rgb(r, g, b))
3173 }
3174 5 => Some(Color::Indexed(iter.next()?.first().copied()? as u8)),
3175 _ => None,
3176 }
3177 }
3178}
3179
3180/// Reflow one screen (joined with its `scrollback`) to `cols` x `rows`, tracking
3181/// `point` (a cursor in screen coordinates). Returns the new screen rows, the new
3182/// scrollback (capped to `limit`), and the new point. The alt screen passes an
3183/// empty scrollback and discards the returned one.
3184/// The fixed dimensions a resize reflows toward.
3185#[derive(Clone, Copy)]
3186struct ReflowDims {
3187 old_cols: usize,
3188 cols: usize,
3189 rows: usize,
3190 limit: usize,
3191 /// Whether a column change may **re-split** this pane's content, or only re-fit its rows.
3192 ///
3193 /// False for the alt screen (#567). Reflow re-splits a long line so history stays readable at
3194 /// the new width — it assumes the content is text that *flows*. The alt screen has no history,
3195 /// its content is a **layout** rather than a paragraph (re-wrapping htop's columns means
3196 /// nothing), and the application already knows the new size and repaints. All three references
3197 /// take the same position with the same shape — one flag on the same resize function:
3198 /// ghostty `alt.resize(.{ .reflow = false })`, alacritty `grid.resize(!is_alt, …)`, xterm.js
3199 /// gating on `_hasScrollback` with the alt buffer built as `new Buffer(false, …)`.
3200 ///
3201 /// It is not merely wasted work: measured on a real `htop` recording taken across a live
3202 /// `SIGWINCH`, re-splitting leaves debris in the cells htop does not overwrite, because htop
3203 /// repaints **without** clearing. `vim` hides it by erasing first.
3204 reflow: bool,
3205}
3206
3207/// The result of reflowing one pane.
3208struct PaneReflow {
3209 screen: Vec<Row>,
3210 scrollback: VecDeque<Row>,
3211 /// The cursor's new screen-relative position.
3212 cursor: (usize, usize),
3213 /// Each tracked extra point's new position **in this pane's own `[history ++ screen]` frame**,
3214 /// index-aligned with the `extra_abs` argument — *before* any history the caller discards.
3215 ///
3216 /// Reported raw, with `evicted` beside it, because the two callers translate differently and
3217 /// doing it here silently picked the primary's answer for both: the primary keeps its history,
3218 /// so an extra's absolute line only moves by what the cap threw away, while the alt pane has no
3219 /// history at all and everything above the screen is *gone*. Adding the alt result to the
3220 /// primary's scrollback length then produced a line the buffer does not have — reachable
3221 /// without any reflow, on a rows-only resize.
3222 extras: Vec<(usize, usize)>,
3223 /// Rows that left the buffer entirely off the front of this pane's history. For the primary
3224 /// that is the scrollback cap's eviction; for the alt pane, whose limit is `0` because it has
3225 /// no history, it is every row the shrink pushed off the top. An extra whose raw line is below
3226 /// this **is not in the buffer any more** — the caller decides what that means for its kind.
3227 evicted: usize,
3228}
3229
3230/// Reflow one pane (its `scrollback` joined with `screen`) to `dims`, tracking
3231/// the screen-relative cursor `point` plus any `extra_abs` points given in
3232/// **absolute** `[scrollback ++ screen]` coordinates (selection anchors).
3233fn reflow_pane(
3234 screen: Vec<Row>,
3235 scrollback: VecDeque<Row>,
3236 point: (usize, usize),
3237 extra_abs: &[(usize, usize)],
3238 dims: ReflowDims,
3239) -> PaneReflow {
3240 let scroll_len = scrollback.len();
3241 let mut all: Vec<Row> = scrollback.into();
3242 all.extend(screen);
3243
3244 // The cursor is screen-relative; lift it to absolute, then track it together
3245 // with the already-absolute extras.
3246 let mut pts: Vec<(usize, usize)> = Vec::with_capacity(1 + extra_abs.len());
3247 pts.push((scroll_len + point.0, point.1));
3248 pts.extend_from_slice(extra_abs);
3249
3250 let pts = if dims.reflow && dims.cols != dims.old_cols {
3251 let (reflowed, np) = crate::grid::reflow(all, dims.cols, &pts);
3252 all = reflowed;
3253 np
3254 } else {
3255 pts
3256 };
3257
3258 // The cursor can land one row past everything the reflow emitted — "just after the content"
3259 // when the content ends on a full row (#562). That row is real, and while the pane is shorter
3260 // than the screen the caller's fit supplies it for free. When the content already fills the
3261 // pane it has to be bought, and the price is one row of history: the pane **scrolls**, which is
3262 // what a terminal does when content grows past the bottom. Without it the cursor was pulled
3263 // back onto the last glyph and the next byte destroyed a character — the ordinary shell shape,
3264 // a prompt at the bottom of a full screen.
3265 //
3266 // Five earlier designs made `reflow` itself materialise the row and were rejected on
3267 // measurements (a cursor at column 59 resized to width 4 emptied the buffer; a blank-line
3268 // exemption turned 22 alt lines into 21). `reflow` cannot see this pane's budget, so it spent
3269 // what it did not have. Here the budget is in scope, and it is the gate: a pane with no history
3270 // cannot pay — the displaced row would be destroyed rather than archived — so it keeps clamping.
3271 //
3272 // `limit > 0`, deliberately, and not "is this the alt screen": since #567 the alt panes pass
3273 // `limit: 0` because that is what an alt screen's history is, so they are excluded by the budget
3274 // rather than by a branch. That branch is what the design carrying this rule was rejected for
3275 // needing.
3276 //
3277 // This **amends** ADR-0025 rather than reading it narrowly: `reflow` does not create rows; the
3278 // seam may, when the pane can pay. What that record measured is that materialising
3279 // *unconditionally* destroys content.
3280 let cursor_abs = pts[0].0 + usize::from(pts[0].1 == dims.cols);
3281 if dims.limit > 0 {
3282 while all.len() <= cursor_abs {
3283 all.push(Row::blank(dims.cols));
3284 }
3285 }
3286
3287 let split = all.len().saturating_sub(dims.rows);
3288 let history: Vec<Row> = all.drain(0..split).collect();
3289 let mut sb: VecDeque<Row> = history.into();
3290 let mut dropped = 0usize;
3291 while sb.len() > dims.limit {
3292 sb.pop_front();
3293 dropped += 1;
3294 }
3295
3296 // `reflow` may answer `col == cols` — "just after the last cell", which is a real place in the
3297 // logical line and no place in the grid (#562). The **cursor's** reading of it is the next
3298 // *write* position, so a full row means the start of the row after; the caller's row fit
3299 // provides that row (`Grid::set_screen` pads at the bottom). A mark reads the same value the
3300 // opposite way and keeps it verbatim — see `Term::resize`.
3301 let cursor_row = pts[0].0.saturating_sub(split);
3302 let cursor = if pts[0].1 == dims.cols {
3303 (cursor_row + 1, 0)
3304 } else {
3305 (cursor_row, pts[0].1)
3306 };
3307
3308 // The bound on a tracked line belongs **here**, not inside `reflow`: this is where the final
3309 // geometry is known. The screen is padded to `dims.rows` whatever `reflow` emitted, so this
3310 // pane's last addressable line is `split + dims.rows - 1`. Bounding against `reflow`'s own row
3311 // count instead clamped away rows the fit was about to create (#562), while still being the
3312 // only thing standing between an out-of-range anchor and a panic in the consumer's process —
3313 // selection anchors and marks are written back raw, unlike the cursor (`Cursor::set_point`).
3314 // Expressed in this pane's own frame, so it is the same frame `extras` and `evicted` are in.
3315 let max_line = split + dims.rows - 1;
3316
3317 // The cursor returns to screen-relative (its absolute index minus the history split). The
3318 // extras stay in this pane's frame — see the field docs for why they are not shifted here.
3319 PaneReflow {
3320 cursor,
3321 extras: pts[1..]
3322 .iter()
3323 .map(|&(l, c)| (l.min(max_line), c))
3324 .collect(),
3325 evicted: dropped,
3326 screen: all,
3327 scrollback: sb,
3328 }
3329}
3330
3331/// Default tab stops: one every 8 columns (incl. column 0), matching xterm.
3332fn default_tabs(cols: usize) -> Vec<bool> {
3333 (0..cols).map(|i| i % 8 == 0).collect()
3334}
3335
3336/// First sub-parameter of CSI param `idx`, or `default` when absent or zero
3337/// (a zero/omitted numeric param means "1" for cursor movement and "0" for
3338/// erase — callers pass the right default).
3339fn param_or(params: &Params, idx: usize, default: u16) -> u16 {
3340 match params.iter().nth(idx).and_then(|p| p.first().copied()) {
3341 Some(v) if v != 0 => v,
3342 _ => default,
3343 }
3344}
3345
3346impl Term {
3347 /// Apply one DEC private mode set (`'h'`) or reset (`'l'`). DECSET/DECRST
3348 /// carry a list of modes, so `csi_dispatch` folds this over every parameter
3349 /// (#56); each mode is an independent toggle, not a stack.
3350 fn set_dec_private_mode(&mut self, action: char, mode: u16) {
3351 match (action, mode) {
3352 ('h', 1049) => self.enter_alt_screen(),
3353 ('l', 1049) => self.leave_alt_screen(),
3354 // Legacy alt-screen variants (#72): ?47/?1047 switch the buffer
3355 // without saving the cursor; ?1048 saves/restores the cursor without
3356 // switching. ?1049 is the two combined.
3357 ('h', 47) | ('h', 1047) => self.switch_to_alt(),
3358 ('l', 47) | ('l', 1047) => self.switch_to_primary(),
3359 ('h', 1048) => self.save_alt_cursor(),
3360 ('l', 1048) => self.restore_alt_cursor(),
3361 ('h', 6) => {
3362 // DECOM: set homes the cursor to the region top.
3363 self.origin_mode = true;
3364 self.goto(0, 0);
3365 }
3366 ('l', 6) => self.origin_mode = false, // unset leaves the cursor put
3367 ('h', 7) => self.autowrap = true, // DECAWM
3368 ('l', 7) => self.autowrap = false,
3369 ('h', 45) => self.reverse_wraparound = true, // reverse wraparound (#80)
3370 ('l', 45) => self.reverse_wraparound = false,
3371 // DECCOLM (#82): the engine is dimension-free, so emit a request the
3372 // consumer may honor by resizing — no screen/cursor/margin change here.
3373 ('h', 3) => self.events.push(TermEvent::ColumnMode { cols: 132 }),
3374 ('l', 3) => self.events.push(TermEvent::ColumnMode { cols: 80 }),
3375 ('h', 25) => self.cursor.visible = true, // DECTCEM show
3376 ('l', 25) => self.cursor.visible = false, // DECTCEM hide
3377 ('h', 12) => self.cursor.blink = true, // att610 cursor blink (#81)
3378 ('l', 12) => self.cursor.blink = false,
3379 ('h', 2004) => self.bracketed_paste = true,
3380 ('l', 2004) => self.bracketed_paste = false,
3381 ('h', 2026) => self.synchronized_output = true, // synchronized output (#73)
3382 ('l', 2026) => self.synchronized_output = false,
3383 ('h', 2027) => self.grapheme_clustering = true, // grapheme-cluster mode (#295)
3384 ('l', 2027) => self.grapheme_clustering = false,
3385 ('h', 2031) => self.color_scheme_updates = true, // color-scheme notifications (#85)
3386 ('l', 2031) => self.color_scheme_updates = false,
3387 ('h', 9001) => self.win32_input_mode = true, // win32-input-mode (#86)
3388 ('l', 9001) => self.win32_input_mode = false,
3389
3390 // Input-encoding modes (#11): DECCKM, mouse tracking + encoding,
3391 // focus reporting. Each set assigns the level; each reset clears
3392 // it (apps enable/disable the same mode, not a stack).
3393 ('h', 1) => self.app_cursor_keys = true, // DECCKM
3394 ('l', 1) => self.app_cursor_keys = false,
3395 ('h', 66) => self.application_keypad = true, // DECNKM (#74)
3396 ('l', 66) => self.application_keypad = false,
3397 // DECANM (#84): set = ANSI (the normal state); reset enters VT52. Only
3398 // the reset is meaningful — `?2h` is a no-op (already ANSI).
3399 ('l', 2) => self.vt52_mode = true,
3400 ('h', 9) => self.mouse_protocol = MouseProtocol::X10, // X10 mouse (#70)
3401 ('h', 1000) => self.mouse_protocol = MouseProtocol::Normal,
3402 ('h', 1002) => self.mouse_protocol = MouseProtocol::ButtonEvent,
3403 ('h', 1003) => self.mouse_protocol = MouseProtocol::AnyEvent,
3404 ('l', 9) | ('l', 1000) | ('l', 1002) | ('l', 1003) => {
3405 self.mouse_protocol = MouseProtocol::Off
3406 }
3407 ('h', 1006) => self.mouse_encoding = MouseEncoding::Sgr,
3408 ('l', 1006) => self.mouse_encoding = MouseEncoding::Default,
3409 ('h', 1015) => self.mouse_encoding = MouseEncoding::Urxvt,
3410 ('l', 1015) => self.mouse_encoding = MouseEncoding::Default,
3411 ('h', 1005) => self.mouse_encoding = MouseEncoding::Utf8,
3412 ('l', 1005) => self.mouse_encoding = MouseEncoding::Default,
3413 ('h', 1016) => self.mouse_encoding = MouseEncoding::SgrPixels,
3414 ('l', 1016) => self.mouse_encoding = MouseEncoding::Default,
3415 ('h', 1004) => self.focus_events = true,
3416 ('l', 1004) => self.focus_events = false,
3417
3418 _ => {} // other DEC modes are later slices
3419 }
3420 }
3421
3422 /// Dispatch one VT52 escape sequence (`ESC <final>`), reached only while
3423 /// `vt52_mode` is set (#84). VT52 is a pre-ANSI dialect: the cursor/erase
3424 /// finals map to the same `Term` primitives the ANSI path uses. `ESC <`
3425 /// returns to ANSI. Unknown finals are ignored.
3426 fn vt52_dispatch(&mut self, byte: u8) {
3427 match byte {
3428 b'A' => self.move_up(1), // cursor up
3429 b'B' => self.move_down(1), // cursor down
3430 b'C' => self.move_forward(1), // cursor right
3431 b'D' => self.move_back(1), // cursor left
3432 b'H' => self.goto(0, 0), // cursor home
3433 b'I' => self.reverse_index(), // reverse line feed
3434 b'J' => self.erase_display(0), // erase cursor → end of screen
3435 b'K' => self.erase_line(0), // erase cursor → end of line
3436 b'Y' => self.vt52_y_pending = 2, // direct address: two coord bytes follow
3437 // Identify (DECID): reply `ESC / Z` — "I am a VT52".
3438 b'Z' => self.replies.extend_from_slice(b"\x1b/Z"),
3439 b'=' => self.application_keypad = true, // enter alternate keypad
3440 b'>' => self.application_keypad = false, // exit alternate keypad
3441 b'<' => self.vt52_mode = false, // exit VT52, return to ANSI
3442 // RIS (`ESC c`) is honored even here: it is a hard "recover from any
3443 // state" reset, and `full_reset` rebuilds `Term` with `vt52_mode`
3444 // cleared, so RIS always escapes VT52 back to ANSI. VT52 defines no
3445 // other meaning for `ESC c`.
3446 b'c' => self.full_reset(),
3447 // Graphics mode (`ESC F`/`ESC G`) is a documented non-goal: the VT52
3448 // graphics glyph set differs from DEC Special Graphics, so reusing that
3449 // charset would render the wrong glyphs. No-op rather than approximate.
3450 b'F' | b'G' => {}
3451 _ => {} // unknown VT52 finals are ignored
3452 }
3453 }
3454
3455 /// Consume one `ESC Y` coordinate byte (#84). The first byte is the row, the
3456 /// second the column; each decodes as `value - 0x20`. On the second byte the
3457 /// cursor is addressed (`goto` clamps out-of-range coordinates). Reached only
3458 /// from `print` while `vt52_y_pending > 0`.
3459 fn vt52_take_coord(&mut self, c: char) {
3460 let coord = (c as usize).saturating_sub(0x20);
3461 if self.vt52_y_pending == 2 {
3462 self.vt52_y_row = coord;
3463 self.vt52_y_pending = 1;
3464 } else {
3465 self.vt52_y_pending = 0;
3466 self.goto(self.vt52_y_row, coord);
3467 }
3468 }
3469
3470 /// The allocation an OSC 8 `id=` names: the live one if that id already named a link
3471 /// with this same URI, else a fresh one recorded under the key (#635).
3472 ///
3473 /// Keyed on **id and URI together**, mirroring xterm.js's `_getEntryIdKey`
3474 /// (`` `${id};;${uri}` ``, `OscLinkService.ts:87`). Keying on the id alone would follow
3475 /// a reused id to a stale target — an application saying "same link" about two
3476 /// different destinations has not said anything the engine should honour.
3477 fn link_for_id(&mut self, id: &str, uri: &str) -> std::sync::Arc<str> {
3478 let key = format!("{id};;{uri}");
3479 // A key whose link has left the buffer is *absent*, not stale — the group it named
3480 // is gone, so this open starts a new one. That is xterm.js's behaviour too, reached
3481 // by deleting the entry rather than by letting a reference die.
3482 if let Some(live) = self.link_ids.get(&key).and_then(std::sync::Weak::upgrade) {
3483 return live;
3484 }
3485 // Amortised sweep before inserting, so dangling keys stay O(live) rather than
3486 // O(ids ever declared). Doubling the threshold keeps it O(1) per open.
3487 if self.link_ids.len() >= self.link_ids_sweep_at {
3488 self.link_ids.retain(|_, weak| weak.strong_count() > 0);
3489 self.link_ids_sweep_at = (self.link_ids.len() * 2).max(LINK_IDS_FIRST_SWEEP);
3490 }
3491 let fresh: std::sync::Arc<str> = std::sync::Arc::from(uri);
3492 self.link_ids.insert(key, std::sync::Arc::downgrade(&fresh));
3493 fresh
3494 }
3495}
3496
3497impl Perform for Term {
3498 fn print(&mut self, c: char) {
3499 // VT52 `ESC Y` direct addressing (#84): vte delivers the two coordinate
3500 // bytes here (it returned to ground after the `Y` final), so intercept
3501 // them before they would be written as glyphs.
3502 if self.vt52_y_pending > 0 {
3503 self.vt52_take_coord(c);
3504 return;
3505 }
3506 // Translate through the active (GL) character set first (#62): under DEC
3507 // Special Graphics a printable byte becomes a line-drawing glyph.
3508 let c = self.charsets[self.gl].map(c);
3509 // Grapheme-cluster mode (DEC ?2027, #295): if `c` extends the previous cell's cluster,
3510 // join it there instead of placing a new cell. OFF → the per-char (wcwidth) path below.
3511 if self.grapheme_clustering && self.try_grapheme_join(c) {
3512 return;
3513 }
3514 match c.width() {
3515 // Zero-width (combining marks): the grapheme-cluster side-table is a
3516 // later slice; drop for now rather than mis-place it as its own cell.
3517 // A zero-width code point is a combining mark — attach it to the
3518 // previous base glyph rather than dropping it.
3519 Some(0) => self.push_combining(c),
3520 None => {}
3521 // Coerced to a pair, because a pair is the only multi-column shape the cell model
3522 // has (`WIDE_CHAR` + exactly one `WIDE_CHAR_SPACER`) — see ADR-0025, which states
3523 // every clause over "a pair" and never over a wider run. `unicode-width` genuinely
3524 // returns 3 for at least one codepoint (U+17D8 KHMER SIGN BEYYAL, a ligature drawn
3525 // as three characters), and that value is not wrong — it is unrepresentable here.
3526 //
3527 // Left uncoerced, the width fell through *every* wide branch in `write_glyph`
3528 // (each gated on `width == 2`) while still driving the cursor advance, so the glyph
3529 // landed as a lone narrow cell followed by columns that no flag distinguished from
3530 // real blanks: search could not find the text on screen and word selection split
3531 // the run, handing the clipboard a space the buffer never held (#595).
3532 //
3533 // All three references bound it, and ghostty says why in the same words —
3534 // `unicode/props.zig:11-13`, *"We clamp to [0, 2] … i.e. 3-em dash becomes a 2-em
3535 // dash"*. Clamping *here* rather than inside `write_glyph` keeps the two jobs apart:
3536 // this is the policy for an out-of-range external value, and the invariant it
3537 // establishes is asserted at the site that depends on it.
3538 Some(width) => self.write_glyph(c, width.min(2)),
3539 }
3540 }
3541
3542 fn execute(&mut self, byte: u8) {
3543 match byte {
3544 // LF, VT, FF all line-feed.
3545 b'\n' | 0x0b | 0x0c => self.linefeed(),
3546 b'\r' => self.carriage_return(),
3547 0x08 => self.backspace(),
3548 b'\t' => self.put_tab(),
3549 0x07 => self.events.push(TermEvent::Bell), // BEL (#12)
3550 0x0e => self.gl = 1, // SO (LS1): GL = G1 (#62)
3551 0x0f => self.gl = 0, // SI (LS0): GL = G0
3552 _ => {}
3553 }
3554 }
3555
3556 fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, action: char) {
3557 // Kitty keyboard-protocol negotiation: CSI > / = / < / ? ... u. The
3558 // leading intermediate distinguishes it from plain `CSI u` (SCORC) (#23).
3559 if action == 'u'
3560 && let Some(&lead) = intermediates.first()
3561 && matches!(lead, b'>' | b'<' | b'=' | b'?')
3562 {
3563 self.kitty_dispatch(lead, params);
3564 return;
3565 }
3566 // DEC private modes arrive with a '?' intermediate.
3567 if intermediates.first() == Some(&b'?') {
3568 // DECRQM (CSI ? Ps $ p) — report whether mode Ps is set. The '$'
3569 // intermediate distinguishes it from a plain `?...p`. It queries a
3570 // single mode, so it keys off the first parameter only.
3571 if action == 'p' && intermediates.contains(&b'$') {
3572 self.decrqm(param_or(params, 0, 0));
3573 return;
3574 }
3575 // Private DSR (CSI ? Ps n): ?996 = color-scheme query (#85). The
3576 // theme-agnostic engine relays it as an event for the consumer.
3577 if action == 'n' {
3578 if param_or(params, 0, 0) == 996 {
3579 self.events.push(TermEvent::ColorSchemeQuery);
3580 }
3581 return;
3582 }
3583 // DECSET/DECRST carry a *list* of modes; apply set/reset to EVERY
3584 // parameter, not just the first — htop batches `?1006;1000h` into one
3585 // CSI, so folding only params[0] dropped the 1000 (#56).
3586 for mode in params.iter().filter_map(|p| p.first().copied()) {
3587 self.set_dec_private_mode(action, mode);
3588 }
3589 return;
3590 }
3591 // DECSTR soft reset: CSI ! p (#53).
3592 if intermediates.first() == Some(&b'!') && action == 'p' {
3593 self.soft_reset();
3594 return;
3595 }
3596 // DECSCUSR set cursor style: CSI Ps SP q (space intermediate) (#89). An
3597 // absent param means 1 (block blink); an explicit 0 means reset — so the
3598 // raw value matters and `param_or` (which folds 0 to its default) is wrong.
3599 if intermediates.first() == Some(&b' ') && action == 'q' {
3600 let param = params.iter().next().and_then(|p| p.first().copied());
3601 self.set_cursor_style(param.unwrap_or(1));
3602 return;
3603 }
3604 // Other private/intermediate sequences are later slices; ignore them
3605 // rather than misinterpret.
3606 if !intermediates.is_empty() {
3607 return;
3608 }
3609 match action {
3610 'A' => self.move_up(param_or(params, 0, 1) as usize),
3611 'B' | 'e' => self.move_down(param_or(params, 0, 1) as usize),
3612 'C' | 'a' => self.move_forward(param_or(params, 0, 1) as usize),
3613 'D' => self.move_back(param_or(params, 0, 1) as usize),
3614 'G' | '`' => self.set_col(param_or(params, 0, 1) as usize - 1),
3615 'd' => self.set_row(param_or(params, 0, 1) as usize - 1),
3616 'H' | 'f' => {
3617 let row = param_or(params, 0, 1) as usize - 1;
3618 let col = param_or(params, 1, 1) as usize - 1;
3619 self.goto(row, col);
3620 }
3621 'J' => self.erase_display(param_or(params, 0, 0)),
3622 'K' => self.erase_line(param_or(params, 0, 0)),
3623 'X' => self.erase_chars(param_or(params, 0, 1) as usize),
3624 '@' => self.insert_chars(param_or(params, 0, 1) as usize),
3625 'P' => self.delete_chars(param_or(params, 0, 1) as usize),
3626 'S' => self.scroll_up_lines(param_or(params, 0, 1) as usize),
3627 'T' => self.scroll_down_lines(param_or(params, 0, 1) as usize),
3628 'L' => self.insert_lines(param_or(params, 0, 1) as usize),
3629 'M' => self.delete_lines(param_or(params, 0, 1) as usize),
3630 'g' => self.clear_tab_stop(param_or(params, 0, 0)),
3631 'r' => {
3632 let rows = self.grid.rows() as u16;
3633 let top = param_or(params, 0, 1) as usize;
3634 let bottom = param_or(params, 1, rows) as usize;
3635 self.set_scroll_region(top, bottom);
3636 }
3637 'm' => self.sgr(params),
3638 's' => self.save_cursor(), // SCOSC (CSI s) — alias of DECSC
3639 'u' => self.restore_cursor(), // SCORC (CSI u) — alias of DECRC
3640 // DA1 (primary device attributes, CSI c): advertise VT220 + ANSI
3641 // colour — the levels justerm actually implements (#27).
3642 'c' => self.replies.extend_from_slice(b"\x1b[?62;22c"),
3643 'n' => self.device_status_report(param_or(params, 0, 0)),
3644 // Non-private SM/RM. Folded over every parameter (modes can batch,
3645 // like the private path #56). IRM (4) and LNM (20) so far.
3646 'h' => {
3647 for m in params.iter().filter_map(|p| p.first().copied()) {
3648 match m {
3649 4 => self.insert_mode = true,
3650 20 => self.newline_mode = true,
3651 _ => {}
3652 }
3653 }
3654 }
3655 'l' => {
3656 for m in params.iter().filter_map(|p| p.first().copied()) {
3657 match m {
3658 4 => self.insert_mode = false,
3659 20 => self.newline_mode = false,
3660 _ => {}
3661 }
3662 }
3663 }
3664 _ => {}
3665 }
3666 }
3667
3668 fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, byte: u8) {
3669 // VT52 mode (#84): the pre-ANSI dialect reuses the same `ESC <final>`
3670 // tokens vte already produces, but with different meanings, so it is a
3671 // mode-gated branch here rather than a separate parser. All VT52 sequences
3672 // are intermediate-free; anything with an intermediate is not VT52.
3673 if self.vt52_mode && intermediates.is_empty() {
3674 self.vt52_dispatch(byte);
3675 return;
3676 }
3677 if let Some(&i) = intermediates.first() {
3678 // SCS: designate a charset to G0 (`ESC ( F`) or G1 (`ESC ) F`) (#62).
3679 if matches!(i, b'(' | b')') {
3680 let set = match byte {
3681 b'0' => Charset::DecSpecialGraphics,
3682 b'A' => Charset::Uk,
3683 b'B' => Charset::Ascii,
3684 _ => return, // other sets are later slices
3685 };
3686 self.charsets[if i == b'(' { 0 } else { 1 }] = set;
3687 }
3688 // Other intermediates (G2/G3 designators, etc.) are later slices.
3689 return;
3690 }
3691 match byte {
3692 b'D' => self.linefeed(), // IND (line-feed without CR)
3693 b'E' => {
3694 // NEL (next line): carriage return + line-feed.
3695 self.carriage_return();
3696 self.linefeed();
3697 }
3698 b'H' => self.set_tab_stop(), // HTS
3699 b'M' => self.reverse_index(), // RI
3700 b'7' => self.save_cursor(), // DECSC
3701 b'8' => self.restore_cursor(), // DECRC
3702 b'c' => self.full_reset(), // RIS (#53)
3703 b'=' => self.application_keypad = true, // DECKPAM (#74)
3704 b'>' => self.application_keypad = false, // DECKPNM
3705 _ => {}
3706 }
3707 }
3708
3709 /// OSC dispatch (#12 event surface): title (0/2), cwd (7). OSC 8 hyperlink
3710 /// is per-cell state, handled in its own slice (#26), not here.
3711 fn osc_dispatch(&mut self, params: &[&[u8]], _bell_terminated: bool) {
3712 // params[0] is the OSC number; params[1..] the payload fields.
3713 let Some(&number) = params.first() else {
3714 return;
3715 };
3716 match number {
3717 // OSC 0 = icon + window title, OSC 2 = window title. Both set title.
3718 b"0" | b"2" => {
3719 if let Some(&title) = params.get(1) {
3720 self.events.push(TermEvent::Title(
3721 String::from_utf8_lossy(title).into_owned(),
3722 ));
3723 }
3724 }
3725 // OSC 7 = current working directory (a file:// URI).
3726 b"7" => {
3727 if let Some(&cwd) = params.get(1) {
3728 self.events
3729 .push(TermEvent::Cwd(String::from_utf8_lossy(cwd).into_owned()));
3730 }
3731 }
3732 // OSC 133 = FinalTerm/iTerm2 shell-integration command marks (#158):
3733 // `A` prompt start, `B` command start, `C` output start, `D[;exit]`
3734 // command finished. Each anchors a kinded marker at the cursor line;
3735 // pairing + navigation is consumer policy (#160). Unknown subcommands
3736 // (or none) are ignored. `D`'s exit field parses to `i32`, else None.
3737 b"133" => match params.get(1).copied() {
3738 Some(b"A") => self.add_command_mark(MarkerKind::PromptStart),
3739 Some(b"B") => self.add_command_mark(MarkerKind::CommandStart),
3740 Some(b"C") => self.add_command_mark(MarkerKind::OutputStart),
3741 Some(b"D") => {
3742 let exit = params
3743 .get(2)
3744 .and_then(|p| core::str::from_utf8(p).ok())
3745 .and_then(|s| s.parse::<i32>().ok());
3746 self.add_command_mark(MarkerKind::CommandFinished(exit));
3747 }
3748 _ => {}
3749 },
3750 // OSC 8 = hyperlink: `OSC 8 ; params ; URI`. A non-empty URI opens a
3751 // link (made current); an empty URI closes it. `params` carries the
3752 // optional `id=` that groups runs into one link (#635).
3753 b"8" => {
3754 // One allocation per *open*, shared by that open's cells and dropped
3755 // with the last row holding it (#628 — there is no pool). Two opens of
3756 // an identical URI stay two links, deliberately: merging them would
3757 // override a distinction the application controls through `id=`. That
3758 // parameter is the *only* dedup performed, which is one rule and not
3759 // two — xterm.js states it as "links with no id will only ever be
3760 // registered a single time" beside a lookup keyed on id-plus-uri
3761 // (`OscLinkService.ts:34`, `:49-54`).
3762 // The URI is `params[2..]` **rejoined**, not `params[2]` (#650). vte splits the
3763 // OSC payload on `;`, so a URI carrying an unencoded `;` arrives in pieces and
3764 // reading only the first dropped the rest — silently, with no error. Nothing is
3765 // lost at the parser: measured, `]8;;https://x/a;b=c` arrives as
3766 // `["8", "", "https://x/a", "b=c"]`. xterm.js special-cases the same thing from
3767 // the other side, splitting on the *first* `;` only and taking all the rest as
3768 // the URI, *"to support unencoded semi-colons in the URIs"*
3769 // (`InputHandler.ts:3106-3112`). Reachable without anything exotic: `?a=1;b=2`
3770 // is a legal query string and `;` is a legal filename byte.
3771 //
3772 // The close survives this: `]8;;` arrives as `["8", "", ""]`, whose rejoin is
3773 // empty, and an empty URI still closes. Never decoded — a `%3B` stays `%3B`,
3774 // because the engine hands the target over exactly as declared (ADR-0017).
3775 let uri: Vec<u8> = params.get(2..).unwrap_or_default().join(&b';');
3776 self.current_link = if uri.is_empty() {
3777 None
3778 } else {
3779 let uri = String::from_utf8_lossy(&uri);
3780 Some(match osc8_link_id(params.get(1).copied().unwrap_or(b"")) {
3781 // No id declared: fresh per open, the reference-correct default.
3782 None => std::sync::Arc::from(&*uri),
3783 Some(id) => self.link_for_id(&String::from_utf8_lossy(id), &uri),
3784 })
3785 };
3786 }
3787 // OSC 4 = set/query an ANSI palette entry: `OSC 4 ; index ; spec`
3788 // (#122). The engine forwards index + raw spec; the consumer applies
3789 // it to its palette (theme-agnostic — the cell keeps `Indexed`).
3790 b"4" => {
3791 // One event per `index ; spec` pair (xterm's `while slots > 1`).
3792 let mut rest = ¶ms[1..];
3793 while let [idx, spec, tail @ ..] = rest {
3794 rest = tail;
3795 if let Ok(index) = String::from_utf8_lossy(idx).parse::<u8>() {
3796 if *spec == b"?" {
3797 self.events.push(TermEvent::QueryPaletteColor { index });
3798 } else {
3799 self.events.push(TermEvent::SetPaletteColor {
3800 index,
3801 spec: String::from_utf8_lossy(spec).into_owned(),
3802 });
3803 }
3804 }
3805 }
3806 }
3807 // OSC 104 = reset palette entries (#122): no arg resets the whole
3808 // table, else one event per named index.
3809 b"104" => {
3810 if params.len() <= 1 {
3811 self.events.push(TermEvent::ResetPaletteColor(None));
3812 } else {
3813 for &idx in ¶ms[1..] {
3814 if let Ok(index) = String::from_utf8_lossy(idx).parse::<u8>() {
3815 self.events.push(TermEvent::ResetPaletteColor(Some(index)));
3816 }
3817 }
3818 }
3819 }
3820 // OSC 10/11 = set/query the default foreground/background, stacking
3821 // specs across the [fg, bg] slots (#122, #137). OSC 10 starts at fg,
3822 // OSC 11 at bg. The engine forwards raw specs (theme-agnostic).
3823 b"10" => self.special_color(params, 0),
3824 b"11" => self.special_color(params, 1),
3825 // OSC 110 / 111 = reset the default foreground / background (#122).
3826 b"110" => self.events.push(TermEvent::ResetForeground),
3827 b"111" => self.events.push(TermEvent::ResetBackground),
3828 _ => {} // other OSCs are later slices
3829 }
3830 }
3831}
3832
3833#[cfg(test)]
3834mod tests {
3835 use crate::Engine;
3836
3837 /// #628 — a hyperlink's storage is released once no live row references it.
3838 ///
3839 /// In-crate on purpose: this defect has **no public observable**, which is why it
3840 /// survived from #46 until #621's completeness pass went looking. Pool indices never
3841 /// cross the wire (`Term::frame` remaps them to frame-local `link_table` positions),
3842 /// and the one public reader takes an index the caller already holds — so from
3843 /// outside the crate a pool of 5 entries and a pool of 50 000 are indistinguishable.
3844 /// The assertion has to stand where the storage does.
3845 ///
3846 /// The fixture is a buffer that cannot hold what it is fed: 2 rows plus 2 lines of
3847 /// scrollback is four lines total, so by the end all but the last four opens have
3848 /// been evicted and nothing on screen or in history refers to them.
3849 #[test]
3850 fn a_link_evicted_from_the_buffer_stops_being_stored() {
3851 let mut e = Engine::with_scrollback(20, 2, 2);
3852 for i in 0..50 {
3853 e.feed(format!("\x1b]8;;https://example.com/{i}\x07L{i}\x1b]8;;\x07\r\n").as_bytes());
3854 }
3855
3856 // The observable had to move with the storage — there is no pool left to count.
3857 // A `Weak` is the stronger form of the same claim anyway: a bounded count can be
3858 // bounded and still wrong, while a dead `Weak` says *this exact allocation* was
3859 // released.
3860 //
3861 // **`e2` must outlive the assertion, and that is the whole test.** The first
3862 // version of this scoped the engine to the block that built the `Weak`, so the
3863 // engine was dropped before the check and the `Weak` died for that reason
3864 // instead. Measured: with a deliberate leak reintroduced (a `Vec<Arc<str>>` on
3865 // `Term`, retaining every open), that version stayed **green** — a tautological
3866 // proof, confirming only that dropping an `Engine` frees its own memory. Keeping
3867 // the engine alive is what makes the assertion about reclamation.
3868 let mut e2 = Engine::with_scrollback(20, 2, 2);
3869 e2.feed(b"\x1b]8;;https://example.com/first\x07L\x1b]8;;\x07\r\n");
3870 let weak = {
3871 let arc = e2
3872 .term
3873 .grid
3874 .row_ref(0)
3875 .link_at(0)
3876 .expect("on screen")
3877 .clone();
3878 std::sync::Arc::downgrade(&arc)
3879 };
3880 // The live half first: a fix that simply never stored the URI would satisfy the
3881 // dead-`Weak` assertion below for the wrong reason.
3882 assert!(
3883 weak.upgrade().is_some(),
3884 "the URI must be alive while its cell is on screen",
3885 );
3886 for i in 0..50 {
3887 e2.feed(format!("filler {i}\r\n").as_bytes());
3888 }
3889 assert!(
3890 weak.upgrade().is_none(),
3891 "the first link scrolled out of a 4-line buffer and nothing should still \
3892 hold its URI — before #628 every OSC 8 open lived for the life of the Term",
3893 );
3894
3895 // The whole-buffer form of the same claim: 50 distinct opens through a buffer
3896 // that holds four lines leaves at most four entries *owned*.
3897 //
3898 // `owned_link_count` and not `link_at`, and that distinction is the test. The
3899 // first version summed the gated reader, which counts **linked cells** — measured
3900 // on an erased screen it read 0 while every URI was still allocated, so it could
3901 // not fail for the property this test exists to assert.
3902 // Deduped by allocation: one open covering three cells is three map entries and
3903 // one URI, so counting entries would fail at 9 for a buffer holding four links.
3904 let owned: std::collections::HashSet<*const u8> = e
3905 .term
3906 .scrollback
3907 .iter()
3908 .chain((0..2).map(|r| e.term.grid.row_ref(r)))
3909 .flat_map(|r| r.owned_links())
3910 .map(|u| std::sync::Arc::as_ptr(u) as *const u8)
3911 .collect();
3912 assert!(
3913 owned.len() <= 4,
3914 "a 4-line buffer cannot own more than 4 distinct URIs, found {}",
3915 owned.len(),
3916 );
3917 }
3918
3919 /// #628 — erasing a cell in place releases its URI, not just its presence bit.
3920 ///
3921 /// The sibling of the eviction test above, and the case that one structurally cannot
3922 /// see: `clear_cells` / `free_cell` blank a cell **without dropping its row**, so no
3923 /// row-lifetime event fires. Under `row-keyed-side-maps` rule 3 leaving the map entry
3924 /// is sanctioned — *"a write that clears the cell owes the bit, not the map"* — and
3925 /// that was exactly right while the value was a 4-byte index: a stale entry is
3926 /// unreadable through the gate and costs nothing.
3927 ///
3928 /// #628 changed what the entry *is*. The map now owns a heap string, so the same
3929 /// sanctioned line retains one. Rule 3 still holds as stated — purging is not the
3930 /// correctness step, and missing a site costs bounded retention rather than a wrong
3931 /// answer — but the optimisation it calls optional became worth taking here.
3932 /// All three references release at this point: alacritty's `Cell::reset` drops the
3933 /// `Option<Arc<CellExtra>>` outright, ghostty's ref-counted set frees at zero, and
3934 /// xterm.js's `_resetBufferLine` clears `_extendedAttrs` and disposes the line's
3935 /// markers so `OscLinkService` deletes the entry.
3936 #[test]
3937 fn an_erased_cell_releases_its_uri_not_only_its_bit() {
3938 let mut e = Engine::new(80, 24);
3939 e.feed(b"]8;;https://example.com/erasedL]8;;");
3940 let weak = {
3941 let a = e
3942 .term
3943 .grid
3944 .row_ref(0)
3945 .link_at(0)
3946 .expect("on screen")
3947 .clone();
3948 std::sync::Arc::downgrade(&a)
3949 };
3950 assert!(weak.upgrade().is_some(), "alive while on screen");
3951
3952 e.feed(b"[2J"); // ED 2 — erases in place; no row is dropped or reused
3953
3954 // The gated reader already says "no link", and so does the frame. Neither can
3955 // see the retention, which is why this assertion holds the `Weak` instead:
3956 // measured before the purge, both public views read 0 while the URI lived.
3957 assert!(e.link_at(0, 0).is_none(), "the presence bit is cleared");
3958 assert!(
3959 weak.upgrade().is_none(),
3960 "and the URI itself is released — before the purge the map kept owning it, so an erased screen retained every link it had shown",
3961 );
3962 }
3963
3964 /// `Arc`, not `Rc`, and this is what makes that a fact rather than a comment.
3965 ///
3966 /// #628 chose `Arc<str>` for the row's link map on the stated ground that `Engine` is
3967 /// `Send + Sync`; `Rc` would have removed both **silently** — no signature changes
3968 /// here, and a downstream `Mutex<Engine>` failing to compile instead. The claim was
3969 /// load-bearing and unpinned: a repo-wide grep for it found only prose.
3970 #[test]
3971 fn the_engine_stays_send_and_sync() {
3972 fn assert_send_sync<T: Send + Sync>() {}
3973 assert_send_sync::<Engine>();
3974 }
3975
3976 /// Two OSC 8 opens of an identical URI are **two links**, not one.
3977 ///
3978 /// Deliberate, and the reason is #635: merging them would override the grouping the
3979 /// application controls through `id=`, which is the one dedup xterm.js performs.
3980 /// Asserted by allocation identity through the in-crate observer rather than through
3981 /// a public accessor — the behaviour is real now, a consumer asking about it is not.
3982 #[test]
3983 fn two_opens_of_one_uri_are_two_links() {
3984 let mut e = Engine::new(40, 2);
3985 // One open covering two cells, then a *separate* open of the very same URI.
3986 e.feed(b"]8;;https://example.com/xAB]8;;");
3987 e.feed(b"]8;;https://example.com/xC]8;;");
3988
3989 let row = e.term.grid.row_ref(0);
3990 let ptr = |c: usize| std::sync::Arc::as_ptr(row.link_at(c).expect("linked")) as *const u8;
3991 assert_eq!(
3992 e.link_at(0, 0).map(|h| h.uri().to_owned()),
3993 e.link_at(0, 2).map(|h| h.uri().to_owned()),
3994 "the text is the same",
3995 );
3996 assert_eq!(ptr(0), ptr(1), "A and B are one open, so one allocation");
3997 assert_ne!(
3998 ptr(0),
3999 ptr(2),
4000 "…but C is a second open — merging the two would override the distinction `id=` exists to express (#635)",
4001 );
4002 }
4003
4004 /// The other half of the rule above: an `id=` the application declared **does** group
4005 /// (#635). One rule, not two — "never merge on URI alone, always merge on a declared
4006 /// id" is how xterm.js states it (`OscLinkService.ts:34`, `:51` at the pinned SHA), and
4007 /// justerm shipped the first half only because #26 ported `registerLink`'s id-minting
4008 /// and not its lookup.
4009 ///
4010 /// Grouping is asserted as **allocation identity**, which is not an implementation
4011 /// detail leaking into a test: since #628 the `Arc`'s address *is* link identity —
4012 /// `Term::frame` interns `link_table` by `Arc::as_ptr`, so one allocation is what makes
4013 /// two runs one link index on the wire, and that index is what a consumer groups by.
4014 #[test]
4015 fn the_same_id_and_uri_group_into_one_link() {
4016 let mut e = Engine::new(40, 2);
4017 // Two separate opens, same `id=` and same URI, on two different lines — the case
4018 // the parameter exists for (a link that cannot be one contiguous run).
4019 e.feed(b"\x1b]8;id=xyz;https://example.com/a\x07A\x1b]8;;\x07\r\n");
4020 e.feed(b"\x1b]8;id=xyz;https://example.com/a\x07B\x1b]8;;\x07");
4021
4022 let ptr = |r: usize, c: usize| {
4023 std::sync::Arc::as_ptr(e.term.grid.row_ref(r).link_at(c).expect("linked")) as *const u8
4024 };
4025 assert_eq!(
4026 ptr(0, 0),
4027 ptr(1, 0),
4028 "the application said these two runs are one link, so they share one allocation",
4029 );
4030
4031 // And the wire agrees, which is the half a consumer can actually see: one entry in
4032 // `link_table`, referenced by both spans. Two entries is the defect.
4033 let f = e.frame();
4034 assert_eq!(f.link_table.len(), 1, "one link ships once");
4035 }
4036
4037 /// Keyed on `id` **and** URI, not on `id` alone — xterm.js's `_getEntryIdKey` is
4038 /// `` `${id};;${uri}` `` (`OscLinkService.ts:87`). An application reusing an id for a
4039 /// different target has not said "same link"; treating it as one would follow a stale
4040 /// declaration to the wrong URI.
4041 #[test]
4042 fn the_same_id_with_a_different_uri_stays_two_links() {
4043 let mut e = Engine::new(40, 2);
4044 e.feed(b"\x1b]8;id=xyz;https://example.com/a\x07A\x1b]8;;\x07\r\n");
4045 e.feed(b"\x1b]8;id=xyz;https://example.com/b\x07B\x1b]8;;\x07");
4046
4047 let ptr = |r: usize, c: usize| {
4048 std::sync::Arc::as_ptr(e.term.grid.row_ref(r).link_at(c).expect("linked")) as *const u8
4049 };
4050 assert_ne!(
4051 ptr(0, 0),
4052 ptr(1, 0),
4053 "same id, different target — two links"
4054 );
4055 assert_eq!(e.frame().link_table.len(), 2, "and both ship");
4056 }
4057
4058 /// `id=` with an **empty value** is no id at all, so the no-id rule applies and each
4059 /// open is its own link. xterm.js reaches this by `parsedParams[i].slice(3) || undefined`
4060 /// (`InputHandler.ts:3130`) — the `||` is the whole behaviour, and reading `slice(3)`
4061 /// alone gives the opposite answer.
4062 ///
4063 /// Worth a test rather than a comment because the empty-string key is the one that
4064 /// would group *every* `id=`-with-no-value link in a session into one, across unrelated
4065 /// URIs — a wrong answer that grows with uptime.
4066 #[test]
4067 fn an_empty_id_value_is_no_id_at_all() {
4068 let mut e = Engine::new(40, 2);
4069 e.feed(b"\x1b]8;id=;https://example.com/a\x07A\x1b]8;;\x07\r\n");
4070 e.feed(b"\x1b]8;id=;https://example.com/a\x07B\x1b]8;;\x07");
4071
4072 let ptr = |r: usize, c: usize| {
4073 std::sync::Arc::as_ptr(e.term.grid.row_ref(r).link_at(c).expect("linked")) as *const u8
4074 };
4075 assert_ne!(
4076 ptr(0, 0),
4077 ptr(1, 0),
4078 "no id declared, so the reference-correct fresh-per-open rule still holds",
4079 );
4080 }
4081
4082 /// `params` is a **`:`-separated** key=value list (`id=xyz123:foo=bar:baz=quux`), and
4083 /// `id` may sit anywhere in it — xterm.js scans with `findIndex(e =>
4084 /// e.startsWith('id='))` (`InputHandler.ts:3129`). Testing only a leading `id=` would
4085 /// pass with a `starts_with` on the whole field, which is the wrong parse.
4086 #[test]
4087 fn the_id_param_is_found_among_other_params() {
4088 let mut e = Engine::new(40, 2);
4089 e.feed(b"\x1b]8;foo=bar:id=xyz:baz=quux;https://example.com/a\x07A\x1b]8;;\x07\r\n");
4090 e.feed(b"\x1b]8;id=xyz;https://example.com/a\x07B\x1b]8;;\x07");
4091
4092 let ptr = |r: usize, c: usize| {
4093 std::sync::Arc::as_ptr(e.term.grid.row_ref(r).link_at(c).expect("linked")) as *const u8
4094 };
4095 assert_eq!(
4096 ptr(0, 0),
4097 ptr(1, 0),
4098 "the id is the same whatever else rides beside it",
4099 );
4100 }
4101
4102 /// The grouping registry must not become the pool #628 deleted.
4103 ///
4104 /// Whatever maps an `id=` to its link has to hold it **weakly**: a strong reference
4105 /// would make every id'd link immortal for the life of the `Term` — the exact defect
4106 /// #628 removed, re-entering through the door #635 opens. xterm.js's equivalent map is
4107 /// reclaimed rather than weak (`_entriesWithId.delete` when the entry's last line
4108 /// marker is disposed, `OscLinkService.ts:98-100`); justerm has no disposal hook by
4109 /// design, so `Weak` is how the same lifetime is expressed here.
4110 ///
4111 /// This is the test that discriminates the two, and nothing public can: both spellings
4112 /// group correctly, and they differ only in what stays alive afterwards.
4113 #[test]
4114 fn the_id_registry_does_not_keep_a_link_alive() {
4115 let mut e = Engine::new(80, 24);
4116 e.feed(b"\x1b]8;id=xyz;https://example.com/grouped\x07L\x1b]8;;\x07");
4117 let weak = {
4118 let a = e
4119 .term
4120 .grid
4121 .row_ref(0)
4122 .link_at(0)
4123 .expect("on screen")
4124 .clone();
4125 std::sync::Arc::downgrade(&a)
4126 };
4127 assert!(weak.upgrade().is_some(), "alive while on screen");
4128
4129 e.feed(b"\x1b[2J"); // ED 2 — the in-place erase that releases the row's side maps
4130
4131 assert!(
4132 weak.upgrade().is_none(),
4133 "the id registry must hold a Weak — a strong entry would outlive the screen and rebuild #628's leak one id at a time",
4134 );
4135 }
4136}