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, UnderlineStyle};
11use crate::color::Color;
12use crate::cursor::{Cursor, CursorShape, Pen};
13use crate::damage::{LineBounds, LineDamage, ScrollOp, TermDamage};
14use crate::event::{ClipboardTarget, TermEvent, Terminator};
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, MAX_SCROLL_COUNT, 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/// Tracked points (#691) — absolute positions the engine keeps on their content for a
46/// holder that lives outside it, and the anchor fixups the write path drives.
47mod tracked;
48
49/// Owns the authoritative screen state and applies VT actions to it.
50pub struct Term {
51 grid: Grid,
52 /// The inactive screen. Swapped with `grid` on alt-screen enter/leave; holds
53 /// whichever of primary/alternate is not currently shown. The alt screen has
54 /// no scrollback (#3 only rings the primary).
55 alt_grid: Grid,
56 cursor: Cursor,
57 /// Cursor saved on alt-screen enter (DEC 1049), restored on leave.
58 saved_cursor: Cursor,
59 /// Whether the alternate screen is currently active. Guards enter/leave so a
60 /// double-enter or double-leave is a no-op.
61 on_alt: bool,
62 /// One flag per column: is there a tab stop here? Explicit per-column state
63 /// (HTS sets, TBC clears), not a fixed modulo. Default = every 8th column.
64 tabs: Vec<bool>,
65 /// Which characters end a word for Word (semantic) selection — **consumer policy**,
66 /// not engine state (ADR-0017). Defaults to [`DEFAULT_WORD_SEPARATORS`]; replaced
67 /// through `set_word_separators`, which is also where the `' '` floor is enforced.
68 ///
69 /// Being *policy* is what puts it on the short list `full_reset` carries across RIS,
70 /// beside `replies` and `events`: `ESC c` resets the terminal's state, and a
71 /// consumer's configuration is not that. All three references survive RIS here by
72 /// construction rather than by remembering to — alacritty's `reset_state` never
73 /// touches `self.config`, xterm.js holds it in `OptionsService`, and ghostty passes
74 /// the set in per call.
75 word_separators: String,
76 /// The window title the application last set (OSC 0/2), retained so that a
77 /// title *pop* has something to restore (#823). Until XTWINOPS 22/23 landed
78 /// the engine forwarded the string and forgot it, which is exactly why a pop
79 /// could not be answered: a party that does not know what it is stacking
80 /// cannot maintain a stack, and the consumer cannot do it either — it holds
81 /// a frame and an event queue and never sees that a pop was requested.
82 ///
83 /// This is **terminal state the application owns**, so it dies on RIS. It
84 /// needs no entry in `full_reset`'s copy-back list — the wholesale rebuild
85 /// drops it, which is the behaviour alacritty spells out (`title_stack =
86 /// Vec::new()` in its `reset_state`). Note it is a *fifth* kind for the RIS
87 /// invariant note's table, which sorts fields into configuration / buffer
88 /// coordinate / pending obligation / id counter: a retained title is none of
89 /// those, and it dies because the application wrote it, not the embedder.
90 window_title: String,
91 /// The icon name, the second axis XTWINOPS addresses (#823). Narrower than
92 /// it looks: **OSC 1 is not parsed**, so the only thing that ever writes
93 /// this is OSC 0, which sets both axes at once. It is retained and stacked
94 /// anyway so that mixed push/pop sequences keep the two axes aligned — the
95 /// engine has no icon-name event, so restoring one has no observable output
96 /// today. That asymmetry is deliberate and stated rather than an oversight;
97 /// xterm.js takes the same route (`setIconName` fires nothing).
98 ///
99 /// **The obligation that outlives this comment: no test can observe this
100 /// field, so a fourth writer would disagree with the other three in
101 /// silence.** There are exactly three today, all in this file — `OSC 0` in
102 /// `osc_dispatch`, and the push and pop in [`Term::window_ops`] — and the
103 /// window axis funnels through [`Term::set_window_title`] precisely so its
104 /// retained string and its event cannot drift apart. This axis has no such
105 /// funnel because it has no event to keep in step with. **Adding `OSC 1` is
106 /// the foreseeable fourth writer** (it is the natural neighbour of the OSC
107 /// work in #47's tail), and whoever adds it should route every write through
108 /// one owner the way the window axis does — not because a test will fail,
109 /// but because none can.
110 icon_name: String,
111 /// The window-title stack (XTWINOPS `CSI 22 t` / `CSI 23 t`), bounded at
112 /// [`TITLE_STACK_DEPTH`]. Separate from `icon_name_stack` because the
113 /// sequence's second parameter selects an axis and real applications use it:
114 /// `vim` emits a fully nested `22;0;0t · 22;2t · 22;1t … 23;2t · 23;1t ·
115 /// 23;0;0t`.
116 ///
117 /// **This is a choice among three models, not two, and the spec does not
118 /// make it** — `ctlseqs.txt:1688-1693` names the two axes and never says how
119 /// many stacks there are. (a) One stack with the axis ignored: alacritty,
120 /// and it restores the wrong string on the sequence above. (b) Two
121 /// independent stacks: xterm.js, and this. (c) One stack of `{icon, window}`
122 /// **pairs**, where an axis-limited push leaves the other member empty and a
123 /// pop that finds an empty member walks *back* through older slots for one:
124 /// xterm (`ptyx.h:2361`, `misc.c:8011`). (c) handles the axis correctly by a
125 /// different mechanism, so the argument against (a) is not an argument
126 /// against it; what separates (b) from (c) is that they do not share a depth
127 /// budget — ten each here, ten *total* there — and that a repeated same-axis
128 /// pop goes silent under (b) while (c) re-emits from an older slot. Neither
129 /// shape is reachable by anything measured, and (b) is the one whose depth
130 /// accounting a reader can predict without simulating a ring.
131 window_title_stack: Vec<String>,
132 /// The icon-name stack — the other half of the pair above.
133 icon_name_stack: Vec<String>,
134 /// Origin mode (DECOM ?6): when set, cursor addressing is relative to the
135 /// scroll region's top margin (and clamped to it).
136 origin_mode: bool,
137 /// Autowrap (DECAWM ?7): default on. When off, a glyph past the right margin
138 /// pins the cursor to the last column and overwrites in place instead of
139 /// wrapping to the next line (matches xterm.js) (#63).
140 autowrap: bool,
141 /// Insert mode (IRM, the non-private SM/RM mode 4): default off (replace).
142 /// When on, a printed glyph shifts the row's tail right first (#64).
143 insert_mode: bool,
144 /// New-line mode (LNM, the non-private SM/RM mode 20): default off. When on,
145 /// a line feed also carriage-returns (`convertEol`). Output-only — the Enter
146 /// key still encodes CR, matching xterm.js (#71).
147 newline_mode: bool,
148 /// Reverse wraparound (DEC ?45): default off. When on, a *backspace* at
149 /// column 0 of a soft-wrapped row moves back to the end of the previous row
150 /// (BS only, soft wraps only — matches xterm.js) (#80).
151 reverse_wraparound: bool,
152 /// Bracketed-paste mode (DEC ?2004). The engine owns the flag; the input
153 /// encoder (#11) reads it to decide whether to wrap pasted text in markers.
154 bracketed_paste: bool,
155 /// Synchronized output (DEC ?2026): the app brackets a frame of output so the
156 /// renderer can paint it atomically. The engine only *tracks* the flag — the
157 /// consumer owns the paint-hold and the spec-mandated timeout (#73).
158 synchronized_output: bool,
159 /// Color-scheme-update notifications (DEC ?2031): the app asked to be told
160 /// when the light/dark scheme changes. The engine is theme-agnostic — it only
161 /// tracks the flag; the consumer (which knows the scheme) drives the ?997
162 /// notification via `report_color_scheme` (#85).
163 color_scheme_updates: bool,
164 /// Grapheme-cluster mode (DEC ?2027, default OFF): the app opted into UAX #29 grapheme-cluster
165 /// width — a ZWJ / skin-tone / flag / emoji+VS16 sequence is clustered into ONE cell instead of
166 /// one cell per scalar (#295). OFF keeps the per-char (wcwidth-compatible) behaviour so the
167 /// cursor stays in sync with wcwidth apps — clustering is opt-in for exactly that reason (#301).
168 grapheme_clustering: bool,
169 /// win32-input-mode (DEC ?9001): the app asked for keys as raw Windows
170 /// key-records. The engine only *tracks* the flag — the raw record encoding
171 /// (`CSI Vk;Sc;Uc;Kd;Cs;Rc _`) is a non-goal (raw passthrough, no semantic
172 /// conversion), left to the ConPTY consumer; `encode_key` is unchanged (#86).
173 win32_input_mode: bool,
174 /// Application cursor keys (DECCKM ?1): when set, cursor keys / Home / End
175 /// encode as SS3 rather than CSI (see `input.rs`).
176 app_cursor_keys: bool,
177 /// Application keypad mode (DECNKM ?66 / DECKPAM `ESC =` / DECKPNM `ESC >`):
178 /// tracked for protocol completeness + DECRQM, but NOT yet acted on in key
179 /// encoding — xterm.js tracks it the same way and never reads it (#74).
180 application_keypad: bool,
181 /// VT52 compatibility mode (DECANM ?2 *reset*): when set, `esc_dispatch` is
182 /// re-routed into the pre-ANSI VT52 dialect (`ESC A`-style sequences) instead
183 /// of the ANSI meaning. `ESC <` clears it. Default off (ANSI). (#84)
184 vt52_mode: bool,
185 /// VT52 `ESC Y row col` direct-addressing state (#84). vte tokenizes `ESC Y`
186 /// as a final and returns to ground, so the two coordinate bytes arrive as
187 /// `print()` calls — not part of the escape sequence. This counts them down
188 /// (2 → 1 → 0; 0 = not addressing) and `vt52_y_row` parks the first (row)
189 /// until the second (col) lands. Each byte decodes as `value - 0x20`.
190 vt52_y_pending: u8,
191 vt52_y_row: usize,
192 /// Mouse tracking mode — what events the app asked to be reported
193 /// (?1000/?1002/?1003). `Off` by default.
194 mouse_protocol: MouseProtocol,
195 /// Mouse coordinate encoding (default X10 vs ?1006 SGR).
196 mouse_encoding: MouseEncoding,
197 /// Focus in/out reporting (?1004): emit `CSI I`/`CSI O` on focus change.
198 focus_events: bool,
199 /// Kitty keyboard-protocol progressive-enhancement flags currently in effect
200 /// (bit0 disambiguate, bit1 report-events, bit2 alt-keys, bit3 all-as-escape,
201 /// bit4 associated-text). 0 = legacy. `encode_key` consults these (#23).
202 kitty_flags: u8,
203 /// Saved `kitty_flags` for the protocol's push/pop stack (`CSI > u` pushes,
204 /// `CSI < u` pops). Capped depth — overflow drops the oldest entry.
205 kitty_stack: Vec<u8>,
206 /// Consumer events (title / bell / cwd) accumulated since the last
207 /// `drain_events` (#12). Pull, not push — see `event.rs`.
208 events: Vec<TermEvent>,
209 /// Outbound reply bytes (DA/DSR/DECRQM query answers, #27) accumulated
210 /// during `feed` for the consumer to write back to the PTY. Raw bytes →
211 /// PTY, kept separate from typed `events` → UI.
212 replies: Vec<u8>,
213 /// The hyperlink currently open (OSC 8 with a URI), stamped onto every glyph
214 /// written until closed (OSC 8 with empty URI). Ambient pen-like state — not
215 /// part of the pen/SGR, and *not* cleared by an SGR reset.
216 ///
217 /// The URI itself, not a pool index — there is no pool (#628). The lead paragraph
218 /// here used to describe one (*"Hyperlink side-table … referenced by `Cell.link`
219 /// (1-based). Append-only (#26)"*), left behind when its field was deleted.
220 current_link: Option<std::sync::Arc<str>>,
221 /// Live OSC 8 `id=` groups: `"id;;uri"` → the allocation that key already named,
222 /// held **weakly** (#635).
223 ///
224 /// `Weak`, not `Arc`, is the whole lifetime story. A strong entry here would make
225 /// every id'd link immortal for the life of the `Term` — precisely the leak #628
226 /// deleted, re-entering through the door grouping opens. A dangling key is the
227 /// correct answer rather than a hole: the link it named has left the buffer, so a
228 /// later open of the same id is genuinely a new link. xterm.js expresses the same
229 /// lifetime by *deleting* its `_entriesWithId` entry when the last line marker
230 /// referencing it is disposed (`OscLinkService.ts:98-100`); justerm has no disposal
231 /// hook by design, and `Weak` is that lifetime without one.
232 link_ids: std::collections::HashMap<String, std::sync::Weak<str>>,
233 /// Map length at which [`Self::link_ids`] is swept for dangling keys, doubling each
234 /// time so the sweep is amortised O(1) per open and dead keys stay O(live).
235 ///
236 /// A sweep is affordable here for the reason #628's rejected option (c) was not:
237 /// staleness is observable in O(1) (`Weak::strong_count`), where (c) had to decide
238 /// "is the pool oversized" by counting live references — the O(buffer) walk it was
239 /// trying to avoid.
240 link_ids_sweep_at: usize,
241 /// Scroll region top/bottom margins (DECSTBM), 0-based inclusive. A
242 /// line-feed at `scroll_bottom` scrolls only rows `[scroll_top..=scroll_bottom]`.
243 /// Default = the full screen.
244 scroll_top: usize,
245 scroll_bottom: usize,
246 /// Lines that have scrolled off the top of the primary screen, oldest at the
247 /// front. Accrues only on a top-anchored, primary-screen scroll.
248 scrollback: VecDeque<Row>,
249 /// How many lines the viewport is scrolled up from the bottom. 0 = following
250 /// the live screen; clamped to `[0, scrollback.len()]`.
251 display_offset: usize,
252 /// Maximum scrollback lines retained; the oldest are evicted past this.
253 scrollback_limit: usize,
254 /// A spare row buffer recycled across full-screen scrolls: the cap-evicted
255 /// oldest line is parked here and reused as the next scroll's blank bottom,
256 /// so a steady-state flood allocates nothing (ADR-0009).
257 recycled_row: Option<Row>,
258 /// Per-line damage bounds since the last `reset_damage` (ack), one per row.
259 line_damage: Vec<LineBounds>,
260 /// A first-class scroll recorded since the last `reset_damage`.
261 scroll: Option<ScrollOp>,
262 /// The whole screen changed (alt switch / clear / later resize+flood) — the
263 /// renderer must redraw everything.
264 full_damage: bool,
265 /// The cursor `(row, col)` at the last `reset_damage` (ack) — where the
266 /// consumer last saw the caret. A pure cursor move records no content
267 /// damage, so `damage()` folds this *old* cell plus the current one into the
268 /// frame; without it a cell-invert caret ghosts at the old spot (mirrors
269 /// Alacritty's `last_cursor`). #38.
270 prev_cursor: (usize, usize),
271 /// The live selection, in absolute buffer coordinates. `None` when nothing
272 /// is selected. See `selection.rs`.
273 selection: Option<Selection>,
274 /// The search highlights the consumer asked to paint (#108). Search
275 /// matches are consumer-owned (it drives next/prev), so the engine holds only
276 /// the set handed back via `set_search_highlights`, and `frame()` projects it
277 /// onto the viewport — the same anchoring path as the selection.
278 search_highlights: Vec<Match>,
279 /// The *active* (current) search match (#428), stored as its absolute span
280 /// (#436) — designated by the consumer (next/prev is its policy) either as
281 /// an index into `search_highlights` (resolved to the span at call time) or
282 /// directly by span, which a capping backend uses for a past-cap match
283 /// (xterm creates its active decoration from the found result, OUTSIDE the
284 /// capped highlight list). A span is NOT structurally tied to the set, so
285 /// every path that voids the set must void this too: `set_search_highlights`
286 /// (hand-over reset, #428) and `invalidate_search_highlights` (the single
287 /// funnel for eviction / every region scroll incl. the accrual sub-region
288 /// branch (#449) / reflow / both alt swaps) — a stale span would otherwise
289 /// keep painting coordinates that now hold other text.
290 ///
291 /// **That list is the *motion* funnel, and it is complete only for motion.** An
292 /// in-place erase or overwrite stales this set too, and deliberately does not
293 /// funnel — read `invalidate_search_highlights`, which owns that decision and its
294 /// grounds. Stated here because this comment enumerating the callers reads as the
295 /// whole rule, and a reader who stops at it concludes the erase verbs were
296 /// forgotten (#750).
297 active_search_highlight: Option<Match>,
298 /// Engine-owned decoration markers (#118), split per buffer like xterm's
299 /// `BufferSet` (#177 S0): each a stable id bound to an absolute buffer line
300 /// that re-anchors through eviction/scroll/reflow like a selection anchor. The
301 /// active buffer's list is selected by `on_alt` — `markers`/`markers_mut`.
302 /// `alt_markers` stays empty while the alt guards (#158/#164) are in place; it
303 /// is disposed on alt-leave (xterm `clearAllMarkers`). `next_marker_id` hands
304 /// out monotonic ids across both buffers so ids never alias.
305 normal_markers: VecDeque<Marker>,
306 alt_markers: VecDeque<Marker>,
307 next_marker_id: u32,
308 /// The basis that keeps a *pulled* marker index valid without re-pulling
309 /// (#490). Both are reported by [`Term::marker_index`] and — from the wire
310 /// slice on — by the frame header, so a consumer can compare what it holds
311 /// against what is current.
312 ///
313 /// `evicted_total` counts lines popped off the front of scrollback since
314 /// startup or RIS. Eviction shifts **every** live marker by the same −1, so
315 /// that whole class of movement is one number rather than M facts, and a
316 /// consumer rebases a held line by the delta.
317 ///
318 /// `marker_epoch` covers everything the delta cannot express: a mutation
319 /// after which a held line is wrong for a reason no single offset repairs.
320 /// It says *"what you pulled no longer describes this buffer"* — not *"a verb
321 /// ran"*, which is why the movers bump it only when a surviving marker's line
322 /// actually moved. Disposal is deliberately **not** a bump: a consumer learns
323 /// of that through `TermEvent::MarkerDisposed` and can drop the entry without
324 /// asking for the rest again.
325 evicted_total: u64,
326 marker_epoch: u32,
327 /// Positions the engine keeps on their content for a holder that lives
328 /// *outside* it (#691). Split per buffer and re-anchored by the same fixups as
329 /// the markers beside them; the difference is that nothing here reaches a
330 /// frame — a tracked point is answered on request, never projected.
331 normal_tracked: Vec<TrackedPoint>,
332 alt_tracked: Vec<TrackedPoint>,
333 next_tracked_id: u32,
334 /// Cursor state saved by DECSC (ESC 7), restored by DECRC (ESC 8). A slot
335 /// separate from `saved_cursor` (which is the alt-screen save). Defaults to
336 /// home/default so a DECRC with no prior DECSC restores a sane state.
337 decsc: SavedCursor,
338 /// SCS-designated character sets G0..G3 (#62). `gl` indexes the active (GL)
339 /// set, switched by SI (→G0) / SO (→G1). First cut uses G0/G1.
340 charsets: [Charset; 4],
341 gl: usize,
342}
343
344/// A character set designated by SCS (#62). First cut: ASCII (default), DEC
345/// Special Graphics (line-drawing), and UK. G2/G3 and the GR half are later.
346#[derive(Clone, Copy, PartialEq, Eq, Default)]
347enum Charset {
348 #[default]
349 Ascii,
350 DecSpecialGraphics,
351 Uk,
352}
353
354impl Charset {
355 /// Map one GL byte (a `char` in the 7-bit range) through this set. ASCII and
356 /// any out-of-range char pass through; UK swaps `#`→£; DEC Special Graphics
357 /// translates `_`..`~` to the line-drawing / symbol glyphs.
358 fn map(self, c: char) -> char {
359 match self {
360 Charset::Ascii => c,
361 Charset::Uk if c == '#' => '£',
362 Charset::Uk => c,
363 Charset::DecSpecialGraphics => dec_special_graphics(c),
364 }
365 }
366}
367
368/// The VT100 DEC Special Graphics set: bytes `_`..`~` (0x5F..0x7E) map to the
369/// box-drawing and symbol glyphs. Matches xterm/alacritty; anything outside the
370/// range passes through unchanged.
371fn dec_special_graphics(c: char) -> char {
372 // Keys ``..`~` only — `_` (0x5F) is deliberately absent, matching xterm.js /
373 // alacritty (it passes through as a literal underscore), not the strict-DEC
374 // "0x5F = blank" reading.
375 match c {
376 '`' => '◆',
377 'a' => '▒',
378 'b' => '␉',
379 'c' => '␌',
380 'd' => '␍',
381 'e' => '␊',
382 'f' => '°',
383 'g' => '±',
384 'h' => '',
385 'i' => '␋',
386 'j' => '┘',
387 'k' => '┐',
388 'l' => '┌',
389 'm' => '└',
390 'n' => '┼',
391 'o' => '⎺',
392 'p' => '⎻',
393 'q' => '─',
394 'r' => '⎼',
395 's' => '⎽',
396 't' => '├',
397 'u' => '┤',
398 'v' => '┴',
399 'w' => '┬',
400 'x' => '│',
401 'y' => '≤',
402 'z' => '≥',
403 '{' => 'π',
404 '|' => '≠',
405 '}' => '£',
406 '~' => '·',
407 other => other,
408 }
409}
410
411/// Default scrollback retention when not specified.
412const DEFAULT_SCROLLBACK: usize = 10_000;
413
414/// The narrowest screen the engine represents: **two columns**.
415///
416/// A width-2 glyph occupies a `WIDE_CHAR` lead *and* the `WIDE_CHAR_SPACER` that
417/// stands for its second half, so one column cannot hold one — and a pair with only
418/// one half written is the malformed state every repair path in this crate keys off
419/// (ADR-0025 D4). `Term::with_scrollback` and [`Term::resize`] clamp `cols` up to
420/// this, which is what makes D4 (*both halves of a pair move together*)
421/// unconditionally satisfiable rather than true only above some unstated width.
422///
423/// Both references that a terminal *engine* can be compared to forbid one column for
424/// exactly this reason — alacritty's `MIN_COLUMNS = 2` and xterm.js's
425/// `MINIMUM_COLS = 2` — and the third (ghostty) permits it only by destroying the
426/// glyph.
427///
428/// The clamp is **silent and pull-only**: a `resize(1, rows)` during a pane drag is
429/// widened rather than rejected, and no event reports it. Both references instead
430/// make the clamped size the one that travels outward — alacritty derives its
431/// `WindowSize` from the clamped `SizeInfo`, xterm.js fires `onResize` with the
432/// clamped pair — so a justerm consumer must do that correlation itself: read the
433/// width back from [`Term::grid`] / the frame header and size the PTY from *that*,
434/// never from the value it requested. Sizing a PTY to one column leaves the
435/// application rendering for a width the buffer does not have (#547).
436pub const MIN_COLUMNS: usize = 2;
437
438/// The built-in word-boundary set for Word (semantic) selection — the default value of
439/// [`Term::set_word_separators`], and **policy the consumer may replace** (ADR-0017:
440/// mechanism in core, policy injected).
441///
442/// It is alacritty's `SEMANTIC_ESCAPE_CHARS` (`alacritty_terminal/src/term/mod.rs:45`
443/// @ `852e971`) verbatim — space, tab and a punctuation set that deliberately omits
444/// `.`, `/` and `-` so a path or URL stays one word — **plus U+3000 IDEOGRAPHIC
445/// SPACE**, which is justerm's one divergence from every reference default.
446///
447/// Two properties of this list are load-bearing and neither is obvious:
448///
449/// - **It is a literal set, not the Unicode `White_Space` property.** The predicate was
450/// `char::is_whitespace()` until #545, so every space-like codepoint ended a word —
451/// including the four `Line_Break=GL` (glue) ones U+00A0, U+2007, U+202F and U+205F,
452/// whose whole purpose is "do not break here". A locale-formatted `1<NNBSP>234`
453/// double-clicked as `1`. All three references are literal sets for the same reason.
454/// - **U+3000 is in it, and no reference's default has it.** It is the only
455/// East-Asian-Wide codepoint `White_Space` accepts (measured over U+0000–U+10FFFF),
456/// so on alacritty and xterm.js ` abc` is one word while justerm gives the useful
457/// answer. Keeping it was argued in #535 *on the grounds that core had no injection
458/// point*; that ground is gone, so it survives here as a **default**, and a consumer
459/// who wants reference-exact behaviour removes it.
460///
461/// [`Term::set_word_separators`] additionally forces `' '` into whatever it is given —
462/// see there for why that floor is not optional.
463pub const DEFAULT_WORD_SEPARATORS: &str = ",│`|:\"' ()[]{}<>\t\u{3000}";
464
465/// A declared OSC 8 hyperlink, as handed to a consumer.
466///
467/// **Owned, not borrowed**, and that is the point: the URI lives in a row's side map, so
468/// a `&str` into it would be tied to `&Engine` and a caller could not hold the link
469/// across the next `feed()` — which is precisely what a hover handler does. Measured on
470/// the alternative: reading a borrow costs 0.75 ns, but keeping it *does not compile*, so
471/// the caller copies the string instead at 62.6 ns. Handing back this handle is 17.9 ns
472/// — cheaper than the workaround it removes, on a call made once per hover.
473///
474/// Cloning is a refcount bump; the allocation is shared with every cell of the same OSC 8
475/// open and released when the last row holding it dies (#628).
476///
477/// **A struct rather than a bare `Arc<str>`** for two reasons: it keeps `Arc` out of the
478/// published signature, and OSC 8's `id=` parameter (#635) lands here as a field without
479/// changing the return type again. Same shape as alacritty's `Hyperlink`, for the same
480/// reasons (`alacritty_terminal/src/term/cell.rs`).
481///
482/// **Link *identity* is deliberately not exposed yet.** Two OSC 8 opens of an identical
483/// URI are two links here, so `uri() == uri()` cannot answer "is this cell part of the
484/// same link as that one?" — an `Arc::ptr_eq` accessor would. It is left out because no
485/// consumer asks it today (nothing outside this crate's tests calls `link_at` at all),
486/// and unlike this type's *shape*, adding a method later is not a breaking change. The
487/// asymmetry decides it: shipping an accessor nobody uses is hard to undo, adding one
488/// when a caller appears is free.
489#[derive(Clone, PartialEq, Eq, Debug)]
490pub struct Hyperlink {
491 uri: std::sync::Arc<str>,
492}
493
494impl Hyperlink {
495 pub(crate) fn new(uri: std::sync::Arc<str>) -> Self {
496 Hyperlink { uri }
497 }
498
499 /// The link target, exactly as the application declared it — never validated,
500 /// never resolved. Whether it is a URL a consumer is willing to open is that
501 /// consumer's policy (ADR-0017), the same way colour resolution is.
502 pub fn uri(&self) -> &str {
503 &self.uri
504 }
505}
506
507/// Length at which the `id=` group map is first swept for dangling keys, doubling from
508/// there. Small enough that a session declaring a handful of ids never pays a sweep,
509/// large enough that the sweep is not the common path.
510const LINK_IDS_FIRST_SWEEP: usize = 16;
511
512/// How deep an XTWINOPS title stack goes before a push starts dropping the
513/// oldest entry (#823).
514///
515/// Ten is **two implementations plus a spec inference**, not a clean sweep, and
516/// the difference is worth stating so nobody reads it as more than it is:
517/// xterm's `MAX_SAVED_TITLES` (`ptyx.h:2357`) and xterm.js's
518/// `Constants.STACK_LIMIT` both say ten, and the spec describes the
519/// direct-access parameter as taking a value *"in the range 1 through 10"*
520/// (`ctlseqs.txt:1698`), which only makes sense against a ten-slot stack.
521/// **alacritty is 4096** (`TITLE_STACK_MAX_DEPTH`), so the corpus is 2–1 on the
522/// number. Nothing observed nests deeper than three, so the bound is a
523/// robustness cap rather than a compatibility one, and ten is the value two
524/// references chose for exactly that job.
525///
526/// The **overflow rule**, unlike the number, is 4-for-4: drop the oldest and let
527/// the push succeed. xterm.js `shift()`s, alacritty `remove(0)`s, and xterm gets
528/// there by arithmetic rather than by saying so — `which = used++ %
529/// MAX_SAVED_TITLES` writes an eleventh push over slot 0, which is the oldest.
530/// Refusing the push instead would break the pairing for the *innermost*
531/// nesting levels — the ones a user unwinds first — and nobody does it.
532const TITLE_STACK_DEPTH: usize = 10;
533
534/// The `id=` value out of an OSC 8 `params` field, or `None` when it is absent or empty.
535///
536/// Three rules, each taken from xterm.js's `_createHyperlink` verbatim rather than from
537/// the spec prose, because each is a place a reasonable reading goes wrong
538/// (`src/common/InputHandler.ts:3128-3131` at the pinned SHA `699f5537b023`):
539///
540/// - **`:`-separated**, not `;` — `params` is one OSC argument holding a key=value list
541/// (`id=xyz123:foo=bar:baz=quux`), so the split is on colons (`params.split(':')`).
542/// - **`id` may sit anywhere in it** (`findIndex(e => e.startsWith('id='))`), so matching
543/// only a leading `id=` is the wrong parse and passes a single-parameter test.
544/// - **an empty value is not an id** (`slice(3) || undefined`). This is the one with teeth:
545/// an empty key would group every `id=`-with-no-value link in a session into one link
546/// across unrelated URIs, and it is a wrong answer that grows with uptime.
547///
548/// Only the first `id=` is consulted, matching `findIndex` — an empty first one yields
549/// `None` rather than searching on for a non-empty sibling.
550fn osc8_link_id(params: &[u8]) -> Option<&[u8]> {
551 params
552 .split(|b| *b == b':')
553 .find_map(|kv| kv.strip_prefix(b"id="))
554 .filter(|value| !value.is_empty())
555}
556
557/// The widest grid the engine will hold, and the mirror of [`MIN_COLUMNS`] — but derived
558/// from a different kind of constraint, which is why the two are not symmetric.
559///
560/// The floor is **semantic**: a width-2 glyph needs two cells, so one column is a screen
561/// no correct grid can be. The ceiling is **representational**: the frame header stores
562/// `cols` and `rows` as `u16` each, so a grid wider than `u16::MAX` cannot be *described*
563/// to a consumer even though the engine could hold it. Without the clamp that mismatch was
564/// silent — measured, `Engine::new(70_000, 2)` built a 70 000-column grid whose frame
565/// declared `cols = 4464` and decoded `Ok`, so a consumer laid out 4464 columns of a
566/// 70 000-column screen with nothing reporting the difference (#621).
567///
568/// No reference bounds a grid this way, and that is expected rather than a divergence:
569/// none of them serializes a grid, so none has a header field to overflow. This is the
570/// one axis where justerm's own wire is the only authority.
571///
572/// **A backstop, not a policy.** A 4K display at a very small font is roughly 550 columns;
573/// this is two orders of magnitude past any real terminal, so it should never be reached
574/// by a consumer that is not already doing something wrong. The clamp is silent and
575/// pull-only on the same terms as [`MIN_COLUMNS`] — read the size back from
576/// [`Term::grid`] rather than trusting the value you passed in.
577pub const MAX_COLUMNS: usize = u16::MAX as usize;
578
579/// The tallest grid the engine will hold. The row half of [`MAX_COLUMNS`] — same
580/// `u16` header field, same reasoning, same silent-clamp contract.
581pub const MAX_ROWS: usize = u16::MAX as usize;
582
583/// The most live markers one buffer will hold (#721).
584///
585/// Derived from the wire the same way [`MAX_COLUMNS`] is: the marker group's count
586/// are `u16`, so a population past `u16::MAX` encodes a wrapped count while writing
587/// every record, and `decode` then reads the next group's count out of the middle of
588/// a marker record and returns `Ok`. The field bounds the value; this constant only
589/// writes that bound down where the value is produced, because `encode` returns
590/// `Vec<u8>` and has no channel to refuse.
591///
592/// **Why a bound is needed at all**, rather than a wider field: markers are allocated
593/// by the *stream*. `add_command_mark` appends per OSC 133 sequence, several marks can
594/// share one line, and scrollback eviction only drops a marker when its line reaches
595/// absolute 0 — so a stream that never emits a newline accumulates marks in a 24-row
596/// buffer without bound (measured: 70 000, #721). [`crate::Engine::feed`] is an untrusted
597/// entry point (ADR-0007), and unbounded allocation behind it is a defect class that
598/// record exists to catch.
599///
600/// **A backstop, not a policy**, on the same terms as [`MAX_COLUMNS`]. Ordinary shell
601/// integration emits at most four marks per command and a command occupies at least one
602/// line, so a default-scrollback session tops out near 40 000 — this is not reached by a
603/// consumer that is not already being fed something hostile. Overflow disposes the
604/// *oldest* marker and announces it through `TermEvent::MarkerDisposed`, which is the
605/// channel scrollback eviction already uses for the same event.
606pub const MAX_MARKERS: usize = u16::MAX as usize;
607
608/// The longest command text an OSC-133 `OutputStart` mark will freeze, in `char`s
609/// (#750). A longer command is captured truncated to this many characters.
610///
611/// **Why a bound at all** is [`MAX_MARKERS`]'s argument one field over: the *stream*
612/// decides the size. The text spans `[B, C)`, and nothing bounds how far apart those
613/// two sequences are — a stream that emits `B`, dumps a full screen and then `C` names
614/// a command as long as the buffer. Re-extracting on demand made that a transient
615/// allocation; freezing it at `C` makes it resident, for as long as the mark lives, and
616/// [`crate::Engine::feed`] is an untrusted entry point (ADR-0007).
617///
618/// **A display bound, not a semantic one.** [`CommandLine::command`]'s consumer
619/// announces it and lists it; a prefix is a usable answer and an absent one is not, so
620/// overflow truncates rather than declining to capture. The truncation is at a `char`
621/// boundary, so the answer is always valid text. No ordinary command reaches it — this
622/// is not a limit a shell user can type into.
623pub const MAX_COMMAND_TEXT: usize = 4096;
624
625/// The longest `OSC 52` base64 payload the engine will decode, in bytes (#828).
626/// A longer one is **dropped whole**, never truncated.
627///
628/// **What this bounds is ours, not the parser's, and the difference was
629/// measured.** `vte` is built with its default features, so its OSC accumulator
630/// is a `Vec<u8>` rather than the `ArrayVec<_, 1024>` of its `no_std` path: a
631/// 4 MB payload arrives at `osc_dispatch` complete, 4 000 003 bytes across three
632/// fields. Anyone reading this bound as "the engine cannot be made to allocate"
633/// has the wrong model; `vte` already did, before the handler ran.
634///
635/// What it does refuse is the **second** allocation — the rejoin, which copies
636/// every field again — and everything downstream of it. That is why the check
637/// sums the field lengths rather than measuring the joined payload: a bound
638/// applied *after* the join would let a hostile stream buy the copy it was
639/// meant to prevent, which is what the first draft did. The decoded `Vec` is a
640/// third; the `String` is not a fourth, since `String::from_utf8` reuses the
641/// buffer it is given. The property all of it buys is the one worth naming: no
642/// unbounded string crosses the boundary into a consumer that then has to hold
643/// it.
644///
645/// **It does not bound the queue, and the queue is unbounded.** `drain_events`
646/// is pull-style with no back-pressure, so a consumer that does not drain
647/// accumulates stores at up to this size each — the pre-existing hole
648/// `docs/map/territory/events-and-replies.md` records, which this sequence
649/// enlarges by roughly three orders of magnitude over the next-largest payload
650/// (`MAX_COMMAND_TEXT`, 4096). Bounding a queue nobody drains is a different
651/// decision from bounding a payload, and it is not taken here.
652///
653/// **Dropped rather than truncated**, which is the opposite of
654/// [`MAX_COMMAND_TEXT`] one field up, and the asymmetry is the point: a prefix of
655/// a command is a usable answer, while a prefix of a clipboard is text the user
656/// pastes somewhere believing it is what they copied. An ignored copy is visible
657/// the moment they paste; a truncated one is not.
658///
659/// **A backstop, not a policy**, and sized so that no real copy reaches it: 16
660/// MiB of base64 is ~12 MiB of text, well past a whole scrollback buffer's worth
661/// of `tmux set-buffer`. Neither reference has a hard cap to import — alacritty
662/// has none at all and ghostty's `MAX_BUF = 2048` is an inline-buffer threshold
663/// with an allocator path past it (`src/terminal/osc.zig:298`) — so this number
664/// is justerm's own and is chosen by what it must not break rather than by what
665/// it permits.
666pub const MAX_CLIPBOARD_BASE64: usize = 16 * 1024 * 1024;
667
668/// The state DECSC (ESC 7) saves and DECRC (ESC 8) restores: position, pen/SGR,
669/// pending-wrap, and origin mode (per ADR-0004 — DECRC restores origin mode,
670/// which Alacritty omits). Cursor *visibility* is deliberately not part of this
671/// (DECTCEM is separate from DECSC).
672#[derive(Clone, Copy, Default)]
673struct SavedCursor {
674 row: usize,
675 col: usize,
676 pen: Pen,
677 pending_wrap: bool,
678 origin_mode: bool,
679 /// SCS charset state at save time — DECSC/DECRC round-trip the designated
680 /// sets and the active GL shift (#62).
681 charsets: [Charset; 4],
682 gl: usize,
683}
684
685/// An engine-owned decoration marker (#118): a stable id bound to an absolute
686/// buffer line. The line shifts in lockstep with eviction/region scroll/reflow
687/// (the same coordinate moves the selection anchor tracks); the marker is
688/// dropped when its line leaves the buffer — **or when `ED` blanks the whole row
689/// it stands on** (#750), which is the one death that is not the buffer moving.
690struct Marker {
691 id: MarkerId,
692 line: usize,
693 /// The cursor column at emit time (#166). Meaningful for OSC-133 command
694 /// marks — CommandStart(B)/OutputStart(C) columns bound the *typed command*
695 /// (excluding the prompt), like VSCode's `commandStartX`/`commandExecutedX`.
696 /// Plain `add_marker` decorations are row-granular and carry `col = 0`.
697 ///
698 /// **Domain is `[0, cols]`, not `[0, cols - 1]` (#562)** — a bound, not a cell.
699 /// A command that exactly fills its row ends *one past* the last column, and
700 /// that value is what `extract_lines` wants: it clips `[b_col, c_col)`, so the
701 /// exclusive end absorbs it through `.min(cells.len())`. Storing `cursor.col`
702 /// alone (the cursor is held at `cols - 1` with `pending_wrap`) cost such a
703 /// command its last character with no resize involved. The **inclusive** side
704 /// cannot absorb it, so `extract_lines` steps a `from` of `cells.len()` to the
705 /// next line rather than selecting an empty run and flushing a `\n`.
706 col: usize,
707 /// Plain for a `add_marker` decoration; a command-boundary role for an
708 /// OSC 133 mark (#158). All kinds share the anchor/eviction machinery.
709 kind: MarkerKind,
710 /// What an `OutputStart` mark froze about the command it closes (#750) —
711 /// `None` on every other kind, and the reason this is one boxed pointer rather
712 /// than two inline fields: three marks in four never carry it, and the
713 /// population is bounded at [`MAX_MARKERS`].
714 ///
715 /// It dies with the marker, which is the point: a side table keyed by
716 /// [`MarkerId`] would need its own purge at every disposal site, i.e. exactly
717 /// the missing-destruction-funnel defect this issue is about (ADR-0025 D1 —
718 /// a fact lives with its owner).
719 command: Option<Box<CommandRecord>>,
720}
721
722/// The part of a command that is **not** in the buffer, frozen on its `OutputStart`
723/// mark (#750).
724///
725/// Both fields are recorded at the instant they are first true, and neither can be
726/// recovered afterwards:
727///
728/// - `text` is complete and on screen exactly when `C` arrives. Re-reading it later
729/// through the recorded `[b_col, c_col)` clip names whatever now occupies those
730/// cells — measured for a plain overwrite, ICH, DCH and an erase, and only the last
731/// of those is a verb any mark-lifetime rule could reach.
732/// - `exit` arrives with `D`, one mark later, and lives in no cell at all. Resolving it
733/// at query time meant pairing over *survivors* (`out.last_mut()`), which re-parented
734/// a code onto the previous command as soon as a disposal broke the run. Written here
735/// when `D` is parsed, a disposal can only drop an answer, never move one.
736struct CommandRecord {
737 text: Box<str>,
738 exit: Option<i32>,
739}
740
741/// A stable handle to a tracked buffer position (#691), handed out by
742/// [`Term::track_point`].
743///
744/// It is deliberately **not** a [`MarkerId`]: a marker is a decoration anchor and
745/// rides two frame groups, so every marker a consumer registers is something the
746/// renderer paints. A tracked point is private to whoever asked for it.
747#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
748pub struct TrackedId(pub(crate) u32);
749
750/// One tracked position: an absolute buffer `(line, col)` the write path keeps on
751/// its content (#691). The element type of `normal_tracked` / `alt_tracked`.
752struct TrackedPoint {
753 id: TrackedId,
754 line: usize,
755 col: usize,
756}
757
758/// One live marker, as the pull query reports it (#490): its stable id, its
759/// **absolute** `[scrollback ++ screen]` line, and the static facts a consumer
760/// would otherwise have to re-learn from every frame.
761///
762/// `kind` and `exit` ride here rather than on the frame because they never change
763/// after `push_marker` — re-sending them per frame is the same class of waste as
764/// re-sending the line.
765#[derive(Debug, Clone, PartialEq, Eq)]
766pub struct MarkerEntry {
767 pub id: MarkerId,
768 pub line: u32,
769 pub kind: MarkerKind,
770}
771
772/// The answer to [`Term::marker_index`] (#490) — every live marker of the *active*
773/// buffer, plus the basis that says how long the answer stays usable.
774///
775/// The consumer keeps this and rebases per frame:
776/// `current = line - (evicted_total_now - evicted_total)`, valid for exactly as long
777/// as `epoch` is unchanged. When the epoch moves, the held lines are wrong in a way
778/// no offset repairs and the consumer asks again.
779///
780/// It reports the active buffer because an absolute index means a different thing on
781/// each screen — the same reason `markers`/`markers_mut` route by `on_alt`. An
782/// alt-screen switch therefore bumps the epoch even though no line moved: what the
783/// answer *describes* changed.
784#[derive(Debug, Clone, PartialEq, Eq)]
785pub struct MarkerIndex {
786 pub markers: Vec<MarkerEntry>,
787 pub evicted_total: u64,
788 pub epoch: u32,
789}
790
791/// One executed shell command recovered from OSC-133 marks (#166), for
792/// screen-reader command navigation. The consumer jumps prompt-to-prompt over
793/// these and announces `command` + a success/fail signal from `exit`.
794#[derive(Debug, Clone, PartialEq, Eq)]
795pub struct CommandLine {
796 /// The command's jump anchor as a *document* line — the logical-line index of
797 /// the CommandStart(B) mark within [`Term::accessible_text`], so the consumer
798 /// reveals the right row of the accessible view (soft-wrapped rows collapse to
799 /// one logical line). This is core's analog of VSCode's
800 /// `bufferToEditorLineMapping`; the frame-mode web side has no wrap info to
801 /// map it itself.
802 ///
803 /// **It is an index into a document, so it is only meaningful together with the
804 /// document it indexes** — the one [`Term::accessible_text`] returns *at the same
805 /// instant, on the primary screen*. Neither half is expressible as a number on this
806 /// struct (#743), and they are the two that recur; they are not a proof of
807 /// sufficiency. The hedge was earned: a mark whose row is erased in place also
808 /// answers about content that is gone — on the primary screen, at one instant, and
809 /// a re-ask reproduces it, so neither half below reaches it. That was **#750**, a
810 /// defect in mark *lifetime* rather than in dating, and it is fixed at the
811 /// lifetime: `ED` now retires the marks on each whole row it blanks, and the
812 /// command's text and exit are frozen when the stream reveals them rather than
813 /// re-read from cells (see [`Term::command_lines`]). One residue is deliberate and
814 /// belongs to this field — `EL`/`ECH` retire nothing, so a mark can still name a
815 /// row they blanked, and this line then resolves onto it:
816 ///
817 /// - **the instant.** No scalar this engine publishes dates a document line, and
818 /// the reason is not one axis but two. Eviction moves it by the number of evicted
819 /// *line-ends*, which equals the row count except when an evicted row soft-wraps
820 /// into the next — measured, one eviction took the absolute lines
821 /// `[12, 12, 12, 13]` → `[11, 11, 11, 12]` while this line stayed at `11`, and the
822 /// very next eviction moved both. And flipping a row's wrap bit, which ordinary
823 /// output does, moves this line while the absolute lines, `evicted_total` and
824 /// `marker_epoch` all stay put — a motion the absolute space does not have.
825 /// Carrying the instant is therefore *buildable but expensive*: a line-end counter
826 /// **and** a generation of its own. ADR-0029 defers it (alternative D) and takes
827 /// the re-ask discharge, which D3 grants this surface on its own merits;
828 /// - **the screen.** The document is `[scrollback ++ primary]`, always. While the
829 /// alt screen is up [`Term::accessible_text`] returns the *alt* document, and this
830 /// line indexes the other one. When the alt screen is the taller of the two the
831 /// index still **resolves**, onto unrelated content — so this is not a bounds
832 /// problem a caller can check its way out of.
833 ///
834 /// So: ask for both together, keep them together, and re-ask rather than rebase.
835 pub line: usize,
836 /// The typed command text, prompt- and output-excluded (B→C columns).
837 ///
838 /// **Frozen at the `133;C` that closed the command (#750)**, not re-read from the
839 /// cells when you ask. Those cells are not reserved for it: a plain overwrite,
840 /// `ICH`, `DCH` and an erase were each measured making the recorded column range
841 /// name somebody else's content, and only the last of the four is a verb any
842 /// mark-lifetime rule could reach. Bounded at [`MAX_COMMAND_TEXT`] `char`s.
843 pub command: String,
844 /// The CommandFinished(D) exit code, if the shell reported one and the
845 /// command has finished.
846 ///
847 /// **Recorded when `133;D` is parsed (#750)**, onto the mark that closed the
848 /// command — not paired here at query time. It lives in no cell, so nothing on
849 /// screen can reconstruct it, and pairing over *survivors* re-parented a code onto
850 /// the previous command as soon as a disposal broke the run.
851 pub exit: Option<i32>,
852}
853
854/// Collect per-line damage bounds into damaged `LineDamage` spans (undamaged
855/// lines dropped). Shared by `damage` (content-only) and `frame_damage`
856/// (content + cursor cells).
857fn bounds_to_lines(bounds: &[LineBounds]) -> Vec<LineDamage> {
858 bounds
859 .iter()
860 .enumerate()
861 .filter(|(_, b)| b.is_damaged())
862 .map(|(line, b)| {
863 let (left, right) = b.span();
864 LineDamage { line, left, right }
865 })
866 .collect()
867}
868
869impl Term {
870 pub fn new(cols: usize, rows: usize) -> Self {
871 Self::with_scrollback(cols, rows, DEFAULT_SCROLLBACK)
872 }
873
874 pub fn with_scrollback(cols: usize, rows: usize, scrollback_limit: usize) -> Self {
875 // Both clamps mirror `resize` exactly, so a screen cannot be born at a size a
876 // resize would refuse. They are not the same *kind* of rule, though: the width
877 // floor is a published contract (#547 — one column was supported and no longer
878 // is), while the row floor is `resize`'s own long-standing "a terminal is never
879 // 0-tall" that this constructor merely failed to enforce while carrying the
880 // same `scroll_bottom: rows - 1` below. That gap was a subtract-overflow panic
881 // on `rows == 0`, not a degenerate screen.
882 // …and the ceiling is the header's, not the glyph's: `frame.cols`/`rows` are u16,
883 // so a wider grid would be built and then misdescribed on the wire (#621).
884 let cols = cols.clamp(MIN_COLUMNS, MAX_COLUMNS);
885 let rows = rows.clamp(1, MAX_ROWS);
886 Term {
887 grid: Grid::new(cols, rows),
888 alt_grid: Grid::new(cols, rows),
889 cursor: Cursor::default(),
890 saved_cursor: Cursor::default(),
891 on_alt: false,
892 origin_mode: false,
893 autowrap: true,
894 insert_mode: false,
895 newline_mode: false,
896 reverse_wraparound: false,
897 bracketed_paste: false,
898 synchronized_output: false,
899 color_scheme_updates: false,
900 grapheme_clustering: false,
901 win32_input_mode: false,
902 app_cursor_keys: false,
903 application_keypad: false,
904 vt52_mode: false,
905 vt52_y_pending: 0,
906 vt52_y_row: 0,
907 mouse_protocol: MouseProtocol::Off,
908 mouse_encoding: MouseEncoding::Default,
909 focus_events: false,
910 kitty_flags: 0,
911 kitty_stack: Vec::new(),
912 events: Vec::new(),
913 replies: Vec::new(),
914 current_link: None,
915 link_ids: std::collections::HashMap::new(),
916 link_ids_sweep_at: LINK_IDS_FIRST_SWEEP,
917 tabs: default_tabs(cols),
918 word_separators: DEFAULT_WORD_SEPARATORS.to_owned(),
919 window_title: String::new(),
920 icon_name: String::new(),
921 window_title_stack: Vec::new(),
922 icon_name_stack: Vec::new(),
923 scroll_top: 0,
924 scroll_bottom: rows - 1,
925 scrollback: VecDeque::new(),
926 display_offset: 0,
927 scrollback_limit,
928 recycled_row: None,
929 line_damage: vec![LineBounds::undamaged(cols); rows],
930 scroll: None,
931 full_damage: false,
932 prev_cursor: (0, 0), // matches the default cursor's home position
933 selection: None,
934 search_highlights: Vec::new(),
935 active_search_highlight: None,
936 normal_markers: VecDeque::new(),
937 alt_markers: VecDeque::new(),
938 next_marker_id: 0,
939 evicted_total: 0,
940 marker_epoch: 0,
941 normal_tracked: Vec::new(),
942 alt_tracked: Vec::new(),
943 next_tracked_id: 0,
944 decsc: SavedCursor::default(),
945 charsets: [Charset::Ascii; 4],
946 gl: 0,
947 }
948 }
949
950 /// What changed since the last `reset_damage()` — line ranges, each with a
951 /// changed column span. See ADR-0003.
952 pub fn damage(&self) -> TermDamage {
953 if self.full_damage {
954 return TermDamage::Full;
955 }
956 // Scrolled up under follow-bottom "stay": the viewport is frozen, so
957 // screen changes below it are not visible — report nothing. (A user
958 // scroll that moves the viewport sets full_damage above.)
959 if self.display_offset > 0 {
960 return TermDamage::Partial(Vec::new());
961 }
962 TermDamage::Partial(bounds_to_lines(&self.line_damage))
963 }
964
965 /// Render damage: content damage plus the cursor cells, for [`Term::frame`].
966 ///
967 /// A pure cursor move changes no cell *content*, so [`Term::damage`] (which
968 /// stays content-only, the cadence/flow-control primitive) would miss it —
969 /// yet a cell-invert caret must clear its old spot and ink the new one. So
970 /// the frame producer folds the old (last-acked) + current cursor cells in,
971 /// but only when the cursor actually moved: a still cursor needs no redraw,
972 /// keeping an idle frame empty. Mirrors Alacritty's `last_cursor`. #38.
973 fn frame_damage(&self) -> TermDamage {
974 if self.full_damage {
975 return TermDamage::Full;
976 }
977 if self.display_offset > 0 {
978 return TermDamage::Partial(Vec::new());
979 }
980 let cur = self.cursor.point();
981 if cur == self.prev_cursor {
982 return TermDamage::Partial(bounds_to_lines(&self.line_damage));
983 }
984 let mut bounds = self.line_damage.clone();
985 bounds[cur.0].expand(cur.1, cur.1);
986 let pr = self.prev_cursor.0.min(self.grid.rows() - 1);
987 let pc = self.prev_cursor.1.min(self.grid.cols() - 1);
988 bounds[pr].expand(pc, pc);
989 TermDamage::Partial(bounds_to_lines(&bounds))
990 }
991
992 /// Clear accumulated damage. The consumer calls this after applying a frame
993 /// (the ack); the next `damage()` reflects only changes since.
994 pub fn reset_damage(&mut self) {
995 for b in &mut self.line_damage {
996 b.reset();
997 }
998 self.scroll = None;
999 self.full_damage = false;
1000 // The consumer has now seen the caret at the current position; the next
1001 // frame's cursor-move damage is measured from here (#38).
1002 self.prev_cursor = self.cursor.point();
1003 }
1004
1005 /// Mark the whole screen damaged (alt switch / clear / flood, and a consumer
1006 /// reattach that needs a full re-sync — see [`crate::Engine::mark_fully_damaged`]).
1007 pub fn mark_fully_damaged(&mut self) {
1008 self.full_damage = true;
1009 }
1010
1011 /// Record that columns `[left, right]` of `row` changed.
1012 ///
1013 /// Both columns are **clamped to the last column, and asserted in debug** (#536).
1014 ///
1015 /// Ten of the fourteen call sites derive their bound from a cursor column or from `cols`.
1016 /// **Four derive it from a wide pair's width**, and that is the shape worth centralising:
1017 /// `write_glyph`'s `col + width - 1` (which had no guard — this issue), `promote_cluster_to_wide`'s
1018 /// `col + 1` (guarded by its own `col + 1 >= cols` early return), `demote_cluster_to_narrow`'s
1019 /// `(col + 1).min(cols - 1)` (self-clamped), and `relocate_cluster_wide`'s literal `(0, 1)`
1020 /// (valid only because `MIN_COLUMNS = 2`, #547). Three carried a private guard and one did not.
1021 ///
1022 /// No reference has this shape to port a clamp from: alacritty computes damage ranges too
1023 /// (`term/mod.rs:1406`, `:1649` @ `852e971`) but always from a column or `columns()` — its print
1024 /// path records no damage at all, relying on the previous and current cursor *points* to bracket
1025 /// the line — while xterm.js tracks whole rows (`markDirty(y)`) and ghostty a per-row
1026 /// `dirty: bool`. alacritty's `LineDamageBounds::expand`, which this one is a copy of, is equally
1027 /// unguarded.
1028 ///
1029 /// The two halves do different jobs:
1030 ///
1031 /// - the **`debug_assert` is the detector**. An out-of-range bound is stored silently and
1032 /// detonates later, when `frame()` slices the row, so the stack trace accuses the reader
1033 /// rather than the writer. That delay is what #536 was filed about, and the assert collapses
1034 /// it — a bad caller dies here, at the site that recorded it (measured: an injected off-by-one
1035 /// moved the panic from `frame()`'s slice to this line).
1036 /// - the **clamp is the release backstop**, and it clamps *toward a false positive*. justerm is
1037 /// a library, so a panic crosses into the consumer's process; over-damaging repaints a cell
1038 /// that did not change, which costs nothing a consumer can see. ghostty states the asymmetry
1039 /// as a rule: *"Dirty tracking may have false positives but should never have false negatives.
1040 /// A false negative would result in a visual artifact on the screen."* (`page.zig:1993-1995`).
1041 ///
1042 /// **`left` is guarded for that reason, and it is the axis that can actually lose a cell.**
1043 /// Clamping `right` cannot under-report — columns past the last do not exist. But `LineBounds`
1044 /// marks a line undamaged with `left = cols, right = 0` and `is_damaged()` is `left <= right`,
1045 /// so a single `expand` with `left > right` on an otherwise-clean line leaves the line reading
1046 /// as undamaged and **drops its whole span silently**. Unreachable from the ten column-derived
1047 /// sites today; guarded because that is precisely the failure ghostty's rule forbids.
1048 ///
1049 /// `row` is deliberately left to panic on the index, and that is **not** in tension with
1050 /// `frame_damage` clamping a row fifty lines below (`prev_cursor.0.min(rows - 1)`). The two
1051 /// rows are different kinds of thing under the same rule: `prev_cursor` is a *stale remembered*
1052 /// coordinate that a shrinking resize may have put out of range, so clamping it repaints the
1053 /// nearest surviving cell — a false positive. `row` here is a *live computed* index for the
1054 /// mutation just made, so clamping it would damage a different line than the one that changed:
1055 /// a false negative on the real line, which is the outcome the rule forbids.
1056 fn damage_span(&mut self, row: usize, left: usize, right: usize) {
1057 let last = self.grid.cols().saturating_sub(1);
1058 debug_assert!(
1059 left <= right && right <= last,
1060 "damage_span({row}, {left}, {right}) is not a span inside [0, {last}]"
1061 );
1062 self.line_damage[row].expand(left.min(last), right.min(last));
1063 }
1064
1065 /// The first-class scroll recorded since the last `reset_damage`, if any.
1066 /// Suppressed while scrolled up — a content scroll must not shift the frozen
1067 /// viewport.
1068 ///
1069 /// **The count is capped at the region's own height (#661).** Shifting a region
1070 /// by more than its height moves every source row outside it, so the surplus
1071 /// names nothing a consumer can act on — while it does overflow the wire's
1072 /// `i16` and turn an up-scroll into a down-scroll: measured, a single
1073 /// 32 770-byte `feed()` of newlines, no slow consumer required. Both references
1074 /// that state a quantity at their own scroll sites clamp it to the same bound
1075 /// (alacritty `term/mod.rs:773`, ghostty `Terminal.zig:2703`).
1076 ///
1077 /// The cap is here, on the **read**, and not on the accumulator in
1078 /// `record_scroll`: a region that scrolls far and comes back then still reports
1079 /// its true small net, instead of one walked down from a saturated value.
1080 ///
1081 /// A second, crate-internal bound backs it up, and it is representational rather
1082 /// than semantic: [`MAX_ROWS`] is `u16::MAX` while the wire field is `i16`, so a
1083 /// region can legally be taller than any count that field can hold. In that
1084 /// corner the magnitude truncates. What it never does is wrap — a wrapped count
1085 /// arrives with the opposite sign and the consumer shifts the wrong way, which is
1086 /// the whole of #661.
1087 pub fn scroll_delta(&self) -> Option<ScrollOp> {
1088 if self.display_offset > 0 {
1089 return None;
1090 }
1091 self.scroll.map(cap_scroll)
1092 }
1093
1094 /// Build a serializable [`Frame`] from the current damage + grid + grapheme
1095 /// pool (#6). `Full` ships every row; `Partial` ships the damaged spans. The
1096 /// global side-table is remapped to **frame-local** indices — the engine pool
1097 /// is append-only and leaky, so a frame carries only the clusters its cells
1098 /// reference, renumbered, with each cell's `extra` rewritten to the local id.
1099 pub fn frame(&self) -> Frame {
1100 let cols = self.grid.cols();
1101 let rows = self.grid.rows();
1102 let (kind, line_spans): (FrameKind, Vec<(usize, usize, usize)>) = match self.frame_damage()
1103 {
1104 TermDamage::Full => (
1105 FrameKind::Full,
1106 (0..rows).map(|l| (l, 0, cols - 1)).collect(),
1107 ),
1108 TermDamage::Partial(lines) => (
1109 FrameKind::Partial,
1110 lines
1111 .into_iter()
1112 .map(|d| (d.line, d.left, d.right))
1113 .collect(),
1114 ),
1115 };
1116
1117 // Frame-local numbering for the hyperlink table (#26). Keyed by the URI's
1118 // *identity* — the `Arc` pointer — so two cells sharing one open share one entry
1119 // and a distinct open gets its own, which is exactly the semantics the pool
1120 // index used to carry.
1121 //
1122 // Sized by this frame, not by session history (#628). It was
1123 // `vec![0u32; hyperlink_pool.len() + 1]`, allocated and zeroed on **every**
1124 // frame against every OSC 8 the session had ever seen — measured at 10 µs per
1125 // frame with 100 000 opens retained. With the pool gone there is nothing left to
1126 // size it by, and the cost disappears rather than being reduced.
1127 let mut link_table: Vec<String> = Vec::new();
1128 let mut link_remap: std::collections::HashMap<*const u8, u32> =
1129 std::collections::HashMap::new();
1130 // Cells come from the viewport at `display_offset`, not the live grid:
1131 // viewport row `line` is absolute buffer line `top + line` (scrollback
1132 // when scrolled up, the live grid when `display_offset == 0`, where
1133 // `top == scrollback.len()` and this is identical to reading the grid).
1134 // Without this, a wire consumer — cells reach it only through `frame()` —
1135 // could never display scrollback (#48).
1136 let top = self.scrollback.len() - self.display_offset;
1137 let mut spans = Vec::with_capacity(line_spans.len());
1138 for (line, left, right) in line_spans {
1139 let mut cells = Vec::with_capacity(right - left + 1);
1140 let mut combining = std::collections::BTreeMap::new();
1141 let mut links = std::collections::BTreeMap::new();
1142 let mut ucolors = std::collections::BTreeMap::new();
1143 let row = self.abs_row(top + line);
1144 let last_col = row.len().saturating_sub(1);
1145 for col in left..=right {
1146 let mut cell = row[col];
1147 // Soft-wrap is a row property (#538), but the wire has no per-row slot — so it is
1148 // *derived* back onto the last cell's WRAPLINE bit here, which keeps the format
1149 // byte-identical and is why moving the storage needed no VERSION bump. The bit is
1150 // therefore wire-only: on a live grid it is never set, and `Row::is_wrapped` is
1151 // the question to ask.
1152 if col == last_col && row.is_wrapped() {
1153 cell.insert_flags(CellFlags::WRAPLINE);
1154 }
1155 // Combining clusters and hyperlinks live in the row's maps; each
1156 // tagged cell contributes its reference to the frame, recorded on
1157 // the span by span-relative column (the cell holds only the bit).
1158 if let Some(marks) = row.combining_at(col) {
1159 // The cluster itself, at its column — no side table and no index
1160 // since v14 (#621). Nothing interned these (this push was
1161 // unconditional), so the index only ever bought indirection.
1162 combining.insert(col - left, marks.to_vec());
1163 }
1164 if let Some(uri) = row.link_at(col) {
1165 // Number each distinct open once per frame (only referenced URIs
1166 // ship). The wire keeps its interning — #621 measured inlining a URI
1167 // per linked cell at +171…403% — so this stays an index into
1168 // `link_table`; only the *engine* side stopped being a table.
1169 let key = std::sync::Arc::as_ptr(uri) as *const u8;
1170 let next = link_table.len() as u32 + 1;
1171 let fidx = *link_remap.entry(key).or_insert_with(|| {
1172 link_table.push(uri.to_string());
1173 next
1174 });
1175 let fidx = core::num::NonZeroU32::new(fidx)
1176 .expect("frame-local link indices are 1-based");
1177 links.insert(col - left, fidx);
1178 }
1179 // Underline colour (SGR 58, #520): a colour reference, not a
1180 // side-table index, so it rides the span inline. `ucolor_at` is
1181 // flag-gated + already Default-filtered (the stamp only fires on an
1182 // underlined cell), so a present entry is a real non-default colour.
1183 if let Some(color) = row.ucolor_at(col) {
1184 ucolors.insert(col - left, color);
1185 }
1186 cells.push(cell);
1187 }
1188 spans.push(Span {
1189 line: line as u16,
1190 left: left as u16,
1191 right: right as u16,
1192 cells,
1193 combining,
1194 links,
1195 ucolors,
1196 });
1197 }
1198
1199 Frame {
1200 cols: cols as u16,
1201 rows: rows as u16,
1202 kind,
1203 // The live cursor: position in screen coords + DECTCEM visibility.
1204 // Reported, not drawn — the consumer renders the caret (#38).
1205 cursor_row: self.cursor.row as u16,
1206 cursor_col: self.cursor.col as u16,
1207 // Hidden while scrolled up: the live cursor is off the frozen
1208 // viewport, and a cell-invert caret would otherwise ink over
1209 // scrollback. Consistent with the frozen-damage policy (no cursor
1210 // damage is emitted while scrolled) and with xterm.js / alacritty,
1211 // which hide the caret when it falls outside the visible rows (#48).
1212 cursor_visible: self.cursor.visible && self.display_offset == 0,
1213 cursor_shape: self.cursor.shape,
1214 cursor_blink: self.cursor.blink,
1215 // Viewport scroll position for the consumer's scrollbar (ADR-0013).
1216 display_offset: self.display_offset as u32,
1217 scrollback_len: self.scrollback.len() as u32,
1218 evicted_total: self.evicted_total,
1219 marker_epoch: self.marker_epoch,
1220 // The active buffer's population, which is what `marker_index` reports and
1221 // therefore what a consumer's held index is compared against (#490).
1222 marker_count: self.markers().len() as u32,
1223 // The mouse tracking mode as a routing mask (#129): which mouse events
1224 // the app wants, derived from the protocol by the single source
1225 // `encode_mouse` shares. The consumer routes app-vs-local on it.
1226 mouse_events: self.mouse_protocol.wanted_events(),
1227 // Alt-screen flag (#149): buffer-global state the consumer can't
1228 // derive from viewport damage; the a11y announce policy gates on it.
1229 alt_screen: self.on_alt,
1230 scroll: self.scroll_delta(),
1231 spans,
1232 link_table,
1233 // Interaction overlays projected onto this viewport (#108): the
1234 // engine-owned selection and the consumer-supplied search highlights,
1235 // each re-projected here so the scroll offset is applied once, by the
1236 // same authority that projects the cells.
1237 overlay: Overlay {
1238 selection: self.selection_range(),
1239 matches: self
1240 .search_highlights
1241 .iter()
1242 .flat_map(|m| self.match_spans(m))
1243 .collect(),
1244 // The consumer-designated active match (#428), projected through
1245 // the same `match_spans` math — usually also present in `matches`
1246 // above (the renderer's ranking resolves the overlap, #424), but
1247 // a span designation may sit OUTSIDE a capped hand-over (#436).
1248 active_match: self
1249 .active_search_highlight
1250 .as_ref()
1251 .map(|m| self.match_spans(m))
1252 .unwrap_or_default(),
1253 markers: self.marker_positions(),
1254 },
1255 }
1256 }
1257
1258 /// Record a scroll of rows `[top, bottom]` by `count` (positive = up).
1259 ///
1260 /// Damage is indexed by row position, so it must follow the content the
1261 /// scroll just moved: rotate the bounds the same way and mark the newly
1262 /// exposed line fully damaged (it is new blank content for the consumer).
1263 fn record_scroll(&mut self, top: usize, bottom: usize, count: isize) {
1264 let cols = self.grid.cols();
1265 match count {
1266 1 => {
1267 self.line_damage[top..=bottom].rotate_left(1);
1268 self.line_damage[bottom] = LineBounds::fully_damaged(cols);
1269 }
1270 -1 => {
1271 self.line_damage[top..=bottom].rotate_right(1);
1272 self.line_damage[top] = LineBounds::fully_damaged(cols);
1273 }
1274 _ => {}
1275 }
1276 // Accumulate repeated scrolls of the same region into one op (flow
1277 // control). A *different* region cannot be expressed as one op, so
1278 // degrade to full rather than silently dropping the earlier scroll.
1279 match self.scroll {
1280 Some(op) if op.top == top && op.bottom == bottom => {
1281 self.scroll = Some(ScrollOp {
1282 top,
1283 bottom,
1284 count: op.count + count,
1285 });
1286 }
1287 None => self.scroll = Some(ScrollOp { top, bottom, count }),
1288 Some(_) => {
1289 self.scroll = None;
1290 self.mark_fully_damaged();
1291 }
1292 }
1293 }
1294
1295 /// Number of lines currently held in scrollback history.
1296 /// Replace the word-boundary set used by Word (semantic) selection — the policy half
1297 /// of `selection_begin(.., SelectionType::Word)`, injected per ADR-0017 (core owns
1298 /// the buffer walk, the consumer owns which characters separate words). The default
1299 /// is [`DEFAULT_WORD_SEPARATORS`].
1300 ///
1301 /// **`' '` is forced into whatever you pass**, and that is not a convenience. A
1302 /// blank cell packs `' '`, so the space terminates the walk at the end of a row's
1303 /// written text *and* backstops the wide-pair rule: without it, double-clicking next
1304 /// to a wide separator starts the highlight on that separator's trailing spacer,
1305 /// bisecting the glyph, and the walk then runs to the row's end through the padding.
1306 /// Enforcing it here rather than in the walk is ghostty's shape — it prepends its own
1307 /// blank codepoint to every parsed set at the config intake (`config/Config.zig`,
1308 /// *"Always include null as first boundary"*), so `selectWord` never has to.
1309 ///
1310 /// A consequence worth knowing before you narrow the set: this predicate is the only
1311 /// thing bounding the walk, so a set that omits the separators actually present in
1312 /// the buffer makes one double-click walk the whole soft-wrap run (see #206).
1313 pub fn set_word_separators(&mut self, separators: &str) {
1314 let mut set: String = separators.to_owned();
1315 if !set.contains(' ') {
1316 set.push(' ');
1317 }
1318 self.word_separators = set;
1319 }
1320
1321 /// The word-boundary set currently in force — what was passed to
1322 /// [`Term::set_word_separators`] plus the forced `' '`, or
1323 /// [`DEFAULT_WORD_SEPARATORS`] if it was never called.
1324 pub fn word_separators(&self) -> &str {
1325 &self.word_separators
1326 }
1327
1328 pub fn scrollback_len(&self) -> usize {
1329 self.scrollback.len()
1330 }
1331
1332 /// Whether the app has an open synchronized-output block (DEC ?2026, #73).
1333 pub fn synchronized_output(&self) -> bool {
1334 self.synchronized_output
1335 }
1336
1337 /// Whether the app enabled color-scheme-update notifications (DEC ?2031, #85).
1338 pub fn color_scheme_updates(&self) -> bool {
1339 self.color_scheme_updates
1340 }
1341
1342 /// Whether the app enabled grapheme-cluster mode (DEC ?2027, #295): emoji ZWJ / skin-tone /
1343 /// flag / VS16 sequences are clustered into one cell. OFF (default) is per-char, wcwidth-compat.
1344 pub fn grapheme_clustering(&self) -> bool {
1345 self.grapheme_clustering
1346 }
1347
1348 /// Whether the app enabled win32-input-mode (DEC ?9001, #86). The engine does
1349 /// not encode the raw key-records itself (a non-goal); a ConPTY consumer reads
1350 /// this to decide whether to emit them.
1351 pub fn win32_input_mode(&self) -> bool {
1352 self.win32_input_mode
1353 }
1354
1355 /// Queue a color-scheme report (`CSI ? 997 ; 1 n` dark / `; 2 n` light) on the
1356 /// reply channel. The consumer calls this to answer a `ColorSchemeQuery` event
1357 /// or, when its scheme changes and `color_scheme_updates()` is set, to send the
1358 /// unsolicited notification. The engine never stores or interprets the scheme
1359 /// (#85).
1360 pub fn report_color_scheme(&mut self, dark: bool) {
1361 let ps = if dark { 1 } else { 2 };
1362 self.replies
1363 .extend_from_slice(format!("\x1b[?997;{ps}n").as_bytes());
1364 }
1365
1366 /// OSC 10/11/12 set/query the default fg/bg/cursor colour, stacking the
1367 /// `;`-separated specs across the `[foreground, background, cursor]` slots —
1368 /// xterm's `ChangeColorsRequest` offset loop (`misc.c:3679`, walking
1369 /// `OSC_TEXT_FG` → `OSC_TEXT_BG` → `OSC_TEXT_CURSOR`, `ptyx.h:1018-1020`).
1370 /// OSC 10 starts at slot 0, OSC 11 at slot 1, OSC 12 at slot 2 (#137, #832).
1371 /// A `?` spec is a query.
1372 ///
1373 /// **An empty spec addresses its slot and leaves it alone** — neither a set
1374 /// nor a reset — and the stack still advances past it, so `OSC 10 ; ; <bg>`
1375 /// is how xterm reaches the background alone. That skip-and-advance is xterm's
1376 /// *implementation*, not documented behaviour: `ctlseqs.txt:2082` documents only
1377 /// the stack (*"each successive parameter changes the next color in the list"*)
1378 /// and expects at least one parameter. The two empty
1379 /// cases are the same rule from both ends: nothing left in the string yields
1380 /// no name (`misc.c:3684-3685`), and a separator where a name should be yields no
1381 /// name either (`misc.c:3687`) before the parse steps past it.
1382 ///
1383 /// That rule is not a hardening detail here, it is a precondition: the empty
1384 /// form is the *only* one real applications emit for OSC 12 (`nvim` sends
1385 /// `ESC ] 12 ; BEL` four to five times per session), so a cursor slot without
1386 /// it would relay a burst of empty-string colour changes every time a user
1387 /// opens an editor (#832).
1388 ///
1389 /// The stack ends after the cursor. xterm's next slots are the pointer
1390 /// colours (`OSC_MOUSE_FG` = 13, `OSC_MOUSE_BG` = 14), which justerm does not
1391 /// model — dropping a fourth spec is better than mis-addressing it.
1392 fn special_color(&mut self, params: &[&[u8]], start: usize, terminator: Terminator) {
1393 for (i, &spec) in params[1..].iter().enumerate() {
1394 if spec.is_empty() {
1395 continue; // skip this slot, but still advance to the next
1396 }
1397 let event = match start + i {
1398 0 if spec == b"?" => TermEvent::QueryForeground { terminator },
1399 0 => TermEvent::SetForeground(String::from_utf8_lossy(spec).into_owned()),
1400 1 if spec == b"?" => TermEvent::QueryBackground { terminator },
1401 1 => TermEvent::SetBackground(String::from_utf8_lossy(spec).into_owned()),
1402 2 if spec == b"?" => TermEvent::QueryCursorColor { terminator },
1403 2 => TermEvent::SetCursorColor(String::from_utf8_lossy(spec).into_owned()),
1404 _ => break, // past [fg, bg, cursor] — the pointer colours are unmodelled
1405 };
1406 self.events.push(event);
1407 }
1408 }
1409
1410 /// OSC 52 (`OSC 52 ; Pc ; Pd`) — an application asking to put text on a
1411 /// selection, or to read one back (#828).
1412 ///
1413 /// The engine's half is *mechanism* and nothing else: recognise the
1414 /// sequence, resolve `Pc` to a [`ClipboardTarget`], decode `Pd`, and relay.
1415 /// It never touches a clipboard, never holds one, and carries no allow/deny
1416 /// knob — under ADR-0017 that gate is the consumer's, and a consumer that
1417 /// drops the event has refused the request. alacritty puts the gate here
1418 /// (`alacritty_terminal/src/term/mod.rs:1706`) because alacritty is the whole
1419 /// terminal; this crate is not.
1420 ///
1421 /// **An absent target field means the clipboard, and that is a divergence
1422 /// from the spec taken deliberately.** `ctlseqs.txt:2161` says *"If the
1423 /// parameter is empty, xterm uses s 0, to specify the configurable
1424 /// primary/clipboard selection and cut-buffer 0"*, and `misc.c:3359` is that
1425 /// sentence in code. Neither half is representable here: `s` is whichever
1426 /// selection a *user resource* has configured — policy, which ADR-0017 puts
1427 /// in the consumer — and cut buffers are not modelled at all. So "follow the
1428 /// spec" is not a well-defined instruction, exactly as it was not for #834's
1429 /// empty colour spec, where xterm's trigger (*a colour that failed to parse*)
1430 /// is a condition this engine structurally cannot observe.
1431 ///
1432 /// Two qualifications, because the short version of this argument overclaims
1433 /// in both directions. **The cut buffer is what is unrepresentable; `s` is
1434 /// merely unmodelled** — xterm resolves it through the `selectToClipboard`
1435 /// resource (`button.c:2081`), and DECSET 1041 sets that same resource *from
1436 /// the stream* (`ctlseqs.txt:1008`), so an engine tracking 1041 could
1437 /// resolve it. justerm declines to model 1041; that is a choice, not an
1438 /// impossibility. And **xterm-as-shipped reads the empty field as PRIMARY**,
1439 /// since the resource defaults to false — so this diverges from what the
1440 /// reference does by default, not merely from a sentence in its manual.
1441 ///
1442 /// What decides it is the other two lines of evidence. **Independent
1443 /// lineages, and there are fewer than a naive count gives**: alacritty never
1444 /// sees an empty field at all, because `vte` substitutes `b'c'` first
1445 /// (`vte-0.15.0/src/ansi.rs:1488`) — those two are *one* lineage, not two.
1446 /// The genuinely separate ones are ghostty, by an explicit byte-scan branch
1447 /// pinned under a test (`clipboard_operation.zig:24`, `:64`), and xterm.js's
1448 /// clipboard addon, whose browser provider ignores the selector entirely and
1449 /// always writes `navigator.clipboard`
1450 /// (`addons/addon-clipboard/src/ClipboardAddon.ts:77`). Three lineages, one
1451 /// answer. **And the emitter's own documentation agrees**: `tmux`'s
1452 /// `set-clipboard` is documented as setting *the terminal clipboard* — never
1453 /// the primary selection — and `tmux` 3.2a is measured sending exactly this
1454 /// empty form.
1455 ///
1456 /// **A missing payload *field* is not an empty payload.** `OSC 52 ; c`
1457 /// arrives as two fields and does nothing; `OSC 52 ; c ;` arrives as three,
1458 /// the third empty, and is a store of the empty string — which is how the
1459 /// sequence clears a selection. xterm makes the same split by construction,
1460 /// its whole handler sitting inside `if (*buf == ';')` (`misc.c:3353`), and
1461 /// ghostty rejects the payload-less form for the same reason
1462 /// (`clipboard_operation.zig:20`).
1463 ///
1464 /// **The payload is `params[2..]` rejoined**, the rule #650 established for
1465 /// OSC 8: `vte` splits the whole OSC body on `;`, so reading `params[2]`
1466 /// alone would take a payload containing a `;` and decode its first piece —
1467 /// which for a well-formed prefix is a *successful* decode of a truncated
1468 /// clipboard, the one failure mode this handler must not have. Rejoined, the
1469 /// stray `;` reaches the decoder and is refused there.
1470 ///
1471 /// **Malformed in, nothing out**, which is the second deliberate divergence
1472 /// — and it is narrower than the spec sentence makes it look. The spec ends
1473 /// a payload that is *"neither a base64 string nor ?"* by clearing the
1474 /// selection (`ctlseqs.txt:2174`); this drops it silently. Clearing is
1475 /// destructive, and inferring one from bytes the engine could not parse
1476 /// means corruption on the wire wipes what the user copied by hand.
1477 ///
1478 /// The family, counted rather than asserted: **three drop** — alacritty
1479 /// (`alacritty_terminal/src/term/mod.rs:1717`), ghostty, which returns on a
1480 /// decode failure (`src/Surface.zig:2186`) and states the rule beside its
1481 /// test as *"Read requests and malformed base64 must never reach the
1482 /// callback"* (`src/terminal/c/terminal.zig:2961`), and this. **One clears**
1483 /// — xterm.js's addon, deliberately: *"Clear clipboard if text is not a
1484 /// base64 encoded string"* (`addons/addon-clipboard/src/ClipboardAddon.ts:55`).
1485 /// **And one neither** — xterm, below.
1486 ///
1487 /// What the divergence actually is, stated precisely because reading the
1488 /// spec alone gets it wrong: **xterm has no validator to disagree with.**
1489 /// `AppendToSelectionBuffer` (`button.c:4679`) decodes one character at a
1490 /// time and `return`s on any byte outside the alphabet (`:4698`), so xterm
1491 /// *filters* rather than rejects — `Zm9v-Zm9v` yields `foofoo` there — and
1492 /// since the store path clears the buffer first (`misc.c:3410`), the spec's
1493 /// "cleared" is what falls out when the filter finds nothing to keep. So the
1494 /// disagreement is about what to **accept**, not about what to do on
1495 /// refusal, and this engine is the stricter of the two on purpose: a filter
1496 /// hands the consumer text assembled from bytes the application did not
1497 /// send.
1498 ///
1499 /// Non-UTF-8 is refused on the same principle one level up: every text
1500 /// surface this crate publishes is UTF-8, and a lossy conversion would hand
1501 /// the consumer characters the application never sent.
1502 fn clipboard(&mut self, params: &[&[u8]], terminator: Terminator) {
1503 let Some(&field) = params.get(1) else {
1504 return;
1505 };
1506 let target = match field {
1507 // Empty and `c` are the same answer; see the divergence note above.
1508 b"" | b"c" => ClipboardTarget::Clipboard,
1509 // `p` and `s` are NOT the same answer, and the first draft made them
1510 // one. See `ClipboardTarget`: a collapse here would put a selector
1511 // the application never wrote into the reply.
1512 b"p" => ClipboardTarget::Primary,
1513 b"s" => ClipboardTarget::Selection,
1514 // Anything else — an unmodelled target like `q` or a cut buffer, and
1515 // also a *multi*-target list like `pc`, which the spec permits
1516 // (`ctlseqs.txt:2156`) and this engine cannot express. Both are
1517 // dropped rather than approximated: honouring one target of two is
1518 // the same defect as truncating a payload, one axis over, and
1519 // `vte`/alacritty's first-byte-wins would do exactly that. ghostty
1520 // rejects a multi-byte field too (`clipboard_operation.zig:36`).
1521 _ => return,
1522 };
1523 // Two fields is `OSC 52 ; c` — no payload field at all, not an empty one.
1524 if params.len() < 3 {
1525 return;
1526 }
1527 // The bound is checked on the fields, BEFORE the join — `join` is itself
1528 // an unconditional full copy, so checking after it would let a hostile
1529 // payload buy a second allocation the size of the first. `+ len - 3` is
1530 // the separators the join puts back.
1531 let fields = ¶ms[2..];
1532 if fields.iter().map(|f| f.len()).sum::<usize>() + fields.len() - 1 > MAX_CLIPBOARD_BASE64 {
1533 return;
1534 }
1535 let payload: Vec<u8> = fields.join(&b';');
1536 if payload == b"?" {
1537 self.events
1538 .push(TermEvent::QueryClipboard { target, terminator });
1539 return;
1540 }
1541 if let Some(bytes) = crate::base64::decode(&payload)
1542 && let Ok(text) = String::from_utf8(bytes)
1543 {
1544 self.events.push(TermEvent::ClipboardStore { target, text });
1545 }
1546 }
1547
1548 /// Answer an OSC 52 [`TermEvent::QueryClipboard`] (#828): base64-encode the
1549 /// consumer's text into the OSC 52 reply envelope, ST-terminated.
1550 ///
1551 /// The consumer hands the target back rather than the engine remembering
1552 /// which one was asked about — the same shape as
1553 /// [`Term::report_palette_color`], which takes its `index` back for the same
1554 /// reason. alacritty is the alternative: its query captures the target and
1555 /// the terminator in a closure the consumer later calls
1556 /// (`alacritty_terminal/src/term/mod.rs:1740`), which is one more piece of
1557 /// hidden state and one more question ("what if replies interleave?") bought
1558 /// for nothing the consumer does not already hold.
1559 ///
1560 /// **Answering is optional, and that is the security property.** The engine
1561 /// holds no clipboard, so a query it is never asked to answer reveals
1562 /// nothing; a consumer refuses a *read* simply by not calling this, whatever
1563 /// it does about *writes*.
1564 ///
1565 /// **The selector round-trips.** `c` / `p` / `s` in, the same one out, which
1566 /// is why [`ClipboardTarget`] keeps `p` and `s` apart: every reference echoes
1567 /// the field the application wrote — xterm the recognised list
1568 /// (`misc.c:3384`), alacritty the raw byte (`…/term/mod.rs:1744`), ghostty
1569 /// its three locations (`src/Surface.zig:5954`) — and it is the one field a
1570 /// client can pair a reply on. The single exception is an **empty** field,
1571 /// answered naming `c`, which is what alacritty also sends once `vte` has
1572 /// defaulted it: the reply says what the engine understood, and there is no
1573 /// selector to echo.
1574 ///
1575 /// The reply echoes the terminator the query arrived with, like every other
1576 /// reply this crate queues — settled for the whole channel rather than for
1577 /// this sequence, which is where #828 left it (#836).
1578 pub fn report_clipboard(
1579 &mut self,
1580 target: ClipboardTarget,
1581 text: &str,
1582 terminator: Terminator,
1583 ) {
1584 let field = match target {
1585 ClipboardTarget::Clipboard => 'c',
1586 ClipboardTarget::Primary => 'p',
1587 ClipboardTarget::Selection => 's',
1588 };
1589 let data = crate::base64::encode(text.as_bytes());
1590 self.replies
1591 .extend_from_slice(format!("\x1b]52;{field};{data}").as_bytes());
1592 self.replies.extend_from_slice(terminator.bytes());
1593 }
1594
1595 /// Answer an OSC 4 palette query (#122): wrap the consumer-supplied spec for
1596 /// `index` in the OSC 4 reply envelope.
1597 ///
1598 /// The reply echoes the terminator the query arrived with, which the
1599 /// consumer takes off the `Query…` event and hands back here (#836): the
1600 /// spec says a terminal *"uses the same terminator used in a query"*
1601 /// (`ctlseqs.txt:2020`), and the engine cannot choose on the consumer's
1602 /// behalf because only the parser ever saw which byte arrived.
1603 pub fn report_palette_color(&mut self, index: u8, spec: &str, terminator: Terminator) {
1604 self.replies
1605 .extend_from_slice(format!("\x1b]4;{index};{spec}").as_bytes());
1606 self.replies.extend_from_slice(terminator.bytes());
1607 }
1608
1609 /// Answer an OSC 10 foreground query (#122): wrap the consumer-supplied spec
1610 /// in the OSC 10 reply envelope.
1611 ///
1612 /// The reply echoes the terminator the query arrived with, which the
1613 /// consumer takes off the `Query…` event and hands back here (#836): the
1614 /// spec says a terminal *"uses the same terminator used in a query"*
1615 /// (`ctlseqs.txt:2020`), and the engine cannot choose on the consumer's
1616 /// behalf because only the parser ever saw which byte arrived.
1617 pub fn report_foreground(&mut self, spec: &str, terminator: Terminator) {
1618 self.replies
1619 .extend_from_slice(format!("\x1b]10;{spec}").as_bytes());
1620 self.replies.extend_from_slice(terminator.bytes());
1621 }
1622
1623 /// Answer an OSC 11 background query (#122): wrap the consumer-supplied spec
1624 /// (it knows its palette) in the OSC 11 reply envelope. The engine formats
1625 /// the envelope only — it never knows the colour.
1626 ///
1627 /// The reply echoes the terminator the query arrived with, which the
1628 /// consumer takes off the `Query…` event and hands back here (#836): the
1629 /// spec says a terminal *"uses the same terminator used in a query"*
1630 /// (`ctlseqs.txt:2020`), and the engine cannot choose on the consumer's
1631 /// behalf because only the parser ever saw which byte arrived.
1632 pub fn report_background(&mut self, spec: &str, terminator: Terminator) {
1633 self.replies
1634 .extend_from_slice(format!("\x1b]11;{spec}").as_bytes());
1635 self.replies.extend_from_slice(terminator.bytes());
1636 }
1637
1638 /// Answer an OSC 12 cursor-colour query (#832): the same envelope one slot
1639 /// over, terminated like its siblings. The consumer supplies the spec — it
1640 /// owns the palette, and the engine never learns the colour.
1641 ///
1642 /// The reply echoes the terminator the query arrived with, which the
1643 /// consumer takes off the `Query…` event and hands back here (#836): the
1644 /// spec says a terminal *"uses the same terminator used in a query"*
1645 /// (`ctlseqs.txt:2020`), and the engine cannot choose on the consumer's
1646 /// behalf because only the parser ever saw which byte arrived.
1647 pub fn report_cursor_color(&mut self, spec: &str, terminator: Terminator) {
1648 self.replies
1649 .extend_from_slice(format!("\x1b]12;{spec}").as_bytes());
1650 self.replies.extend_from_slice(terminator.bytes());
1651 }
1652
1653 /// The cells of visible row `i` (0..rows) at the current scroll position.
1654 /// The viewport windows into `[history.. ; screen..]`: rows above
1655 /// `scrollback.len()` come from history, the rest from the live screen.
1656 pub fn viewport_line(&self, i: usize) -> &[Cell] {
1657 let top = self.scrollback.len() - self.display_offset;
1658 let idx = top + i;
1659 if idx < self.scrollback.len() {
1660 &self.scrollback[idx]
1661 } else {
1662 self.grid.row(idx - self.scrollback.len())
1663 }
1664 }
1665
1666 /// Scroll the viewport up by `n` lines into history (clamped to the oldest).
1667 pub fn scroll_up(&mut self, n: usize) {
1668 let target = (self.display_offset + n).min(self.scrollback.len());
1669 self.set_display_offset(target);
1670 }
1671
1672 /// Scroll the viewport down by `n` lines toward the live screen.
1673 pub fn scroll_down(&mut self, n: usize) {
1674 let target = self.display_offset.saturating_sub(n);
1675 self.set_display_offset(target);
1676 }
1677
1678 /// Jump the viewport back to the live screen (follow the bottom).
1679 pub fn scroll_to_bottom(&mut self) {
1680 self.set_display_offset(0);
1681 }
1682
1683 /// Move the viewport. A user scroll changes which lines are visible, so the
1684 /// whole viewport is repainted (full damage) when the offset actually moves.
1685 fn set_display_offset(&mut self, offset: usize) {
1686 // The alt screen has no scrollback to view; scroll intents are no-ops.
1687 if self.on_alt {
1688 return;
1689 }
1690 if offset != self.display_offset {
1691 self.display_offset = offset;
1692 self.mark_fully_damaged();
1693 }
1694 }
1695
1696 // ---- selection -----------------------------------------------------------
1697
1698 /// Map a viewport cell `(row, col)` to an absolute buffer point. The top
1699 /// visible row is `scrollback.len() - display_offset`, so viewport row `i`
1700 /// is that plus `i`.
1701 ///
1702 /// **`row` is clamped to the last visible row, and that is the anchor's whole defence
1703 /// against a caller that hands it one past the end (#660).** The row arrives from a
1704 /// pointer position, so it is off the end whenever a drag leaves the grid — including
1705 /// the ordinary case where the container is a sub-cell remainder taller than
1706 /// `rows × cell_height` and a click lands in that strip. Stored unclamped it does not
1707 /// fail here: it detonates later, in whichever read walks the selection
1708 /// (`selection_range`, `selection_text`, the word extents), so the stack trace accuses
1709 /// the reader rather than the caller — the delay `damage_span`'s doc describes and
1710 /// #536 was filed about.
1711 ///
1712 /// **Clamped, and deliberately *not* `debug_assert`ed** — which is where this differs
1713 /// from `damage_span`, whose split it otherwise mirrors. That function is engine-
1714 /// internal, so an out-of-range span there is a justerm bug and the assert names its
1715 /// producer. This one is reached from `Engine::selection_begin` / `selection_extend`,
1716 /// whose documented input is *"what a mouse event carries"* — and a pointer leaves the
1717 /// grid whenever a drag does, so a row past the end is **ordinary input, not a defect**.
1718 /// Asserting on it would panic a consumer's debug build for a legal gesture.
1719 ///
1720 /// Clamping is also what every producer already wants: a drag past the bottom edge
1721 /// selects to the edge. alacritty clamps at the same boundary (`Point::grid_clamp`);
1722 /// ghostty's pins cannot express an out-of-range row at all.
1723 ///
1724 /// **This is a backstop, and since #667 nothing in the family relies on it.** The
1725 /// sentence here used to read that justerm-web's selection converter did *not* clamp,
1726 /// which was what made an unclamped row reachable in the shipped stack rather than
1727 /// only in theory; that converter now bounds both axes at its own seam, as all three
1728 /// references do at theirs. The claim is retracted rather than deleted because it was
1729 /// the record of why this clamp was worth adding.
1730 ///
1731 /// **`col` is bounded the same way, and against the grid rather than the line
1732 /// (#671).** #660 reasoned about the row alone and this function passed the column
1733 /// through, which was not a smaller version of the same gap — it was a *different*
1734 /// one, because the two axes are consumed differently downstream. A column reaches
1735 /// `resolve`, where the `Side` decides whether it gets a `+ 1`, and the two readers
1736 /// then bound only one end each: `selection_range`'s Linear arm clips `right_excl`
1737 /// and not `left`, `selection_text`'s Block arm clips `hi` and not `from`. So
1738 /// `Side::Right` was already safe by accident (its `+ 1` lands past the end and the
1739 /// clip catches it) while **`Side::Left` had no `+ 1` to clip**, and the raw column
1740 /// survived into `left` — silently deleting the anchor's own row from both the
1741 /// projection and the copy. `usize::MAX` was the one value that panicked instead,
1742 /// on the `+ 1`.
1743 ///
1744 /// Bounding here rather than in `resolve` keeps one site answering "what does a
1745 /// viewport coordinate mean", and makes both axes total for the same reason; the
1746 /// alternative — clamping at each `+ 1` — is five sites for one rule. alacritty
1747 /// bounds both endpoints' columns in `Selection::to_range` *before* its own side
1748 /// arithmetic and pairs the `+ 1` with an explicit *"column == columns → wrap to the
1749 /// next line"*; justerm reaches that same outcome through the reader's
1750 /// `right_excl > left`, which is why an **in-range** `Side::Right` on the last column
1751 /// still starts the selection on the following row and is pinned as unchanged.
1752 ///
1753 /// The grid, not `abs_line(..).len()`, is the bound: `SelectionType::Line` already
1754 /// resolves `to` as `grid.cols()`, so the whole type works in grid coordinates and a
1755 /// short line must not shrink a selection that reaches past it.
1756 ///
1757 /// **`resolve`'s five `+ 1`s stay unguarded, and that is sound only while every stored
1758 /// anchor arrives through here.** The completeness pass enumerated the writers: the
1759 /// three coordinate fixups move `.line` or write columns that are in range by
1760 /// construction, `resize`'s primary branch re-clamps the reflowed points (#562) and its
1761 /// alt branch drops the selection outright (#660), and `Term::resize` is the only writer
1762 /// of `grid.cols()`. So no path strands a column that was clamped here. The condition is
1763 /// **a fourth writer of `self.selection`** — one that builds an `Anchor` without this
1764 /// function would put `resolve` back in reach of its own arithmetic.
1765 fn viewport_to_abs(&self, row: usize, col: usize) -> BufferPoint {
1766 let top = self.scrollback.len() - self.display_offset;
1767 let last = self.grid.rows().saturating_sub(1);
1768 BufferPoint {
1769 line: top + row.min(last),
1770 col: col.min(self.grid.cols().saturating_sub(1)),
1771 }
1772 }
1773
1774 /// The hyperlink **URI** at **screen** `(row, col)` (the live grid), or `None` —
1775 /// flag-gated through the row's link map. Since #628 the map holds the URI itself,
1776 /// so there is no index and no second call to resolve one.
1777 /// Mirrors `grid().cell(row, col)`.
1778 pub(crate) fn screen_link_at(&self, row: usize, col: usize) -> Option<Hyperlink> {
1779 self.grid
1780 .row_ref(row)
1781 .link_at(col)
1782 .cloned()
1783 .map(Hyperlink::new)
1784 }
1785
1786 /// The underline colour (SGR 58, #520) at screen `(row, col)`, as a theme-agnostic
1787 /// reference. `Color::Default` means "follow the fg" — the common case, and what an
1788 /// unset cell returns. Mirror of [`Term::screen_link_at`].
1789 pub(crate) fn screen_underline_color_at(&self, row: usize, col: usize) -> Color {
1790 self.grid.row_ref(row).ucolor_at(col).unwrap_or_default()
1791 }
1792
1793 /// The hyperlink URI at **viewport** `(row, col)` (visible window, history
1794 /// included at the current scroll), or `None`. Mirrors `viewport_line(row)`.
1795 pub(crate) fn viewport_link_at(&self, row: usize, col: usize) -> Option<Hyperlink> {
1796 let idx = self.scrollback.len() - self.display_offset + row;
1797 self.abs_row(idx).link_at(col).cloned().map(Hyperlink::new)
1798 }
1799
1800 /// Resize the screen to `cols` x `rows`. Rows dropped off the top (on shrink)
1801 /// enter scrollback. Column reflow of soft-wrapped lines is layered on top
1802 /// separately (#7). The whole screen is damaged.
1803 ///
1804 /// `cols` is widened to [`MIN_COLUMNS`] — a narrower screen cannot hold a
1805 /// width-2 glyph, so it is clamped rather than represented (#547).
1806 pub fn resize(&mut self, cols: usize, rows: usize) {
1807 // A terminal is never 0-tall; clamp so the math below (rows - 1) can't
1808 // underflow. Columns clamp to MIN_COLUMNS, not 1: chunking by cols needs a
1809 // non-zero width, but a *wide glyph* needs two (#547). The ceiling is the frame
1810 // header's u16, clamped here as well as in the constructor — a ceiling that held
1811 // only until the first resize is the gap `new` had against this function's own row
1812 // floor before #547 (#621).
1813 let cols = cols.clamp(MIN_COLUMNS, MAX_COLUMNS);
1814 let rows = rows.clamp(1, MAX_ROWS);
1815 let old_cols = self.grid.cols();
1816 let old_rows = self.grid.rows();
1817 let limit = self.scrollback_limit;
1818
1819 // A reflow rewrites marker lines outright, at three separate sites below and in
1820 // two different frames of reference — so a held index goes stale in a way no
1821 // offset repairs (#490). Bumped **here**, once, rather than beside each rewrite:
1822 // this function is the only way any of them runs, and a per-site obligation is
1823 // the shape `docs/map/territory/marker.md` already records as a known hole for
1824 // the alt guard. Gated on a *dimension change* as well as on there being a marker:
1825 // `resize` has no early return for unchanged geometry and `ResizePort` states no
1826 // idempotency guarantee, so a consumer may call this with the size it already has —
1827 // `justerm-web`'s fit does exactly that when the *cell* moves and the proposed grid
1828 // does not, since it dedupes on cell and grid together. An ungated bump would then be
1829 // a full re-pull for a resize that changed nothing — measured at 100 bumps for 100
1830 // no-op resizes.
1831 //
1832 // This comment said the fit loop *"re-asserts the size every frame"*, which stopped being true
1833 // when #632 gave `FitController` its four-field dedupe. The gate is unaffected: what it
1834 // rests on is the missing early return, and that is still measured.
1835 if (cols != old_cols || rows != old_rows)
1836 && (!self.normal_markers.is_empty() || !self.alt_markers.is_empty())
1837 {
1838 self.bump_marker_epoch();
1839 }
1840
1841 // A reflow moves match coordinates (and can change the match set), so the
1842 // query-derived highlights are invalidated; the consumer re-searches at
1843 // the new width. The selection re-anchors below — it is user-authored.
1844 self.invalidate_search_highlights();
1845
1846 // ...except on the alt screen, where a *shrink* drops the selection (#660). The
1847 // primary pane carries user-authored points through `reflow_pane` and gets them back
1848 // mapped to the new geometry; on alt the selection is dropped instead.
1849 //
1850 // **This is a policy choice, not a capability limit, and the difference matters
1851 // because the first version of this comment got it wrong.** It claimed the alt pane
1852 // has "nothing to re-anchor through" — measurably false: the alt branch below makes
1853 // its own `reflow_pane` call with tracked points and already uses the returned
1854 // `extras` / `evicted` to rotate and dispose alt *markers*. `reflow: false` disables
1855 // the column re-split, not the point tracking. Installing a false "cannot" as the
1856 // justification for fixing a false "cannot" is precisely the failure #660 is.
1857 //
1858 // What actually makes rotation the wrong trade here is that a marker and a selection
1859 // are different shapes. A marker is one point with a binary fate — survive, or be
1860 // disposed with an event. A selection is two *ordered* endpoints, so when a shrink
1861 // destroys the row under one of them and not the other, "dispose" has no meaning and
1862 // the correct behaviour is the clamp-and-overtake policy `selection_rotate_region`
1863 // implements for scrolls. Reusing the marker path would give a selection whose ends
1864 // moved by different rules; writing the second policy is a feature, not this fix.
1865 // And on a column shrink an alt anchor would additionally need the `.min(cols - 1)`
1866 // the primary branch applies, which alt markers deliberately do *not* take.
1867 //
1868 // Both references drop rather than rotate on the axis they consider unsafe —
1869 // alacritty on a width change (`term/mod.rs:680-682`), xterm.js on a height change,
1870 // its comment naming this bug class outright (`SelectionService.ts:156-160`).
1871 //
1872 // **Any geometry change, not just a shrink — and the `!=` is load-bearing in the
1873 // direction that looks wasteful.** The obvious refinement is to keep the selection on
1874 // a *grow*, since the alt pane pads rather than moving content, so no anchor looks
1875 // invalidated. That was tried and a randomised sweep refuted it in one run: an alt
1876 // resize also reflows the **primary** pane below, and on the alt screen `scrollback`
1877 // *is* that primary history — so `scrollback.len()` moves under an anchor whose
1878 // absolute line was measured from the old base, even when the alt grid itself does not
1879 // move at all. `selection_text` then walks off the end. The branch below already knows
1880 // this and converts alt *markers* through `old_base` → new base for exactly that
1881 // reason ("the primary scrollback below may rewrap and change length even when the alt
1882 // grid does not move"); the selection has no such conversion, so the geometry change
1883 // is the honest trigger.
1884 //
1885 // Rebasing instead of dropping is the better answer and is not done here: a grow has
1886 // no destroyed row, so both endpoints could shift by the base delta unambiguously —
1887 // but a *shrink* still needs the two-endpoint policy below, and shipping half of it
1888 // would leave the two axes behaving differently for no stated reason.
1889 //
1890 // The exact no-op is still not a resize: nothing reflows when neither axis moves, so
1891 // the base cannot shift, and a consumer that re-asserts its size every frame (a
1892 // `fit()` loop) must not make selecting on the alt screen impossible.
1893 if self.on_alt && (cols != old_cols || rows != old_rows) {
1894 self.selection = None;
1895 }
1896
1897 // Both screens are resized. Scrollback pairs with the PRIMARY screen
1898 // (whichever is active) — the alt screen has no history of its own.
1899 // `reflow: true` is the *primary* pane's setting and is deliberately a constant, **not**
1900 // `self.autowrap`. ghostty gates its equivalent on DECAWM — `.reflow =
1901 // self.modes.get(.wraparound)` (`terminal/Terminal.zig` `resize`) — and the reading that
1902 // makes that coherent is the one this file just accepted for the alt screen: an application
1903 // that turns autowrap off is placing lines itself, so its content is a layout rather than a
1904 // flow, and re-wrapping it changes what it drew.
1905 //
1906 // Not followed here, for three reasons, and they are recorded rather than filed because
1907 // nothing observable is known to break either way (measured: with DECAWM off, a full
1908 // 6-column row still re-splits into two rows at width 3 exactly as it does with DECAWM on;
1909 // the difference against ghostty is that ghostty truncates that row instead).
1910 //
1911 // - **The wrap flag is not a lie.** `Row::is_wrapped` means "this row continues into the
1912 // next", which after a re-split is simply true. DECAWM governs the **write** path — where
1913 // a glyph goes when the cursor is at the last column — not how stored content is laid out
1914 // again later. Dropping the flag would make `"abcdef"` extract as `"abc\ndef"`.
1915 // - **The mode is global and momentary; the buffer is neither.** DECAWM is read at resize
1916 // time and would decide the fate of history written under the opposite setting. A TUI that
1917 // turns it off while drawing would, on a resize landing in that window, leave every
1918 // properly wrapped line in scrollback un-reflowed.
1919 // - **It costs content.** Not reflowing truncates each row to the new width, so the tail of
1920 // a long line leaves the grid. justerm keeps it.
1921 //
1922 // There is no per-row signal to be finer with: a row written under DECAWM off and a row that
1923 // merely ended early are both simply unwrapped. "Do not re-split an unwrapped logical line"
1924 // would break ordinary reflow, since a line that exactly fills its width carries no wrap
1925 // flag either.
1926 let dims = ReflowDims {
1927 old_cols,
1928 cols,
1929 rows,
1930 limit,
1931 reflow: true,
1932 };
1933 let scrollback = std::mem::take(&mut self.scrollback);
1934 if self.on_alt {
1935 // Active = alt (cursor, no scrollback); inactive = primary. No selection anchors
1936 // to track here — but *because the geometry change above dropped them* (#660),
1937 // not because there cannot be any. This comment used to read "selection is
1938 // primary-only and cleared on alt enter": the clearing is real
1939 // (`enter_alt_screen` / `leave_alt_screen`, "a selection cannot survive a screen
1940 // swap") and says nothing about a selection made *while* the alt screen is up,
1941 // which is the ordinary act of copying out of vim. A premise that held at one
1942 // instant was read as an invariant holding for the screen's lifetime.
1943 // Alt markers still ride this pane, but **not because it reflows** — since #567 it does
1944 // not, so a marker's content no longer moves under it and the old reason here ("justerm
1945 // column-reflows the alt grid, so a marker must follow its content") is retracted. What
1946 // they ride it for is the row *fit*: a shrink still drops rows off the top, and a marker
1947 // on one of those has left a screen with no history to hold it. Their stored line is
1948 // `base + alt_row` (base = primary scrollback len), so convert to alt-local rows here
1949 // and re-anchor on the new base afterward — the primary scrollback below may rewrap and
1950 // change length even when the alt grid does not move at all.
1951 let old_base = scrollback.len();
1952 let mut alt_pts: Vec<(usize, usize)> = self
1953 .alt_markers
1954 .iter()
1955 .map(|m| (m.line - old_base, m.col))
1956 .collect();
1957 // Alt-scoped tracked points are stored on the same `base + alt_row`
1958 // frame as the alt markers, so they convert and come back the same way
1959 // (#691).
1960 let alt_tracked_off = alt_pts.len();
1961 alt_pts.extend(
1962 self.alt_tracked
1963 .iter()
1964 .map(|p| (p.line.saturating_sub(old_base), p.col)),
1965 );
1966 let alt = self.grid.take_lines();
1967 let r_alt = reflow_pane(
1968 alt,
1969 VecDeque::new(),
1970 self.cursor.point(),
1971 &alt_pts,
1972 ReflowDims {
1973 limit: 0,
1974 reflow: false,
1975 ..dims
1976 },
1977 );
1978 self.grid.set_screen(r_alt.screen, cols, rows);
1979 self.cursor.set_point(r_alt.cursor, rows, cols);
1980
1981 // Primary is inactive here, but markers anchor *primary* content, so
1982 // they reflow with it. There is no *primary* selection to carry alongside them —
1983 // `switch_to_alt` nulls it before the alt screen exists, so one cannot coexist
1984 // with `on_alt` — which is a different statement from the "cleared on alt enter"
1985 // this comment used to make, and the difference is #660: that clearing says
1986 // nothing about the *alt* selection the branch above now drops.
1987 // `(line, col)`, not `(line, 0)`: the column is what bounds OSC-133 command-text
1988 // extraction (#166), and discarding it here truncated the recorded command for any
1989 // resize taken while a full-screen app was up. The primary branch below has always
1990 // passed and restored both; this one is the sibling that did not.
1991 let mut marker_pts: Vec<(usize, usize)> = self
1992 .normal_markers
1993 .iter()
1994 .map(|m| (m.line, m.col))
1995 .collect();
1996 // Primary-scoped tracked points anchor primary content too, so they
1997 // reflow with this pane even though the alt screen is the active one
1998 // (#691) — the same reason the markers above do.
1999 let tracked_off = marker_pts.len();
2000 marker_pts.extend(self.normal_tracked.iter().map(|p| (p.line, p.col)));
2001 let primary = self.alt_grid.take_lines();
2002 let r = reflow_pane(
2003 primary,
2004 scrollback,
2005 self.saved_cursor.point(),
2006 &marker_pts,
2007 dims,
2008 );
2009 self.alt_grid.set_screen(r.screen, cols, rows);
2010 self.scrollback = r.scrollback;
2011 self.saved_cursor.set_point(r.cursor, rows, cols);
2012 for (i, m) in self.normal_markers.iter_mut().enumerate() {
2013 m.line = r.extras[i].0.saturating_sub(r.evicted);
2014 m.col = r.extras[i].1;
2015 }
2016 // Released rather than clamped when the reflow evicted its line — see
2017 // the primary-active branch for why the two loops differ (#691).
2018 let mut ti = 0;
2019 let evicted = r.evicted;
2020 let extras = &r.extras;
2021 self.normal_tracked.retain_mut(|p| {
2022 let (line, col) = extras[tracked_off + ti];
2023 ti += 1;
2024 match line.checked_sub(evicted) {
2025 Some(line) => {
2026 p.line = line;
2027 p.col = col;
2028 true
2029 }
2030 None => false,
2031 }
2032 });
2033 // The alt half lives in a different frame from the primary one above, and adding the
2034 // two was the defect: `extras` count from the top of the alt pane's own history, and
2035 // the alt screen **has** no history — every row the shrink pushed off the top is gone,
2036 // not archived. Passing the primary's scrollback limit made `reflow_pane` keep them,
2037 // so a rows-only resize (no reflow at all) reported a marker four lines past the end of
2038 // the buffer. The limit is `0` here because that is what an alt screen's history is.
2039 //
2040 // A marker whose row went with it is **disposed**, matching what the alt screen already
2041 // does when a row leaves by scrolling (`markers_rotate_region` fires `MarkerDisposed`
2042 // for the marker on the departing edge). Silently relocating it to row 0 would put a
2043 // decoration on content it was never attached to.
2044 let new_base = self.scrollback.len();
2045 let mut alt_disposed = Vec::new();
2046 let mut i = 0;
2047 self.alt_markers.retain_mut(|m| {
2048 let (line, col) = r_alt.extras[i];
2049 i += 1;
2050 match line.checked_sub(r_alt.evicted) {
2051 Some(row) if row < rows => {
2052 m.line = new_base + row;
2053 // The column rides along for the same reason as the primary half, but
2054 // **unpinned**: `add_marker` always passes column 0, and I could not get an
2055 // OSC-133 mark (the only column-bearing kind) to appear in `alt_markers` at
2056 // all. That is a gap in my knowledge, not evidence the column is
2057 // structurally zero — `push_marker` takes a column and `markers_mut` routes
2058 // by active buffer, so the field is reachable in principle. Carrying it
2059 // keeps the two halves stating one invariant; if the alt path really is
2060 // marker-column-free, this line is a no-op.
2061 m.col = col;
2062 true
2063 }
2064 _ => {
2065 alt_disposed.push(m.id);
2066 false
2067 }
2068 }
2069 });
2070 for id in alt_disposed {
2071 self.events.push(TermEvent::MarkerDisposed(id));
2072 }
2073 // The alt half's tracked points, on the alt marker's rule: a row the
2074 // shrink pushed off an unarchived screen is gone, so the point is
2075 // released rather than relocated (#691).
2076 //
2077 // The `row < rows` half of that guard is **unproven, deliberately kept**.
2078 // A mutation dropping it stays green, and a sweep of 324 alt resizes
2079 // (rows 1..6 x cols {4,10,30}, both directions, a point on every alt row
2080 // at columns 0 / 1 / cols-1 / cols plus one past the pane) never reached
2081 // it — the alt fit runs with `reflow: false`, so a surviving row always
2082 // maps below the new row count. That is a measured *validity condition*,
2083 // not a proof of unreachability, and the marker loop above carries the
2084 // same bound: parity is the reason it stays.
2085 let mut ai = 0;
2086 let alt_extras = &r_alt.extras;
2087 let alt_evicted = r_alt.evicted;
2088 self.alt_tracked.retain_mut(|p| {
2089 let (line, col) = alt_extras[alt_tracked_off + ai];
2090 ai += 1;
2091 match line.checked_sub(alt_evicted) {
2092 Some(row) if row < rows => {
2093 p.line = new_base + row;
2094 p.col = col;
2095 true
2096 }
2097 _ => false,
2098 }
2099 });
2100 } else {
2101 // Active = primary (cursor, scrollback); inactive = alt. The selection
2102 // anchors (absolute) reflow alongside the cursor so they keep their
2103 // content across a column change.
2104 let sel_pts: Vec<(usize, usize)> = self
2105 .selection
2106 .as_ref()
2107 .map(|s| {
2108 vec![
2109 (s.anchor.point.line, s.anchor.point.col),
2110 (s.focus.point.line, s.focus.point.col),
2111 ]
2112 })
2113 .unwrap_or_default();
2114 // Markers reflow on the same pane by (line, col) — the column matters
2115 // for OSC-133 command marks, whose B/C columns bound the extracted
2116 // command text (#166). They ride after the selection points so each
2117 // reads its own reflowed slot back from `extras` (#118).
2118 let mut pts = sel_pts.clone();
2119 pts.extend(self.normal_markers.iter().map(|m| (m.line, m.col)));
2120 // Tracked points ride after the markers, reading their own slots back
2121 // by the same offset idiom (#691).
2122 let tracked_off = pts.len();
2123 pts.extend(self.normal_tracked.iter().map(|p| (p.line, p.col)));
2124
2125 let primary = self.grid.take_lines();
2126 let r = reflow_pane(primary, scrollback, self.cursor.point(), &pts, dims);
2127 self.grid.set_screen(r.screen, cols, rows);
2128 self.scrollback = r.scrollback;
2129 self.cursor.set_point(r.cursor, rows, cols);
2130 if let Some(sel) = &mut self.selection {
2131 // A selection endpoint is **UI** state, so its reading of `col == cols` (#562) is
2132 // neither the cursor's nor a mark's: it is clamped into the grid. UI state may not
2133 // move the application's content to make room for itself — the criterion that
2134 // decided this, and the one ghostty encodes by clamping every non-cursor pin before
2135 // it can widen a row (`terminal/PageList.zig:1576-1585` @ `e6e26e1`) while leaving
2136 // the cursor pin unclamped (`:1602-1606`).
2137 sel.anchor.point = BufferPoint {
2138 line: r.extras[0].0.saturating_sub(r.evicted),
2139 col: r.extras[0].1.min(cols - 1),
2140 };
2141 sel.focus.point = BufferPoint {
2142 line: r.extras[1].0.saturating_sub(r.evicted),
2143 col: r.extras[1].1.min(cols - 1),
2144 };
2145 }
2146 let marker_off = sel_pts.len();
2147 for (i, m) in self.normal_markers.iter_mut().enumerate() {
2148 m.line = r.extras[marker_off + i].0.saturating_sub(r.evicted);
2149 m.col = r.extras[marker_off + i].1;
2150 }
2151 // A point whose reflowed line fell inside the evicted prefix has left
2152 // the buffer, so it is released rather than clamped to line 0 (#691) —
2153 // deliberately unlike the marker loop above, which saturates. A marker
2154 // that lands on the wrong line still paints something the consumer can
2155 // see and correct; a tracked point is *asked* for a position, and
2156 // answering with content the caller never anchored to is the exact
2157 // failure this whole module exists to remove.
2158 let mut i = 0;
2159 let evicted = r.evicted;
2160 let extras = &r.extras;
2161 self.normal_tracked.retain_mut(|p| {
2162 let (line, col) = extras[tracked_off + i];
2163 i += 1;
2164 match line.checked_sub(evicted) {
2165 Some(line) => {
2166 p.line = line;
2167 p.col = col;
2168 true
2169 }
2170 None => false,
2171 }
2172 });
2173
2174 let alt = self.alt_grid.take_lines();
2175 let r = reflow_pane(
2176 alt,
2177 VecDeque::new(),
2178 (0, 0),
2179 &[],
2180 ReflowDims {
2181 limit: 0,
2182 reflow: false,
2183 ..dims
2184 },
2185 );
2186 self.alt_grid.set_screen(r.screen, cols, rows);
2187 }
2188
2189 // Carry the deferred wrap across the resize — it is *cursor* state, and it used to be
2190 // reset here alongside the scroll margins. Losing it meant the next byte overwrote the
2191 // last glyph instead of wrapping past it, on a column resize *and* on a rows-only one
2192 // where no reflow runs at all.
2193 //
2194 // This comment also named the tab stops beside the margins, as state that "does
2195 // legitimately reset". That was wrong, and #849 measured it: the table is indexed by
2196 // *columns* and written by the *application*, so rebuilding it on a rows-only resize
2197 // destroyed it on an axis it does not name. The margins still reset; the table does not,
2198 // and the extension below is why.
2199 //
2200 // The flag means "the cursor is logically one past the column it sits on". Where the
2201 // reflow leaves it somewhere other than the last column that logical position **is**
2202 // representable, so the flag is cleared and the cursor takes it instead — ghostty's rule,
2203 // stated in its own words for the saved cursor: *"If we had pending wrap set and we're no
2204 // longer at the end of the line, we unset the pending wrap and move the cursor to reflect
2205 // the correct next position"* (`terminal/Screen.zig:2092-2098` @ `e6e26e1`). alacritty
2206 // reaches the same place from the other side, lifting the cursor outside the grid before
2207 // reflowing and clamping it back afterwards (`grid/resize.rs:113-116`, `:248-251`,
2208 // `:173-177` @ `852e971`); xterm.js needs no rule because `x === cols` is representable.
2209 //
2210 // `col + 1` cannot overflow the row: the branch requires `col != cols - 1`, and `col` is
2211 // already clamped below `cols` by `Cursor::set_point`.
2212 if self.cursor.pending_wrap && self.cursor.col != cols - 1 {
2213 self.cursor.pending_wrap = false;
2214 self.cursor.col += 1;
2215 }
2216 // The scroll region is a *range over the current screen*, so a geometry change discards
2217 // it: at a new row count the range names rows that are not the ones it was set for. Every
2218 // reference does the same, and the spec's own width change says so in an enumeration —
2219 // xterm's DECCOLM handler calls `resetMargins` under a `DEC 070, pp 5-71 to 5-72`
2220 // citation (`charproc.c:7446`, `:7463` @ `6380a3e`).
2221 //
2222 // What is **not** a geometry change is a resize to the size the terminal already has, and
2223 // until this gate the two were the same call. `resize` has no early return, so a consumer
2224 // re-asserting its size destroyed a region only the *application* could restore — and the
2225 // application is never told, so nothing restores it. Every reference is guarded against
2226 // that by an early return this function does not have (`alacritty_terminal/src/term/
2227 // mod.rs:662` @ `852e971`, `src/terminal/Terminal.zig:3753` @ `e6e26e1`,
2228 // `src/browser/CoreBrowserTerminal.ts:1055` @ `699f553`); the gate is the narrow form of
2229 // the same guard, and it is the predicate this function already uses twice above.
2230 if cols != old_cols || rows != old_rows {
2231 self.scroll_top = 0;
2232 self.scroll_bottom = rows - 1;
2233 }
2234 // Extend, never rebuild and never trim (#849). A resize changes the *grid*; the
2235 // tab-stop table is state the application wrote through the stream. The line this
2236 // replaces rebuilt it from defaults on every call, so a window dragged one row
2237 // taller — or a consumer merely re-asserting its size, which reaches here because
2238 // this function has no early return for unchanged geometry — silently replaced an
2239 // application's stops with multiples of eight, on an axis the table is not even
2240 // indexed by.
2241 //
2242 // New columns take the default ladder at their **absolute** index, which is why the
2243 // closure carries `index` instead of counting from zero: filling them with `false`
2244 // keeps the length right and loses every default stop past the old width.
2245 //
2246 // Nothing is trimmed, so `tabs.len()` is the widest this terminal has ever been and
2247 // the invariant the two walks need is `len() >= cols` rather than equality — every
2248 // index site is bounded by `cols` or by `cursor.col`. A stop pushed outside the grid
2249 // by a narrowing is unreachable while narrow and returns when the grid widens again.
2250 // The corpus splits 2-2 on that half: xterm keeps it structurally (MAX_TABS is 1024,
2251 // independent of the screen, `ptyx.h:3611` @ `6380a3e`) and xterm.js keeps it in a
2252 // sparse map, while alacritty truncates through `Vec::resize_with`
2253 // (`alacritty_terminal/src/term/mod.rs:2341` @ `852e971`) and ghostty rebuilds the
2254 // table outright when the column count moves (`src/terminal/Terminal.zig:3759` @
2255 // `e6e26e1`). No tie-breaker row covers the axis, so the call is the maintainer's,
2256 // recorded on #849.
2257 //
2258 // Only the *rebuild* is wrong here, not every reset: RIS still restores the default
2259 // ladder, because `full_reset` replaces the whole struct and takes the table from
2260 // the constructor — the right answer for state the application wrote and `ESC c`
2261 // resets.
2262 if cols > self.tabs.len() {
2263 let mut index = self.tabs.len();
2264 self.tabs.resize_with(cols, || {
2265 let is_stop = is_default_tab_stop(index);
2266 index += 1;
2267 is_stop
2268 });
2269 }
2270 self.display_offset = self.display_offset.min(self.scrollback.len());
2271
2272 // Damage tracking is sized to the screen; a resize repaints everything,
2273 // so drop any pending scroll op (it points at the old rows).
2274 self.line_damage = vec![LineBounds::undamaged(cols); rows];
2275 self.scroll = None;
2276 self.mark_fully_damaged();
2277 }
2278
2279 pub fn grid(&self) -> &Grid {
2280 &self.grid
2281 }
2282
2283 pub fn cursor(&self) -> &Cursor {
2284 &self.cursor
2285 }
2286
2287 /// Whether bracketed-paste mode (DEC ?2004) is enabled. The input encoder
2288 /// (#11) reads this to decide whether to wrap pasted text in markers.
2289 pub fn bracketed_paste(&self) -> bool {
2290 self.bracketed_paste
2291 }
2292
2293 // ---- input encoding (#11) ------------------------------------------------
2294
2295 /// Encode a key event to bytes using the active cursor-key mode (DECCKM)
2296 /// and the kitty keyboard-protocol flags (`encode_key` consults both).
2297 pub fn encode_key(&self, ev: KeyEvent) -> Option<Vec<u8>> {
2298 encode_key(
2299 &ev,
2300 self.app_cursor_keys,
2301 self.application_keypad,
2302 self.kitty_flags,
2303 )
2304 }
2305
2306 /// Encode a mouse event using the active tracking mode + encoding. `None`
2307 /// when reporting is off or the event is filtered by the mode.
2308 pub fn encode_mouse(&self, ev: MouseEvent) -> Option<Vec<u8>> {
2309 encode_mouse(&ev, self.mouse_protocol, self.mouse_encoding)
2310 }
2311
2312 /// Encode pasted text, wrapping it in bracketed-paste markers when ?2004 is
2313 /// on.
2314 pub fn encode_paste(&self, text: &str) -> Vec<u8> {
2315 encode_paste(text, self.bracketed_paste)
2316 }
2317
2318 /// Encode a focus change (`CSI I`/`CSI O`), or `None` when focus reporting
2319 /// (?1004) is off.
2320 pub fn encode_focus(&self, focused: bool) -> Option<Vec<u8>> {
2321 encode_focus(focused, self.focus_events)
2322 }
2323
2324 /// Take the consumer events queued since the last drain, emptying the queue.
2325 pub fn drain_events(&mut self) -> Vec<TermEvent> {
2326 std::mem::take(&mut self.events)
2327 }
2328
2329 /// Take the reply bytes queued since the last drain (DA/DSR/DECRQM answers),
2330 /// emptying the buffer. The consumer writes them back to the PTY.
2331 pub fn drain_replies(&mut self) -> Vec<u8> {
2332 std::mem::take(&mut self.replies)
2333 }
2334
2335 /// Device Status Report (CSI Ps n): 6 = cursor position, 5 = operating
2336 /// status. Queues the reply for `drain_replies` (#27).
2337 fn device_status_report(&mut self, param: u16) {
2338 match param {
2339 6 => {
2340 // CSI row;col R, 1-based — region-relative under origin mode
2341 // (the coordinate system the app is addressing in).
2342 let row = if self.origin_mode {
2343 self.cursor.row.saturating_sub(self.scroll_top)
2344 } else {
2345 self.cursor.row
2346 } + 1;
2347 let col = self.cursor.col + 1;
2348 self.replies
2349 .extend_from_slice(format!("\x1b[{row};{col}R").as_bytes());
2350 }
2351 5 => self.replies.extend_from_slice(b"\x1b[0n"), // status: OK
2352 _ => {}
2353 }
2354 }
2355
2356 /// Kitty keyboard-protocol negotiation (#23). `lead` is the leading CSI
2357 /// intermediate: `?` query, `>` push, `=` set, `<` pop.
2358 fn kitty_dispatch(&mut self, lead: u8, params: &Params) {
2359 match lead {
2360 // Query → report the current flags as `CSI ? flags u` (#27 channel).
2361 b'?' => self
2362 .replies
2363 .extend_from_slice(format!("\x1b[?{}u", self.kitty_flags).as_bytes()),
2364 // Push: save the current flags, then set the new ones (default 0).
2365 b'>' => {
2366 const KITTY_STACK_CAP: usize = 16;
2367 if self.kitty_stack.len() >= KITTY_STACK_CAP {
2368 self.kitty_stack.remove(0); // drop the oldest on overflow
2369 }
2370 self.kitty_stack.push(self.kitty_flags);
2371 self.kitty_flags = param_or(params, 0, 0) as u8;
2372 }
2373 // Pop `n` (default 1): restore from the stack, 0 once empty.
2374 b'<' => {
2375 for _ in 0..param_or(params, 0, 1) {
2376 self.kitty_flags = self.kitty_stack.pop().unwrap_or(0);
2377 }
2378 }
2379 // Set in place (no push): mode 1 replace, 2 or-in, 3 and-not.
2380 b'=' => {
2381 let flags = param_or(params, 0, 0) as u8;
2382 self.kitty_flags = match param_or(params, 1, 1) {
2383 1 => flags,
2384 2 => self.kitty_flags | flags,
2385 3 => self.kitty_flags & !flags,
2386 _ => self.kitty_flags,
2387 };
2388 }
2389 _ => {}
2390 }
2391 }
2392
2393 /// DECRQM (CSI ? Ps $ p): report whether DEC private mode `Ps` is set —
2394 /// `CSI ? Ps ; val $ y` with val 1=set, 2=reset, 0=not recognized (#27).
2395 fn decrqm(&mut self, mode: u16) {
2396 let state = match mode {
2397 1 => Some(self.app_cursor_keys),
2398 // DECANM (#84): set = ANSI mode (the normal state), reset = VT52.
2399 2 => Some(!self.vt52_mode),
2400 6 => Some(self.origin_mode),
2401 // DECCOLM: derived from the actual width, never a tracked flag — a
2402 // flag would lie if the consumer ignored the resize request (#82).
2403 3 => Some(self.grid.cols() == 132),
2404 7 => Some(self.autowrap),
2405 45 => Some(self.reverse_wraparound),
2406 9 => Some(self.mouse_protocol == MouseProtocol::X10),
2407 66 => Some(self.application_keypad),
2408 12 => Some(self.cursor.blink),
2409 25 => Some(self.cursor.visible),
2410 // Mouse tracking is a single-state enum (the levels are mutually
2411 // exclusive — an app enables one), so querying ?1000 while ?1002 is
2412 // active reports "reset". Faithful to that model.
2413 1000 => Some(self.mouse_protocol == MouseProtocol::Normal),
2414 1002 => Some(self.mouse_protocol == MouseProtocol::ButtonEvent),
2415 1003 => Some(self.mouse_protocol == MouseProtocol::AnyEvent),
2416 1004 => Some(self.focus_events),
2417 1006 => Some(self.mouse_encoding == MouseEncoding::Sgr),
2418 1015 => Some(self.mouse_encoding == MouseEncoding::Urxvt),
2419 1005 => Some(self.mouse_encoding == MouseEncoding::Utf8),
2420 1016 => Some(self.mouse_encoding == MouseEncoding::SgrPixels),
2421 47 | 1047 | 1049 => Some(self.on_alt),
2422 2004 => Some(self.bracketed_paste),
2423 2026 => Some(self.synchronized_output),
2424 2027 => Some(self.grapheme_clustering),
2425 2031 => Some(self.color_scheme_updates),
2426 9001 => Some(self.win32_input_mode),
2427 _ => None,
2428 };
2429 let val = match state {
2430 Some(true) => 1,
2431 Some(false) => 2,
2432 None => 0,
2433 };
2434 self.replies
2435 .extend_from_slice(format!("\x1b[?{mode};{val}$y").as_bytes());
2436 }
2437
2438 // ---- cursor / scroll primitives ------------------------------------------
2439
2440 /// Move down one line. At the bottom margin, scroll the region instead;
2441 /// below the region, just descend (no scroll). Column is unchanged (raw LF;
2442 /// CR is what returns to column 0).
2443 /// An ordinary line feed — `LF`/`VT`/`FF`, `IND` and `NEL`. None of them serves a wrap.
2444 ///
2445 /// It clears the deferred wrap, as every acting positioner does — see
2446 /// [`Cursor::pending_wrap`]. The clear sits here rather than in
2447 /// [`Term::linefeed_inner`] because the wrap machinery drives that same
2448 /// primitive and *consumes* the flag rather than clearing it; folding the two
2449 /// together would put one property's arm and its clear in one statement.
2450 /// Leaving it armed made the print after an `LF` wrap a second time, landing a
2451 /// row further down and leaving the row the feed had reached blank (#848).
2452 fn linefeed(&mut self) {
2453 self.linefeed_inner(false);
2454 self.cursor.pending_wrap = false;
2455 }
2456
2457 /// A line feed, carrying the one fact the shift itself cannot see: whether the auto-wrap asked
2458 /// for it.
2459 ///
2460 /// `serves_wrap` is the **bottom** seam's exemption in `shift_region`, the mirror of
2461 /// `evicts_to_scrollback` for the top one. When `wrapline` drives this, the blank that lands at
2462 /// the region's bottom *is* where the wrapped text is about to go — so the row that #540 would
2463 /// call "the one that lost its continuation to the blank" is in fact the row whose continuation
2464 /// that blank **is** (#557).
2465 ///
2466 /// xterm.js threads the identical fact through the identical seam, in the opposite direction:
2467 /// `BufferService.scroll(eraseAttr, isWrapped)` stamps the *destination* row
2468 /// (`common/services/BufferService.ts:68`/`:77` @ `699f553`), and exactly one of its four
2469 /// non-test callers passes `true` — the auto-wrap branch of `_print` (`InputHandler.ts:588`),
2470 /// not `lineFeed`, `index` or the ED-2 loop.
2471 fn linefeed_inner(&mut self, serves_wrap: bool) {
2472 // New-line mode (LNM ?20): a line feed also returns to column 0 (#71).
2473 if self.newline_mode {
2474 self.carriage_return();
2475 }
2476 if self.cursor.row == self.scroll_bottom {
2477 // A top-anchored primary-screen scroll pushes the evicted top line
2478 // into scrollback history.
2479 if self.scroll_top == 0 && !self.on_alt {
2480 // Scrollback accrues whenever the scroll is top-anchored on the
2481 // primary screen (`scroll_top == 0`) — but the O(1) ring handshake
2482 // only applies to a *full-screen* scroll (`scroll_bottom` at the
2483 // last row). A top-anchored *sub-region* (`[0..k]`, k < rows-1)
2484 // still accrues, yet must scroll only its region, so it keeps the
2485 // copy + region scroll. These are distinct predicates (ADR-0009).
2486 let evicted = if self.scroll_bottom == self.grid.rows() - 1 {
2487 // Full-screen hot path: move the evicted top row out, install
2488 // a recycled blank as the new bottom (zero-alloc steady state).
2489 let blank = self
2490 .recycled_row
2491 .take()
2492 .unwrap_or_else(|| Row::from_cells(Vec::with_capacity(self.grid.cols())));
2493 let evicted = self.grid.scroll_up_recycle(blank);
2494 // The one row-shifting path that does not route through `shift_region` (it
2495 // needs the primitive that *returns* the evicted row), so it records its own
2496 // scroll op — and owes no seam clear, which is now an argument rather than a
2497 // coincidence: the top seam is exempt because this evicts into scrollback
2498 // (adjacency is preserved one row back), and the bottom seam is exempt because
2499 // this branch only runs at `scroll_bottom == rows - 1`, where a wrap on the
2500 // last row is the state the scroll exists to serve.
2501 self.record_scroll(self.scroll_top, self.scroll_bottom, 1);
2502 evicted
2503 } else {
2504 // Top-anchored sub-region: copy row 0, then region-scroll
2505 // `[0..=scroll_bottom]` (rows below stay fixed).
2506 //
2507 // #449: the fixed rows keep their GRID position while
2508 // scrollback grows, so their content's concatenated absolute
2509 // index shifts +1 — re-anchor the content-tracking anchors
2510 // (selection, markers; alacritty's swap-back of the fixed
2511 // bottom lines is the screen-relative equivalent of this)
2512 // and invalidate the query-derived highlights
2513 // (drop-not-re-anchor policy, #108). In-region and
2514 // scrollback content keeps stable indices — untouched.
2515 let below = self.scrollback.len() + self.scroll_bottom + 1;
2516 self.selection_shift_below_margin(below);
2517 self.markers_shift_below_margin(below);
2518 self.tracked_shift_below_margin(below);
2519 self.invalidate_search_highlights();
2520 let evicted = self.grid.row_owned(0);
2521 self.shift_region(
2522 self.scroll_top,
2523 self.scroll_bottom,
2524 false,
2525 true,
2526 serves_wrap,
2527 );
2528 evicted
2529 };
2530 self.scrollback.push_back(evicted);
2531 // Follow-bottom = stay: if the user is scrolled up, bump the
2532 // offset so the same lines stay in view instead of being yanked
2533 // to the bottom.
2534 if self.display_offset > 0 {
2535 self.display_offset = (self.display_offset + 1).min(self.scrollback.len());
2536 }
2537 // Cap: evict the oldest line past the limit. The view is anchored
2538 // to history, so dropping the front shifts the offset down too
2539 // (xterm.js trims ybase and ydisp together) — also keeps the
2540 // offset within `[0, len]`. The evicted row is parked for reuse.
2541 if self.scrollback.len() > self.scrollback_limit {
2542 self.recycled_row = self.scrollback.pop_front();
2543 // Every absolute index below just shifted by exactly one, which is
2544 // what makes this class of movement expressible as a scalar (#490).
2545 // Counted here rather than in `markers_evict_oldest` because the fact
2546 // is about the *buffer*, not about markers.
2547 //
2548 // **Scope, because the name over-promises.** This counts the
2549 // scrollback *cap* evicting one line. Reflow also drops lines off the
2550 // front (`PaneReflow::evicted`, installed by replacing the deque) and
2551 // is deliberately not counted: it moves the survivors non-uniformly,
2552 // so no delta repairs them and `marker_epoch` signals it instead. A
2553 // holder rebasing off this number *without* also watching the epoch
2554 // gets a wrong answer across every resize.
2555 self.evicted_total += 1;
2556 // Every absolute index just shifted down by one; move the
2557 // selection with it so its anchors keep their content.
2558 self.selection_evict_oldest();
2559 // Query-derived highlights can't survive the index shift (see
2560 // the method doc); selection re-anchors, highlights invalidate.
2561 self.invalidate_search_highlights();
2562 // Markers are persistent anchors: shift them down with the
2563 // index, disposing any whose line was the evicted one (#118).
2564 self.markers_evict_oldest();
2565 // Same obligation for a position held outside the engine — the
2566 // one holder in this space that had no fixup at all (#691).
2567 self.tracked_evict_oldest();
2568 if self.display_offset > 0 {
2569 // Scrolled up: evicting the oldest line advanced the
2570 // viewport, so it must be repainted (the "frozen while
2571 // scrolled" rule does not apply when the view itself moved).
2572 self.display_offset -= 1;
2573 self.mark_fully_damaged();
2574 }
2575 }
2576 } else {
2577 // Region (top margin > 0) or alt-screen scroll: the evicted line
2578 // does NOT enter scrollback, so content moves *within* the screen
2579 // and absolute indices in the region shift. Rotate the selection
2580 // up so it follows; an endpoint on the dropped line clears it.
2581 let base = self.scrollback.len();
2582 self.selection_rotate_region(
2583 base + self.scroll_top,
2584 base + self.scroll_bottom,
2585 true,
2586 );
2587 // Rotate the active buffer's markers with the content (#187):
2588 // per-buffer storage (#186) scopes them, so an alt scroll rotates
2589 // *alt* marks and leaves the frozen primary list untouched — no
2590 // guard needed. `markers_rotate_region` routes via `markers_mut`.
2591 self.markers_rotate_region(base + self.scroll_top, base + self.scroll_bottom, true);
2592 self.tracked_rotate_region(base + self.scroll_top, base + self.scroll_bottom, true);
2593 self.invalidate_search_highlights();
2594 self.shift_region(
2595 self.scroll_top,
2596 self.scroll_bottom,
2597 false,
2598 false,
2599 serves_wrap,
2600 );
2601 }
2602 } else if self.cursor.row + 1 < self.grid.rows() {
2603 self.cursor.row += 1;
2604 }
2605 }
2606
2607 /// DECSTBM (CSI r): set the top/bottom scroll margins (1-based inclusive).
2608 /// An invalid region (top ≥ bottom) is ignored.
2609 fn set_scroll_region(&mut self, top: usize, bottom: usize) {
2610 let bottom = bottom.min(self.grid.rows());
2611 if top >= bottom {
2612 return;
2613 }
2614 self.scroll_top = top - 1;
2615 self.scroll_bottom = bottom - 1;
2616 self.goto(0, 0); // DECSTBM homes the cursor (absolute)
2617 }
2618
2619 // ---- alt screen (DEC 1049) -----------------------------------------------
2620
2621 /// Enter the alternate screen: save the cursor, swap in the other grid, and
2622 /// clear it.
2623 /// Save the cursor into the alt-screen slot — `?1048` set, and the first
2624 /// half of `?1049` enter (#72).
2625 fn save_alt_cursor(&mut self) {
2626 self.saved_cursor = self.cursor;
2627 }
2628
2629 /// Restore the cursor from the alt-screen slot — `?1048` reset, and the
2630 /// second half of `?1049` leave. DECTCEM visibility is a standalone mode, not
2631 /// part of the save, so preserve it across the restore (#38/#72).
2632 fn restore_alt_cursor(&mut self) {
2633 let visible = self.cursor.visible;
2634 self.cursor = self.saved_cursor;
2635 self.cursor.visible = visible;
2636 self.settle_restored_wrap();
2637 }
2638
2639 /// Switch to the (cleared) alternate buffer without touching the cursor —
2640 /// `?47`/`?1047` set, and the second half of `?1049` enter (#72).
2641 fn switch_to_alt(&mut self) {
2642 if self.on_alt {
2643 return;
2644 }
2645 // The pulled index reports the ACTIVE buffer, so a swap changes what the
2646 // consumer's held answer even describes — with no line having moved (#490).
2647 // Gated on a marker existing on either side, since an empty index is already
2648 // correct for both buffers.
2649 if !self.normal_markers.is_empty() || !self.alt_markers.is_empty() {
2650 self.bump_marker_epoch();
2651 }
2652 std::mem::swap(&mut self.grid, &mut self.alt_grid);
2653 self.grid.clear();
2654 self.on_alt = true;
2655 self.display_offset = 0; // the alt screen has no scrollback to view
2656 self.selection = None; // a selection cannot survive a screen swap
2657 self.invalidate_search_highlights(); // matches index the primary buffer
2658 self.mark_fully_damaged();
2659 }
2660
2661 /// Switch back to the primary buffer without touching the cursor —
2662 /// `?47`/`?1047` reset, and the first half of `?1049` leave (#72).
2663 fn switch_to_primary(&mut self) {
2664 if !self.on_alt {
2665 return;
2666 }
2667 // Dispose the alt buffer's markers on leave — xterm `activateNormalBuffer`
2668 // → `clearAllMarkers` (#177 S0). Empty while the alt guards stand, so this
2669 // fires nothing today; it's the seam the alt-marker slices (#187) build on.
2670 for m in self.alt_markers.drain(..) {
2671 self.events.push(TermEvent::MarkerDisposed(m.id));
2672 }
2673 // Same fate for alt-scoped tracked points, and for the same reason: the alt
2674 // buffer is not archived, so leaving it destroys what they named (#691).
2675 // No announcement — `tracked_point` answers `None` on the next ask.
2676 // The pulled index reports the ACTIVE buffer, so a swap changes what the
2677 // consumer's held answer even describes — with no line having moved (#490).
2678 // Only `normal_markers` is asked: the drain above just emptied `alt_markers`, so
2679 // an `|| !alt_markers.is_empty()` disjunct would be dead code carrying a comment
2680 // that claims it reads "either side". What was alt-scoped left through
2681 // `MarkerDisposed`; what can still be stale is the primary population.
2682 if !self.normal_markers.is_empty() {
2683 self.bump_marker_epoch();
2684 }
2685 self.alt_tracked.clear();
2686 std::mem::swap(&mut self.grid, &mut self.alt_grid);
2687 self.on_alt = false;
2688 self.display_offset = 0; // return to the primary at its bottom
2689 self.selection = None; // a selection cannot survive a screen swap
2690 self.invalidate_search_highlights(); // matches index the swapped-out buffer
2691 self.mark_fully_damaged();
2692 }
2693
2694 fn enter_alt_screen(&mut self) {
2695 if self.on_alt {
2696 return;
2697 }
2698 self.save_alt_cursor();
2699 self.switch_to_alt();
2700 }
2701
2702 /// Leave the alternate screen: swap the primary grid back in and restore the
2703 /// saved cursor.
2704 fn leave_alt_screen(&mut self) {
2705 if !self.on_alt {
2706 return;
2707 }
2708 self.switch_to_primary();
2709 self.restore_alt_cursor();
2710 }
2711
2712 /// RI (ESC M): move up one line. At the top margin, scroll the region down
2713 /// instead.
2714 fn reverse_index(&mut self) {
2715 if self.cursor.row == self.scroll_top {
2716 // RI never enters scrollback; the region scrolls down within the
2717 // screen, so absolute indices in it shift down. Rotate the selection.
2718 let base = self.scrollback.len();
2719 self.selection_rotate_region(base + self.scroll_top, base + self.scroll_bottom, false);
2720 // Rotate the active buffer's markers (#187) — alt-scoped on the alt
2721 // screen, so no guard (see `linefeed`).
2722 self.markers_rotate_region(base + self.scroll_top, base + self.scroll_bottom, false);
2723 self.tracked_rotate_region(base + self.scroll_top, base + self.scroll_bottom, false);
2724 self.invalidate_search_highlights();
2725 self.shift_region(self.scroll_top, self.scroll_bottom, true, false, false);
2726 } else if self.cursor.row > 0 {
2727 self.cursor.row -= 1;
2728 }
2729 // Cleared in both branches, because both are the verb acting: one moves the
2730 // cursor, the other scrolls the content under it. Branching on which would
2731 // reintroduce the per-verb special-casing [`Cursor::pending_wrap`] exists to
2732 // remove, and xterm and ghostty both clear unconditionally here (`cursor.c:284`
2733 // via `CursorUp`; `Terminal.zig:2174` in `index`, under a comment saying so).
2734 self.cursor.pending_wrap = false;
2735 }
2736
2737 // ---- cursor save/restore (DECSC / DECRC) ---------------------------------
2738
2739 /// DECSC (ESC 7): save the cursor position, pen, pending-wrap, and origin
2740 /// mode. Visibility is not saved (DECTCEM is separate).
2741 fn save_cursor(&mut self) {
2742 self.decsc = SavedCursor {
2743 row: self.cursor.row,
2744 col: self.cursor.col,
2745 pen: self.cursor.pen,
2746 pending_wrap: self.cursor.pending_wrap,
2747 origin_mode: self.origin_mode,
2748 charsets: self.charsets,
2749 gl: self.gl,
2750 };
2751 }
2752
2753 /// DECRC (ESC 8): restore what DECSC saved. Origin mode is restored (per
2754 /// ADR-0004); visibility is left as-is. The position is clamped to the
2755 /// current screen in case it shrank since the save.
2756 fn restore_cursor(&mut self) {
2757 let s = self.decsc;
2758 self.cursor.row = s.row.min(self.grid.rows() - 1);
2759 self.cursor.col = s.col.min(self.grid.cols() - 1);
2760 self.cursor.pen = s.pen;
2761 self.cursor.pending_wrap = s.pending_wrap;
2762 self.origin_mode = s.origin_mode;
2763 self.charsets = s.charsets;
2764 self.gl = s.gl;
2765 self.settle_restored_wrap();
2766 }
2767
2768 /// A restored deferred wrap is only meaningful at the last column; anywhere
2769 /// else the logical position is representable and the cursor takes it.
2770 ///
2771 /// The same translation `Term::resize` applies to the **live** cursor, applied
2772 /// to the two saved slots — which is where ghostty puts it, on the saved cursor
2773 /// it has just reflowed (`terminal/Screen.zig:2094`: *"If we had pending wrap
2774 /// set and we're no longer at the end of the line, we unset the pending wrap and
2775 /// move the cursor to reflect the correct next position"*).
2776 ///
2777 /// justerm's slots are not reflowed, so the repair belongs at the restore rather
2778 /// than at the resize: `decsc` is untouched by `Term::resize` and clamped here,
2779 /// and the alt slot is copied whole. Measured before the fix: 4 columns, `abcd`,
2780 /// `DECSC`, `resize(8, 3)`, `DECRC` left the flag armed at column 3 of an
2781 /// 8-column grid — five columns from the edge — and the next print wrapped
2782 /// instead of landing at column 4 (#848).
2783 fn settle_restored_wrap(&mut self) {
2784 if self.cursor.pending_wrap && self.cursor.col + 1 < self.grid.cols() {
2785 self.cursor.pending_wrap = false;
2786 self.cursor.col += 1;
2787 }
2788 }
2789
2790 /// Set the window title and tell the consumer, in one place.
2791 ///
2792 /// Both writers come through here — the OSC 0/2 path and the XTWINOPS pop
2793 /// path — so the retained string and the event a consumer sees cannot
2794 /// disagree. A pop deliberately fires the **same** `TermEvent::Title` an
2795 /// ordinary title change does: both implementations that carry the stack do
2796 /// exactly this (xterm.js's `setTitle` fires `_onTitleChange`, alacritty's
2797 /// `pop_title` routes through `set_title`), and a second event would ask
2798 /// every consumer to learn a distinction it has no use for. The consequence
2799 /// worth stating: `Title` now means *"the title is this"*, not *"the
2800 /// application just set this"*.
2801 fn set_window_title(&mut self, title: String) {
2802 self.window_title.clone_from(&title);
2803 self.events.push(TermEvent::Title(title));
2804 }
2805
2806 /// XTWINOPS (`CSI Ps ; Ps ; Ps t`) — window manipulation, of which this
2807 /// engine implements exactly two operations (#823).
2808 ///
2809 /// It owns no window, so most of the family is meaningless here: 14/16 ask
2810 /// about pixels the engine has no concept of, and the resize/move/iconify
2811 /// operations are requests about a window the consumer owns. 22 (push
2812 /// title) and 23 (pop title) are different — they are pure VT state, and
2813 /// they were the single most-emitted unimplemented sequence in the capture
2814 /// sweep that produced #823. **This does not make `CSI t` a handled final**;
2815 /// every other first parameter still falls through and is ignored.
2816 ///
2817 /// The second parameter selects the axis: absent or `0` both, `1` the icon
2818 /// name, `2` the window title, anything else no axis at all. It is honoured
2819 /// rather than assumed away because real applications use it — `vim` emits
2820 /// a fully nested `22;0;0t · 22;2t · 22;1t … 23;2t · 23;1t · 23;0;0t`,
2821 /// which a single shared stack gets wrong.
2822 ///
2823 /// **The optional third parameter is deliberately ignored**, and that is a
2824 /// divergence from the spec rather than a simplification of it:
2825 /// `ctlseqs.txt:1698` gives a value in 1..10 *direct access to the stack*,
2826 /// storing or retrieving without pushing or popping, and xterm implements
2827 /// it (`charproc.c:9272`). Measured reach of that form is zero on every
2828 /// axis checked — no occurrence across seven programs recorded under real
2829 /// ptys, no file under `/usr/bin` or `/usr/lib64` containing it, no
2830 /// terminfo capability that emits `CSI 22/23 t` at all under any candidate
2831 /// `TERM`, and no other implementation honouring it (xterm.js ignores it,
2832 /// alacritty's dispatch never reads past the first parameter, ghostty
2833 /// carries the index and then drops the command). It entered xterm in patch
2834 /// #385 (2023-10-01) for symmetry with XTPUSHCOLORS, not because an
2835 /// application asked. So `CSI 22;2;3t` is an ordinary push here.
2836 fn window_ops(&mut self, params: &Params) {
2837 // `param_or` folds an explicit 0 to the default, which is what both
2838 // axis and operation want: absent and `0` mean the same thing in each.
2839 let axis = param_or(params, 1, 0);
2840 let (window, icon) = (axis == 0 || axis == 2, axis == 0 || axis == 1);
2841 match param_or(params, 0, 0) {
2842 22 => {
2843 if window {
2844 let title = self.window_title.clone();
2845 push_title(&mut self.window_title_stack, title);
2846 }
2847 if icon {
2848 let name = self.icon_name.clone();
2849 push_title(&mut self.icon_name_stack, name);
2850 }
2851 }
2852 23 => {
2853 if window && let Some(title) = self.window_title_stack.pop() {
2854 self.set_window_title(title);
2855 }
2856 // Restoring the icon name has no observable output: the engine
2857 // has no icon-name event. The stack is still popped so the two
2858 // axes stay aligned across mixed push/pop sequences.
2859 if icon && let Some(name) = self.icon_name_stack.pop() {
2860 self.icon_name = name;
2861 }
2862 }
2863 _ => {}
2864 }
2865 }
2866
2867 /// RIS (ESC c) — full reset to the power-on state (#53). Reconstruct every
2868 /// screen/mode field to its construction default (preserving only the
2869 /// dimensions and the scrollback cap), but keep the consumer-bound output
2870 /// queues (`replies`/`events`) that accrued earlier in this `feed`, and
2871 /// signal a full repaint. The vte parser lives outside `Term`, so replacing
2872 /// `self` does not disturb in-progress parsing. Mirrors xterm.js fullReset.
2873 ///
2874 /// The XTWINOPS title stacks and the retained title/icon strings (#823) are
2875 /// **not** on the copy-back list and must not be: they are terminal state the
2876 /// application wrote, so *justerm's own* RIS invariant drops them, and the
2877 /// wholesale rebuild does that for free.
2878 ///
2879 /// Read "justerm's own" literally — this is a **minority position, 1–2**, and
2880 /// no spec text settles it. Only alacritty agrees (`title_stack =
2881 /// Vec::new()` in `reset_state`); xterm.js's `reset()` touches nothing but
2882 /// attribute data, and xterm's only bulk free of `saved_titles` is inside
2883 /// `VTDestroy`, i.e. widget teardown rather than `ESC c`. So the two
2884 /// references whose *model* this slice copied — two stacks, the bound, the
2885 /// drop-oldest rule — are the two that keep the stack across a reset. The
2886 /// grounds are the invariant note next door, which already records that no
2887 /// reference can be cited here because none of them holds the embedder's
2888 /// configuration in the object its reset replaces.
2889 fn full_reset(&mut self) {
2890 let replies = std::mem::take(&mut self.replies);
2891 let mut events = std::mem::take(&mut self.events);
2892 // RIS wipes the buffer, so every marker's line is gone — announce each
2893 // disposal so the consumer drops its decorations (and isn't confused when
2894 // the reset id counter reissues the same ids). The events survive the
2895 // reset below (#118).
2896 events.extend(
2897 self.normal_markers
2898 .iter()
2899 .chain(&self.alt_markers)
2900 .map(|m| TermEvent::MarkerDisposed(m.id)),
2901 );
2902 let (cols, rows) = (self.grid.cols(), self.grid.rows());
2903 // The word-boundary set is consumer *policy* (ADR-0017), not terminal state, so
2904 // RIS does not own it — an application printing `reset` must not silently revert
2905 // a setting the embedder chose. It rides across with `replies`/`events` because
2906 // this reset rebuilds `Term` wholesale; the references never face the question,
2907 // holding the equivalent outside the object RIS clears (#545).
2908 let word_separators = std::mem::take(&mut self.word_separators);
2909 // Tracked points die here with everything else, but their *id counter*
2910 // rides across (#691). They have no disposal event — a holder learns its
2911 // point is gone by being told `None` — so a reissued id would answer a
2912 // stale ask with a *different* point's position, silently. The markers
2913 // above take the other route and announce; the counter is what a pull-only
2914 // handle has instead.
2915 let next_tracked_id = self.next_tracked_id;
2916 // The marker epoch rides across too, and then moves — for the reason one
2917 // paragraph up, now that a marker index is *pulled* as well as announced (#490).
2918 // A consumer re-pulls when the epoch differs; resetting it to 0 leaves it equal
2919 // to the value a quiet session already holds, so the one signal it watches would
2920 // not fire for the mutation that invalidates everything. `evicted_total`
2921 // legitimately restarts — the buffer it counted is gone — and the epoch change is
2922 // what stops the consumer rebasing against the old basis.
2923 let marker_epoch = self.marker_epoch;
2924 // Marker ids ride across for the *same* reason as `next_tracked_id`, which this
2925 // slice makes true of markers: a pulled handle outlives the announcement that
2926 // killed it, so a reissued id lets a stale `MarkerDisposed(7)` drop the live
2927 // post-RIS marker 7.
2928 let next_marker_id = self.next_marker_id;
2929 *self = Term::with_scrollback(cols, rows, self.scrollback_limit);
2930 self.replies = replies;
2931 self.events = events;
2932 self.word_separators = word_separators;
2933 self.next_tracked_id = next_tracked_id;
2934 self.next_marker_id = next_marker_id;
2935 self.marker_epoch = marker_epoch;
2936 self.bump_marker_epoch();
2937 self.mark_fully_damaged();
2938 }
2939
2940 /// DECSTR (CSI ! p) — soft reset (#53). Resets a defined subset of modes to
2941 /// their defaults *without* destroying screen content or scrollback, moving
2942 /// the active cursor, or touching the mouse/focus reporting subsystem. Per
2943 /// xterm.js softReset, autowrap returns to ON (the xterm default), not off.
2944 fn soft_reset(&mut self) {
2945 self.cursor.visible = true;
2946 self.cursor.pen = Pen::default();
2947 self.scroll_top = 0;
2948 self.scroll_bottom = self.grid.rows() - 1;
2949 self.origin_mode = false;
2950 self.app_cursor_keys = false;
2951 self.bracketed_paste = false;
2952 self.grapheme_clustering = false; // ?2027 back to the wcwidth-compat default (#295)
2953 self.autowrap = true; // xterm default is ON (not the VT100 "off")
2954 self.insert_mode = false;
2955 self.charsets = [Charset::Ascii; 4];
2956 self.gl = 0;
2957 self.decsc = SavedCursor::default();
2958 }
2959
2960 fn carriage_return(&mut self) {
2961 self.cursor.col = 0;
2962 self.cursor.pending_wrap = false;
2963 }
2964
2965 /// DECSCUSR (CSI Ps SP q): set the caret shape + blink (#89). 0/2 = steady
2966 /// block, 1 = blinking block; 3/4 = blinking/steady underline; 5/6 =
2967 /// blinking/steady bar (odd = blink). 0 resets to the default (steady block).
2968 /// An unknown param leaves the style unchanged. Mirrors xterm.js.
2969 fn set_cursor_style(&mut self, param: u16) {
2970 let (shape, blink) = match param {
2971 0 | 2 => (CursorShape::Block, false),
2972 1 => (CursorShape::Block, true),
2973 3 => (CursorShape::Underline, true),
2974 4 => (CursorShape::Underline, false),
2975 5 => (CursorShape::Bar, true),
2976 6 => (CursorShape::Bar, false),
2977 _ => return,
2978 };
2979 self.cursor.shape = shape;
2980 self.cursor.blink = blink;
2981 }
2982
2983 /// Backspace (BS, 0x08): move the cursor one column left. With reverse
2984 /// wraparound (?45) a backspace at column 0 of a *soft-wrapped* row moves
2985 /// back to the last column of the previous row — undoing one autowrap. Only
2986 /// soft wraps reverse (the previous row carries `WRAPLINE`); a hard CR/LF
2987 /// line does not. BS only (not cursor-left), matching xterm.js (#80).
2988 fn backspace(&mut self) {
2989 self.cursor.pending_wrap = false;
2990 if self.cursor.col > 0 {
2991 self.cursor.col -= 1;
2992 return;
2993 }
2994 if self.reverse_wraparound
2995 && self.cursor.row > self.scroll_top
2996 && self.cursor.row <= self.scroll_bottom
2997 {
2998 let prev = self.cursor.row - 1;
2999 let last = self.grid.cols() - 1;
3000 if self.grid.row_ref(prev).is_wrapped() {
3001 self.grid.row_mut(prev).set_wrapped(false);
3002 self.cursor.row = prev;
3003 self.cursor.col = last;
3004 }
3005 }
3006 }
3007
3008 /// Auto-wrap at end of line: line-feed then return to column 0.
3009 fn wrapline(&mut self) {
3010 self.linefeed_inner(true);
3011 self.cursor.col = 0;
3012 self.cursor.pending_wrap = false;
3013 }
3014
3015 // ---- tab stops (HT / HTS / TBC) ------------------------------------------
3016
3017 /// HT: advance to the next set tab stop, or the last column if none remain
3018 /// (no wrap).
3019 ///
3020 /// **The deferred wrap is cleared only when the walk actually moves** — see
3021 /// [`Cursor::pending_wrap`], which owns the rule. At the last column there is
3022 /// no stop to the right, so this verb changes nothing and must leave the flag
3023 /// armed; clearing it there discarded the parked position and let the next
3024 /// print overwrite the character already in that column (#848).
3025 ///
3026 /// Three of the four references keep it here, by three different mechanisms:
3027 /// xterm's `TabToNextStop` clamps to `LineMaxCol` and never touches `do_wrap`
3028 /// (`tabs.c:142-158`; the one `ResetWrap` on this path is in `TabNext`, gated
3029 /// on the `curses` resource at `tabs.c:113`, off by default), ghostty's loop
3030 /// condition `cursor.x < scrolling_region.right` is already false
3031 /// (`Terminal.zig:2111`), and xterm.js returns early on `x >= cols` because
3032 /// that *is* its parked state (`InputHandler.ts:850`). alacritty is the
3033 /// outlier and consumes the wrap instead (`term/mod.rs:1366`); it preserves
3034 /// the character too, and differs only on which row the next one lands in.
3035 fn put_tab(&mut self) {
3036 let cols = self.grid.cols();
3037 let mut col = self.cursor.col;
3038 while col + 1 < cols {
3039 col += 1;
3040 if self.tabs[col] {
3041 break;
3042 }
3043 }
3044 if col != self.cursor.col {
3045 self.cursor.col = col;
3046 self.cursor.pending_wrap = false;
3047 }
3048 }
3049
3050 /// CBT (CSI Ps Z): step back `n` tab stops, or to column one if fewer
3051 /// remain.
3052 ///
3053 /// The mirror of [`Term::put_tab`] over the *same* table, walked in the
3054 /// other direction. Writing it as a mirror rather than as arithmetic is
3055 /// what keeps the two from drifting: an application that moved a stop with
3056 /// HTS has moved it for both directions, and a modulo here would disagree
3057 /// with the forward walk the moment it did.
3058 ///
3059 /// The count repeats the walk — `n` stops, not one stop `n` columns away —
3060 /// which is what makes a multi-field jump land correctly when the stops are
3061 /// unevenly spaced.
3062 ///
3063 /// Backward tabulation is defined *within the line*: it clamps at column
3064 /// one and never wraps to the row above, which would make it a
3065 /// cursor-relocating operation across rows and give it interactions with
3066 /// the wrap state it should not have. The outer loop breaks at column zero
3067 /// for that reason and for a second one — it bounds the work by the grid
3068 /// rather than by the parameter, so a hostile `CSI 65535 Z` costs one walk
3069 /// of the row. That second reason is **defensive only**: `vte` saturates a
3070 /// parameter at `u16::MAX`, so the unbounded form would cost 65535 no-op
3071 /// iterations, and no test can redden the guard.
3072 ///
3073 /// The **count** converges 4/4 — every reference repeats the *walk* rather
3074 /// than computing a column: alacritty `term/mod.rs:1571-1585` @ `852e971`,
3075 /// ghostty `stream_terminal.zig:593-599` + `Terminal.zig:2124-2136` @
3076 /// `e6e26e1`, xterm `charproc.c:3745-3752` + `tabs.c:131-180` @ `6380a3e`,
3077 /// xterm.js `InputHandler.ts:1141-1151` + `Buffer.ts:599-603` @ `699f553`.
3078 ///
3079 /// The **clamp is 3/4**, and the outlier is alacritty — the one whose loop
3080 /// shape this most resembles, which is why the resemblance is worth
3081 /// distrusting. It seeds `col` with the cursor's current column and
3082 /// overwrites it only inside `if self.tabs[i]` (`term/mod.rs:1578-1583`),
3083 /// so with **no stop to the left it writes the cursor back unchanged** — a
3084 /// no-op where the walk below runs to column zero. Reachable after
3085 /// `CSI 3 g`, since clearing all stops clears column zero too in both
3086 /// engines. xterm (`TabPrev` returns 0), ghostty (returns at
3087 /// `x <= left_limit`) and xterm.js (`x < 0 ? 0 : x`) all clamp, and
3088 /// `back_tab_with_no_stops_lands_at_column_one` is the test that pins which
3089 /// side justerm is on.
3090 ///
3091 /// **Clearing the deferred wrap diverges from all four, deliberately.**
3092 /// None of them clears it here: alacritty's `move_backward_tabs` writes no
3093 /// `input_needs_wrap` though its own CUB does (`term/mod.rs:1253`);
3094 /// ghostty's `horizontalTabBack` calls the *screen*-level `cursorLeft`,
3095 /// which does not touch `pending_wrap`, where its terminal-level one does
3096 /// (`Terminal.zig:1768`); xterm's `TabToPrevStop` has no `ResetWrap` at all
3097 /// — the only one in `tabs.c` is in the *forward* walk and is gated on the
3098 /// `curses` resource (`tabs.c:113`), off by default; and xterm.js has no
3099 /// flag, representing the state as `x == cols` and returning early on it,
3100 /// so its CBT from the right margin moves *nothing*.
3101 ///
3102 /// ADR-0004's spec-first rule has nothing to award here, and the reason is
3103 /// stronger than "the spec is quiet". `ctlseqs.txt:755` is one line, but
3104 /// that file is xterm's documentation *of xterm* rather than the normative
3105 /// text; CBT's normative home is ECMA-48, which no pinned tree carries. The
3106 /// argument that does not depend on a document nobody here can open: the
3107 /// deferred wrap is an **implementation device** for "the cursor is parked
3108 /// at the last column", not an ECMA-48 concept, so no version of that spec
3109 /// can rule on it in principle.
3110 ///
3111 /// So the grounds are this engine's own coherence: **every horizontal-positioning
3112 /// verb here clears the flag** — `move_forward`, `move_back`, `set_col`,
3113 /// `set_row`, `goto`, `move_up`, `move_down`, `backspace`,
3114 /// `carriage_return`, `put_tab` — so a back-tab that did not would be the
3115 /// sole exception. Leaving it armed also reproduces the very bug #826
3116 /// exists to fix, from the other side: the next character would land on the
3117 /// *following row* rather than in the column the back-tab chose.
3118 ///
3119 /// The unanimity is over that population and not over every writer of
3120 /// `cursor.col`. `linefeed_inner` and `reverse_index` do **not** clear it,
3121 /// which is a separate and unsettled question — justerm is the outlier 3-1
3122 /// there — and deliberately outside this change. Nor is `put_tab` a clean
3123 /// precedent: at the right edge of a full row it clears the flag *without
3124 /// moving*, so the next character overwrites the last column instead of
3125 /// wrapping, which all four references avoid. The rule is sound where the
3126 /// verb actually moves the cursor, and CBT always does. See
3127 /// `docs/agents/reference-facts.md`.
3128 ///
3129 /// **What the divergence actually costs, stated as behaviour rather than as
3130 /// a flag.** On a full row, `CSI Z` then a print puts the character where
3131 /// the back-tab landed; every reference puts it on the *following row*,
3132 /// having in effect discarded the back-tab. Pinned by
3133 /// `back_tab_on_a_full_row_prints_where_it_landed_not_on_the_next_row`.
3134 ///
3135 /// **Cleared concern, with its validity condition.** xterm and ghostty
3136 /// clamp a back-tab to the **left margin** under origin mode
3137 /// (`tabs.c:171-175`; `Terminal.zig:2126`), not to column zero. That is
3138 /// inert here only because this engine implements no DECSLRM, so both
3139 /// reduce to zero. **If DECSLRM ever lands, this function is a site**, and
3140 /// nothing else in the tree points at it.
3141 fn put_back_tab(&mut self, n: usize) {
3142 let mut col = self.cursor.col;
3143 for _ in 0..n {
3144 if col == 0 {
3145 break;
3146 }
3147 while col > 0 {
3148 col -= 1;
3149 if self.tabs[col] {
3150 break;
3151 }
3152 }
3153 }
3154 self.cursor.col = col;
3155 self.cursor.pending_wrap = false;
3156 }
3157
3158 /// HTS (ESC H): set a tab stop at the cursor column.
3159 fn set_tab_stop(&mut self) {
3160 let col = self.cursor.col;
3161 self.tabs[col] = true;
3162 }
3163
3164 /// TBC (CSI g): clear the tab stop at the cursor (mode 0) or all stops
3165 /// (mode 3).
3166 fn clear_tab_stop(&mut self, mode: u16) {
3167 match mode {
3168 0 => {
3169 let col = self.cursor.col;
3170 self.tabs[col] = false;
3171 }
3172 3 => self.tabs.iter_mut().for_each(|t| *t = false),
3173 _ => {}
3174 }
3175 }
3176
3177 // ---- printing ------------------------------------------------------------
3178
3179 /// The extended attributes the pen currently stamps onto a cell it writes: the open OSC 8
3180 /// hyperlink (#26/#46) and a non-default underline colour (SGR 58, #520).
3181 ///
3182 /// The colour is gated on the UNDERLINE attribute — an underline colour is meaningless on a
3183 /// cell that draws no underline, and xterm likewise does not persist it there
3184 /// (`AttributeData isEmpty()` ignores the colour; `InputHandler.test.ts:2084`). That keeps
3185 /// it off the wire for cells that never draw it (ADR-0020: no inert per-cell payload). SGR 58
3186 /// is the *underline* colour, so STRIKETHROUGH alone does not arm it.
3187 ///
3188 /// One place, because three sites take their extended attrs from the PEN — the glyph, its wide
3189 /// spacer, and the vacated wrap column — mirroring the pen half of `Row::ext_attrs_at`, so a
3190 /// later rider is added here rather than at each of them (#521/#528).
3191 ///
3192 /// **Five sites build a cell from the pen, not three, and the other two deliberately do not
3193 /// come here**: `promote_cluster_to_wide` and `relocate_cluster_wide` synthesise a pair's
3194 /// spacer, whose attrs are the LEAD's rather than the pen's (ADR-0025 D4), so they read
3195 /// `Row::ext_attrs_at` and the lead's underline style directly. Counting them in is how a
3196 /// rider gets added here and silently misses them — which is exactly what #829's underline
3197 /// style did until a refuting pass measured it.
3198 fn pen_ext_attrs(&self) -> ExtAttrs {
3199 let ucolor = self.cursor.pen.underline_color;
3200 let armed =
3201 ucolor != Color::Default && self.cursor.pen.flags.contains(CellFlags::UNDERLINE);
3202 ExtAttrs::from_pen(self.current_link.clone(), armed.then_some(ucolor))
3203 }
3204
3205 /// Free a cell that has stopped being part of a glyph — the *structural repair* every
3206 /// overwrite, erase and row-shift owes the no-orphan invariant when it destroys one half of
3207 /// a width-2 glyph, plus the spacer a mode-2027 demotion no longer needs.
3208 ///
3209 /// This is **not** an erase. The app asked for something at a *different* column; freeing
3210 /// this one is the engine keeping its own invariant. But it is still a mutation, so it
3211 /// **damages** — and that is the half every site used to forget, because each function
3212 /// damaged its own range and the repaired cell lies outside it by construction (that is what
3213 /// makes it a repair). A frame-mode consumer therefore kept painting the destroyed glyph.
3214 /// Bundling the reset with its damage is the point of this helper: a repair site added later
3215 /// cannot forget the half that has no compiler behind it (#530).
3216 ///
3217 /// The cell it leaves is a **blank carrying the current background** — the same rule
3218 /// `clear_cells` already applies to a BCE erase, extended to the repair, so one sentence
3219 /// covers both: *a blank cell carries the current background.* A bare `Cell::default()`
3220 /// would punch an uncoloured notch into a coloured run, which no reference implementation
3221 /// does.
3222 ///
3223 /// Deliberately the pen's **background only** — not its full attributes, and this is not a
3224 /// compromise between references: it is byte-for-byte xterm.js's `_eraseAttrData()`
3225 /// (`DEFAULT_ATTR_DATA` + `curAttr.bg & ~0xFC000000`, i.e. default everything plus the pen's
3226 /// background colour), which is what its `replaceCells` / `insertCells` / `deleteCells`
3227 /// repairs are handed — eight of the twelve sites here. Only xterm's *print* path uses the
3228 /// whole pen. Taking the whole pen
3229 /// (xterm.js `setCellFromCodepoint(x, 0, 1, curAttr)`) would plant the pen's hyperlink and,
3230 /// worse, its DECSCA protection onto a cell the app never wrote — a cell no later erase could
3231 /// clear. Taking the *cell's own* attributes (alacritty `clear_wide`, which keeps `extra`)
3232 /// would leave the destroyed glyph's hyperlink alive and clickable, the defect #529 is filed
3233 /// against. Both were considered and rejected; the maintainer chose this on 2026-07-24 and it
3234 /// is theirs to reverse (see #530 for what they were shown).
3235 ///
3236 /// What used to be recorded here as a known limitation is **resolved** (#538, ADR-0025 D1):
3237 /// `reset()` still clears the whole content word, but the soft-wrap link is no longer part of
3238 /// it. The live flag is on the `Row`, so freeing the last column — here or on the erase path —
3239 /// cannot break a wrap; `CellFlags::WRAPLINE` is wire-only, derived onto the last cell at
3240 /// encode time and never read back (`cell.rs`). Ending a wrap is now an explicit per-verb call
3241 /// (`end_wrap`), which is the shape both references already had and the reason the move was
3242 /// made: ghostty and xterm.js hold the flag on the row/line, and xterm.js takes `clearWrap` as
3243 /// an explicit argument on its erase helper (`_eraseInBufferLine`, `InputHandler.ts:1175`)
3244 /// rather than letting a cell clear decide it.
3245 ///
3246 /// Known cost, accepted rather than overlooked: with DECSCA the freed cell loses its
3247 /// protection. ghostty has the same hole and flags it in its own source; justerm does not
3248 /// implement DECSCA today, so revisit if it lands.
3249 fn free_cell(&mut self, row: usize, col: usize) {
3250 let bg = self.cursor.pen.bg;
3251 let cell = self.grid.cell_mut(row, col);
3252 cell.reset();
3253 cell.set_bg(bg);
3254 // As in `clear_cells`: the bits are gone, so release what they gated (#628).
3255 self.grid.row_mut(row).purge_side_maps(col..col + 1);
3256 self.damage_span(row, col, col);
3257 }
3258
3259 /// Will the next `wrapline()` actually reach another row?
3260 ///
3261 /// `wrapline` → `linefeed` advances in exactly two cases: the cursor sits at the scroll
3262 /// region's bottom (so the region scrolls under it), or it has a row below it on screen.
3263 /// Parked *below* a DECSTBM region on the last row it does neither — it silently stays put.
3264 ///
3265 /// Both wide-at-boundary paths must ask before they commit anything, because both destroy
3266 /// content on the assumption that the row is about to change: `write_glyph` blanks the column
3267 /// it is leaving, and `relocate_cluster_wide` writes its cluster to `(cursor.row, 0..=1)`
3268 /// *after* the wrap — which is the same row when nothing advanced, so it lands on live cells.
3269 /// Reasoning only about the vacated source column misses that second case entirely.
3270 ///
3271 /// This mirrors `linefeed`'s own condition; the two must be read together.
3272 fn wrapline_advances(&self) -> bool {
3273 self.cursor.row == self.scroll_bottom || self.cursor.row + 1 < self.grid.rows()
3274 }
3275
3276 /// Blank the last column as the soft-wrap artefact it is, when a width-2 glyph could not fit
3277 /// there (#528). Shared by the two paths that reach this state: `write_glyph`'s wide-at-boundary
3278 /// wrap and `relocate_cluster_wide`'s promoted cluster.
3279 ///
3280 /// The column is **written**, not merely flagged: a blank built from the current pen, exactly
3281 /// as every reference does it — xterm.js `setCellFromCodepoint(col, 0, 1, curAttr)`
3282 /// (`InputHandler.ts:609-611`; `BufferLine.ts:244-251` takes the pen's fg/bg *and* its
3283 /// `extended` link/colour), ghostty `printCell(0, .spacer_head)` (`Terminal.zig:1410-1412`,
3284 /// whose `printCell` stamps the cursor's hyperlink), alacritty `write_at_cursor(' ')` under a
3285 /// `LEADING_WIDE_CHAR_SPACER` template (`mod.rs:1108-1113`, assigning `extra` from it).
3286 ///
3287 /// Flagging in place instead left the previous occupant's glyph, hyperlink and underline colour
3288 /// alive in a cell every text reader skips — so a renderer drew a character that could not be
3289 /// copied, searched or announced (#528). Building from `Pen::cell` also clears the presence
3290 /// bits, so no stale side-map entry can be read back through the new cell.
3291 ///
3292 /// WRAPLINE marks the row a continuation rather than a hard line-end (search, logical lines
3293 /// #113 and reflow #7 all read it); the leading-spacer marker makes the text extractors skip
3294 /// the blank instead of joining `"ab한"` → `"ab 한"`.
3295 ///
3296 /// The marker is **alacritty's** `LEADING_WIDE_CHAR_SPACER` (`term/cell.rs`) — ghostty calls
3297 /// the same thing `.spacer_head`. It is *not* xterm's: xterm.js has no marker at all, writing
3298 /// a bare null cell and re-inferring the artefact at reflow time from "ends in null and the
3299 /// following line starts with a wide char". That difference has a consequence here — in
3300 /// xterm.js a lost marker degrades to an empty cell that trimming drops anyway, whereas
3301 /// justerm writes `' '`, so the marker is the *only* thing keeping this column out of the
3302 /// extracted text.
3303 fn vacate_for_wrap(&mut self, row: usize, col: usize) {
3304 // Writing this column makes the vacate an overwrite like any other, so it inherits the
3305 // no-orphan obligation every other overwrite site carries (`write_glyph`,
3306 // `promote_cluster_to_wide`, `insert_chars`, `delete_chars`, the erase path): if the
3307 // column was the *spacer* of a wide glyph, blanking it destroys the spacer marker and
3308 // strands the lead. That is unrecoverable rather than merely untidy — every repair path
3309 // keys off `is_wide_spacer()`, so once the marker is gone no later write, ECH, EL, ICH
3310 // or DCH can ever clear the orphan.
3311 if col > 0 && self.grid.cell(row, col).is_wide_spacer() {
3312 self.free_cell(row, col - 1);
3313 }
3314 let mut vacated = self.cursor.pen.cell(' ');
3315 vacated.set_leading_spacer();
3316 *self.grid.cell_mut(row, col) = vacated;
3317 self.begin_wrap(row);
3318 let ext = self.pen_ext_attrs();
3319 self.grid.row_mut(row).set_ext_attrs(col, ext);
3320 // The cell's contents changed, so a frame-mode consumer must be told or it keeps painting
3321 // the old glyph (ADR-0003: every mutation site records damage). The *repaired* lead above
3322 // is damaged by `free_cell`, which owns that pairing for all twelve repair sites — this
3323 // site used to hand-roll it, and keeping both left neither able to discriminate.
3324 self.damage_span(row, col, col);
3325 }
3326
3327 /// Write one glyph at the cursor, handling deferred wrap and the wide-char
3328 /// spacer, then advance the cursor (deferring the wrap if it hits the edge).
3329 fn write_glyph(&mut self, c: char, width: usize) {
3330 // Every wide branch below is gated on `width == 2`, and the four unguarded uses
3331 // (`insert_chars`, `col + width - 1` twice, the cursor advance) assume the same bound.
3332 // The caller coerces (#595); this states the assumption at the site that holds it, so a
3333 // future second caller fails a test rather than writing an unmarked run of blanks.
3334 // Ghostty pairs its own source-side clamp with the same assertion for the same reason
3335 // (`Terminal.zig`, *"it is possible to have a width of 3 … assert(width <= 2)"*).
3336 debug_assert!(
3337 width <= 2,
3338 "write_glyph({c:?}, {width}) — the cell model represents at most a pair"
3339 );
3340 let cols = self.grid.cols();
3341
3342 // Resolve a deferred last-column wrap before placing the next glyph.
3343 // The row being left soft-wrapped: mark its last cell so reflow (#7) can
3344 // tell it from a hard CR/LF line-end.
3345 if self.cursor.pending_wrap && !self.autowrap {
3346 // DECAWM was turned off *after* the flag was armed — `write_glyph` arms it
3347 // as `pending_wrap = self.autowrap`, so it is never set while the mode is
3348 // off, but nothing clears it when `?7l` arrives. Without this the parked
3349 // print wrapped with autowrap disabled, and **all four references print in
3350 // place**: xterm clears `do_wrap` and only then asks `WRAPAROUND`
3351 // (`charproc.c:7059`, `:7192`), xterm.js un-parks with `x = cols - 1` in
3352 // the else arm of its `wraparoundMode` branch (`InputHandler.ts:612`),
3353 // ghostty gates the whole consume (`Terminal.zig:1368`) and alacritty's
3354 // `wrapline` early-returns on `!LINE_WRAP` (`term/mod.rs:962`).
3355 //
3356 // Those four split 2-2 on whether the *flag* survives; the tie is broken by
3357 // this crate's own arm site, which never sets it while the mode is off, so
3358 // a flag that outlives `?7l` contradicts the rule that wrote it. That is
3359 // xterm's and xterm.js's shape: consume the park, do not wrap.
3360 //
3361 // Pre-existing, and #848 widened it: until that change `put_tab` cleared
3362 // the flag, so `abc` + `?7l` + `HT` + `X` printed in place by accident.
3363 self.cursor.pending_wrap = false;
3364 }
3365 if self.cursor.pending_wrap {
3366 let row = self.cursor.row;
3367 // Claim the wrap only if there will *be* a next row to continue into. Parked below a
3368 // DECSTBM region on the last row, `wrapline` → `linefeed` advances nothing and the
3369 // glyph overwrites this same row from column 0 — so the wrap never happened, and a
3370 // flag set here is permanently false: the cursor never leaves, nothing clears it, and
3371 // it survives into `backspace`'s reverse-wraparound, reflow, and every text reader.
3372 //
3373 // The predicate is not new and neither is its rationale: `wrapline_advances` was
3374 // written for exactly this state and is already asked by both wide-at-boundary paths.
3375 // This narrow path was the one caller that committed without asking. (Surfaced by the
3376 // #540 completeness pass, which found a row-shift verb inheriting the bogus flag and
3377 // merging two unrelated logical lines.)
3378 if self.wrapline_advances() {
3379 self.begin_wrap(row);
3380 }
3381 self.wrapline();
3382 }
3383
3384 // A width-2 glyph that cannot fit in the last column wraps first — unless
3385 // autowrap is off, in which case it is dropped (xterm.js `continue`), not
3386 // squeezed or wrapped.
3387 if width == 2 && self.cursor.col + 1 >= cols {
3388 if !self.autowrap {
3389 return;
3390 }
3391 // …but only if the wrap actually happens: vacating for a wrap that never occurs
3392 // blanks a column holding a live glyph.
3393 if self.wrapline_advances() {
3394 self.vacate_for_wrap(self.cursor.row, cols - 1);
3395 }
3396 self.wrapline();
3397 }
3398
3399 // Insert mode (IRM): open a `width`-wide gap at the cursor first, shifting
3400 // the row's tail right (off-edge cells discarded, wide halves repaired),
3401 // then write into the gap — mirrors xterm.js's insertCells (#64).
3402 if self.insert_mode {
3403 self.insert_chars(width);
3404 }
3405
3406 let (row, col) = (self.cursor.row, self.cursor.col);
3407
3408 // Overwriting either half of a pair that wrapped from the row above ends that pair, so the
3409 // row above's artefact record is void (#534). The one exception is the in-place same-width
3410 // overwrite — a wide lead replaced by another wide lead at the same column — which is
3411 // ghostty's `if (cell.wide != wide)` escape and the reason this is asked *before* the
3412 // write rather than after it. Note IRM has already run its own check inside `insert_chars`
3413 // by the time this would fire, on the pre-shift state, which is the correct one.
3414 if col <= 1 && self.wrapped_pair_at_row_start(row) && !(col == 0 && width == 2) {
3415 self.void_wrap_artefact_above(row);
3416 }
3417
3418 // Overwriting one half of an existing wide glyph orphans the other —
3419 // clear it so no stray lead/spacer is left behind.
3420 let last = col + width - 1;
3421 if col > 0 && self.grid.cell(row, col).is_wide_spacer() {
3422 self.free_cell(row, col - 1);
3423 }
3424 if last + 1 < cols && self.grid.cell(row, last).is_wide() {
3425 self.free_cell(row, last + 1);
3426 }
3427
3428 let mut cell = self.cursor.pen.cell(c);
3429 if width == 2 {
3430 cell.insert_flags(CellFlags::WIDE_CHAR);
3431 }
3432 *self.grid.cell_mut(row, col) = cell;
3433 // Stamp the pen's extended attrs — the open hyperlink (#26/#46) and a non-default
3434 // underline colour (#520) — into the row's side maps.
3435 let ext = self.pen_ext_attrs();
3436 // `.clone()`: `ExtAttrs` stopped being `Copy` at #628 (the link rider is a shared
3437 // `Arc<str>`), and the spacer below stamps the same value — a refcount bump, not
3438 // a second string.
3439 self.grid.row_mut(row).set_ext_attrs(col, ext.clone());
3440
3441 // The trailing column of a wide glyph carries a distinct spacer marker —
3442 // and the same link + underline colour, so a hover/selection/underline over
3443 // either half agrees.
3444 if width == 2 && col + 1 < cols {
3445 let mut spacer = self.cursor.pen.cell(' ');
3446 spacer.insert_flags(CellFlags::WIDE_CHAR_SPACER);
3447 *self.grid.cell_mut(row, col + 1) = spacer;
3448 self.grid.row_mut(row).set_ext_attrs(col + 1, ext);
3449 }
3450
3451 // Record damage for the cell(s) just written.
3452 self.damage_span(row, col, col + width - 1);
3453
3454 // Advance. Reaching/passing the last column sets pending-wrap instead of
3455 // wrapping eagerly — the cursor parks on the last column.
3456 let new_col = col + width;
3457 if new_col >= cols {
3458 self.cursor.col = cols - 1;
3459 // With autowrap off (DECAWM ?7l) the cursor pins to the last column
3460 // and the next glyph overwrites in place — no deferred wrap (#63).
3461 self.cursor.pending_wrap = self.autowrap;
3462 } else {
3463 self.cursor.col = new_col;
3464 }
3465 }
3466
3467 /// Attach a combining mark (width-0 code point) to the grapheme it modifies —
3468 /// the cell the cursor just left. With pending-wrap the cursor still sits on
3469 /// the just-written last-column glyph, so attach in place (no back-up, no
3470 /// deferred wrap); otherwise step back one column, and once more over a
3471 /// wide-char spacer to reach its lead. Stored in the grapheme side-table.
3472 fn push_combining(&mut self, c: char) {
3473 let row = self.cursor.row;
3474 let mut col = if self.cursor.pending_wrap {
3475 self.cursor.col
3476 } else {
3477 self.cursor.col.saturating_sub(1)
3478 };
3479 if self.grid.cell(row, col).is_wide_spacer() {
3480 col = col.saturating_sub(1);
3481 }
3482 // Append the mark to the row's combining map at this column (setting the
3483 // cell's combining bit). No global pool — the cluster rides the row.
3484 self.grid.row_mut(row).push_combining(col, c);
3485 self.damage_span(row, col, col);
3486 }
3487
3488 /// Mode 2027 (#295): if `c` **extends** the previous cell's grapheme cluster (UAX #29), append
3489 /// it to that cell's side-table — no new cell, no cursor advance — and return `true`. Otherwise
3490 /// return `false` so `print` takes the normal per-scalar path (a break starts a new cell).
3491 ///
3492 /// The break state is reconstructed fresh from the previous cell's stored cluster (base scalar +
3493 /// side-table marks) rather than persisted across calls, so cursor moves / CR-LF can't corrupt
3494 /// it (mirrors ghostty). Width promotion for a narrow base (a flag's second RI, a text-base +
3495 /// VS16) is handled by the caller in a later step; here the base's existing width holds.
3496 fn try_grapheme_join(&mut self, c: char) -> bool {
3497 let row = self.cursor.row;
3498 // Locate the previous cluster's base cell, exactly as `push_combining`: with pending-wrap
3499 // the cursor still sits on the last glyph; else step back one, and over a wide spacer.
3500 let col = if self.cursor.pending_wrap {
3501 self.cursor.col
3502 } else if self.cursor.col == 0 {
3503 return false; // nothing precedes on this row
3504 } else {
3505 self.cursor.col - 1
3506 };
3507 let col = if self.grid.cell(row, col).is_wide_spacer() {
3508 col.saturating_sub(1)
3509 } else {
3510 col
3511 };
3512 // Reconstruct the previous cluster's text: base scalar + any already-joined scalars.
3513 let mut prev = String::new();
3514 prev.push(self.grid.cell(row, col).c());
3515 if let Some(marks) = self.grid.row_ref(row).combining_at(col) {
3516 prev.extend(marks.iter().copied());
3517 }
3518 if !crate::grapheme::grapheme_extends(&prev, c) {
3519 return false;
3520 }
3521 // Join: ride the side-table (no new cell).
3522 self.grid.row_mut(row).push_combining(col, c);
3523 // Width promotion: a flag's second regional indicator, or a text-base + VS16, grows the
3524 // cluster to width 2. `UnicodeWidthStr` gives the cluster width (RI-pair → 2, VS16 → 2). If
3525 // the base cell is still narrow, widen it in place.
3526 let cluster_w = {
3527 prev.push(c);
3528 UnicodeWidthStr::width(prev.as_str())
3529 };
3530 if cluster_w == 2 && !self.grid.cell(row, col).is_wide() {
3531 self.promote_cluster_to_wide(row, col);
3532 } else if cluster_w == 1 && self.grid.cell(row, col).is_wide() {
3533 // The mirror case: a default-wide emoji + VS15 (text selector) shrinks to width 1.
3534 self.demote_cluster_to_narrow(row, col);
3535 }
3536 self.damage_span(row, col, col);
3537 true
3538 }
3539
3540 /// Shrink a wide cluster cell back to a single-width cell (#295): a default-wide emoji joined by
3541 /// VS15 (U+FE0E, the text selector) requests text presentation → width 1. Remove `WIDE_CHAR`,
3542 /// free the spacer, and back the cursor up over it (the inverse of `promote_cluster_to_wide`).
3543 fn demote_cluster_to_narrow(&mut self, row: usize, col: usize) {
3544 let cols = self.grid.cols();
3545 self.grid
3546 .cell_mut(row, col)
3547 .remove_flags(CellFlags::WIDE_CHAR);
3548 if col + 1 < cols {
3549 self.free_cell(row, col + 1); // free the now-unused spacer
3550 }
3551 // The cluster shrank 2→1: the cursor sat just past the wide cell (col+2, or pending-wrap on
3552 // the last column); it now sits just past the single-width cell at col+1.
3553 self.cursor.pending_wrap = false;
3554 self.cursor.col = (col + 1).min(cols - 1);
3555 self.damage_span(row, col, (col + 1).min(cols - 1));
3556 }
3557
3558 /// Widen a narrow base cell to a double-width cluster in place (#295): set `WIDE_CHAR`, write
3559 /// its spacer, and step the cursor over it. Only reached when a joining scalar (flag's 2nd RI,
3560 /// VS16) promotes the cluster to width 2. A base pinned at the last column has no room for a
3561 /// spacer — relocation is a later step; until then it stays narrow (rare, renders single-width).
3562 fn promote_cluster_to_wide(&mut self, row: usize, col: usize) {
3563 let cols = self.grid.cols();
3564 if col + 1 >= cols {
3565 // No spacer room at the last column: relocate the whole cluster to the next line as a
3566 // wide cell (the row soft-wraps), mirroring write_glyph's wide-at-boundary wrap (#303).
3567 self.relocate_cluster_wide(row, col);
3568 return;
3569 }
3570 // Overwriting col+1 with the spacer can orphan the far half of a WIDE glyph standing there
3571 // (the cursor may have been repositioned before the joining scalar arrived). Reset that
3572 // orphan, exactly as write_glyph does (2462-2470), so no dangling spacer survives.
3573 if self.grid.cell(row, col + 1).is_wide() && col + 2 < cols {
3574 self.free_cell(row, col + 2);
3575 }
3576 self.grid
3577 .cell_mut(row, col)
3578 .insert_flags(CellFlags::WIDE_CHAR);
3579 // The spacer is the lead's second half, so it takes the LEAD's extended attrs — the
3580 // hyperlink and underline colour riding the row's side maps — exactly as write_glyph
3581 // stamps both halves of a wide write. `pen.cell(' ')` carries neither (and the pen may
3582 // have moved on since the base was printed), so they are re-attached here; a base with
3583 // none clears whatever the overwritten column held (#521).
3584 let ext = self.grid.row_ref(row).ext_attrs_at(col);
3585 // The underline STYLE needs the same treatment as those extended attrs and for the same
3586 // reason this comment already gives (#829): it is the LEAD's, and the pen may have moved
3587 // on. It rides the packed cell rather than a side map, so it is carried across directly
3588 // instead of through `set_ext_attrs`. ADR-0025 D4 — a path that *synthesises* one half of
3589 // a pair carries the whole pair; taking this from the pen curls the left half and leaves
3590 // the right half bare.
3591 let lead_style = self.grid.cell(row, col).underline_style();
3592 let mut spacer = self.cursor.pen.cell(' ');
3593 spacer.insert_flags(CellFlags::WIDE_CHAR_SPACER);
3594 spacer.set_underline_style(lead_style);
3595 *self.grid.cell_mut(row, col + 1) = spacer;
3596 self.grid.row_mut(row).set_ext_attrs(col + 1, ext);
3597 // The cursor sat at col+1 (just past the narrow base); move it over the new spacer, applying
3598 // the same last-column pending-wrap rule as a wide write.
3599 let new_col = col + 2;
3600 if new_col >= cols {
3601 self.cursor.col = cols - 1;
3602 self.cursor.pending_wrap = self.autowrap;
3603 } else {
3604 self.cursor.col = new_col;
3605 }
3606 self.damage_span(row, col, col + 1);
3607 }
3608
3609 /// Relocate a last-column narrow cluster to the next line as a wide cell (#303): its base +
3610 /// side-table marks move to `(next_row, 0..=1)` and the vacated last column becomes a soft-wrap
3611 /// (WRAPLINE + leading spacer), exactly as `write_glyph` wraps a wide glyph that can't fit. With
3612 /// autowrap off it stays narrow.
3613 ///
3614 /// The destination is an **overwrite**, so it owes the no-orphan repair every other overwrite
3615 /// site owes (#529, ADR-0025 D4) — see the comment at that site for why justerm restates it
3616 /// once per wide-writing path where the references get it structurally.
3617 ///
3618 /// The `cols < 2` arm is **unreachable since #547** —
3619 /// `MIN_COLUMNS = 2` is the floor on every path that sets a width — and is kept only as a
3620 /// bounds guard for the `col + 1` writes below, not as a described behaviour.
3621 fn relocate_cluster_wide(&mut self, row: usize, col: usize) {
3622 let cols = self.grid.cols();
3623 if cols < 2 || !self.autowrap || !self.wrapline_advances() {
3624 // Nowhere to place a wide cell — leave it narrow. `!wrapline_advances()` joins the
3625 // other two for the same reason: with no next row, the relocation would write the
3626 // cluster over columns 0-1 of the *current* row and destroy whatever is there.
3627 return;
3628 }
3629 // Capture the base cell (glyph + attrs), its marks, and its extended attrs before
3630 // vacating. The extended attrs (hyperlink, underline colour) must be read HERE and not
3631 // after the move: they live in the *source row's* side maps, and `wrapline()` below may
3632 // scroll — after which that row is a different (or recycled) `Row` (#521).
3633 let base = *self.grid.cell(row, col);
3634 let marks: Vec<char> = self
3635 .combining_at(row, col)
3636 .map(<[char]>::to_vec)
3637 .unwrap_or_default();
3638 let ext = self.grid.row_ref(row).ext_attrs_at(col);
3639 // Vacate the last column as a soft-wrap artefact — the same step `write_glyph` takes for a
3640 // wide glyph that cannot fit, and now literally the same code, so the two cannot drift
3641 // apart again (#528; they held opposite behaviours until then).
3642 self.vacate_for_wrap(row, col);
3643 // Advance to the next line (scrolls if at the bottom); cursor lands at col 0.
3644 self.wrapline();
3645 let nr = self.cursor.row;
3646 // The destination is an overwrite like any other, so it owes the same no-orphan repair
3647 // `write_glyph` performs for its own trailing column (#529, D4): the spacer about to land
3648 // on `(nr, 1)` half-destroys a wide glyph standing there, stranding its far half at
3649 // `(nr, 2)` — a `WIDE_CHAR_SPACER` with no lead to its left, still carrying the destroyed
3650 // glyph's hyperlink and underline colour. Asked *before* the writes, on the pre-write
3651 // state, exactly as `write_glyph`'s `last + 1` check is.
3652 //
3653 // Two of the three references have this exact site, and both repair it without a rule of
3654 // their own, because they write a pair as two *separate* cell writes and the repair lives
3655 // in the write:
3656 // - xterm.js names the case outright — *"Combining character widens 1 column to 2. Move
3657 // old character to next line."* (`InputHandler.ts:583-611` @ 699f553,
3658 // `copyCellsFrom(oldRow, oldCol, 0, oldWidth, false)` at `:605-607`). The relocation
3659 // leaves `x == 2`, so its once-per-run right-edge repair (`:668-669`) lands on exactly
3660 // the orphaned column.
3661 // - ghostty relocates in `Terminal.zig:1188-1252` @ e6e26e1 and reaches the repair
3662 // through `cursorRight(1); printCell(0, .spacer_tail)` (`:1251-1252`) — that second
3663 // `printCell` runs the `cell.wide != wide` switch (`:1484`) whose `.wide` arm clears
3664 // the neighbouring lead's tail (`:1489-1499`).
3665 // - alacritty has **no** counterpart: a width-0 codepoint returns early through
3666 // `push_zerowidth` (`term/mod.rs:1069-1085` @ 852e971), so a cluster never changes
3667 // width and nothing is ever relocated. Its orphan repair (`:994-1008`) is still the
3668 // mechanism reference, reached the same way — one repair per `write_at_cursor`.
3669 // justerm writes both halves in one step, so the repair is not structural here and each
3670 // wide-writing path restates it — this is the third (`write_glyph`,
3671 // `promote_cluster_to_wide`, and now the relocation).
3672 //
3673 // What justerm does **not** copy is ghostty's reach-back at this site: its `.wide` arm
3674 // also clears the previous row's `.spacer_head` (`:1504-1506`, gated `cursor.y > 0 and
3675 // cursor.x <= 1`) — the very marker this relocation set seven statements earlier
3676 // (`:1200`). Derived from source, not executed. Suppressing it here is #534's rule
3677 // verbatim: a repair keyed on a state predicate must not fire while that state is
3678 // mid-construction.
3679 //
3680 // The other two obligations `write_glyph` carries are N/A here, recorded because an
3681 // unexplained omission is what gets re-litigated:
3682 // - the *left*-orphan repair asks `col > 0`, and the lead lands at column 0.
3683 // - `void_wrap_artefact_above(nr)` would clear a record that `vacate_for_wrap` **just
3684 // set**, in both the advance case (`nr == row + 1`, so its target `nr - 1` is `row`)
3685 // and the scroll case (`nr == row`, the source rotated up to `row - 1`). Firing it
3686 // would be self-clobbering, not merely redundant — the same shape as #534's
3687 // mid-construction rule. Measured after a repairing relocation: `is_row_wrapped(0)`
3688 // and `(0, cols-1).is_leading_spacer()` both hold.
3689 //
3690 // `2 < cols` is a live bound, not defence in depth. The print paths cannot leave a
3691 // `WIDE_CHAR` lead in the last column — `write_glyph` wraps rather than write one there
3692 // and `promote_cluster_to_wide` relocates rather than promote in place — but `Row::resize`
3693 // can: the alt screen resizes without reflowing (#567), so truncating a row through a pair
3694 // strands its lead in the final column. The relocation then meets `is_wide() == true` at
3695 // `cols == 2`, and without the bound reads `(nr, 2)` on a two-column grid — an
3696 // out-of-bounds panic in a library, inside a consumer's process, reachable by shrinking a
3697 // window over a CJK glyph. Pinned by `min_columns.rs::
3698 // a_relocation_beside_a_truncated_wide_lead_does_not_index_past_the_row`.
3699 if 2 < cols && self.grid.cell(nr, 1).is_wide() {
3700 self.free_cell(nr, 2);
3701 }
3702 // Re-place the base as a wide lead + spacer, re-attaching the marks fresh (drop the combining
3703 // bit so push_combining starts a clean cluster at the new column).
3704 let mut lead = base;
3705 lead.set_combined(false);
3706 lead.insert_flags(CellFlags::WIDE_CHAR);
3707 *self.grid.cell_mut(nr, 0) = lead;
3708 for m in marks {
3709 self.grid.row_mut(nr).push_combining(0, m);
3710 }
3711 // Re-attach the extended attrs to BOTH halves at the new row. `lead` copied the base's
3712 // presence bits but not its map entries, so without this the bit is set with nothing
3713 // behind it — the read is gated and silently returns the default, and the frame stops
3714 // round-tripping (the cell encodes as linked with no index).
3715 self.grid.row_mut(nr).set_ext_attrs(0, ext.clone());
3716 // …and the underline style from the relocated LEAD, for the reason the sibling site above
3717 // states (#829, ADR-0025 D4).
3718 let lead_style = self.grid.cell(nr, 0).underline_style();
3719 let mut spacer = self.cursor.pen.cell(' ');
3720 spacer.insert_flags(CellFlags::WIDE_CHAR_SPACER);
3721 spacer.set_underline_style(lead_style);
3722 *self.grid.cell_mut(nr, 1) = spacer;
3723 self.grid.row_mut(nr).set_ext_attrs(1, ext);
3724 // Cursor just past the wide cell (pending-wrap if it fills a 2-column row).
3725 if cols <= 2 {
3726 self.cursor.col = cols - 1;
3727 self.cursor.pending_wrap = self.autowrap;
3728 } else {
3729 self.cursor.col = 2;
3730 self.cursor.pending_wrap = false;
3731 }
3732 self.damage_span(nr, 0, 1);
3733 }
3734
3735 // ---- cursor movement (CSI A/B/C/D/G/d/H/f) -------------------------------
3736
3737 fn move_up(&mut self, n: usize) {
3738 self.cursor.row = self.cursor.row.saturating_sub(n);
3739 self.cursor.pending_wrap = false;
3740 }
3741
3742 fn move_down(&mut self, n: usize) {
3743 self.cursor.row = (self.cursor.row + n).min(self.grid.rows() - 1);
3744 self.cursor.pending_wrap = false;
3745 }
3746
3747 fn move_forward(&mut self, n: usize) {
3748 self.cursor.col = (self.cursor.col + n).min(self.grid.cols() - 1);
3749 self.cursor.pending_wrap = false;
3750 }
3751
3752 fn move_back(&mut self, n: usize) {
3753 self.cursor.col = self.cursor.col.saturating_sub(n);
3754 self.cursor.pending_wrap = false;
3755 }
3756
3757 fn set_col(&mut self, col: usize) {
3758 self.cursor.col = col.min(self.grid.cols() - 1);
3759 self.cursor.pending_wrap = false;
3760 }
3761
3762 fn set_row(&mut self, row: usize) {
3763 self.cursor.row = row.min(self.grid.rows() - 1);
3764 self.cursor.pending_wrap = false;
3765 }
3766
3767 fn goto(&mut self, row: usize, col: usize) {
3768 // Origin mode addresses rows relative to the scroll region's top margin
3769 // and clamps to its bottom; otherwise rows are absolute to the screen.
3770 let (offset, max_row) = if self.origin_mode {
3771 (self.scroll_top, self.scroll_bottom)
3772 } else {
3773 (0, self.grid.rows() - 1)
3774 };
3775 self.cursor.row = (row + offset).min(max_row);
3776 self.cursor.col = col.min(self.grid.cols() - 1);
3777 self.cursor.pending_wrap = false;
3778 }
3779
3780 // ---- erase (CSI J / K) ---------------------------------------------------
3781
3782 /// Clear cells `from..to` on `row`.
3783 ///
3784 /// Background Color Erase (BCE): erased cells carry the current SGR
3785 /// background only — fg and text attributes reset to default (matches
3786 /// xterm/alacritty, where the fill is `cursor.template.bg.into()`).
3787 ///
3788 /// **Cleared concern, with its validity condition — an empty range would break the pair
3789 /// invariant.** With `from == to` the first guard below still frees the lead at `from - 1`
3790 /// while the second is skipped (`to > from` is false) and the fill loop does nothing, so the
3791 /// spacer at `from` would survive its lead — an ADR-0025 D4 break, and the exact lead-less
3792 /// orphan the word walk must then treat as opaque. This is unreachable **as long as every
3793 /// caller passes a non-empty range**, which holds today: `ECH` clamps to
3794 /// `(col + n).min(cols)` with `n >= 1` (both `CSI X` and `CSI 0 X` erase one cell), and every
3795 /// `EL`/`ED` site passes `0..cols` or `0..=cursor`. A future caller that can pass an empty
3796 /// range must guard here first.
3797 fn clear_cells(&mut self, row: usize, from: usize, to: usize) {
3798 let cols = self.grid.cols();
3799 // Erasing either half of a pair that wrapped from the row above ends it, so that row's
3800 // artefact record is void (#534). `from <= 1` rather than `from == 0` because erasing from
3801 // column 1 destroys the spacer and the no-orphan repair below then frees the lead. ghostty
3802 // reaches the same row from its erase path — `Screen.splitCellBoundary`'s `x == 0 or x ==
3803 // 1` branch (`Screen.zig:1873` @ `e6e26e1`), called from `eraseChars` (`Terminal.zig:3159`).
3804 if from <= 1 && to > from && self.wrapped_pair_at_row_start(row) {
3805 self.void_wrap_artefact_above(row);
3806 }
3807 // Don't orphan a wide char straddling the erase boundary.
3808 if from > 0 && self.grid.cell(row, from).is_wide_spacer() {
3809 self.free_cell(row, from - 1);
3810 }
3811 if to > from && to < cols && self.grid.cell(row, to - 1).is_wide() {
3812 self.free_cell(row, to);
3813 }
3814
3815 let bg = self.cursor.pen.bg;
3816 for col in from..to {
3817 let cell = self.grid.cell_mut(row, col);
3818 cell.reset();
3819 cell.set_bg(bg);
3820 }
3821 // `reset` cleared the presence bits; this releases what they gated (#628).
3822 self.grid.row_mut(row).purge_side_maps(from..to);
3823 if to > from {
3824 self.damage_span(row, from, to - 1);
3825 }
3826 }
3827
3828 /// End `row`'s soft wrap, because something just destroyed the content that was continuing
3829 /// onto the next row.
3830 ///
3831 /// Which verbs owe this is **not** derivable from the erased range — it is a per-verb rule,
3832 /// and both references spell it out call site by call site rather than inferring it:
3833 ///
3834 /// | verb | ends the wrap? | xterm | ghostty |
3835 /// |---|---|---|---|
3836 /// | `EL 0` (erase right) | **yes**, at any column | `ClearRight` → `LineClrWrapped` unconditionally (`util.c:1871`) | `cursorResetWrap()` in `eraseLine(.right)` |
3837 /// | `ECH` | **yes**, at any column | same `ClearRight` (`util.c:1961`) | `cursorResetWrap()` in `eraseChars` |
3838 /// | `DCH` | **yes** | `screen.c` | `cursorResetWrap()` — *"Our row's soft-wrap is always reset"* |
3839 /// | `EL 1` (erase left) | no | `ClearLeft`, no clear | no |
3840 /// | `ICH` | no | no | no |
3841 ///
3842 /// The shape behind the three that do: each destroys content **from the cursor rightward**, so
3843 /// "this row continues past its last column" can no longer be asserted. Erasing leftward or
3844 /// inserting blanks leaves the tail — and whatever it flowed into — intact.
3845 ///
3846 /// **`EL 2` is a deliberate divergence.** justerm ends the wrap; xterm does not (`ClearLine`,
3847 /// `util.c:1905`, has no `LineClrWrapped`) and ghostty copies that with a comment naming it —
3848 /// *"it seems like complete should reset the soft-wrap state of the line but in xterm it does
3849 /// not."* justerm differs because it *joins* logical lines for `accessible_text` / `search` /
3850 /// selection text, so a blanked-but-still-wrapped row visibly merges two lines in copy — a
3851 /// consequence xterm does not carry. Recorded rather than silently matched or silently
3852 /// Mark `row` as soft-wrapping into the next one — and damage the cell the bit rides on.
3853 ///
3854 /// The exact mirror of [`Term::end_wrap`], and it exists for the mirror of that function's
3855 /// reason. The flag lives on the `Row` (#538) and reaches a consumer only as the last cell's
3856 /// `WRAPLINE`, derived at encode time. Every other cell-carried fact changes when that cell is
3857 /// written, so damage covers it for free; this one does not, and a `Partial` frame would never
3858 /// ship the bit — a frame-mode consumer rebuilding logical lines from cells then keeps the two
3859 /// rows *split* forever, the exact dual of the "joined forever" that `end_wrap` guards.
3860 ///
3861 /// `end_wrap` took that obligation in #540; the set side never did. It stayed invisible because
3862 /// a wrap normally moves the cursor to the next row, and `frame_damage` tops the frame up with
3863 /// the old cursor cell. When a **scroll serves the wrap** the cursor keeps its row index, so
3864 /// nothing tops it up — which is how #557 surfaced it.
3865 ///
3866 /// Damaging here rather than at each caller is what keeps this true for set sites added later,
3867 /// the same argument `end_wrap`'s comment makes.
3868 fn begin_wrap(&mut self, row: usize) {
3869 self.grid.row_mut(row).set_wrapped(true);
3870 let last = self.grid.cols() - 1;
3871 self.damage_span(row, last, last);
3872 }
3873
3874 /// diverged; see #538.
3875 fn end_wrap(&mut self, row: usize) {
3876 self.grid.row_mut(row).set_wrapped(false);
3877 // The flag is stored on the `Row` but rides the wire on the row's **last cell**, derived
3878 // at encode time. Every other cell-carried fact changes only when that cell is written,
3879 // so damage covers it for free; this one does not, and a `Partial` frame would never
3880 // re-ship the bit — leaving a frame-mode consumer with two rows joined forever. Damaging
3881 // here rather than at each caller is what keeps that true for call sites added later.
3882 let last = self.grid.cols() - 1;
3883 self.damage_span(row, last, last);
3884 // The wrap artefact goes with the wrap. The marker's claim is "the last column is the
3885 // blank a width-2 glyph vacated **because this row continues onto the next**", so a row
3886 // that stops continuing cannot hold one (ADR-0025 D3 — position is part of the test, and
3887 // so is the wrap it is positioned in). Coupling the two here is what makes the row-shift
3888 // seams and every wrap-ending erase a single rule instead of a clear per verb: ghostty
3889 // couples them in one function the same way — `Screen.cursorResetWrap`
3890 // (`terminal/Screen.zig:1524` @ `e6e26e1`, spacer-head clear at `:1539-1545`), reached from
3891 // `deleteChars` / `eraseChars` / `eraseLine`. It early-returns on `if (!page_row.wrap)`;
3892 // this one clears unconditionally, which is strictly safer.
3893 //
3894 // Most callers erase through this column anyway, so the clear is redundant for them; the
3895 // ones it is *not* redundant for are the row-shift seams (#540's `shift_region`, which
3896 // ends a wrap without touching a cell) and `delete_chars`, whose marker rides the shift.
3897 // The leftward erases are the mirror case — they blank this column while the wrap
3898 // legitimately survives — and go through `drop_artefact_if_erased` instead.
3899 //
3900 // One wrap-ending path deliberately does *not* reach here: `shift_region`'s `top == 0`
3901 // seam, whose row is in scrollback rather than the grid. It couples the same two clears
3902 // inline; see the comment there.
3903 self.grid.cell_mut(row, last).clear_leading_spacer();
3904 }
3905
3906 /// The pair that wrapped into `row` is about to be destroyed or moved, so the artefact record
3907 /// on the row **above** it is void — drop it. **Call before the mutation.**
3908 ///
3909 /// The marker makes a claim with two clauses: this row soft-wraps (owned by `end_wrap`), and
3910 /// its last column is the blank *that specific pair* vacated. This is the second clause, and
3911 /// the rule behind every call site is one sentence: **the record survives only an in-place
3912 /// same-width overwrite.** Anything else that reaches columns 0/1 of the continuation — a
3913 /// narrow write, an erase, a shift in either direction — ends the pair the record was about,
3914 /// and a wide lead that arrives afterwards by some other route did not *wrap* from anywhere.
3915 ///
3916 /// Both references gate on that, and both gate on the state **before** the write rather than
3917 /// after it:
3918 ///
3919 /// - ghostty `Terminal.zig:1484` @ `e6e26e1` — the whole wide-repair `switch` sits under
3920 /// `if (cell.wide != wide)`, so a wide glyph overwritten by another wide glyph skips it; the
3921 /// reach-back stanza then appears in the `.wide` (`:1501-1506`) and `.spacer_tail`
3922 /// (`:1529-1532`) arms only.
3923 /// - alacritty `term/mod.rs:994` @ `852e971` — the reach-back at `:1004-1008` is inside
3924 /// `if cursor_cell.flags.intersects(WIDE_CHAR | WIDE_CHAR_SPACER)`, but with no
3925 /// width-unchanged escape, so it drops a record that is still true. Alacritty is the outlier
3926 /// of the two and justerm follows ghostty.
3927 ///
3928 /// Asking *after* the mutation instead looks equivalent and is not: it answers "is some wide
3929 /// lead standing at column 0", which a `DCH` that pulls the *next* wide glyph left also
3930 /// satisfies, and which a two-step placement (a narrow base promoted to wide by VS16 under
3931 /// mode 2027, or IRM's insert-then-write) satisfies only at the end. Both were measured
3932 /// disagreeing with the rule above before this took its current form.
3933 ///
3934 /// The erase and intra-row-shift call sites are **ported, not derived**: ghostty's
3935 /// `Screen.splitCellBoundary` (`Screen.zig:1831`, the `x == 0 or x == 1` branch at `:1873`)
3936 /// reaches up one row and clears the previous row's spacer head, and it is called from
3937 /// `deleteChars` (`Terminal.zig:3107-3109`) and `eraseChars` (`:3159-3160`). Only justerm's
3938 /// `ICH` site has no counterpart — ghostty's `insertBlanks` (`:2988`) calls it nowhere.
3939 ///
3940 /// `row == 0` does not mean "no row above": on the primary screen the text readers walk
3941 /// `[scrollback ++ grid]` as one buffer (`abs_floor() == 0`), so the row above grid row 0 is
3942 /// the last **scrollback** row and it can carry the marker. Alacritty reaches the same row for
3943 /// the same reason — its `topmost_line()` is `Line(-history_size)` (`grid/mod.rs:504`), so
3944 /// `point.line - 1` indexes into history; ghostty is the one that stops at the viewport
3945 /// (`cursor.y > 0`). On the alt screen `abs_floor()` is the screen top, so no join crosses the
3946 /// boundary and there is nothing to repair.
3947 ///
3948 /// No damage is owed by either branch, and for a stronger reason than #540's: the marker is a
3949 /// `content` bit outside `CONTENT_MARKER_MASK`, so `Cell::flags()` never sees it and it does
3950 /// not cross the wire at all. The `damage_span` below is defensive, not load-bearing.
3951 fn void_wrap_artefact_above(&mut self, row: usize) {
3952 if row > 0 {
3953 let last = self.grid.cols() - 1;
3954 if self.grid.cell(row - 1, last).is_leading_spacer() {
3955 self.grid.cell_mut(row - 1, last).clear_leading_spacer();
3956 self.damage_span(row - 1, last, last);
3957 }
3958 } else if !self.on_alt
3959 && let Some(cell) = self.scrollback.back_mut().and_then(|r| r.last_mut())
3960 {
3961 cell.clear_leading_spacer();
3962 }
3963 }
3964
3965 /// Is a wide pair standing at columns 0..=1 of `row` — i.e. is there a record for
3966 /// `void_wrap_artefact_above` to void? A cheap pre-mutation test the four call sites share, so
3967 /// the rule lives in one place rather than being re-derived per verb (ADR-0025 D2).
3968 fn wrapped_pair_at_row_start(&self, row: usize) -> bool {
3969 self.grid.cell(row, 0).is_wide()
3970 }
3971
3972 /// Drop a wide-wrap artefact marker that has outlived the wrap it belonged to, without
3973 /// touching the wrap itself.
3974 ///
3975 /// The mirror of the marker clean-up inside `end_wrap`, for the verbs that erase *leftward*:
3976 /// `EL 1` and `ED 1` correctly leave the wrap alone (the row's tail still flows onward), but
3977 /// they can still clear the last column, and then the artefact's blank turns into visible
3978 /// text that a reflow bakes in permanently. Only the marker goes; the wrap is the caller's
3979 /// business.
3980 fn drop_artefact_if_erased(&mut self, row: usize, from: usize, to: usize) {
3981 let last = self.grid.cols() - 1;
3982 if from <= last && to > last {
3983 self.grid.cell_mut(row, last).clear_leading_spacer();
3984 }
3985 }
3986
3987 /// Shift `[top..=bottom]` by one line — up unless `down` — and end the wraps the shift
3988 /// falsified. Every row-shifting verb (IL/DL/SU/SD and the region paths in LF/RI) goes
3989 /// through here so the repair cannot be forgotten at a call site (ADR-0025 D2).
3990 ///
3991 /// The wrap flag claims "this row continues into the **next** row", so it is a statement about
3992 /// *adjacency*, and rotating whole `Row`s keeps it true for free: both halves of a pair inside
3993 /// the region move by the same line, so the claim still describes the same neighbour. Only the
3994 /// two seams falsify it, where a row's next neighbour changed underneath it:
3995 ///
3996 /// - **`top - 1`**, just outside the region. Its continuation rotated away (up-shift) or was
3997 /// pushed down (down-shift), so whatever now sits at `top` is a stranger. This is the seam
3998 /// that merges two unrelated logical lines in copy/search/accessible text (#540's repro).
3999 /// - **the row that lost its continuation to the blank** — `bottom - 1` after an up-shift (the
4000 /// blank lands at `bottom`), `bottom` after a down-shift (its continuation rotated up to
4001 /// `top` and was blanked there). The down-shift form is the one that reaches *outside* the
4002 /// region: the stale claim points at `bottom + 1`, a row the verb never touched.
4003 ///
4004 /// Damaging matters as much as clearing, and `end_wrap` does both: `top - 1` is outside the
4005 /// region, so the scroll op the caller records does not cover it and a `Partial` frame would
4006 /// never re-ship the derived `WRAPLINE` bit.
4007 ///
4008 /// **Each seam has exactly one exemption, and both are facts about the caller that this
4009 /// function cannot see** — which is why they are parameters rather than tests:
4010 ///
4011 /// - `evicts_to_scrollback` exempts the **top** seam: a linefeed pushes row 0 into scrollback,
4012 /// so the readers' `[scrollback ++ grid]` walk finds the continuation one row further back
4013 /// and adjacency survives.
4014 /// - `serves_wrap` exempts the **bottom** seam: the shift was asked for by `wrapline`, so the
4015 /// blank it exposes at `bottom` is not a stranger that displaced a continuation — it *is*
4016 /// the continuation, about to be written into (#557).
4017 ///
4018 /// Both are one-sided on purpose. A wrap-serving scroll still falsifies the top seam, and a
4019 /// scrollback-evicting linefeed still falsifies the bottom one when no wrap asked for it.
4020 ///
4021 /// **No reference implements this rule**, so it is derived rather than ported — ADR-0004, the
4022 /// spec is the authority for VT semantics, above any implementation:
4023 ///
4024 /// - **ghostty** clears the wrap on *every* row a full-width IL/DL touches
4025 /// (`terminal/Terminal.zig:2746-2752`, `:2906-2912` @ `e6e26e1`). The clear runs *before* the
4026 /// row swap at `:2936-2939`, so both ends stay false: an interior pair is split, not
4027 /// preserved. It still never reaches the row above the shifted range.
4028 /// - **alacritty** has no `WRAPLINE` clear on any scroll path (@ `852e971`).
4029 /// - **xterm.js** splices whole line objects and never touches `isWrapped`
4030 /// (`common/InputHandler.ts:1345-1402` @ `699f553`). Its opposite polarity — "I continue the
4031 /// *previous* row" (`common/buffer/Buffer.ts:566-570`) — moves the exposure to the mirrored
4032 /// seam rather than removing it: a spliced-in line keeps a continuation claim about a
4033 /// predecessor it never met.
4034 ///
4035 /// The seam row's wide-wrap *marker* is the same shift's other half, and it now rides along:
4036 /// `end_wrap` clears both (#534), and the `top == 0` branch below — the one seam whose row is
4037 /// not a grid row — couples them inline for the same reason.
4038 ///
4039 /// **Validity condition for clearing at the seams rather than everywhere.** ghostty clears the
4040 /// wrap and the spacer head on *every* row a full-width IL/DL touches, and its own comment
4041 /// gives two reasons: it splits interior pairs, **and** it supports left/right margins
4042 /// (DECSLRM), where a partial-row shift can break an interior pair without moving its
4043 /// neighbour. justerm rotates whole `Row`s and implements no DECSLRM, so an interior pair and
4044 /// its continuation always move together and seam-only is sound. If left/right margins ever
4045 /// land, this rule and #534's marker rule break at the same time — neither is safe under a
4046 /// shift that moves part of a row.
4047 fn shift_region(
4048 &mut self,
4049 top: usize,
4050 bottom: usize,
4051 down: bool,
4052 evicts_to_scrollback: bool,
4053 serves_wrap: bool,
4054 ) {
4055 if down {
4056 self.grid.scroll_down_region(top, bottom);
4057 } else {
4058 self.grid.scroll_up_region(top, bottom);
4059 }
4060 // Recording the scroll op is part of shifting, not a step a caller adds after: damage is
4061 // indexed by row position, so `record_scroll` rotates `line_damage` with the content. A
4062 // seam clear damaged *before* that rotation is carried to the wrong row — and on a
4063 // down-shift it lands on `top`, which `record_scroll` immediately overwrites with
4064 // `fully_damaged`. The clear then never reaches the wire at all: the model splits the
4065 // rows, a `Partial` frame does not say so, and the consumer keeps them joined forever.
4066 // Ordering it here is what makes that unrepeatable at a sixth call site.
4067 self.record_scroll(top, bottom, if down { -1 } else { 1 });
4068 if top > 0 {
4069 self.end_wrap(top - 1);
4070 } else if !evicts_to_scrollback && !self.on_alt {
4071 // `top == 0` does not mean "no row above": on the primary the text readers walk
4072 // `[scrollback ++ grid]` as one buffer (`abs_floor() == 0`), so the row above grid row
4073 // 0 is the last *scrollback* row and it can wrap into the screen. A full-screen SU /
4074 // DL / RI therefore leaves this issue's defect one row higher, outside the grid.
4075 //
4076 // `evicts_to_scrollback` is what keeps `linefeed` out: it pushes grid row 0 into
4077 // scrollback, so the continuation is re-attached one row further back and the claim
4078 // stays true — clearing there would split a line the scroll preserved. On the alt
4079 // screen `abs_floor()` is the screen top, so no join crosses the boundary at all.
4080 //
4081 // No damage is owed with the clear, unlike `end_wrap`'s grid form: a scrollback row
4082 // only reaches the wire while `display_offset > 0`, and there `damage()` returns an
4083 // empty `Partial` (`term.rs`, the frozen-viewport short-circuit) while any scroll that
4084 // *moves* the viewport marks full damage. Valid as long as that short-circuit holds.
4085 //
4086 // The artefact marker goes with the wrap here exactly as it does in `end_wrap`, and
4087 // this branch is the reason that coupling cannot simply live in `end_wrap`: it is the
4088 // one wrap-ending path whose row is not a grid row, so it does not call it. Leaving it
4089 // out left #534's defect alive one row above the grid — reachable from every
4090 // `scroll_region_lines` verb, since all of them pass `evicts_to_scrollback: false`,
4091 // and visible as a word selection one cell too wide plus a reflow that bakes the
4092 // stranded marker mid-row.
4093 if let Some(row) = self.scrollback.back_mut() {
4094 row.set_wrapped(false);
4095 if let Some(cell) = row.last_mut() {
4096 cell.clear_leading_spacer();
4097 }
4098 }
4099 }
4100 // The blank lands at `bottom` going up and at `top` going down, so the row that lost its
4101 // continuation is the one just above it. Going down that is `top - 1`, already cleared
4102 // above; going up it is `bottom - 1`, which for a one-row region is that same row.
4103 //
4104 // The up-shift form needs the `bottom + 1` guard, and it is not defensive — without it the
4105 // clear destroys a **live** wrap. A row at the screen's bottom edge that wraps is the
4106 // ordinary soft-wrap-at-the-last-row state: `wrapline` sets the flag and the linefeed
4107 // scrolls precisely so the continuation has somewhere to land, which is the *next* row
4108 // after this shift. Its claim is about a row that does not exist yet, so the shift makes it
4109 // true rather than false.
4110 //
4111 // **The rest of that guard's original rationale was too narrow, and #557 is what it cost.**
4112 // It read: *"the link is only broken when there is a stationary row below the region
4113 // (`bottom + 1 < rows`): then the continuation stayed put while its lead moved up."* A
4114 // stationary row below is **necessary but not sufficient**. At a *region's* bottom the same
4115 // wrapline-asked-for scroll happens with `bottom + 1 < rows` perfectly true, and the clear
4116 // then split the logical line the scroll existed to continue. The geometry was never the
4117 // discriminator; **why the shift is happening** is — which is what `serves_wrap` carries.
4118 //
4119 // The guard stays anyway: it is the screen-bottom case of the same fact, and it also holds
4120 // for a *non*-wrap-serving linefeed at the screen edge.
4121 //
4122 // One invariant is still worth naming, because it was not true when this guard was first
4123 // written: **a row only claims a wrap if a next row will exist for it**. A row parked below
4124 // a DECSTBM region kept a permanent false claim, and this guard preserved it — the #540
4125 // completeness pass merged two unrelated logical lines through exactly that hole. The claim
4126 // is now gated at its set site (`write_glyph` asks `wrapline_advances`), so the guard's
4127 // premise holds. Valid as long as that gate stays.
4128 let orphaned = if serves_wrap {
4129 // The blank this shift just exposed is the continuation the wrap is waiting for, so
4130 // there is nothing to falsify — see the `serves_wrap` note on `linefeed_inner` (#557).
4131 None
4132 } else if down {
4133 Some(bottom)
4134 } else if bottom + 1 < self.grid.rows() {
4135 bottom.checked_sub(1)
4136 } else {
4137 None
4138 };
4139 if let Some(row) = orphaned {
4140 self.end_wrap(row);
4141 }
4142 }
4143
4144 fn erase_display(&mut self, mode: u16) {
4145 let (cols, rows) = (self.grid.cols(), self.grid.rows());
4146 let (cr, cc) = (self.cursor.row, self.cursor.col);
4147 match mode {
4148 0 => {
4149 // Erases this row's tail and every row below, so nothing can continue from here
4150 // — and the rows below cannot continue either.
4151 self.clear_cells(cr, cc, cols);
4152 self.end_wrap(cr);
4153 for row in (cr + 1)..rows {
4154 self.clear_cells(row, 0, cols);
4155 self.end_wrap(row);
4156 self.dispose_markers_on_row(row);
4157 }
4158 }
4159 1 => {
4160 // Leftward: this row's tail survives, so its own wrap does. The rows *above* are
4161 // gone entirely.
4162 for row in 0..cr {
4163 self.clear_cells(row, 0, cols);
4164 self.end_wrap(row);
4165 self.dispose_markers_on_row(row);
4166 }
4167 self.clear_cells(cr, 0, cc + 1);
4168 self.drop_artefact_if_erased(cr, 0, cc + 1);
4169 // Covering the whole row means nothing continues from it. xterm.js has a
4170 // dedicated arm for exactly this case, in its own words: *"Deleted entire
4171 // previous line. This next line can no longer be wrapped."*
4172 // (`InputHandler.ts:1248-1252` — under its continuation polarity that assignment
4173 // is this engine's `end_wrap(cr)`.) `EL 1` has no such arm there, and none here.
4174 if cc + 1 == cols {
4175 self.end_wrap(cr);
4176 }
4177 }
4178 2 => {
4179 for row in 0..rows {
4180 self.clear_cells(row, 0, cols);
4181 self.end_wrap(row);
4182 self.dispose_markers_on_row(row);
4183 }
4184 }
4185 // Notably **`3` (ED 3, erase scrollback) is not implemented** and falls through
4186 // here as a no-op. Recorded rather than filed, because an unimplemented verb is
4187 // not a defect — but whoever implements it inherits an obligation that is invisible
4188 // from this site (#660's completeness pass): it would be the first verb that
4189 // shortens the buffer *from the front* by N lines, so it needs both an anchor
4190 // fixup (selection, markers **and** tracked points are absolute-from-oldest — three
4191 // holders since #691, and the third has no frame to make its drift visible; the
4192 // *fourth* is search highlights, which every list of this obligation has so far
4193 // omitted — see the destruction-funnel invariant note) and a `display_offset`
4194 // clamp. Without the second, `selection_range`'s `scrollback.len() - display_offset`
4195 // underflows — and so do the same expressions in `viewport_line`,
4196 // `viewport_link_at` and `match_spans`. alacritty's `ClearMode::Saved` arm does
4197 // both (`term/mod.rs:1806-1811`).
4198 //
4199 // **A real `clear` emits it** — measured on the VM, `ESC[H ESC[2J ESC[3J`
4200 // (`tests/fixtures/osc133_clear.raw`) — so this no-op is reached in ordinary
4201 // traffic and not only by a test. Its visible consequence since #750: `ED 2`
4202 // now retires the command marks on the screen while the ones that already
4203 // scrolled off survive a `clear` a real terminal would have erased them with
4204 // (`command_lines_capture.rs` pins that, so implementing this verb will move
4205 // a test rather than silently changing an answer).
4206 _ => {}
4207 }
4208 }
4209
4210 /// Erase in line (EL): 0 = cursor→end, 1 = start→cursor, 2 = whole line.
4211 fn erase_line(&mut self, mode: u16) {
4212 let cols = self.grid.cols();
4213 let (cr, cc) = (self.cursor.row, self.cursor.col);
4214 match mode {
4215 // Erase right — ends the wrap at any column (xterm's `ClearRight`).
4216 0 => {
4217 self.clear_cells(cr, cc, cols);
4218 self.end_wrap(cr);
4219 }
4220 // Erase left — the tail survives, so the wrap does. The artefact marker does not:
4221 // if the erase reached the last column it just blanked the cell the marker described.
4222 1 => {
4223 self.clear_cells(cr, 0, cc + 1);
4224 self.drop_artefact_if_erased(cr, 0, cc + 1);
4225 }
4226 // Erase the whole line — see `end_wrap`: a deliberate divergence from xterm.
4227 2 => {
4228 self.clear_cells(cr, 0, cols);
4229 self.end_wrap(cr);
4230 }
4231 _ => {}
4232 }
4233 }
4234
4235 // ---- intra-line editing (ICH / DCH / ECH) --------------------------------
4236
4237 /// ECH (CSI Pn X): erase `n` cells in place from the cursor — no shift.
4238 /// BCE-filled (via `clear_cells`); pending-wrap is left untouched.
4239 fn erase_chars(&mut self, n: usize) {
4240 let cols = self.grid.cols();
4241 let (row, col) = (self.cursor.row, self.cursor.col);
4242 let to = (col + n).min(cols);
4243 self.clear_cells(row, col, to);
4244 // Destroys content from the cursor rightward, so the row can no longer be continuing —
4245 // unconditionally, at any column and for any `n`. Both references do exactly this (see
4246 // `end_wrap`): xterm routes ECH through the same `ClearRight` as `EL 0`, ghostty calls
4247 // `cursorResetWrap()` in `eraseChars`.
4248 self.end_wrap(row);
4249 }
4250
4251 /// ICH (CSI Pn @): insert `n` blanks at the cursor, shifting the rest of the
4252 /// line right; cells pushed past the right edge are lost. The opened gap is
4253 /// BCE-filled; pending-wrap is left untouched.
4254 fn insert_chars(&mut self, n: usize) {
4255 let cols = self.grid.cols();
4256 let (r, col) = (self.cursor.row, self.cursor.col);
4257 let n = n.min(cols - col);
4258 if n == 0 {
4259 return;
4260 }
4261 // Shifting a wrapped pair out of columns 0/1 ends it, so the row above's artefact record
4262 // is void (#534). Asked **before** the shift, which is what keeps IRM correct: `write_glyph`
4263 // routes its wide-at-boundary insert through here *after* `vacate_for_wrap` has just set
4264 // the marker on the row above, and a post-shift test would see the freshly blanked gap and
4265 // clear the marker inside its own SET site's critical section. Pre-shift the question is
4266 // about the pair that was actually there, which is the one the record is about.
4267 if col <= 1 && self.wrapped_pair_at_row_start(r) {
4268 self.void_wrap_artefact_above(r);
4269 }
4270 let bg = self.cursor.pen.bg;
4271 let row = self.grid.row_mut(r);
4272 // Shift [col .. cols-n) right by n; the tail falls off the edge. The
4273 // combining map follows the moved cells (the bit travels with the raw
4274 // copy, the cluster data must too).
4275 row.copy_within(col..cols - n, col + n);
4276 row.move_maps(col..cols - n, col + n);
4277 for cell in &mut row[col..col + n] {
4278 cell.reset();
4279 cell.set_bg(bg);
4280 }
4281 // Repair wide-char halves split at the seams (no-orphan invariant):
4282 // a lead just before the gap lost its spacer; the first shifted cell may
4283 // be a spacer whose lead did not move.
4284 if col > 0 && self.grid.cell(r, col - 1).is_wide() {
4285 self.free_cell(r, col - 1);
4286 }
4287 if col + n < cols && self.grid.cell(r, col + n).is_wide_spacer() {
4288 self.free_cell(r, col + n);
4289 }
4290 // A lead shifted to the last column lost its spacer off the edge.
4291 if self.grid.cell(r, cols - 1).is_wide() {
4292 self.free_cell(r, cols - 1);
4293 }
4294 // Note ICH needs no repair to *this* row's marker: a right shift always pushes the last
4295 // column off the edge, so it discards a marker rather than carrying one inward —
4296 // measured, and pinned by `ich_discards_the_marker_off_the_edge`.
4297 self.damage_span(r, col, cols - 1);
4298 }
4299
4300 /// DCH (CSI Pn P): delete `n` cells at the cursor, shifting the tail left; the
4301 /// vacated cells at the right are BCE-blanked. Pending-wrap is left untouched.
4302 fn delete_chars(&mut self, n: usize) {
4303 let cols = self.grid.cols();
4304 let (r, col) = (self.cursor.row, self.cursor.col);
4305 let n = n.min(cols - col);
4306 if n == 0 {
4307 return;
4308 }
4309 // The shift pulls the tail left and blanks the far end, so the row stops continuing —
4310 // ghostty says it outright (*"Our row's soft-wrap is always reset"* in `deleteChars`,
4311 // `Terminal.zig:3133` @ `e6e26e1`).
4312 //
4313 // **Before the shift, not after** (#534): `end_wrap` clears the artefact marker at the
4314 // *last* column, and the marker is a cell bit that the shift carries inward with every
4315 // other cell. Ending the wrap afterwards would clear a column the marker has already left,
4316 // stranding it mid-row where it describes nothing (ADR-0025 D3) and silently swallows the
4317 // blank between two runs in copy, search and accessible text. Same shape as #540's
4318 // `record_scroll` ordering: the clear has to happen where the state still is.
4319 self.end_wrap(r);
4320 // Deleting a wrapped pair out of columns 0/1 ends it, so the row above's artefact record
4321 // is void — and this is where the "ask before, not after" rule earns its keep twice over:
4322 // a `DCH` can pull the *next* wide glyph left into column 0, which a post-shift "is a wide
4323 // lead standing here?" test happily accepts even though the pair the record was about has
4324 // been deleted. ghostty asks the same question at the same point:
4325 // `Screen.splitCellBoundary(cursor.x)` from `deleteChars` (`Terminal.zig:3107` @ `e6e26e1`),
4326 // whose `x == 0 or x == 1` branch reaches up a row and clears the spacer head.
4327 if col <= 1 && self.wrapped_pair_at_row_start(r) {
4328 self.void_wrap_artefact_above(r);
4329 }
4330 let bg = self.cursor.pen.bg;
4331 let row = self.grid.row_mut(r);
4332 // Shift [col+n .. cols) left to [col ..); BCE-fill the vacated tail. The
4333 // combining map follows the moved cells.
4334 row.copy_within(col + n..cols, col);
4335 row.move_maps(col + n..cols, col);
4336 for cell in &mut row[cols - n..cols] {
4337 cell.reset();
4338 cell.set_bg(bg);
4339 }
4340 // Repair wide-char halves split by the deletion (no-orphan invariant):
4341 // a lead just before the cut lost its spacer; the cell now at the cursor
4342 // may be a spacer whose lead was deleted.
4343 if col > 0 && self.grid.cell(r, col - 1).is_wide() {
4344 self.free_cell(r, col - 1);
4345 }
4346 if self.grid.cell(r, col).is_wide_spacer() {
4347 self.free_cell(r, col);
4348 }
4349 self.damage_span(r, col, cols - 1);
4350 }
4351
4352 // ---- line/region editing (IL / DL / SU / SD) -----------------------------
4353
4354 /// Scroll rows `[top..=bottom]` by `n` lines, BCE-filling the exposed lines.
4355 /// `down` inserts blanks at the top (content moves down); otherwise content
4356 /// moves up and blanks appear at the bottom. Reuses the one-line region scroll
4357 /// primitives (so damage + scroll-op accumulation come for free), then fills
4358 /// the exposed lines with the current SGR background.
4359 fn scroll_region_lines(&mut self, top: usize, bottom: usize, n: usize, down: bool) {
4360 let height = bottom - top + 1;
4361 let n = n.min(height);
4362 if n == 0 {
4363 return;
4364 }
4365 // Anchors (selection #3, markers #118/#158) live at absolute buffer lines;
4366 // SU/SD/IL/DL don't accrue scrollback, so `base` is stable across the loop.
4367 let base = self.scrollback.len();
4368 for _ in 0..n {
4369 self.shift_region(top, bottom, down, false, false);
4370 // Rotate anchors with the content, like `linefeed`/`reverse_index`
4371 // (#162). `up` = content moved up = the non-`down` case. Markers rotate
4372 // with the active buffer (#187) — alt-scoped on the alt screen, so no
4373 // guard. **The selection is unguarded here for the same reason, not because
4374 // "it is cleared on alt enter"** — that was this comment's claim until #660 and
4375 // it is false: a selection made while the alt screen is up is ordinary, it does
4376 // reach this line, and rotating it is correct, because the content really did
4377 // move under it. The code was right; only its stated reason was wrong.
4378 self.selection_rotate_region(base + top, base + bottom, !down);
4379 self.markers_rotate_region(base + top, base + bottom, !down);
4380 self.tracked_rotate_region(base + top, base + bottom, !down);
4381 }
4382 self.invalidate_search_highlights();
4383 // BCE-fill the n exposed lines (the primitives blank to default).
4384 let bg = self.cursor.pen.bg;
4385 let (fill_top, fill_end) = if down {
4386 (top, top + n)
4387 } else {
4388 (bottom + 1 - n, bottom + 1)
4389 };
4390 let cols = self.grid.cols();
4391 for r in fill_top..fill_end {
4392 for c in 0..cols {
4393 let cell = self.grid.cell_mut(r, c);
4394 cell.reset();
4395 cell.set_bg(bg);
4396 }
4397 }
4398 }
4399
4400 /// SU (CSI Pn S): scroll the scroll region up by `n`.
4401 fn scroll_up_lines(&mut self, n: usize) {
4402 self.scroll_region_lines(self.scroll_top, self.scroll_bottom, n, false);
4403 }
4404
4405 /// SD (CSI Pn T): scroll the scroll region down by `n`.
4406 fn scroll_down_lines(&mut self, n: usize) {
4407 self.scroll_region_lines(self.scroll_top, self.scroll_bottom, n, true);
4408 }
4409
4410 /// IL (CSI Pn L): insert `n` blank lines at the cursor, scrolling
4411 /// `[cursor..=scroll_bottom]` down. A no-op when the cursor is outside the
4412 /// scroll region.
4413 fn insert_lines(&mut self, n: usize) {
4414 let cur = self.cursor.row;
4415 if cur < self.scroll_top || cur > self.scroll_bottom {
4416 return;
4417 }
4418 // 3-1 for clearing, and the odd one out is the row-shift family's usual
4419 // outlier: xterm `util.c:1295`, ghostty `Terminal.zig:2691` (*"Always unset
4420 // pending wrap"*), and xterm.js structurally — `insertLines` opens with
4421 // `_restrictCursor()`, whose `Math.min(cols - 1, …)` un-parks the column
4422 // (`InputHandler.ts:1346`, `:890`). Only alacritty leaves it.
4423 //
4424 // `SU`/`SD` deliberately do **not** join them: ghostty saves and restores the
4425 // flag around those two on purpose (`Terminal.zig:2390`), so this is a
4426 // per-verb answer and not "row-shift verbs clear" (#848).
4427 self.cursor.pending_wrap = false;
4428 self.scroll_region_lines(cur, self.scroll_bottom, n, true);
4429 }
4430
4431 /// DL (CSI Pn M): delete `n` lines at the cursor, scrolling
4432 /// `[cursor..=scroll_bottom]` up. A no-op when the cursor is outside the
4433 /// scroll region.
4434 fn delete_lines(&mut self, n: usize) {
4435 let cur = self.cursor.row;
4436 if cur < self.scroll_top || cur > self.scroll_bottom {
4437 return;
4438 }
4439 // Same 3-1 as `Term::insert_lines`; xterm `util.c:1388`, ghostty
4440 // `Terminal.zig:2856`, xterm.js `InputHandler.ts:1380` via `_restrictCursor`.
4441 self.cursor.pending_wrap = false;
4442 self.scroll_region_lines(cur, self.scroll_bottom, n, false);
4443 }
4444
4445 // ---- SGR (CSI m) ---------------------------------------------------------
4446
4447 fn sgr(&mut self, params: &Params) {
4448 let pen = &mut self.cursor.pen;
4449 let mut iter = params.iter();
4450 while let Some(param) = iter.next() {
4451 let code = param.first().copied().unwrap_or(0);
4452 match code {
4453 0 => pen.reset(),
4454 1 => pen.flags.insert(CellFlags::BOLD),
4455 2 => pen.flags.insert(CellFlags::DIM),
4456 3 => pen.flags.insert(CellFlags::ITALIC),
4457 // SGR 4 and its colon sub-parameter form (#829). The sub-parameter is already
4458 // here — `params.iter()` yields the whole `&[u16]` and every other arm reads only
4459 // `first()` — so `4:3` has been arriving as `[4, 3]` and being truncated to a
4460 // plain underline. `4:0` is an explicit off in every reference that implements
4461 // the form. An unrecognised sub-style stays a single underline: three of the four
4462 // references degrade that way (xterm is the outlier and swallows the whole
4463 // parameter), and losing an underline entirely is a worse failure than drawing the
4464 // wrong kind — the application asked for emphasis and would get nothing, with no way
4465 // to tell. #830 confirmed that rule against the corpus rather than changing it.
4466 //
4467 // Every value is stored **and every value is now drawn** (#830). #829 stored all six
4468 // while the shader branched on `Curly` alone, because storing and drawing are not
4469 // symmetric in cost: storing 2/4/5 was three arms and no pixel, while NOT storing
4470 // them was a loss #830 could not have repaired — a cell written `4:5m` and scrolled
4471 // into history would have recorded `Single` forever.
4472 4 => {
4473 let style = match param.get(1) {
4474 None | Some(1) => UnderlineStyle::Single,
4475 Some(0) => UnderlineStyle::None,
4476 Some(2) => UnderlineStyle::Double,
4477 Some(3) => UnderlineStyle::Curly,
4478 Some(4) => UnderlineStyle::Dotted,
4479 Some(5) => UnderlineStyle::Dashed,
4480 Some(_) => UnderlineStyle::Single,
4481 };
4482 pen.flags.set_underline_style(style);
4483 }
4484 5 => pen.flags.insert(CellFlags::BLINK),
4485 7 => pen.flags.insert(CellFlags::INVERSE),
4486 8 => pen.flags.insert(CellFlags::HIDDEN),
4487 9 => pen.flags.insert(CellFlags::STRIKETHROUGH),
4488 // The legacy double underline (#830), which predates the sub-parameter form above.
4489 // It lands on the same field, so `24` clears both spellings — ghostty gets that by
4490 // construction (4, 4:x, 21 and 24 all reduce to one variant on one arm,
4491 // `Screen.zig:2269-2271`) where xterm leaves two independent bits set and lets each
4492 // consumer resolve them (`html.c:208-216` against `svg.c:271`).
4493 //
4494 // **Decided by the spec, not by a head count**, because the corpus is not
4495 // unanimous: `vte` — the crate this engine's own parser is built on — reads `[21]`
4496 // as `CancelBold` (`vte-0.15.0/src/ansi.rs:1849`), so alacritty produces no double
4497 // underline from it at all. `ctlseqs.txt:1200` reads *"Doubly-underlined, ECMA-48
4498 // 3rd"*, and the VT tie-breaker puts the spec above any implementation including
4499 // ours; xterm (`charproc.c:4407-4409`), ghostty (`sgr.zig:301`) and xterm.js
4500 // (`InputHandler.ts:2653-2655`) all agree. A reference that *contradicts* rather
4501 // than omits is the third case ADR-0004's text does not classify — #824 settled
4502 // that routing for DA2 and it applies unchanged here.
4503 //
4504 // The consequence, pinned rather than left to a bug report: an application sending
4505 // `CSI 1m` then `CSI 21m` **meaning "stop bold"** gets a double underline and keeps
4506 // its bold. That is what `22` is for, and this arm deliberately does not touch it.
4507 21 => pen.flags.set_underline_style(UnderlineStyle::Double),
4508 22 => pen.flags.remove(CellFlags::BOLD | CellFlags::DIM),
4509 23 => pen.flags.remove(CellFlags::ITALIC),
4510 // Clears the style, not just the derived flag (#829) — removing `UNDERLINE` alone
4511 // would leave a styled-but-not-underlined pen, the disagreement this model exists
4512 // to make unrepresentable.
4513 24 => pen.flags.set_underline_style(UnderlineStyle::None),
4514 25 => pen.flags.remove(CellFlags::BLINK),
4515 27 => pen.flags.remove(CellFlags::INVERSE),
4516 28 => pen.flags.remove(CellFlags::HIDDEN),
4517 29 => pen.flags.remove(CellFlags::STRIKETHROUGH),
4518 30..=37 => pen.fg = Color::Indexed((code - 30) as u8),
4519 38 => {
4520 if let Some(c) = parse_extended_color(param, &mut iter) {
4521 pen.fg = c;
4522 }
4523 }
4524 39 => pen.fg = Color::Default,
4525 40..=47 => pen.bg = Color::Indexed((code - 40) as u8),
4526 48 => {
4527 if let Some(c) = parse_extended_color(param, &mut iter) {
4528 pen.bg = c;
4529 }
4530 }
4531 49 => pen.bg = Color::Default,
4532 // Underline colour (SGR 58 / 59, #520) — same extended-colour grammar
4533 // as 38/48 (colon `58:2:r:g:b` / `58:5:n`, or legacy semicolon), so it
4534 // reuses `parse_extended_color` verbatim. 59 returns to "follow the fg".
4535 58 => {
4536 if let Some(c) = parse_extended_color(param, &mut iter) {
4537 pen.underline_color = c;
4538 }
4539 }
4540 59 => pen.underline_color = Color::Default,
4541 // bright foreground/background (aixterm) → palette 8..=15.
4542 90..=97 => pen.fg = Color::Indexed((code - 90 + 8) as u8),
4543 100..=107 => pen.bg = Color::Indexed((code - 100 + 8) as u8),
4544 _ => {}
4545 }
4546 }
4547 }
4548}
4549
4550/// Cap a recorded scroll to what a consumer can act on **and** what the wire can
4551/// carry (#661) — two bounds for two different reasons, see [`Term::scroll_delta`].
4552///
4553/// A free function so the second bound is provable without building the grid that
4554/// reaches it: a region taller than `i16::MAX` means a screen taller than 32 767
4555/// rows, and every scroll of it rotates a `line_damage` of that length, so driving
4556/// the engine to that corner costs ~10⁹ element moves (measured: 16 s in a debug
4557/// build, for one assertion). The engine-level tests in `tests/damage.rs` prove
4558/// `scroll_delta` applies this at ordinary sizes; the wire-level one in
4559/// `tests/serialize.rs` proves a count at the bound survives `encode`.
4560fn cap_scroll(op: ScrollOp) -> ScrollOp {
4561 let height = op.bottom.saturating_sub(op.top).saturating_add(1) as isize;
4562 let bound = height.min(MAX_SCROLL_COUNT);
4563 ScrollOp {
4564 count: op.count.clamp(-bound, bound),
4565 ..op
4566 }
4567}
4568
4569/// Parse `38`/`48`/`58` extended colour (foreground / background / underline colour, #520), in
4570/// either form:
4571/// - sub-parameter (colon) form inline in `param`: `38:5:n`, `38:2:r:g:b`
4572/// (optionally `38:2:cs:r:g:b` with a colorspace id), or
4573/// - legacy (semicolon) form: pull the following top-level params from `iter`.
4574///
4575/// The colon RGB form is **count-based** (`off = if param.len() >= 6 { 3 } else { 2 }`): a 5-param
4576/// `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
4577/// `38:2::r:g:b` with an *empty* cs, the form kitty/nvim actually emit — skips the colorspace slot.
4578/// The short 5-param form is **non-conformant to T.416 / ISO-8613-6** (the de-jure standard always
4579/// carries a colorspace field), but tolerating it is the **ecosystem-dominant** behaviour, verified
4580/// against real source (2026-07, #520): VTE (`src/sgr.hh`, branches on `n > 4`), foot (`csi.c`,
4581/// `sub.idx >= 5`) and alacritty (`ansi.rs`, `params.len() > 4`) all count the sub-parameters and
4582/// decode the short form as RGB(r,g,b), exactly as here. VTE's own comment calls it a "common
4583/// misinterpretation of the standard" (foot: "bastard version") that it supports anyway; **only
4584/// xterm.js is strict** (always consumes a colorspace slot, so it misreads the short form). So a
4585/// difference from xterm here is deliberate leniency shared with the non-xterm ecosystem, not a
4586/// defect — the ADR-0004 spec-faithfulness is about not *omitting* behaviour, not about rejecting a
4587/// widely-emitted non-standard input.
4588fn parse_extended_color<'a, I>(param: &[u16], iter: &mut I) -> Option<Color>
4589where
4590 I: Iterator<Item = &'a [u16]>,
4591{
4592 if param.len() > 1 {
4593 // Colon sub-parameter form: kind is param[1].
4594 match param[1] {
4595 2 => {
4596 // 38:2:r:g:b (len 5) or 38:2:cs:r:g:b (len 6, colorspace skipped).
4597 let off = if param.len() >= 6 { 3 } else { 2 };
4598 let r = *param.get(off)? as u8;
4599 let g = *param.get(off + 1)? as u8;
4600 let b = *param.get(off + 2)? as u8;
4601 Some(Color::Rgb(r, g, b))
4602 }
4603 5 => Some(Color::Indexed(*param.get(2)? as u8)),
4604 _ => None,
4605 }
4606 } else {
4607 // Legacy semicolon form: kind, then its operands, are separate params.
4608 match iter.next()?.first().copied()? {
4609 2 => {
4610 let r = iter.next()?.first().copied()? as u8;
4611 let g = iter.next()?.first().copied()? as u8;
4612 let b = iter.next()?.first().copied()? as u8;
4613 Some(Color::Rgb(r, g, b))
4614 }
4615 5 => Some(Color::Indexed(iter.next()?.first().copied()? as u8)),
4616 _ => None,
4617 }
4618 }
4619}
4620
4621/// Reflow one screen (joined with its `scrollback`) to `cols` x `rows`, tracking
4622/// `point` (a cursor in screen coordinates). Returns the new screen rows, the new
4623/// scrollback (capped to `limit`), and the new point. The alt screen passes an
4624/// empty scrollback and discards the returned one.
4625/// The fixed dimensions a resize reflows toward.
4626#[derive(Clone, Copy)]
4627struct ReflowDims {
4628 old_cols: usize,
4629 cols: usize,
4630 rows: usize,
4631 limit: usize,
4632 /// Whether a column change may **re-split** this pane's content, or only re-fit its rows.
4633 ///
4634 /// False for the alt screen (#567). Reflow re-splits a long line so history stays readable at
4635 /// the new width — it assumes the content is text that *flows*. The alt screen has no history,
4636 /// its content is a **layout** rather than a paragraph (re-wrapping htop's columns means
4637 /// nothing), and the application already knows the new size and repaints. All three references
4638 /// take the same position with the same shape — one flag on the same resize function:
4639 /// ghostty `alt.resize(.{ .reflow = false })`, alacritty `grid.resize(!is_alt, …)`, xterm.js
4640 /// gating on `_hasScrollback` with the alt buffer built as `new Buffer(false, …)`.
4641 ///
4642 /// It is not merely wasted work: measured on a real `htop` recording taken across a live
4643 /// `SIGWINCH`, re-splitting leaves debris in the cells htop does not overwrite, because htop
4644 /// repaints **without** clearing. `vim` hides it by erasing first.
4645 reflow: bool,
4646}
4647
4648/// The result of reflowing one pane.
4649struct PaneReflow {
4650 screen: Vec<Row>,
4651 scrollback: VecDeque<Row>,
4652 /// The cursor's new screen-relative position.
4653 cursor: (usize, usize),
4654 /// Each tracked extra point's new position **in this pane's own `[history ++ screen]` frame**,
4655 /// index-aligned with the `extra_abs` argument — *before* any history the caller discards.
4656 ///
4657 /// Reported raw, with `evicted` beside it, because the two callers translate differently and
4658 /// doing it here silently picked the primary's answer for both: the primary keeps its history,
4659 /// so an extra's absolute line only moves by what the cap threw away, while the alt pane has no
4660 /// history at all and everything above the screen is *gone*. Adding the alt result to the
4661 /// primary's scrollback length then produced a line the buffer does not have — reachable
4662 /// without any reflow, on a rows-only resize.
4663 extras: Vec<(usize, usize)>,
4664 /// Rows that left the buffer entirely off the front of this pane's history. For the primary
4665 /// that is the scrollback cap's eviction; for the alt pane, whose limit is `0` because it has
4666 /// no history, it is every row the shrink pushed off the top. An extra whose raw line is below
4667 /// this **is not in the buffer any more** — the caller decides what that means for its kind.
4668 evicted: usize,
4669}
4670
4671/// Reflow one pane (its `scrollback` joined with `screen`) to `dims`, tracking
4672/// the screen-relative cursor `point` plus any `extra_abs` points given in
4673/// **absolute** `[scrollback ++ screen]` coordinates (selection anchors).
4674fn reflow_pane(
4675 screen: Vec<Row>,
4676 scrollback: VecDeque<Row>,
4677 point: (usize, usize),
4678 extra_abs: &[(usize, usize)],
4679 dims: ReflowDims,
4680) -> PaneReflow {
4681 let scroll_len = scrollback.len();
4682 let mut all: Vec<Row> = scrollback.into();
4683 all.extend(screen);
4684
4685 // The cursor is screen-relative; lift it to absolute, then track it together
4686 // with the already-absolute extras.
4687 let mut pts: Vec<(usize, usize)> = Vec::with_capacity(1 + extra_abs.len());
4688 pts.push((scroll_len + point.0, point.1));
4689 pts.extend_from_slice(extra_abs);
4690
4691 let pts = if dims.reflow && dims.cols != dims.old_cols {
4692 let (reflowed, np) = crate::grid::reflow(all, dims.cols, &pts);
4693 all = reflowed;
4694 np
4695 } else {
4696 pts
4697 };
4698
4699 // The cursor can land one row past everything the reflow emitted — "just after the content"
4700 // when the content ends on a full row (#562). That row is real, and while the pane is shorter
4701 // than the screen the caller's fit supplies it for free. When the content already fills the
4702 // pane it has to be bought, and the price is one row of history: the pane **scrolls**, which is
4703 // what a terminal does when content grows past the bottom. Without it the cursor was pulled
4704 // back onto the last glyph and the next byte destroyed a character — the ordinary shell shape,
4705 // a prompt at the bottom of a full screen.
4706 //
4707 // Five earlier designs made `reflow` itself materialise the row and were rejected on
4708 // measurements (a cursor at column 59 resized to width 4 emptied the buffer; a blank-line
4709 // exemption turned 22 alt lines into 21). `reflow` cannot see this pane's budget, so it spent
4710 // what it did not have. Here the budget is in scope, and it is the gate: a pane with no history
4711 // cannot pay — the displaced row would be destroyed rather than archived — so it keeps clamping.
4712 //
4713 // `limit > 0`, deliberately, and not "is this the alt screen": since #567 the alt panes pass
4714 // `limit: 0` because that is what an alt screen's history is, so they are excluded by the budget
4715 // rather than by a branch. That branch is what the design carrying this rule was rejected for
4716 // needing.
4717 //
4718 // This **amends** ADR-0025 rather than reading it narrowly: `reflow` does not create rows; the
4719 // seam may, when the pane can pay. What that record measured is that materialising
4720 // *unconditionally* destroys content.
4721 let cursor_abs = pts[0].0 + usize::from(pts[0].1 == dims.cols);
4722 if dims.limit > 0 {
4723 while all.len() <= cursor_abs {
4724 all.push(Row::blank(dims.cols));
4725 }
4726 }
4727
4728 let split = all.len().saturating_sub(dims.rows);
4729 let history: Vec<Row> = all.drain(0..split).collect();
4730 let mut sb: VecDeque<Row> = history.into();
4731 let mut dropped = 0usize;
4732 while sb.len() > dims.limit {
4733 sb.pop_front();
4734 dropped += 1;
4735 }
4736
4737 // `reflow` may answer `col == cols` — "just after the last cell", which is a real place in the
4738 // logical line and no place in the grid (#562). The **cursor's** reading of it is the next
4739 // *write* position, so a full row means the start of the row after; the caller's row fit
4740 // provides that row (`Grid::set_screen` pads at the bottom). A mark reads the same value the
4741 // opposite way and keeps it verbatim — see `Term::resize`.
4742 let cursor_row = pts[0].0.saturating_sub(split);
4743 let cursor = if pts[0].1 == dims.cols {
4744 (cursor_row + 1, 0)
4745 } else {
4746 (cursor_row, pts[0].1)
4747 };
4748
4749 // The bound on a tracked line belongs **here**, not inside `reflow`: this is where the final
4750 // geometry is known. The screen is padded to `dims.rows` whatever `reflow` emitted, so this
4751 // pane's last addressable line is `split + dims.rows - 1`. Bounding against `reflow`'s own row
4752 // count instead clamped away rows the fit was about to create (#562), while still being the
4753 // only thing standing between an out-of-range anchor and a panic in the consumer's process —
4754 // selection anchors and marks are written back raw, unlike the cursor (`Cursor::set_point`).
4755 // Expressed in this pane's own frame, so it is the same frame `extras` and `evicted` are in.
4756 let max_line = split + dims.rows - 1;
4757
4758 // The cursor returns to screen-relative (its absolute index minus the history split). The
4759 // extras stay in this pane's frame — see the field docs for why they are not shifted here.
4760 PaneReflow {
4761 cursor,
4762 extras: pts[1..]
4763 .iter()
4764 .map(|&(l, c)| (l.min(max_line), c))
4765 .collect(),
4766 evicted: dropped,
4767 screen: all,
4768 scrollback: sb,
4769 }
4770}
4771
4772/// Default tab stops: one every 8 columns (incl. column 0), matching xterm.
4773fn default_tabs(cols: usize) -> Vec<bool> {
4774 (0..cols).map(is_default_tab_stop).collect()
4775}
4776
4777/// Whether `col` carries a stop in the default ladder.
4778///
4779/// Shared by the table the constructor builds and the extension [`Term::resize`]
4780/// performs, which fills at the *absolute* column index. The two must not drift,
4781/// and a second literal `8` is exactly how they would.
4782fn is_default_tab_stop(col: usize) -> bool {
4783 col.is_multiple_of(8)
4784}
4785
4786/// First sub-parameter of CSI param `idx`, or `default` when absent or zero
4787/// (a zero/omitted numeric param means "1" for cursor movement and "0" for
4788/// erase — callers pass the right default).
4789/// Push onto an XTWINOPS title stack, bounded at [`TITLE_STACK_DEPTH`] (#823).
4790///
4791/// At the bound the **oldest** entry goes and the push still succeeds, which is
4792/// what both implementations carrying this feature do — xterm.js `shift()`s its
4793/// array, alacritty `remove(0)`s its `Vec`, and xterm wraps a fixed-size one.
4794/// The alternative (refuse the push) is worse in the case that actually occurs:
4795/// it breaks the pairing for the innermost nesting levels, and those are the
4796/// ones a user unwinds first.
4797fn push_title(stack: &mut Vec<String>, value: String) {
4798 if stack.len() >= TITLE_STACK_DEPTH {
4799 stack.remove(0);
4800 }
4801 stack.push(value);
4802}
4803
4804fn param_or(params: &Params, idx: usize, default: u16) -> u16 {
4805 match params.iter().nth(idx).and_then(|p| p.first().copied()) {
4806 Some(v) if v != 0 => v,
4807 _ => default,
4808 }
4809}
4810
4811/// The `Pv` field of the secondary device-attributes report (#824), derived
4812/// from the crate version so a release cannot ship a report that disagrees
4813/// with what was published.
4814///
4815/// Semver components are padded base-100, so a higher version always reports a
4816/// higher number. That is alacritty's scheme, and it is a *reinterpretation* of
4817/// the spec rather than a divergence from it: `ctlseqs.txt` calls `Pv` "the
4818/// firmware version" and fixes no encoding for it.
4819///
4820/// **The encoding has a functional floor, and it is not cosmetic.** Measured on
4821/// a real pty by sweeping this field alone (RHEL 9.2, vim 8.2), vim picks its
4822/// mouse protocol off `Pv`:
4823///
4824/// ```text
4825/// Pv < 95 -> ttymouse=xterm (no upgrade at all)
4826/// Pv = 95 -> ttymouse=sgr (vim special-cases the exact >1;95;0c
4827/// signature that macOS Terminal sends)
4828/// 95..276 -> ttymouse=xterm2
4829/// Pv >= 277 -> ttymouse=sgr
4830/// ```
4831///
4832/// The mouse rows above reproduced across two independent runs, 11 arms each,
4833/// with no-reply controls bracketing both.
4834///
4835/// A further gate sits on the same field: vim's XTGETTCAP key-code
4836/// interrogation, which its `term.txt` (*xterm-codes*) documents as needing a
4837/// response indicating "patchlevel 141 or higher". Measured here only to the
4838/// extent of bracketing — present at 276 and 1500, absent at 1, 94 and 95 — so
4839/// the doc's 141 is consistent but not independently pinned. The number
4840/// therefore gates upgrades at three separate thresholds rather than one, and
4841/// 1500 clears all three.
4842///
4843/// justerm at 0.15.0 maps to 1500 and clears it comfortably. A `0.2.x` would map
4844/// to 200 and silently cost every consumer the SGR mouse encoding — so the
4845/// base-100 scheme is load-bearing for a reason that has nothing to do with
4846/// monotonicity, and lowering the base would be a behavioural change.
4847///
4848/// Three edges, all deliberate. A component of 100 or more carries into the
4849/// next place — justerm is far from that, and widening the base would change
4850/// every number already reported for no measured gain. The pre-release suffix
4851/// is cut at the **first** hyphen, which is where semver says it begins;
4852/// alacritty cuts at the last, which mis-parses a two-part suffix like
4853/// `-rc.1-dev`. And the monotonicity above holds only below `u16::MAX`: a CSI
4854/// parameter is a `u16` in `vte` (saturating), and ghostty types the field
4855/// `firmware_version: u16`, so a `Pv` past 65535 — major version 7 — reaches a
4856/// receiver saturated. alacritty carries the same latent property; the corpus
4857/// prescribes no wider encoding, so this is recorded rather than designed
4858/// around.
4859const fn version_number(version: &str) -> u32 {
4860 let bytes = version.as_bytes();
4861 let mut parts = [0u32; 3];
4862 let mut part = 0usize;
4863 let mut i = 0usize;
4864 while i < bytes.len() {
4865 let b = bytes[i];
4866 if b == b'-' || b == b'+' {
4867 break;
4868 } else if b == b'.' {
4869 part += 1;
4870 if part >= 3 {
4871 break;
4872 }
4873 } else if b.is_ascii_digit() {
4874 parts[part] = parts[part] * 10 + (b - b'0') as u32;
4875 }
4876 i += 1;
4877 }
4878 parts[0] * 10_000 + parts[1] * 100 + parts[2]
4879}
4880
4881/// `Pp` of the secondary DA report: 1 = VT220, from the spec's closed table
4882/// (`ctlseqs.txt:825`). It names the same terminal DA1 already advertises —
4883/// `CSI ? 62 ; 22 c`, where 62 *is* VT220 (`ctlseqs.txt:778`).
4884///
4885/// The two are not forced to agree in general: DA1's first parameter is an
4886/// operating **level** and `Pp` a device **type**, and xterm can decouple them
4887/// through DECTID. justerm implements neither DECTID nor DECSCL, so nothing
4888/// here can decouple them — which is why one terminal identity is the only
4889/// coherent answer, not because disagreeing would be malformed.
4890///
4891/// ghostty is the convergence check: it derives both from one device type
4892/// (`src/terminal/device_attributes.zig:82`, `:161` @ `e6e26e1`) and pairs a
4893/// level-62 DA1 with `Pp = 1` (`src/termio/stream_handler.zig:843`). Its DA1 is
4894/// *not* byte-identical to justerm's at its default — `clipboard-write` is
4895/// `.allow` (`src/config/Config.zig:2380`), which appends `;52`; the identical
4896/// string is its deny branch. alacritty pairs `?6c` (VT102) with `Pp = 0` and
4897/// xterm.js `?1;2c` (VT100) with `Pp = 0`, both of which are what this same
4898/// rule produces at those levels — so neither is a counterexample, and no
4899/// reference in the corpus pairs a level-62 DA1 with a `Pp` other than 1.
4900const DA2_TERMINAL_TYPE: u32 = 1;
4901
4902/// `Pc` of the secondary DA report: 0.
4903///
4904/// The ground is first-principles and needs no reference: `Pc` is a **ROM
4905/// cartridge registration number**, justerm has no cartridge, and 0 is the
4906/// absence value. That is how both implementations that comment the field read
4907/// it — xterm writes it as `/* options (none) */` (`charproc.c:4267`) and
4908/// ghostty as *"Always 0 for emulators"* (`src/terminal/device_attributes.zig:88`
4909/// @ `e6e26e1`). xterm.js sends 0 in all three of its branches; alacritty alone
4910/// sends 1 (`alacritty_terminal/src/term/mod.rs:1267` @ `852e971`).
4911///
4912/// **What does *not* carry this choice, stated because it looks like it should.**
4913/// `ctlseqs.txt:839` says `Pc` *"is always zero"* only of a **DEC terminal** —
4914/// a description of hardware in xterm's own documentation, not a requirement on
4915/// emulators. And ADR-0004 tie-breaks the spec against alacritty where alacritty
4916/// *"merely omits or under-implements"*; here alacritty **contradicts**, which
4917/// is neither of its branches, and its other branch (genuine ambiguity → follow
4918/// alacritty) would give 1. So the value rests on the argument above and on the
4919/// 3-1 head count, not on a spec mandate that does not exist.
4920const DA2_ROM_CARTRIDGE: u32 = 0;
4921
4922/// The `Pv` this build reports, folded at compile time so the doc-comment above
4923/// is literally true and the query path does no arithmetic.
4924const DA2_VERSION: u32 = version_number(env!("CARGO_PKG_VERSION"));
4925
4926impl Term {
4927 /// Apply one DEC private mode set (`'h'`) or reset (`'l'`). DECSET/DECRST
4928 /// carry a list of modes, so `csi_dispatch` folds this over every parameter
4929 /// (#56); each mode is an independent toggle, not a stack.
4930 fn set_dec_private_mode(&mut self, action: char, mode: u16) {
4931 match (action, mode) {
4932 ('h', 1049) => self.enter_alt_screen(),
4933 ('l', 1049) => self.leave_alt_screen(),
4934 // Legacy alt-screen variants (#72): ?47/?1047 switch the buffer
4935 // without saving the cursor; ?1048 saves/restores the cursor without
4936 // switching. ?1049 is the two combined.
4937 ('h', 47) | ('h', 1047) => self.switch_to_alt(),
4938 ('l', 47) | ('l', 1047) => self.switch_to_primary(),
4939 ('h', 1048) => self.save_alt_cursor(),
4940 ('l', 1048) => self.restore_alt_cursor(),
4941 ('h', 6) => {
4942 // DECOM: set homes the cursor to the region top.
4943 self.origin_mode = true;
4944 self.goto(0, 0);
4945 }
4946 ('l', 6) => self.origin_mode = false, // unset leaves the cursor put
4947 ('h', 7) => self.autowrap = true, // DECAWM
4948 ('l', 7) => self.autowrap = false,
4949 ('h', 45) => self.reverse_wraparound = true, // reverse wraparound (#80)
4950 ('l', 45) => self.reverse_wraparound = false,
4951 // DECCOLM (#82): the engine is dimension-free, so emit a request the
4952 // consumer may honor by resizing — no screen/cursor/margin change here.
4953 ('h', 3) => self.events.push(TermEvent::ColumnMode { cols: 132 }),
4954 ('l', 3) => self.events.push(TermEvent::ColumnMode { cols: 80 }),
4955 ('h', 25) => self.cursor.visible = true, // DECTCEM show
4956 ('l', 25) => self.cursor.visible = false, // DECTCEM hide
4957 ('h', 12) => self.cursor.blink = true, // att610 cursor blink (#81)
4958 ('l', 12) => self.cursor.blink = false,
4959 ('h', 2004) => self.bracketed_paste = true,
4960 ('l', 2004) => self.bracketed_paste = false,
4961 ('h', 2026) => self.synchronized_output = true, // synchronized output (#73)
4962 ('l', 2026) => self.synchronized_output = false,
4963 ('h', 2027) => self.grapheme_clustering = true, // grapheme-cluster mode (#295)
4964 ('l', 2027) => self.grapheme_clustering = false,
4965 ('h', 2031) => self.color_scheme_updates = true, // color-scheme notifications (#85)
4966 ('l', 2031) => self.color_scheme_updates = false,
4967 ('h', 9001) => self.win32_input_mode = true, // win32-input-mode (#86)
4968 ('l', 9001) => self.win32_input_mode = false,
4969
4970 // Input-encoding modes (#11): DECCKM, mouse tracking + encoding,
4971 // focus reporting. Each set assigns the level; each reset clears
4972 // it (apps enable/disable the same mode, not a stack).
4973 ('h', 1) => self.app_cursor_keys = true, // DECCKM
4974 ('l', 1) => self.app_cursor_keys = false,
4975 ('h', 66) => self.application_keypad = true, // DECNKM (#74)
4976 ('l', 66) => self.application_keypad = false,
4977 // DECANM (#84): set = ANSI (the normal state); reset enters VT52. Only
4978 // the reset is meaningful — `?2h` is a no-op (already ANSI).
4979 ('l', 2) => self.vt52_mode = true,
4980 ('h', 9) => self.mouse_protocol = MouseProtocol::X10, // X10 mouse (#70)
4981 ('h', 1000) => self.mouse_protocol = MouseProtocol::Normal,
4982 ('h', 1002) => self.mouse_protocol = MouseProtocol::ButtonEvent,
4983 ('h', 1003) => self.mouse_protocol = MouseProtocol::AnyEvent,
4984 ('l', 9) | ('l', 1000) | ('l', 1002) | ('l', 1003) => {
4985 self.mouse_protocol = MouseProtocol::Off
4986 }
4987 ('h', 1006) => self.mouse_encoding = MouseEncoding::Sgr,
4988 ('l', 1006) => self.mouse_encoding = MouseEncoding::Default,
4989 ('h', 1015) => self.mouse_encoding = MouseEncoding::Urxvt,
4990 ('l', 1015) => self.mouse_encoding = MouseEncoding::Default,
4991 ('h', 1005) => self.mouse_encoding = MouseEncoding::Utf8,
4992 ('l', 1005) => self.mouse_encoding = MouseEncoding::Default,
4993 ('h', 1016) => self.mouse_encoding = MouseEncoding::SgrPixels,
4994 ('l', 1016) => self.mouse_encoding = MouseEncoding::Default,
4995 ('h', 1004) => self.focus_events = true,
4996 ('l', 1004) => self.focus_events = false,
4997
4998 _ => {} // other DEC modes are later slices
4999 }
5000 }
5001
5002 /// Dispatch one VT52 escape sequence (`ESC <final>`), reached only while
5003 /// `vt52_mode` is set (#84). VT52 is a pre-ANSI dialect: the cursor/erase
5004 /// finals map to the same `Term` primitives the ANSI path uses. `ESC <`
5005 /// returns to ANSI. Unknown finals are ignored.
5006 fn vt52_dispatch(&mut self, byte: u8) {
5007 match byte {
5008 b'A' => self.move_up(1), // cursor up
5009 b'B' => self.move_down(1), // cursor down
5010 b'C' => self.move_forward(1), // cursor right
5011 b'D' => self.move_back(1), // cursor left
5012 b'H' => self.goto(0, 0), // cursor home
5013 b'I' => self.reverse_index(), // reverse line feed
5014 b'J' => self.erase_display(0), // erase cursor → end of screen
5015 b'K' => self.erase_line(0), // erase cursor → end of line
5016 b'Y' => self.vt52_y_pending = 2, // direct address: two coord bytes follow
5017 // Identify (DECID): reply `ESC / Z` — "I am a VT52".
5018 b'Z' => self.replies.extend_from_slice(b"\x1b/Z"),
5019 b'=' => self.application_keypad = true, // enter alternate keypad
5020 b'>' => self.application_keypad = false, // exit alternate keypad
5021 b'<' => self.vt52_mode = false, // exit VT52, return to ANSI
5022 // RIS (`ESC c`) is honored even here: it is a hard "recover from any
5023 // state" reset, and `full_reset` rebuilds `Term` with `vt52_mode`
5024 // cleared, so RIS always escapes VT52 back to ANSI. VT52 defines no
5025 // other meaning for `ESC c`.
5026 b'c' => self.full_reset(),
5027 // Graphics mode (`ESC F`/`ESC G`) is a documented non-goal: the VT52
5028 // graphics glyph set differs from DEC Special Graphics, so reusing that
5029 // charset would render the wrong glyphs. No-op rather than approximate.
5030 b'F' | b'G' => {}
5031 _ => {} // unknown VT52 finals are ignored
5032 }
5033 }
5034
5035 /// Consume one `ESC Y` coordinate byte (#84). The first byte is the row, the
5036 /// second the column; each decodes as `value - 0x20`. On the second byte the
5037 /// cursor is addressed (`goto` clamps out-of-range coordinates). Reached only
5038 /// from `print` while `vt52_y_pending > 0`.
5039 fn vt52_take_coord(&mut self, c: char) {
5040 let coord = (c as usize).saturating_sub(0x20);
5041 if self.vt52_y_pending == 2 {
5042 self.vt52_y_row = coord;
5043 self.vt52_y_pending = 1;
5044 } else {
5045 self.vt52_y_pending = 0;
5046 self.goto(self.vt52_y_row, coord);
5047 }
5048 }
5049
5050 /// The allocation an OSC 8 `id=` names: the live one if that id already named a link
5051 /// with this same URI, else a fresh one recorded under the key (#635).
5052 ///
5053 /// Keyed on **id and URI together**, mirroring xterm.js's `_getEntryIdKey`
5054 /// (`` `${id};;${uri}` ``, `OscLinkService.ts:87`). Keying on the id alone would follow
5055 /// a reused id to a stale target — an application saying "same link" about two
5056 /// different destinations has not said anything the engine should honour.
5057 fn link_for_id(&mut self, id: &str, uri: &str) -> std::sync::Arc<str> {
5058 let key = format!("{id};;{uri}");
5059 // A key whose link has left the buffer is *absent*, not stale — the group it named
5060 // is gone, so this open starts a new one. That is xterm.js's behaviour too, reached
5061 // by deleting the entry rather than by letting a reference die.
5062 if let Some(live) = self.link_ids.get(&key).and_then(std::sync::Weak::upgrade) {
5063 return live;
5064 }
5065 // Amortised sweep before inserting, so dangling keys stay O(live) rather than
5066 // O(ids ever declared). Doubling the threshold keeps it O(1) per open.
5067 if self.link_ids.len() >= self.link_ids_sweep_at {
5068 self.link_ids.retain(|_, weak| weak.strong_count() > 0);
5069 self.link_ids_sweep_at = (self.link_ids.len() * 2).max(LINK_IDS_FIRST_SWEEP);
5070 }
5071 let fresh: std::sync::Arc<str> = std::sync::Arc::from(uri);
5072 self.link_ids.insert(key, std::sync::Arc::downgrade(&fresh));
5073 fresh
5074 }
5075}
5076
5077impl Perform for Term {
5078 fn print(&mut self, c: char) {
5079 // VT52 `ESC Y` direct addressing (#84): vte delivers the two coordinate
5080 // bytes here (it returned to ground after the `Y` final), so intercept
5081 // them before they would be written as glyphs.
5082 if self.vt52_y_pending > 0 {
5083 self.vt52_take_coord(c);
5084 return;
5085 }
5086 // Translate through the active (GL) character set first (#62): under DEC
5087 // Special Graphics a printable byte becomes a line-drawing glyph.
5088 let c = self.charsets[self.gl].map(c);
5089 // Grapheme-cluster mode (DEC ?2027, #295): if `c` extends the previous cell's cluster,
5090 // join it there instead of placing a new cell. OFF → the per-char (wcwidth) path below.
5091 if self.grapheme_clustering && self.try_grapheme_join(c) {
5092 return;
5093 }
5094 match c.width() {
5095 // Zero-width (combining marks): the grapheme-cluster side-table is a
5096 // later slice; drop for now rather than mis-place it as its own cell.
5097 // A zero-width code point is a combining mark — attach it to the
5098 // previous base glyph rather than dropping it.
5099 Some(0) => self.push_combining(c),
5100 None => {}
5101 // Coerced to a pair, because a pair is the only multi-column shape the cell model
5102 // has (`WIDE_CHAR` + exactly one `WIDE_CHAR_SPACER`) — see ADR-0025, which states
5103 // every clause over "a pair" and never over a wider run. `unicode-width` genuinely
5104 // returns 3 for at least one codepoint (U+17D8 KHMER SIGN BEYYAL, a ligature drawn
5105 // as three characters), and that value is not wrong — it is unrepresentable here.
5106 //
5107 // Left uncoerced, the width fell through *every* wide branch in `write_glyph`
5108 // (each gated on `width == 2`) while still driving the cursor advance, so the glyph
5109 // landed as a lone narrow cell followed by columns that no flag distinguished from
5110 // real blanks: search could not find the text on screen and word selection split
5111 // the run, handing the clipboard a space the buffer never held (#595).
5112 //
5113 // All three references bound it, and ghostty says why in the same words —
5114 // `unicode/props.zig:11-13`, *"We clamp to [0, 2] … i.e. 3-em dash becomes a 2-em
5115 // dash"*. Clamping *here* rather than inside `write_glyph` keeps the two jobs apart:
5116 // this is the policy for an out-of-range external value, and the invariant it
5117 // establishes is asserted at the site that depends on it.
5118 Some(width) => self.write_glyph(c, width.min(2)),
5119 }
5120 }
5121
5122 fn execute(&mut self, byte: u8) {
5123 match byte {
5124 // LF, VT, FF all line-feed.
5125 b'\n' | 0x0b | 0x0c => self.linefeed(),
5126 b'\r' => self.carriage_return(),
5127 0x08 => self.backspace(),
5128 b'\t' => self.put_tab(),
5129 0x07 => self.events.push(TermEvent::Bell), // BEL (#12)
5130 0x0e => self.gl = 1, // SO (LS1): GL = G1 (#62)
5131 0x0f => self.gl = 0, // SI (LS0): GL = G0
5132 _ => {}
5133 }
5134 }
5135
5136 fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, action: char) {
5137 // Kitty keyboard-protocol negotiation: CSI > / = / < / ? ... u. The
5138 // leading intermediate distinguishes it from plain `CSI u` (SCORC) (#23).
5139 if action == 'u'
5140 && let Some(&lead) = intermediates.first()
5141 && matches!(lead, b'>' | b'<' | b'=' | b'?')
5142 {
5143 self.kitty_dispatch(lead, params);
5144 return;
5145 }
5146 // DEC private modes arrive with a '?' intermediate.
5147 if intermediates.first() == Some(&b'?') {
5148 // DECRQM (CSI ? Ps $ p) — report whether mode Ps is set. The '$'
5149 // intermediate distinguishes it from a plain `?...p`. It queries a
5150 // single mode, so it keys off the first parameter only.
5151 if action == 'p' && intermediates.contains(&b'$') {
5152 self.decrqm(param_or(params, 0, 0));
5153 return;
5154 }
5155 // Private DSR (CSI ? Ps n): ?996 = color-scheme query (#85). The
5156 // theme-agnostic engine relays it as an event for the consumer.
5157 if action == 'n' {
5158 if param_or(params, 0, 0) == 996 {
5159 self.events.push(TermEvent::ColorSchemeQuery);
5160 }
5161 return;
5162 }
5163 // DECSET/DECRST carry a *list* of modes; apply set/reset to EVERY
5164 // parameter, not just the first — htop batches `?1006;1000h` into one
5165 // CSI, so folding only params[0] dropped the 1000 (#56).
5166 for mode in params.iter().filter_map(|p| p.first().copied()) {
5167 self.set_dec_private_mode(action, mode);
5168 }
5169 return;
5170 }
5171 // DECSTR soft reset: CSI ! p (#53).
5172 if intermediates.first() == Some(&b'!') && action == 'p' {
5173 self.soft_reset();
5174 return;
5175 }
5176 // DECSCUSR set cursor style: CSI Ps SP q (space intermediate) (#89). An
5177 // absent param means 1 (block blink); an explicit 0 means reset — so the
5178 // raw value matters and `param_or` (which folds 0 to its default) is wrong.
5179 if intermediates.first() == Some(&b' ') && action == 'q' {
5180 let param = params.iter().next().and_then(|p| p.first().copied());
5181 self.set_cursor_style(param.unwrap_or(1));
5182 return;
5183 }
5184 // DA2 (secondary device attributes, CSI > c) — the query vim uses to
5185 // fill `v:termresponse` and identify what it is talking to (#824).
5186 //
5187 // What answering it actually buys, measured as a control pair on a real
5188 // pty (RHEL 9.2, vim 8.2, TERM=xterm-256color, 24x80; every other query
5189 // answered identically in both arms, controls run before and after):
5190 //
5191 // no reply -> ttymouse=xterm
5192 // ESC[>1;1500;0c -> ttymouse=sgr
5193 //
5194 // The mouse protocol is what the version number buys *directly*: legacy
5195 // `xterm` encoding cannot report a column past 223 and cannot report a
5196 // release, `sgr` has neither limit.
5197 //
5198 // It is not the only effect, and the second one is the larger. Answering
5199 // also makes vim **ask ten more questions** — `DCS + q <hex> ST`
5200 // (XTGETTCAP) for `Co`, `ku`, `kd`, `kl`, `kr`, `k1`, `#2`, `#4`, `%i`
5201 // and `*7`: the colour count and the arrow / function / shifted key
5202 // codes. vim's own `term.txt` gates that on the reply indicating
5203 // "patchlevel 141 or higher", and its point is that a terminal produces
5204 // different key codes in different modes, so it asks instead of
5205 // guessing. Measured rather than taken from the doc: the requests appear
5206 // at `Pv` 276 and 1500 and are absent at 1, 94 and 95, which brackets the
5207 // gate to (95, 276] and is consistent with 141 without pinning it. What
5208 // is stable across runs is whether they appear at all; *how many* arrive
5209 // is not — one arm sent each capability once where every other sent it
5210 // twice. **justerm answers none of those today** — they fall to the
5211 // same intermediate catch-all this block sits above — so the capability
5212 // is unlocked and then unanswered. That is the honest state, and it is
5213 // #47 tail rather than this slice.
5214 //
5215 // modifyOtherKeys is *not* gated on any of it: vim emits `CSI > 4 ; 2 m`
5216 // about 180 bytes before it asks.
5217 //
5218 // `>` reaches us as an *intermediate*, so DA2 was not "unhandled" but
5219 // unreachable: the catch-all below returns before the final is ever
5220 // examined. This opens exactly one route through it — the `>` prefix
5221 // alone, with the `c` final.
5222 //
5223 // It is one route out of ten `>` finals xterm routes, and the choice is
5224 // reach, not completeness: across this repo's capture corpus `CSI > c`
5225 // occurs 3 times and XTMODKEYS `CSI > m` 7 — the latter is the highest-
5226 // reach `>` sequence justerm does not route, and it keeps falling
5227 // through here exactly as before, tracked with the rest of the tail in
5228 // #47. (XTVERSION `CSI > q`, which reads like the obvious neighbour,
5229 // occurs **zero** times.)
5230 //
5231 // The match is on the whole slice rather than `.first()`, so
5232 // `CSI > $ c` is not DA2. That is 3-1: xterm drops it
5233 // (`VTPrsTbl.c:4747`, `$` is CASE_CSI_IGNORE inside `dec2_table`),
5234 // ghostty drops it (`src/terminal/stream.zig:1612`, `else => null`) and
5235 // xterm.js drops it (its handler key packs prefix *and* intermediates,
5236 // `InputHandler.ts:233`), while alacritty **would answer** it
5237 // (`vte-0.15.0/src/ansi.rs:1572` passes `intermediates.first()`). No
5238 // producer of that form exists in any pinned corpus, so this is a
5239 // divergence with no measured reach — pinned by a test regardless,
5240 // because the predicate is otherwise unfalsifiable.
5241 if intermediates == [b'>'] && action == 'c' {
5242 // Only `Ps = 0` or omitted is a request; a qualifier we do not
5243 // recognise is answered with silence rather than with a report that
5244 // does not address it. xterm (`charproc.c:4220`), xterm.js
5245 // (`InputHandler.ts:1738`) and alacritty (`ansi.rs:1572`) all gate
5246 // this way; ghostty reads no parameter at all and has no test that
5247 // would notice.
5248 if param_or(params, 0, 0) == 0 {
5249 self.replies.extend_from_slice(
5250 format!("\x1b[>{DA2_TERMINAL_TYPE};{DA2_VERSION};{DA2_ROM_CARTRIDGE}c")
5251 .as_bytes(),
5252 );
5253 }
5254 return;
5255 }
5256 // Other private/intermediate sequences are later slices; ignore them
5257 // rather than misinterpret.
5258 if !intermediates.is_empty() {
5259 return;
5260 }
5261 match action {
5262 'A' => self.move_up(param_or(params, 0, 1) as usize),
5263 'B' | 'e' => self.move_down(param_or(params, 0, 1) as usize),
5264 'C' | 'a' => self.move_forward(param_or(params, 0, 1) as usize),
5265 'D' => self.move_back(param_or(params, 0, 1) as usize),
5266 // CBT (CSI Ps Z): back-tab, the mirror of HT over the tab-stop
5267 // table. Cursor motion only — it writes no cell (#826).
5268 'Z' => self.put_back_tab(param_or(params, 0, 1) as usize),
5269 'G' | '`' => self.set_col(param_or(params, 0, 1) as usize - 1),
5270 'd' => self.set_row(param_or(params, 0, 1) as usize - 1),
5271 'H' | 'f' => {
5272 let row = param_or(params, 0, 1) as usize - 1;
5273 let col = param_or(params, 1, 1) as usize - 1;
5274 self.goto(row, col);
5275 }
5276 'J' => self.erase_display(param_or(params, 0, 0)),
5277 'K' => self.erase_line(param_or(params, 0, 0)),
5278 'X' => self.erase_chars(param_or(params, 0, 1) as usize),
5279 '@' => self.insert_chars(param_or(params, 0, 1) as usize),
5280 'P' => self.delete_chars(param_or(params, 0, 1) as usize),
5281 'S' => self.scroll_up_lines(param_or(params, 0, 1) as usize),
5282 'T' => self.scroll_down_lines(param_or(params, 0, 1) as usize),
5283 'L' => self.insert_lines(param_or(params, 0, 1) as usize),
5284 'M' => self.delete_lines(param_or(params, 0, 1) as usize),
5285 'g' => self.clear_tab_stop(param_or(params, 0, 0)),
5286 'r' => {
5287 let rows = self.grid.rows() as u16;
5288 let top = param_or(params, 0, 1) as usize;
5289 let bottom = param_or(params, 1, rows) as usize;
5290 self.set_scroll_region(top, bottom);
5291 }
5292 'm' => self.sgr(params),
5293 's' => self.save_cursor(), // SCOSC (CSI s) — alias of DECSC
5294 't' => self.window_ops(params), // XTWINOPS — only 22/23 (#823)
5295 'u' => self.restore_cursor(), // SCORC (CSI u) — alias of DECRC
5296 // DA1 (primary device attributes, CSI c): advertise VT220 + ANSI
5297 // colour — the levels justerm actually implements (#27).
5298 'c' => self.replies.extend_from_slice(b"\x1b[?62;22c"),
5299 'n' => self.device_status_report(param_or(params, 0, 0)),
5300 // Non-private SM/RM. Folded over every parameter (modes can batch,
5301 // like the private path #56). IRM (4) and LNM (20) so far.
5302 'h' => {
5303 for m in params.iter().filter_map(|p| p.first().copied()) {
5304 match m {
5305 4 => self.insert_mode = true,
5306 20 => self.newline_mode = true,
5307 _ => {}
5308 }
5309 }
5310 }
5311 'l' => {
5312 for m in params.iter().filter_map(|p| p.first().copied()) {
5313 match m {
5314 4 => self.insert_mode = false,
5315 20 => self.newline_mode = false,
5316 _ => {}
5317 }
5318 }
5319 }
5320 _ => {}
5321 }
5322 }
5323
5324 fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, byte: u8) {
5325 // VT52 mode (#84): the pre-ANSI dialect reuses the same `ESC <final>`
5326 // tokens vte already produces, but with different meanings, so it is a
5327 // mode-gated branch here rather than a separate parser. All VT52 sequences
5328 // are intermediate-free; anything with an intermediate is not VT52.
5329 if self.vt52_mode && intermediates.is_empty() {
5330 self.vt52_dispatch(byte);
5331 return;
5332 }
5333 if let Some(&i) = intermediates.first() {
5334 // SCS: designate a charset to G0 (`ESC ( F`) or G1 (`ESC ) F`) (#62).
5335 if matches!(i, b'(' | b')') {
5336 let set = match byte {
5337 b'0' => Charset::DecSpecialGraphics,
5338 b'A' => Charset::Uk,
5339 b'B' => Charset::Ascii,
5340 _ => return, // other sets are later slices
5341 };
5342 self.charsets[if i == b'(' { 0 } else { 1 }] = set;
5343 }
5344 // Other intermediates (G2/G3 designators, etc.) are later slices.
5345 return;
5346 }
5347 match byte {
5348 b'D' => self.linefeed(), // IND (line-feed without CR)
5349 b'E' => {
5350 // NEL (next line): carriage return + line-feed.
5351 self.carriage_return();
5352 self.linefeed();
5353 }
5354 b'H' => self.set_tab_stop(), // HTS
5355 b'M' => self.reverse_index(), // RI
5356 b'7' => self.save_cursor(), // DECSC
5357 b'8' => self.restore_cursor(), // DECRC
5358 b'c' => self.full_reset(), // RIS (#53)
5359 b'=' => self.application_keypad = true, // DECKPAM (#74)
5360 b'>' => self.application_keypad = false, // DECKPNM
5361 _ => {}
5362 }
5363 }
5364
5365 /// OSC dispatch (#12 event surface): title (0/2), cwd (7). OSC 8 hyperlink
5366 /// is per-cell state, handled in its own slice (#26), not here.
5367 fn osc_dispatch(&mut self, params: &[&[u8]], bell_terminated: bool) {
5368 // Which byte ended the sequence decides which byte ends its reply, and
5369 // this is the only place it is observable — vte hands it over per
5370 // dispatch and keeps nothing (#836). It rides outward on the query
5371 // events rather than being remembered: `drain_events` is a batch, so
5372 // two queries can be outstanding at once and one stored scalar could
5373 // not say which exchange it belonged to.
5374 let terminator = if bell_terminated {
5375 Terminator::Bel
5376 } else {
5377 Terminator::St
5378 };
5379 // params[0] is the OSC number; params[1..] the payload fields.
5380 let Some(&number) = params.first() else {
5381 return;
5382 };
5383 match number {
5384 // OSC 0 = icon + window title, OSC 2 = window title. Both set title.
5385 // Since #823 the string is also *retained*, because a title pop has
5386 // nothing to restore otherwise. OSC 0 writes both axes and OSC 2
5387 // only the window one — the distinction was invisible while the
5388 // engine merely forwarded, and becomes observable the moment an
5389 // axis-limited push/pop pair (which `vim` emits) is answered.
5390 b"0" | b"2" => {
5391 if let Some(&title) = params.get(1) {
5392 let title = String::from_utf8_lossy(title).into_owned();
5393 if number == b"0" {
5394 self.icon_name.clone_from(&title);
5395 }
5396 self.set_window_title(title);
5397 }
5398 }
5399 // OSC 7 = current working directory (a file:// URI).
5400 b"7" => {
5401 if let Some(&cwd) = params.get(1) {
5402 self.events
5403 .push(TermEvent::Cwd(String::from_utf8_lossy(cwd).into_owned()));
5404 }
5405 }
5406 // OSC 133 = FinalTerm/iTerm2 shell-integration command marks (#158):
5407 // `A` prompt start, `B` command start, `C` output start, `D[;exit]`
5408 // command finished. Each anchors a kinded marker at the cursor line;
5409 // pairing + navigation is consumer policy (#160). Unknown subcommands
5410 // (or none) are ignored. `D`'s exit field parses to `i32`, else None.
5411 b"133" => match params.get(1).copied() {
5412 Some(b"A") => self.add_command_mark(MarkerKind::PromptStart),
5413 Some(b"B") => self.add_command_mark(MarkerKind::CommandStart),
5414 Some(b"C") => self.add_command_mark(MarkerKind::OutputStart),
5415 Some(b"D") => {
5416 let exit = params
5417 .get(2)
5418 .and_then(|p| core::str::from_utf8(p).ok())
5419 .and_then(|s| s.parse::<i32>().ok());
5420 self.add_command_mark(MarkerKind::CommandFinished(exit));
5421 }
5422 _ => {}
5423 },
5424 // OSC 8 = hyperlink: `OSC 8 ; params ; URI`. A non-empty URI opens a
5425 // link (made current); an empty URI closes it. `params` carries the
5426 // optional `id=` that groups runs into one link (#635).
5427 b"8" => {
5428 // One allocation per *open*, shared by that open's cells and dropped
5429 // with the last row holding it (#628 — there is no pool). Two opens of
5430 // an identical URI stay two links, deliberately: merging them would
5431 // override a distinction the application controls through `id=`. That
5432 // parameter is the *only* dedup performed, which is one rule and not
5433 // two — xterm.js states it as "links with no id will only ever be
5434 // registered a single time" beside a lookup keyed on id-plus-uri
5435 // (`OscLinkService.ts:34`, `:49-54`).
5436 // The URI is `params[2..]` **rejoined**, not `params[2]` (#650). vte splits the
5437 // OSC payload on `;`, so a URI carrying an unencoded `;` arrives in pieces and
5438 // reading only the first dropped the rest — silently, with no error. Nothing is
5439 // lost at the parser: measured, `]8;;https://x/a;b=c` arrives as
5440 // `["8", "", "https://x/a", "b=c"]`. xterm.js special-cases the same thing from
5441 // the other side, splitting on the *first* `;` only and taking all the rest as
5442 // the URI, *"to support unencoded semi-colons in the URIs"*
5443 // (`InputHandler.ts:3106-3112`). Reachable without anything exotic: `?a=1;b=2`
5444 // is a legal query string and `;` is a legal filename byte.
5445 //
5446 // The close survives this: `]8;;` arrives as `["8", "", ""]`, whose rejoin is
5447 // empty, and an empty URI still closes. Never decoded — a `%3B` stays `%3B`,
5448 // because the engine hands the target over exactly as declared (ADR-0017).
5449 let uri: Vec<u8> = params.get(2..).unwrap_or_default().join(&b';');
5450 self.current_link = if uri.is_empty() {
5451 None
5452 } else {
5453 let uri = String::from_utf8_lossy(&uri);
5454 Some(match osc8_link_id(params.get(1).copied().unwrap_or(b"")) {
5455 // No id declared: fresh per open, the reference-correct default.
5456 None => std::sync::Arc::from(&*uri),
5457 Some(id) => self.link_for_id(&String::from_utf8_lossy(id), &uri),
5458 })
5459 };
5460 }
5461 // OSC 4 = set/query an ANSI palette entry: `OSC 4 ; index ; spec`
5462 // (#122). The engine forwards index + raw spec; the consumer applies
5463 // it to its palette (theme-agnostic — the cell keeps `Indexed`).
5464 b"4" => {
5465 // One event per `index ; spec` pair (xterm's `while slots > 1`).
5466 let mut rest = ¶ms[1..];
5467 while let [idx, spec, tail @ ..] = rest {
5468 rest = tail;
5469 if let Ok(index) = String::from_utf8_lossy(idx).parse::<u8>() {
5470 if *spec == b"?" {
5471 self.events
5472 .push(TermEvent::QueryPaletteColor { index, terminator });
5473 } else {
5474 self.events.push(TermEvent::SetPaletteColor {
5475 index,
5476 spec: String::from_utf8_lossy(spec).into_owned(),
5477 });
5478 }
5479 }
5480 }
5481 }
5482 // OSC 104 = reset palette entries (#122): an **empty payload** resets
5483 // the whole table, else one event per named index.
5484 //
5485 // Empty means both `OSC 104` and `OSC 104 ;` (#832). vte hands those
5486 // over as `["104"]` and `["104", ""]`, and testing only the first left
5487 // the second falling into the index loop, where `"".parse::<u8>()`
5488 // fails and the reset evaporated silently. xterm tests the payload
5489 // string rather than the field count — `if (*buf != '\0')`
5490 // (`misc.c:3057`), whose else-branch is *"resetting all colors"*
5491 // (`misc.c:3077`) — and xterm.js gates on the same emptiness
5492 // (`InputHandler.ts:3223-3224`, a slot-less RESTORE).
5493 //
5494 // The test is `params[1]` being the *whole* payload, not "every field
5495 // is empty": for `OSC 104 ; ;` xterm's buf is `";"`, which is not
5496 // empty, so that form takes the index path. (ghostty differs here —
5497 // `tokenizeScalar` drops both separators and it resets everything —
5498 // but it agrees on the form that matters, `misc.c` and xterm.js do
5499 // not, and ADR-0004 puts the spec proxy on top.)
5500 b"104" => {
5501 if params.len() <= 1 || (params.len() == 2 && params[1].is_empty()) {
5502 self.events.push(TermEvent::ResetPaletteColor(None));
5503 } else {
5504 for &idx in ¶ms[1..] {
5505 if let Ok(index) = String::from_utf8_lossy(idx).parse::<u8>() {
5506 self.events.push(TermEvent::ResetPaletteColor(Some(index)));
5507 }
5508 }
5509 }
5510 }
5511 // OSC 10/11/12 = set/query the default foreground/background/cursor
5512 // colour, stacking specs across the [fg, bg, cursor] slots (#122,
5513 // #137, #832). Each code names the slot the stack starts at. The
5514 // engine forwards raw specs (theme-agnostic).
5515 b"10" => self.special_color(params, 0, terminator),
5516 b"11" => self.special_color(params, 1, terminator),
5517 b"12" => self.special_color(params, 2, terminator),
5518 // OSC 52 = manipulate selection data (#828): `OSC 52 ; Pc ; Pd`.
5519 // The engine decodes `Pd` and relays the request; the *clipboard* is
5520 // the consumer's, and so is every policy about it.
5521 b"52" => self.clipboard(params, terminator),
5522 // OSC 110 / 111 / 112 = reset the default foreground / background /
5523 // cursor colour (#122, #832). One slot each, never a stack — xterm's
5524 // reset path resolves a single index from the code itself and walks
5525 // nothing (`misc.c:3729`).
5526 b"110" => self.events.push(TermEvent::ResetForeground),
5527 b"111" => self.events.push(TermEvent::ResetBackground),
5528 b"112" => self.events.push(TermEvent::ResetCursorColor),
5529 _ => {} // other OSCs are later slices
5530 }
5531 }
5532}
5533
5534#[cfg(test)]
5535mod tests {
5536 use super::cap_scroll;
5537 use super::version_number;
5538 use crate::Engine;
5539 use crate::damage::ScrollOp;
5540 use crate::serialize::MAX_SCROLL_COUNT;
5541
5542 /// #824 — the `Pv` mapping, in-crate because the seam cannot reach it.
5543 ///
5544 /// `tests/reply.rs` asserts the reply carries the number *this* crate
5545 /// version maps to, which is the assertion that fires the moment a release
5546 /// drifts from its report. What it cannot observe is the mapping itself:
5547 /// there is only ever one crate version at run time, so a hand-written
5548 /// literal equal to today's number is byte-identical to the derivation at
5549 /// that seam. Measured, by replacing the call site with `1500`:
5550 /// `cargo test --workspace` stayed green, and `cargo clippy --workspace
5551 /// --all-targets -- -D warnings` **failed** — `version_number` loses its
5552 /// only non-test caller and `dead_code` is an error under the gate. So the
5553 /// test suite alone cannot see it and the gate can; these cases are what
5554 /// make the mapping itself falsifiable rather than assumed.
5555 #[test]
5556 fn version_number_pads_semver_base_100() {
5557 // Each place is two decimal digits wide, so a higher version always
5558 // reports a higher number — which is the only property `Pv` promises.
5559 assert_eq!(version_number("0.0.1"), 1);
5560 assert_eq!(version_number("0.15.0"), 15_00);
5561 assert_eq!(version_number("1.2.3"), 1_02_03);
5562 assert_eq!(version_number("999.99.99"), 9_99_99_99);
5563 assert!(version_number("0.16.0") > version_number("0.15.9"));
5564 assert!(version_number("1.0.0") > version_number("0.99.99"));
5565 }
5566
5567 #[test]
5568 fn version_number_strips_a_pre_release_suffix() {
5569 // Semver starts the pre-release at the FIRST hyphen, so a two-part
5570 // suffix strips whole. alacritty cuts at the last and mis-parses this.
5571 assert_eq!(version_number("0.15.0-dev"), 15_00);
5572 assert_eq!(version_number("1.2.3-rc.1-dev"), 1_02_03);
5573 assert_eq!(version_number("1.2.3+build.5"), 1_02_03);
5574 // The cases above cannot observe the strip at all: their suffixes carry
5575 // no digits, so removing the `-` break leaves the number unchanged —
5576 // measured, by doing exactly that and watching them stay green. A digit
5577 // *inside* the suffix is what the strip is for.
5578 assert_eq!(version_number("0.15.0-rc2"), 15_00);
5579 assert_eq!(version_number("1.2.3-4"), 1_02_03);
5580 }
5581
5582 #[test]
5583 fn version_number_tolerates_a_short_or_odd_version() {
5584 // A missing component reads as zero rather than panicking, and a fourth
5585 // component is ignored — the report must not be able to fail.
5586 assert_eq!(version_number("2"), 2_00_00);
5587 assert_eq!(version_number("2.7"), 2_07_00);
5588 assert_eq!(version_number("1.2.3.4"), 1_02_03);
5589 assert_eq!(version_number(""), 0);
5590 }
5591
5592 /// #661 — the wire's `i16` bound, not the region-height one.
5593 ///
5594 /// In-crate on purpose, and the reason is cost rather than visibility: reaching
5595 /// this through `Engine` needs a screen taller than 32 767 rows *and* 32 768
5596 /// scrolls of it, each rotating a `line_damage` of that length. Measured at
5597 /// 15.8 s in a debug build for a single assertion — the whole `serialize` suite
5598 /// is 0.2 s without it. See `cap_scroll`'s note for how the coverage is split.
5599 #[test]
5600 fn a_region_taller_than_the_wire_field_truncates_rather_than_wraps() {
5601 // 40 000 rows: over i16::MAX, under MAX_ROWS (u16::MAX), so the region
5602 // height alone would let 35 000 through — and 35 000 as i16 is -30 536.
5603 let up = cap_scroll(ScrollOp {
5604 top: 0,
5605 bottom: 40_000,
5606 count: 35_000,
5607 });
5608 assert_eq!(up.count, MAX_SCROLL_COUNT, "capped, and still an up-scroll");
5609
5610 let down = cap_scroll(ScrollOp {
5611 top: 0,
5612 bottom: 40_000,
5613 count: -35_000,
5614 });
5615 assert_eq!(down.count, -MAX_SCROLL_COUNT, "sign survives the cap");
5616 }
5617
5618 /// The cap is a ceiling, not a rewrite: a count inside both bounds is reported
5619 /// exactly, and the region it names is untouched.
5620 #[test]
5621 fn a_scroll_inside_both_bounds_passes_through_unchanged() {
5622 let op = ScrollOp {
5623 top: 4,
5624 bottom: 9,
5625 count: -2,
5626 };
5627 assert_eq!(cap_scroll(op), op);
5628 }
5629
5630 /// #628 — a hyperlink's storage is released once no live row references it.
5631 ///
5632 /// In-crate on purpose: this defect has **no public observable**, which is why it
5633 /// survived from #46 until #621's completeness pass went looking. Pool indices never
5634 /// cross the wire (`Term::frame` remaps them to frame-local `link_table` positions),
5635 /// and the one public reader takes an index the caller already holds — so from
5636 /// outside the crate a pool of 5 entries and a pool of 50 000 are indistinguishable.
5637 /// The assertion has to stand where the storage does.
5638 ///
5639 /// The fixture is a buffer that cannot hold what it is fed: 2 rows plus 2 lines of
5640 /// scrollback is four lines total, so by the end all but the last four opens have
5641 /// been evicted and nothing on screen or in history refers to them.
5642 #[test]
5643 fn a_link_evicted_from_the_buffer_stops_being_stored() {
5644 let mut e = Engine::with_scrollback(20, 2, 2);
5645 for i in 0..50 {
5646 e.feed(format!("\x1b]8;;https://example.com/{i}\x07L{i}\x1b]8;;\x07\r\n").as_bytes());
5647 }
5648
5649 // The observable had to move with the storage — there is no pool left to count.
5650 // A `Weak` is the stronger form of the same claim anyway: a bounded count can be
5651 // bounded and still wrong, while a dead `Weak` says *this exact allocation* was
5652 // released.
5653 //
5654 // **`e2` must outlive the assertion, and that is the whole test.** The first
5655 // version of this scoped the engine to the block that built the `Weak`, so the
5656 // engine was dropped before the check and the `Weak` died for that reason
5657 // instead. Measured: with a deliberate leak reintroduced (a `Vec<Arc<str>>` on
5658 // `Term`, retaining every open), that version stayed **green** — a tautological
5659 // proof, confirming only that dropping an `Engine` frees its own memory. Keeping
5660 // the engine alive is what makes the assertion about reclamation.
5661 let mut e2 = Engine::with_scrollback(20, 2, 2);
5662 e2.feed(b"\x1b]8;;https://example.com/first\x07L\x1b]8;;\x07\r\n");
5663 let weak = {
5664 let arc = e2
5665 .term
5666 .grid
5667 .row_ref(0)
5668 .link_at(0)
5669 .expect("on screen")
5670 .clone();
5671 std::sync::Arc::downgrade(&arc)
5672 };
5673 // The live half first: a fix that simply never stored the URI would satisfy the
5674 // dead-`Weak` assertion below for the wrong reason.
5675 assert!(
5676 weak.upgrade().is_some(),
5677 "the URI must be alive while its cell is on screen",
5678 );
5679 for i in 0..50 {
5680 e2.feed(format!("filler {i}\r\n").as_bytes());
5681 }
5682 assert!(
5683 weak.upgrade().is_none(),
5684 "the first link scrolled out of a 4-line buffer and nothing should still \
5685 hold its URI — before #628 every OSC 8 open lived for the life of the Term",
5686 );
5687
5688 // The whole-buffer form of the same claim: 50 distinct opens through a buffer
5689 // that holds four lines leaves at most four entries *owned*.
5690 //
5691 // `owned_link_count` and not `link_at`, and that distinction is the test. The
5692 // first version summed the gated reader, which counts **linked cells** — measured
5693 // on an erased screen it read 0 while every URI was still allocated, so it could
5694 // not fail for the property this test exists to assert.
5695 // Deduped by allocation: one open covering three cells is three map entries and
5696 // one URI, so counting entries would fail at 9 for a buffer holding four links.
5697 let owned: std::collections::HashSet<*const u8> = e
5698 .term
5699 .scrollback
5700 .iter()
5701 .chain((0..2).map(|r| e.term.grid.row_ref(r)))
5702 .flat_map(|r| r.owned_links())
5703 .map(|u| std::sync::Arc::as_ptr(u) as *const u8)
5704 .collect();
5705 assert!(
5706 owned.len() <= 4,
5707 "a 4-line buffer cannot own more than 4 distinct URIs, found {}",
5708 owned.len(),
5709 );
5710 }
5711
5712 /// #628 — erasing a cell in place releases its URI, not just its presence bit.
5713 ///
5714 /// The sibling of the eviction test above, and the case that one structurally cannot
5715 /// see: `clear_cells` / `free_cell` blank a cell **without dropping its row**, so no
5716 /// row-lifetime event fires. Under `row-keyed-side-maps` rule 3 leaving the map entry
5717 /// is sanctioned — *"a write that clears the cell owes the bit, not the map"* — and
5718 /// that was exactly right while the value was a 4-byte index: a stale entry is
5719 /// unreadable through the gate and costs nothing.
5720 ///
5721 /// #628 changed what the entry *is*. The map now owns a heap string, so the same
5722 /// sanctioned line retains one. Rule 3 still holds as stated — purging is not the
5723 /// correctness step, and missing a site costs bounded retention rather than a wrong
5724 /// answer — but the optimisation it calls optional became worth taking here.
5725 /// All three references release at this point: alacritty's `Cell::reset` drops the
5726 /// `Option<Arc<CellExtra>>` outright, ghostty's ref-counted set frees at zero, and
5727 /// xterm.js's `_resetBufferLine` clears `_extendedAttrs` and disposes the line's
5728 /// markers so `OscLinkService` deletes the entry.
5729 #[test]
5730 fn an_erased_cell_releases_its_uri_not_only_its_bit() {
5731 let mut e = Engine::new(80, 24);
5732 e.feed(b"]8;;https://example.com/erasedL]8;;");
5733 let weak = {
5734 let a = e
5735 .term
5736 .grid
5737 .row_ref(0)
5738 .link_at(0)
5739 .expect("on screen")
5740 .clone();
5741 std::sync::Arc::downgrade(&a)
5742 };
5743 assert!(weak.upgrade().is_some(), "alive while on screen");
5744
5745 e.feed(b"[2J"); // ED 2 — erases in place; no row is dropped or reused
5746
5747 // The gated reader already says "no link", and so does the frame. Neither can
5748 // see the retention, which is why this assertion holds the `Weak` instead:
5749 // measured before the purge, both public views read 0 while the URI lived.
5750 assert!(e.link_at(0, 0).is_none(), "the presence bit is cleared");
5751 assert!(
5752 weak.upgrade().is_none(),
5753 "and the URI itself is released — before the purge the map kept owning it, so an erased screen retained every link it had shown",
5754 );
5755 }
5756
5757 /// `Arc`, not `Rc`, and this is what makes that a fact rather than a comment.
5758 ///
5759 /// #628 chose `Arc<str>` for the row's link map on the stated ground that `Engine` is
5760 /// `Send + Sync`; `Rc` would have removed both **silently** — no signature changes
5761 /// here, and a downstream `Mutex<Engine>` failing to compile instead. The claim was
5762 /// load-bearing and unpinned: a repo-wide grep for it found only prose.
5763 #[test]
5764 fn the_engine_stays_send_and_sync() {
5765 fn assert_send_sync<T: Send + Sync>() {}
5766 assert_send_sync::<Engine>();
5767 }
5768
5769 /// Two OSC 8 opens of an identical URI are **two links**, not one.
5770 ///
5771 /// Deliberate, and the reason is #635: merging them would override the grouping the
5772 /// application controls through `id=`, which is the one dedup xterm.js performs.
5773 /// Asserted by allocation identity through the in-crate observer rather than through
5774 /// a public accessor — the behaviour is real now, a consumer asking about it is not.
5775 #[test]
5776 fn two_opens_of_one_uri_are_two_links() {
5777 let mut e = Engine::new(40, 2);
5778 // One open covering two cells, then a *separate* open of the very same URI.
5779 e.feed(b"]8;;https://example.com/xAB]8;;");
5780 e.feed(b"]8;;https://example.com/xC]8;;");
5781
5782 let row = e.term.grid.row_ref(0);
5783 let ptr = |c: usize| std::sync::Arc::as_ptr(row.link_at(c).expect("linked")) as *const u8;
5784 assert_eq!(
5785 e.link_at(0, 0).map(|h| h.uri().to_owned()),
5786 e.link_at(0, 2).map(|h| h.uri().to_owned()),
5787 "the text is the same",
5788 );
5789 assert_eq!(ptr(0), ptr(1), "A and B are one open, so one allocation");
5790 assert_ne!(
5791 ptr(0),
5792 ptr(2),
5793 "…but C is a second open — merging the two would override the distinction `id=` exists to express (#635)",
5794 );
5795 }
5796
5797 /// The other half of the rule above: an `id=` the application declared **does** group
5798 /// (#635). One rule, not two — "never merge on URI alone, always merge on a declared
5799 /// id" is how xterm.js states it (`OscLinkService.ts:34`, `:51` at the pinned SHA), and
5800 /// justerm shipped the first half only because #26 ported `registerLink`'s id-minting
5801 /// and not its lookup.
5802 ///
5803 /// Grouping is asserted as **allocation identity**, which is not an implementation
5804 /// detail leaking into a test: since #628 the `Arc`'s address *is* link identity —
5805 /// `Term::frame` interns `link_table` by `Arc::as_ptr`, so one allocation is what makes
5806 /// two runs one link index on the wire, and that index is what a consumer groups by.
5807 #[test]
5808 fn the_same_id_and_uri_group_into_one_link() {
5809 let mut e = Engine::new(40, 2);
5810 // Two separate opens, same `id=` and same URI, on two different lines — the case
5811 // the parameter exists for (a link that cannot be one contiguous run).
5812 e.feed(b"\x1b]8;id=xyz;https://example.com/a\x07A\x1b]8;;\x07\r\n");
5813 e.feed(b"\x1b]8;id=xyz;https://example.com/a\x07B\x1b]8;;\x07");
5814
5815 let ptr = |r: usize, c: usize| {
5816 std::sync::Arc::as_ptr(e.term.grid.row_ref(r).link_at(c).expect("linked")) as *const u8
5817 };
5818 assert_eq!(
5819 ptr(0, 0),
5820 ptr(1, 0),
5821 "the application said these two runs are one link, so they share one allocation",
5822 );
5823
5824 // And the wire agrees, which is the half a consumer can actually see: one entry in
5825 // `link_table`, referenced by both spans. Two entries is the defect.
5826 let f = e.frame();
5827 assert_eq!(f.link_table.len(), 1, "one link ships once");
5828 }
5829
5830 /// Keyed on `id` **and** URI, not on `id` alone — xterm.js's `_getEntryIdKey` is
5831 /// `` `${id};;${uri}` `` (`OscLinkService.ts:87`). An application reusing an id for a
5832 /// different target has not said "same link"; treating it as one would follow a stale
5833 /// declaration to the wrong URI.
5834 #[test]
5835 fn the_same_id_with_a_different_uri_stays_two_links() {
5836 let mut e = Engine::new(40, 2);
5837 e.feed(b"\x1b]8;id=xyz;https://example.com/a\x07A\x1b]8;;\x07\r\n");
5838 e.feed(b"\x1b]8;id=xyz;https://example.com/b\x07B\x1b]8;;\x07");
5839
5840 let ptr = |r: usize, c: usize| {
5841 std::sync::Arc::as_ptr(e.term.grid.row_ref(r).link_at(c).expect("linked")) as *const u8
5842 };
5843 assert_ne!(
5844 ptr(0, 0),
5845 ptr(1, 0),
5846 "same id, different target — two links"
5847 );
5848 assert_eq!(e.frame().link_table.len(), 2, "and both ship");
5849 }
5850
5851 /// `id=` with an **empty value** is no id at all, so the no-id rule applies and each
5852 /// open is its own link. xterm.js reaches this by `parsedParams[i].slice(3) || undefined`
5853 /// (`InputHandler.ts:3130`) — the `||` is the whole behaviour, and reading `slice(3)`
5854 /// alone gives the opposite answer.
5855 ///
5856 /// Worth a test rather than a comment because the empty-string key is the one that
5857 /// would group *every* `id=`-with-no-value link in a session into one, across unrelated
5858 /// URIs — a wrong answer that grows with uptime.
5859 #[test]
5860 fn an_empty_id_value_is_no_id_at_all() {
5861 let mut e = Engine::new(40, 2);
5862 e.feed(b"\x1b]8;id=;https://example.com/a\x07A\x1b]8;;\x07\r\n");
5863 e.feed(b"\x1b]8;id=;https://example.com/a\x07B\x1b]8;;\x07");
5864
5865 let ptr = |r: usize, c: usize| {
5866 std::sync::Arc::as_ptr(e.term.grid.row_ref(r).link_at(c).expect("linked")) as *const u8
5867 };
5868 assert_ne!(
5869 ptr(0, 0),
5870 ptr(1, 0),
5871 "no id declared, so the reference-correct fresh-per-open rule still holds",
5872 );
5873 }
5874
5875 /// `params` is a **`:`-separated** key=value list (`id=xyz123:foo=bar:baz=quux`), and
5876 /// `id` may sit anywhere in it — xterm.js scans with `findIndex(e =>
5877 /// e.startsWith('id='))` (`InputHandler.ts:3129`). Testing only a leading `id=` would
5878 /// pass with a `starts_with` on the whole field, which is the wrong parse.
5879 #[test]
5880 fn the_id_param_is_found_among_other_params() {
5881 let mut e = Engine::new(40, 2);
5882 e.feed(b"\x1b]8;foo=bar:id=xyz:baz=quux;https://example.com/a\x07A\x1b]8;;\x07\r\n");
5883 e.feed(b"\x1b]8;id=xyz;https://example.com/a\x07B\x1b]8;;\x07");
5884
5885 let ptr = |r: usize, c: usize| {
5886 std::sync::Arc::as_ptr(e.term.grid.row_ref(r).link_at(c).expect("linked")) as *const u8
5887 };
5888 assert_eq!(
5889 ptr(0, 0),
5890 ptr(1, 0),
5891 "the id is the same whatever else rides beside it",
5892 );
5893 }
5894
5895 /// The grouping registry must not become the pool #628 deleted.
5896 ///
5897 /// Whatever maps an `id=` to its link has to hold it **weakly**: a strong reference
5898 /// would make every id'd link immortal for the life of the `Term` — the exact defect
5899 /// #628 removed, re-entering through the door #635 opens. xterm.js's equivalent map is
5900 /// reclaimed rather than weak (`_entriesWithId.delete` when the entry's last line
5901 /// marker is disposed, `OscLinkService.ts:98-100`); justerm has no disposal hook by
5902 /// design, so `Weak` is how the same lifetime is expressed here.
5903 ///
5904 /// This is the test that discriminates the two, and nothing public can: both spellings
5905 /// group correctly, and they differ only in what stays alive afterwards.
5906 #[test]
5907 fn the_id_registry_does_not_keep_a_link_alive() {
5908 let mut e = Engine::new(80, 24);
5909 e.feed(b"\x1b]8;id=xyz;https://example.com/grouped\x07L\x1b]8;;\x07");
5910 let weak = {
5911 let a = e
5912 .term
5913 .grid
5914 .row_ref(0)
5915 .link_at(0)
5916 .expect("on screen")
5917 .clone();
5918 std::sync::Arc::downgrade(&a)
5919 };
5920 assert!(weak.upgrade().is_some(), "alive while on screen");
5921
5922 e.feed(b"\x1b[2J"); // ED 2 — the in-place erase that releases the row's side maps
5923
5924 assert!(
5925 weak.upgrade().is_none(),
5926 "the id registry must hold a Weak — a strong entry would outlive the screen and rebuild #628's leak one id at a time",
5927 );
5928 }
5929}