Skip to main content

justerm_core/
grid.rs

1//! The grid — the 2D array of cells representing the current screen.
2//!
3//! Rows are stored as separate `Vec`s (not one flat buffer) so the scrollback
4//! ring (a later slice) can move whole rows in/out cheaply.
5
6use crate::cell::Cell;
7use crate::color::Color;
8use core::num::NonZeroU32;
9use std::collections::BTreeMap;
10use std::ops::{Deref, DerefMut};
11
12/// A row's combining clusters: column → the combining marks attached to that
13/// column's base glyph. Sparse (most rows have none) and **flag-gated** — an
14/// entry is only ever read when the cell at that column has its
15/// `COMBINED_PRESENT` bit set (xterm's `_combined` invariant, #45). Stale entries
16/// left by an overwrite/erase are therefore harmless; only live entries must be
17/// carried when cells move column (ICH/DCH/reflow).
18type Combining = BTreeMap<usize, Vec<char>>;
19
20/// A row's hyperlinks: column → the global `hyperlink_pool` index (OSC 8). Same
21/// per-row, flag-gated sparse-map design as [`Combining`], gated by the cell's
22/// `LINK_PRESENT` bit instead (xterm's `_extendedAttrs` / `HAS_EXTENDED`, #46).
23type Links = BTreeMap<usize, NonZeroU32>;
24
25/// A row's non-default underline colours (SGR 58, #520): column → the underline
26/// `Color` reference. Same per-row, flag-gated sparse-map design as [`Links`],
27/// gated by the cell's `UCOLOR_PRESENT` bit. Only non-`Default` colours get an
28/// entry — a `Default` underline follows the fg and needs no storage.
29type UColors = BTreeMap<usize, Color>;
30
31/// Every **extended attribute** live at one column — the family that rides the
32/// row's flag-gated side maps rather than the 12-byte cell: the OSC 8 hyperlink
33/// (#46) and the SGR 58 underline colour (#520). Combining marks are deliberately
34/// *not* here: they are content, re-attached mark-by-mark through
35/// [`Row::push_combining`], not carried as an opaque value.
36///
37/// It exists so a path that *moves* or *grows* a cell carries the whole family in
38/// one step ([`Row::ext_attrs_at`] → [`Row::set_ext_attrs`]) instead of naming each
39/// rider — the same shape as xterm.js's `_copyCellMapsFrom`, which re-keys
40/// `_combined` and `_extendedAttrs` together for every cell `copyCellsFrom` moves.
41/// Adding a rider (an underline *style*, say) is a field here plus the two arms
42/// below; every carry site is covered by construction (#521).
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
44pub(crate) struct ExtAttrs {
45    link: Option<NonZeroU32>,
46    ucolor: Option<Color>,
47}
48
49impl ExtAttrs {
50    /// The family as the *pen* currently holds it — the other source besides a cell
51    /// (`Row::ext_attrs_at`). Every print-path site that stamps a freshly built cell
52    /// goes through here, so the gating rules live in one place and a later rider is
53    /// added once (#521/#528).
54    pub(crate) fn from_pen(link: Option<NonZeroU32>, ucolor: Option<Color>) -> ExtAttrs {
55        ExtAttrs { link, ucolor }
56    }
57}
58
59/// Re-key a sparse column map to follow a `copy_within(src, dst)` cell shift: the
60/// live entry for a moved cell travels to the cell's new column. Vacated source
61/// keys whose cell loses its gate bit are left stale — harmless under the
62/// flag-gate — so only the live carry is done. Generic over the value type so the
63/// combining and link maps share one implementation.
64fn move_map<V>(map: &mut BTreeMap<usize, V>, src: std::ops::Range<usize>, dst: usize) {
65    if map.is_empty() {
66        return;
67    }
68    let start = src.start;
69    let moved: Vec<(usize, V)> = src
70        .filter_map(|s| map.remove(&s).map(|v| (dst + (s - start), v)))
71        .collect();
72    for (col, v) in moved {
73        map.insert(col, v);
74    }
75}
76
77/// One row of cells **plus** its per-row, column-keyed combining, link, and
78/// underline-colour maps.
79///
80/// The maps ride with the row through scroll / scrollback / reflow for free (the
81/// row is the unit that moves), which is why combining (#45), hyperlinks (#46),
82/// and underline colours (#520) live here rather than in global per-cell indices —
83/// no leak, cleared on row reuse. `Row` derefs to `[Cell]`, so index/iterate/slice
84/// sites are unchanged; the maps are reached through the dedicated methods so the
85/// flag-gate (read iff the cell's `COMBINED_PRESENT` / `LINK_PRESENT` /
86/// `UCOLOR_PRESENT` bit is set) is never bypassed.
87#[derive(Clone, Debug, PartialEq, Eq, Default)]
88pub struct Row {
89    cells: Vec<Cell>,
90    combining: Combining,
91    links: Links,
92    ucolors: UColors,
93    /// Did this row soft-wrap (auto-wrap) into the next one?
94    ///
95    /// A property of the **row**, and stored on the row for a reason: it used to ride
96    /// `CellFlags::WRAPLINE` in the last cell, where every whole-cell write and clear destroyed it
97    /// — ordinary typing in the last column silently split the logical line (#538). Here no cell
98    /// operation can reach it. Both references keep it off the cell too, though the field is not
99    /// the same: ghostty's `Row.wrap` is this exact flag (the row wraps *into* the next), while
100    /// xterm.js's `BufferLine.isWrapped` is the opposite-polarity link (the row *continues* the
101    /// previous one) — ghostty's `wrap_continuation`, not its `wrap`. The distinction matters when
102    /// borrowing xterm.js's `clearWrap` values, which describe the *previous* row's link.
103    ///
104    /// It still crosses the wire as the last cell's `WRAPLINE` bit, derived at encode time, so the
105    /// format is unchanged.
106    wrapped: bool,
107}
108
109impl Row {
110    /// A row of `cols` blank cells.
111    pub(crate) fn blank(cols: usize) -> Row {
112        Row {
113            cells: vec![Cell::default(); cols],
114            combining: Combining::new(),
115            links: Links::new(),
116            ucolors: UColors::new(),
117            wrapped: false,
118        }
119    }
120
121    /// Wrap a cell vector as a row with no combining marks, links, or ucolors.
122    pub(crate) fn from_cells(cells: Vec<Cell>) -> Row {
123        Row {
124            cells,
125            combining: Combining::new(),
126            links: Links::new(),
127            ucolors: UColors::new(),
128            wrapped: false,
129        }
130    }
131
132    /// Build a row from cells and its maps (the reflow re-split path).
133    pub(crate) fn new(
134        cells: Vec<Cell>,
135        combining: Combining,
136        links: Links,
137        ucolors: UColors,
138    ) -> Row {
139        Row {
140            cells,
141            combining,
142            links,
143            ucolors,
144            wrapped: false,
145        }
146    }
147
148    /// Consume the row into its cells, combining map, link map, and ucolor map
149    /// (the reflow join path).
150    pub(crate) fn into_parts(self) -> (Vec<Cell>, Combining, Links, UColors) {
151        (self.cells, self.combining, self.links, self.ucolors)
152    }
153
154    /// Resize to `cols`, padding with blanks or truncating; map entries for
155    /// dropped columns are pruned (xterm's shrink-prune).
156    pub(crate) fn resize(&mut self, cols: usize) {
157        self.cells.resize(cols, Cell::default());
158        if self
159            .combining
160            .keys()
161            .next_back()
162            .is_some_and(|&m| m >= cols)
163        {
164            self.combining.retain(|&col, _| col < cols);
165        }
166        if self.links.keys().next_back().is_some_and(|&m| m >= cols) {
167            self.links.retain(|&col, _| col < cols);
168        }
169        if self.ucolors.keys().next_back().is_some_and(|&m| m >= cols) {
170            self.ucolors.retain(|&col, _| col < cols);
171        }
172    }
173
174    /// Empty the row, keeping the cell allocation — for recycling a row buffer
175    /// (`scroll_up_recycle`). Clears cells and both maps so a reused row never
176    /// surfaces a previous occupant's marks or links.
177    pub(crate) fn clear(&mut self) {
178        self.cells.clear();
179        self.combining.clear();
180        self.links.clear();
181        self.ucolors.clear();
182        self.wrapped = false;
183    }
184
185    /// Blank this row **in place** — every cell reset, and every row-scoped property with them.
186    ///
187    /// The distinction from a cell loop is the whole point. Soft-wrap is a property of the row
188    /// (#538), so `for cell in row { cell.reset() }` leaves a blanked row still claiming to
189    /// continue into the next one — and because the row *struct* is what scroll rotates and what
190    /// the alt grid keeps, that stale claim outlives the content it described. Blanking is one
191    /// operation so a caller cannot blank half of a row's state; a future row-scoped field is
192    /// covered by construction, the same way `Row::clear` covers the side maps for a recycled
193    /// buffer.
194    ///
195    /// Keeps the cell allocation and the row's width — unlike [`Row::clear`], which empties the
196    /// `Vec` for a buffer about to be re-fitted.
197    pub(crate) fn blank_in_place(&mut self) {
198        for cell in self.cells.iter_mut() {
199            cell.reset();
200        }
201        self.wrapped = false;
202    }
203
204    /// Did this row soft-wrap into the next one? See [`Row::wrapped`] for why this is a row
205    /// property and not a cell flag (#538).
206    pub(crate) fn is_wrapped(&self) -> bool {
207        self.wrapped
208    }
209
210    /// Mark (or unmark) this row as soft-wrapped into the next.
211    ///
212    /// Unmarking is per-verb, not derivable from what was erased — see `Term::end_wrap`, which is
213    /// the only place that unmarks and carries the rule with its references. An *overwrite* of the
214    /// last column must leave it set (that was the whole point of #538: a cell write cannot decide
215    /// a row property), and so must a leftward erase.
216    pub(crate) fn set_wrapped(&mut self, wrapped: bool) {
217        self.wrapped = wrapped;
218    }
219
220    /// The combining marks at `col`, or `None`. Flag-gated: returns `Some` only
221    /// when the cell carries the `COMBINED_PRESENT` bit, so a stale map entry is
222    /// never surfaced.
223    pub(crate) fn combining_at(&self, col: usize) -> Option<&[char]> {
224        if self.cells[col].is_combined() {
225            self.combining.get(&col).map(Vec::as_slice)
226        } else {
227            None
228        }
229    }
230
231    /// The hyperlink-pool index at `col`, or `None`. Flag-gated by the cell's
232    /// `LINK_PRESENT` bit (mirror of [`Row::combining_at`]).
233    pub(crate) fn link_at(&self, col: usize) -> Option<NonZeroU32> {
234        if self.cells[col].is_linked() {
235            self.links.get(&col).copied()
236        } else {
237            None
238        }
239    }
240
241    /// The non-default underline colour at `col`, or `None` (which the caller reads
242    /// as `Default` — follow the fg). Flag-gated by the cell's `UCOLOR_PRESENT` bit,
243    /// so a stale map entry an overwrite left behind is never surfaced (#520).
244    pub(crate) fn ucolor_at(&self, col: usize) -> Option<Color> {
245        if self.cells[col].is_ucolored() {
246            self.ucolors.get(&col).copied()
247        } else {
248            None
249        }
250    }
251
252    /// Attach a combining mark to `col`'s glyph. The first mark on a cell starts a
253    /// fresh cluster — dropping any stale entry an overwrite left behind (the bit
254    /// was clear) — and sets the presence bit; subsequent marks append. Mirrors
255    /// xterm's `addCodepointToCell`.
256    pub(crate) fn push_combining(&mut self, col: usize, mark: char) {
257        if self.cells[col].is_combined() {
258            self.combining.entry(col).or_default().push(mark);
259        } else {
260            self.cells[col].set_combined(true);
261            self.combining.insert(col, vec![mark]);
262        }
263    }
264
265    /// Stamp `col`'s glyph with a hyperlink-pool index, setting the presence bit
266    /// (the print path calls this on every cell written while a link is open).
267    pub(crate) fn set_link(&mut self, col: usize, link: NonZeroU32) {
268        self.cells[col].set_linked(true);
269        self.links.insert(col, link);
270    }
271
272    /// Stamp `col`'s glyph with a non-default underline colour, setting the presence
273    /// bit (the print path calls this on every cell written while the pen's underline
274    /// colour is non-default, #520). Mirror of [`Row::set_link`].
275    pub(crate) fn set_ucolor(&mut self, col: usize, color: Color) {
276        self.cells[col].set_ucolored(true);
277        self.ucolors.insert(col, color);
278    }
279
280    /// Every extended attribute live at `col`, as one value (#521). Flag-gated per
281    /// rider, so a stale entry an overwrite left behind is never picked up.
282    pub(crate) fn ext_attrs_at(&self, col: usize) -> ExtAttrs {
283        ExtAttrs {
284            link: self.link_at(col),
285            ucolor: self.ucolor_at(col),
286        }
287    }
288
289    /// Make `col` carry **exactly** `attrs` — each rider's presence bit and map
290    /// entry set together, or *both cleared*. Clearing matters as much as setting:
291    /// the promotion paths write over a column that may still hold a live entry, and
292    /// they build the new cell by copying one that may still carry a presence bit,
293    /// so "set what is there" alone would leave either half of the gate dangling
294    /// (#521).
295    pub(crate) fn set_ext_attrs(&mut self, col: usize, attrs: ExtAttrs) {
296        match attrs.link {
297            Some(link) => self.set_link(col, link),
298            None => {
299                self.cells[col].set_linked(false);
300                self.links.remove(&col);
301            }
302        }
303        match attrs.ucolor {
304            Some(color) => self.set_ucolor(col, color),
305            None => {
306                self.cells[col].set_ucolored(false);
307                self.ucolors.remove(&col);
308            }
309        }
310    }
311
312    /// Re-key every map to follow a `copy_within(src, dst)` cell shift (ICH/DCH),
313    /// so a cluster, link, or underline colour stays attached to its glyph at the
314    /// new column.
315    pub(crate) fn move_maps(&mut self, src: std::ops::Range<usize>, dst: usize) {
316        move_map(&mut self.combining, src.clone(), dst);
317        move_map(&mut self.links, src.clone(), dst);
318        move_map(&mut self.ucolors, src, dst);
319    }
320}
321
322impl Deref for Row {
323    type Target = [Cell];
324    fn deref(&self) -> &[Cell] {
325        &self.cells
326    }
327}
328
329impl DerefMut for Row {
330    fn deref_mut(&mut self) -> &mut [Cell] {
331        &mut self.cells
332    }
333}
334
335/// Re-wrap physical `rows` to `new_cols`. Soft-wrapped rows are joined into logical lines, then
336/// each logical line is re-split at `new_cols` with the wrap flag set on every segment but the
337/// last. Trailing blank rows are absorbed (re-created by the caller's row-count fit). See #7.
338///
339/// The flag is read from and written to the **`Row`**, not the last cell: soft wrap is a row
340/// property (#538) and `WRAPLINE` survives only as a wire bit derived at encode time.
341///
342/// `points` are `(row, col)` coordinates to track through the reflow — the cursor, any selection
343/// anchors, **and every OSC-133 command mark** — and the returned `Vec` maps each to its new
344/// position, index-aligned with the input. That last group is why the mapping is a single pass
345/// rather than a test inside the re-split loop: `points` scales with the number of commands in the
346/// buffer, and the loop scales with rows.
347///
348/// **A returned point is a position in the logical line, not necessarily a cell.** Two of its
349/// components deliberately leave the grid (#562), because a point that sits *just after* the last
350/// cell is a real place and the caller — not this function — knows what that means for the kind of
351/// point it holds:
352///
353/// - `col` may equal `new_cols`. The cursor reads that as the next write position (the row after);
354///   an OSC-133 mark reads it as an **exclusive** bound meaning "all of this row"; a selection
355///   anchor is clamped. Answering `(row + 1, 0)` here picked the cursor's reading for all three.
356/// - `row` may be **past the last row emitted**, for a point on a trailing blank line the join
357///   absorbed. Nothing extra is emitted for it: the row is one the caller's fit will create
358///   (`Grid::set_screen` pads at the bottom), and bounding it against `out.len()` here would clamp
359///   away a row that is about to exist. The bound belongs at the seam, against the final geometry.
360///
361/// **A wide pair straddling the new boundary *is* special-cased** — the re-split emits a short row
362/// rather than splitting the pair, and marks the column it vacates as the wrap artefact (#533). An
363/// earlier version of this comment said the opposite long after the guard landed, and the mapping
364/// below was written against that sentence: it divided the offset by `new_cols`, which is only
365/// right if every row is full (#549).
366///
367/// Common-90%: trailing blanks on a hard-ended row are trimmed by *content*, so a BCE-coloured
368/// tail does not re-split into a phantom row (#530).
369pub(crate) fn reflow(
370    rows: Vec<Row>,
371    new_cols: usize,
372    points: &[(usize, usize)],
373) -> (Vec<Row>, Vec<(usize, usize)>) {
374    // 1. Join soft-wrapped rows into logical lines, recording each tracked
375    //    point's logical coordinate (line index + offset within the line). The
376    //    combining map is carried alongside: a row's entries are re-keyed by the
377    //    join offset so a cluster stays attached to its glyph across the wrap.
378    let mut logical: Vec<Vec<Cell>> = Vec::new();
379    let mut logical_comb: Vec<Combining> = Vec::new();
380    let mut logical_links: Vec<Links> = Vec::new();
381    let mut logical_ucolors: Vec<UColors> = Vec::new();
382    let mut current: Vec<Cell> = Vec::new();
383    let mut current_comb: Combining = Combining::new();
384    let mut current_links: Links = Links::new();
385    let mut current_ucolors: UColors = UColors::new();
386    // Per point: (logical line, offset, found-yet).
387    let mut tracked: Vec<(usize, usize, bool)> = vec![(0, 0, false); points.len()];
388    for (i, row) in rows.into_iter().enumerate() {
389        for (pi, &(pr, pc)) in points.iter().enumerate() {
390            if i == pr && !tracked[pi].2 {
391                tracked[pi] = (logical.len(), current.len() + pc, true);
392            }
393        }
394        let soft = row.is_wrapped();
395        let base = current.len();
396        let (cells, comb, links, ucolors) = row.into_parts();
397        // Carry live map entries, re-keyed to the logical-line offset (flag-gated:
398        // a stale entry whose cell lost its bit is dropped).
399        for (col, marks) in comb {
400            if cells[col].is_combined() {
401                current_comb.insert(base + col, marks);
402            }
403        }
404        for (col, link) in links {
405            if cells[col].is_linked() {
406                current_links.insert(base + col, link);
407            }
408        }
409        for (col, color) in ucolors {
410            if cells[col].is_ucolored() {
411                current_ucolors.insert(base + col, color);
412            }
413        }
414        if soft {
415            let mut cells = cells;
416            // A wide char that wrapped at the boundary (write_glyph / relocate_cluster_wide) left a
417            // leading-spacer placeholder in the vacated last column. It is a wrap artefact, not
418            // content — drop it on the join so the logical line (and re-split) never carries a
419            // phantom blank into accessible_text / search / copy (#303). The `soft` flag was already
420            // read from this cell above, so removing it now is safe.
421            if cells.last().is_some_and(Cell::is_leading_spacer) {
422                cells.pop();
423            }
424            current.extend(cells);
425        } else {
426            let mut cells = cells;
427            // Trim the hard-ended line's trailing blanks by **content**, not by full-cell
428            // equality. A cell the app never wrote and one it erased to a coloured background
429            // (BCE) are both "no content" — reflow is finding where the logical line *ends*, and a
430            // background is not content. Comparing against `Cell::default()` kept a BCE tail on the
431            // line, so a narrowing resize re-split it into an extra row of coloured blanks the app
432            // never typed (a phantom row that steals from scrollback on a short screen). Both
433            // references trim on content only: xterm.js `getTrimmedLength` tests `HAS_CONTENT_MASK`,
434            // alacritty `line_length` tests `c != ' '` — and xterm keeps the background-aware
435            // variant a *separate* function for the callers (the DOM renderer) that want it, which
436            // reflow is not. This does not erase a cell that survives on screen (#530): it decides
437            // a line's length, it does not blank anything.
438            while cells.last().is_some_and(Cell::is_blank) {
439                cells.pop();
440            }
441            current.extend(cells);
442            logical.push(std::mem::take(&mut current));
443            logical_comb.push(std::mem::take(&mut current_comb));
444            logical_links.push(std::mem::take(&mut current_links));
445            logical_ucolors.push(std::mem::take(&mut current_ucolors));
446        }
447    }
448    if !current.is_empty() {
449        logical.push(current);
450        logical_comb.push(current_comb);
451        logical_links.push(current_links);
452        logical_ucolors.push(current_ucolors);
453    }
454    // Trailing blank lines are absorbed, not preserved as rows (the maps are
455    // trimmed in lockstep so all four stay index-aligned).
456    while logical.last().is_some_and(|l| l.is_empty()) {
457        logical.pop();
458        logical_comb.pop();
459        logical_links.pop();
460        logical_ucolors.pop();
461    }
462
463    // 2. Re-split each logical line into `new_cols`-wide rows, mapping each
464    //    tracked point to its new (row, col).
465    let mut out: Vec<Row> = Vec::new();
466    let mut new_points = vec![(0usize, 0usize); points.len()];
467    // Where each emitted row of the current logical line actually starts and how many content
468    // cells it actually holds: `(first offset, cells, row index)`. The re-split loop is the owner
469    // of that extent — it is the thing that decides `take` — so the point mapping below reads it
470    // instead of recomputing the position as `off / new_cols`, which silently assumes every row is
471    // full. It is not: the anti-split guard emits a **short** row whenever one would end on a
472    // `WIDE_CHAR` lead, and each such row shifted every later point by one, accumulating until the
473    // point crossed into a neighbouring row (#549, an ADR-0025 D1 read-side violation — the same
474    // "don't re-derive what the owner already knows" clause the wrap flag lives under).
475    //
476    // All three references decide the position where the real extent is known, and none divides an
477    // offset by the new width:
478    //
479    // - **xterm.js precomputes exactly this array** — `reflowSmallerGetNewLineLengths`
480    //   (`common/buffer/BufferReflow.ts:179` @ `699f553`), whose doc names the reason: *"pre-compute
481    //   the wrapping points since wide characters may need to be wrapped onto the following line …
482    //   will only contain the values `newCols` … and `newCols - 1` (when the line does end with a
483    //   wide character), except for the last value"*. That is this `Vec`, in the reference.
484    // - **ghostty** moves a tracked pin by assignment from the write cursor's live position inside
485    //   its reflow loop (`terminal/PageList.zig:1650-1659` @ `e6e26e1`) — its `tracked_pins` is the
486    //   closest analogue of `points` (anchors *and* marks, not just the cursor).
487    // - **alacritty** re-anchors the cursor on the iteration that processes its own line, against
488    //   `num_wrapped` (`alacritty_terminal/src/grid/resize.rs:169-188` @ `852e971`).
489    //
490    // (xterm.js also skips the cursor's wrapped run in the *larger* path, but that is gated on its
491    // `reflowCursorLine` option — `BufferReflow.ts:45`, `Buffer.ts:337`/`:370`/`:391` — so it is a
492    // policy, not a refusal.)
493    //
494    // Held outside the loop and cleared per line, so this costs one allocation. Mapped in a single
495    // pass afterwards rather than tested per segment: `points` carries every OSC-133 command mark
496    // in the buffer, and the per-segment shape would be rows × points. Note what that does **not**
497    // claim — it is not faster than the arithmetic it replaces. That was `O(points)` per logical
498    // line and this is too (the `pl != li` filter below is the dominant term either way); measured
499    // on 8000 marks over 8000 lines, a narrow-then-widen resize is identical within noise.
500    let mut segments: Vec<(usize, usize, usize)> = Vec::new();
501    for (li, line) in logical.iter().enumerate() {
502        let comb = &logical_comb[li];
503        let links = &logical_links[li];
504        let ucolors = &logical_ucolors[li];
505        let start = out.len();
506        segments.clear();
507        if line.is_empty() {
508            out.push(Row::blank(new_cols));
509        } else {
510            let mut i = 0;
511            while i < line.len() {
512                let mut take = (line.len() - i).min(new_cols);
513                // Don't split a wide char from its spacer: if the row would end
514                // on a WIDE_CHAR lead, drop it to the next row (xterm's newCols-1).
515                let vacates_for_wide = i + take < line.len() && line[i + take - 1].is_wide();
516                if vacates_for_wide {
517                    take -= 1;
518                }
519                // `take == 0` is reachable only at `new_cols == 1`, and #547 made that width
520                // unreachable: `MIN_COLUMNS = 2` floors every entry into `Term::resize`, this
521                // function's only caller. The guard stays anyway, because what it prevents is a
522                // *hang*, not a wrong cell — at `take == 0` this loop never advances `i`.
523                // xterm.js documents the identical failure at the identical width
524                // ("Calling this with a `newCols` value of `1` will lock up.",
525                // `common/buffer/BufferReflow.ts:173`), so the cost of one `max` is well spent
526                // on the day someone adds a second caller. Valid as long as `MIN_COLUMNS >= 2`.
527                let take = take.max(1);
528                // Segment maps: entries in [i, i+take) re-keyed to col - i.
529                let seg_comb: Combining = comb
530                    .range(i..i + take)
531                    .map(|(&col, marks)| (col - i, marks.clone()))
532                    .collect();
533                let seg_links: Links = links
534                    .range(i..i + take)
535                    .map(|(&col, &link)| (col - i, link))
536                    .collect();
537                let seg_ucolors: UColors = ucolors
538                    .range(i..i + take)
539                    .map(|(&col, &color)| (col - i, color))
540                    .collect();
541                let mut row =
542                    Row::new(line[i..i + take].to_vec(), seg_comb, seg_links, seg_ucolors);
543                row.resize(new_cols);
544                // Reflow is a *producer* of the wide-wrap artefact, so it owes the artefact's
545                // marker — the column just vacated is a blank the text extractors must skip, not
546                // a space the app typed. Without it a resize injects a phantom space into copy,
547                // search and accessible text (#533). alacritty marks the same cell at both of its
548                // equivalent sites (`grid/resize.rs:155-157` grow, `:293-297` shrink, the latter
549                // `mem::replace`-ing the last column with a `LEADING_WIDE_CHAR_SPACER`); ghostty
550                // sets `.wide = .spacer_head` (`PageList.zig:1767`). The cell stays a **default**
551                // blank: unlike the print path (#528), reflow has no pen — it is a re-split of
552                // rows that already exist — and all three references build it from defaults.
553                if vacates_for_wide && take < new_cols {
554                    row.cells[new_cols - 1].set_leading_spacer();
555                }
556                segments.push((i, take, out.len()));
557                i += take;
558                if i < line.len() {
559                    row.set_wrapped(true);
560                }
561                out.push(row);
562            }
563        }
564        for (pi, &(pl, poff, _)) in tracked.iter().enumerate() {
565            if pl != li {
566                continue;
567            }
568            let off = poff.min(line.len());
569            new_points[pi] = match segments.last() {
570                // The empty-line branch emits one blank row and runs no segment loop, so the only
571                // offset a point can have here is 0.
572                None => (start, 0),
573                Some(&(last_off, last_take, last_row)) if off >= last_off + last_take => {
574                    // `off == line.len()`: the point sits *after* the last cell, so no segment
575                    // contains it — parked past the content rather than on a glyph. The honest
576                    // answer is the column just after the last one, and when that row came out
577                    // **full** it is `new_cols` — a column the grid does not have.
578                    //
579                    // Returned anyway, because the three kinds of point want different things from
580                    // it and this function cannot know which it holds (#562): the cursor wants the
581                    // next *write* position (the row after), an OSC-133 mark wants an **exclusive**
582                    // bound meaning "all of this row" (`extract_lines` clips `[b, c)`), and a
583                    // selection anchor wants to be clamped inside the grid. Answering `(row + 1, 0)`
584                    // here picked the cursor's answer for all three, which put a mark on the first
585                    // row of the *next logical line* and made it swallow that line's newline.
586                    // `Term::resize` resolves it per kind at the seam.
587                    //
588                    // ghostty splits **two** of the three the same way inside its own reflow: a
589                    // non-cursor pin is clamped before it can widen anything, the cursor pin never
590                    // is (`terminal/PageList.zig:1576-1606` @ `e6e26e1`). The mark's reading has no
591                    // prior art there and is derived here — ghostty's clamp puts a pin strictly
592                    // *inside* the destination and then widens the row to include it, the opposite
593                    // of a bound sitting outside the grid, and it has no column-bearing semantic
594                    // mark to want one (`semantic_prompt` is a row property, `:1573`). The nearest
595                    // reference for "one past is representable" is xterm.js's `x === cols`, which
596                    // is its **cursor**. Derived, not ported: `extract_lines` clips `[b, c)`, so the
597                    // exclusive end is the only value that can mean "all of this row".
598                    (last_row, last_take)
599                }
600                Some(_) => {
601                    // Segments tile `[0, line.len())` in order, so the one holding `off` is the
602                    // last whose start is `<= off`.
603                    let k = segments.partition_point(|&(s, _, _)| s <= off) - 1;
604                    let (seg_off, _, seg_row) = segments[k];
605                    (seg_row, off - seg_off)
606                }
607            };
608        }
609    }
610    // A point whose logical line was a **trailing blank** keeps its distance from the content, in
611    // lines. The join absorbs those lines rather than emitting them, so the row named here is one
612    // this function never produced — and that is correct: `reflow` does not own the row count. Its
613    // caller's fit does (`Grid::set_screen` pads blank rows at the bottom), and the bound belongs
614    // there too, against the *final* geometry rather than against `out.len()`.
615    //
616    // Clamping it here instead collapsed the cursor onto the last content row, so the next byte
617    // overwrote the content it should have followed (#562 symptom 2). The earlier guard also
618    // clamped a point that was merely one row past — a row the fit was about to create — which is
619    // how a resize folded the cursor back onto the last glyph and destroyed it (symptom 3).
620    //
621    // Nothing is materialised for this, and ghostty is the precedent — but for a narrower reason
622    // than "a blank row is free". It **defers** the row (`if (!src_row.wrap_continuation)
623    // self.new_rows += 1; return;`, `terminal/PageList.zig:1610-1616` @ `e6e26e1`) and *pays the
624    // debt by scrolling* the moment a non-blank row follows (`while (self.new_rows > 0)
625    // cursorScrollOrNewPage(...)`, `:1634-1637`). What is free is specifically a blank row with
626    // nothing after it — its own comment: *"so that blank rows at the end of the page list are
627    // never written"*. That is exactly this case, because the join only absorbs **trailing** blank
628    // lines. A port that emitted a real row here instead would pay out of the active area, and on a
629    // pane with no scrollback to absorb the displaced one — the alt screen — that is content
630    // destruction. Measured: 22 alt lines became 21.
631    for (pi, &(pl, poff, _)) in tracked.iter().enumerate() {
632        if pl >= logical.len() {
633            // Clamped **below** `new_cols`, not to it. `col == new_cols` is the "just past a full
634            // row" signal the seam reads, and an absorbed line is blank — it has no full row for
635            // the cursor to be just past. Clamping to `new_cols` made the signal fall out of
636            // ordinary arithmetic: a cursor parked one column further left stayed on its row while
637            // one column further right jumped a whole row (measured at width 4, parked columns 3
638            // and 4). A value that carries meaning must not also be an upper bound.
639            new_points[pi] = (
640                out.len() + (pl - logical.len()),
641                poff.min(new_cols.saturating_sub(1)),
642            );
643        }
644    }
645
646    (out, new_points)
647}
648
649/// The current screen: `rows` × `cols` cells.
650#[derive(Clone, Debug)]
651pub struct Grid {
652    cols: usize,
653    rows: usize,
654    lines: Vec<Row>,
655}
656
657impl Grid {
658    /// A blank grid of the given size.
659    pub fn new(cols: usize, rows: usize) -> Self {
660        let lines = vec![Row::blank(cols); rows];
661        Grid { cols, rows, lines }
662    }
663
664    pub fn cols(&self) -> usize {
665        self.cols
666    }
667
668    pub fn rows(&self) -> usize {
669        self.rows
670    }
671
672    /// Did `row` soft-wrap (auto-wrap) into the next one — i.e. are the two rows one logical
673    /// line?
674    ///
675    /// Ask this, not the last cell's `WRAPLINE` flag: soft-wrap is a property of the row and is
676    /// stored there, so a cell never carries it on a live grid (#538). The flag still appears on
677    /// the *wire*, derived onto a span's last cell at encode time, which is a different layer —
678    /// see `docs/architecture.md` §Cell on the two things called "cell" here.
679    pub fn is_row_wrapped(&self, row: usize) -> bool {
680        self.lines[row].is_wrapped()
681    }
682
683    /// Read a cell. Panics on out-of-bounds (callers clamp to the grid).
684    pub fn cell(&self, row: usize, col: usize) -> &Cell {
685        &self.lines[row][col]
686    }
687
688    /// Mutable access to a cell.
689    pub fn cell_mut(&mut self, row: usize, col: usize) -> &mut Cell {
690        &mut self.lines[row][col]
691    }
692
693    /// Read a whole row.
694    pub fn row(&self, row: usize) -> &[Cell] {
695        &self.lines[row]
696    }
697
698    /// Read a whole row including its combining map — for combining-aware reads
699    /// (text extraction, serialization).
700    pub(crate) fn row_ref(&self, row: usize) -> &Row {
701        &self.lines[row]
702    }
703
704    /// Mutable access to a whole row (cells + combining map) — for in-row cell
705    /// shifts (ICH/DCH), which must re-key combining alongside the cell move.
706    pub(crate) fn row_mut(&mut self, row: usize) -> &mut Row {
707        &mut self.lines[row]
708    }
709
710    /// A clone of a whole row (cells + combining map) — for the sub-region scroll
711    /// eviction, which copies row 0 out to scrollback (the full-screen path moves
712    /// the row instead, via `scroll_up_recycle`).
713    pub(crate) fn row_owned(&self, row: usize) -> Row {
714        self.lines[row].clone()
715    }
716
717    /// Scroll the rows `[top..=bottom]` up by one line: the top line of the
718    /// region is dropped and a blank line appears at `bottom`. Rows outside the
719    /// region are untouched.
720    ///
721    /// `rotate_left` moves whole-row `Vec` *handles* (24 bytes each), not cell
722    /// data — cheap even at the screen's bounded row count, so the per-newline
723    /// scrollback cost lives in the *eviction*, not here (see `scroll_up_recycle`
724    /// and ADR-0009).
725    pub fn scroll_up_region(&mut self, top: usize, bottom: usize) {
726        // Rotate the region's top line to its bottom, then blank it: every line
727        // in the region shifts up one and the region's bottom becomes empty.
728        self.lines[top..=bottom].rotate_left(1);
729        self.lines[bottom].blank_in_place();
730    }
731
732    /// Full-screen scroll up that **moves** the evicted top row out instead of
733    /// copying it (`Term::linefeed`'s hot path): `rotate_left` puts logical row 0
734    /// in the bottom slot, then a recycled `blank` is swapped into that slot and
735    /// the evicted row returned by value (the caller pushes it into scrollback).
736    /// The grid clears + fits `blank` to `cols`, so the caller may hand it a
737    /// dirty recycled row — reusing its allocation, so a steady-state flood does
738    /// no per-line alloc/copy (ADR-0009). No ring: the win is recycling the row
739    /// buffer, not making the cheap handle-rotate O(1).
740    pub(crate) fn scroll_up_recycle(&mut self, mut blank: Row) -> Row {
741        blank.clear(); // drop any recycled content (keeps the allocation)
742        blank.resize(self.cols);
743        self.lines.rotate_left(1); // logical row 0 -> the bottom slot
744        let last = self.rows - 1;
745        std::mem::replace(&mut self.lines[last], blank)
746    }
747
748    /// Extract all rows, leaving the grid empty. Used by `Term::resize` to
749    /// reflow the screen together with scrollback as one stream.
750    pub(crate) fn take_lines(&mut self) -> Vec<Row> {
751        std::mem::take(&mut self.lines)
752    }
753
754    /// Replace the screen with `lines` at `cols` x `rows`: each row is fit to
755    /// `cols` and the screen is padded with blank rows / truncated to `rows`.
756    pub(crate) fn set_screen(&mut self, mut lines: Vec<Row>, cols: usize, rows: usize) {
757        for row in &mut lines {
758            row.resize(cols);
759        }
760        while lines.len() < rows {
761            lines.push(Row::blank(cols));
762        }
763        lines.truncate(rows);
764        self.lines = lines;
765        self.cols = cols;
766        self.rows = rows;
767    }
768
769    /// Reset every cell to a blank default. Used when switching to the alt
770    /// screen (which always starts cleared).
771    pub fn clear(&mut self) {
772        for row in &mut self.lines {
773            row.blank_in_place();
774        }
775    }
776
777    /// Scroll the rows `[top..=bottom]` down by one line: a blank line appears at
778    /// `top` and the bottom region line is dropped. Rows outside are untouched.
779    /// Used by RI (reverse index) at the top margin.
780    pub fn scroll_down_region(&mut self, top: usize, bottom: usize) {
781        // Rotate the region's bottom line to its top, then blank it: every line
782        // in the region shifts down one and the region's top becomes empty.
783        self.lines[top..=bottom].rotate_right(1);
784        self.lines[top].blank_in_place();
785    }
786}
787
788#[cfg(test)]
789mod tests {
790    use super::*;
791
792    /// A grid whose row `r` carries the char `'a' + r` in column 0 — a distinct
793    /// marker per logical row so a scroll's row mapping is observable.
794    fn stamped(cols: usize, rows: usize) -> Grid {
795        let mut g = Grid::new(cols, rows);
796        for r in 0..rows {
797            g.cell_mut(r, 0).set_c(char::from(b'a' + r as u8));
798        }
799        g
800    }
801
802    /// Column-0 chars read top-to-bottom in *logical* row order.
803    fn col0(g: &Grid) -> String {
804        (0..g.rows()).map(|r| g.cell(r, 0).c()).collect()
805    }
806
807    #[test]
808    fn full_screen_scroll_up_shifts_content_and_blanks_bottom() {
809        let mut g = stamped(2, 3); // logical col0 = "abc"
810        g.scroll_up_region(0, 2);
811        assert_eq!(col0(&g), "bc "); // shifted up, bottom blanked
812    }
813
814    #[test]
815    fn full_screen_scroll_down_shifts_content_and_blanks_top() {
816        // RI at the top margin: blank appears at the top, the bottom line is lost.
817        let mut g = stamped(2, 3); // "abc"
818        g.scroll_down_region(0, 2);
819        assert_eq!(col0(&g), " ab");
820    }
821
822    #[test]
823    fn sub_region_scroll_leaves_rows_outside_the_region_untouched() {
824        let mut g = stamped(2, 4); // "abcd"
825        g.scroll_up_region(0, 1); // sub-region [0..=1] only
826        // rows 0..=1 ("ab") scroll up → "b" then blank; rows 2,3 ("c","d") stay.
827        assert_eq!(col0(&g), "b cd");
828    }
829
830    #[test]
831    fn scroll_up_recycle_moves_out_row0_and_blanks_a_dirty_recycled_row() {
832        let mut g = stamped(2, 3); // "abc"
833        // Hand it a *dirty* recycled row (full width, stale content) — the new
834        // bottom must come out blank, not carrying the recycled row's text.
835        let mut x = Cell::default();
836        x.set_c('X');
837        let dirty = Row::from_cells(vec![x; 2]);
838        let evicted = g.scroll_up_recycle(dirty);
839        assert_eq!(evicted[0].c(), 'a'); // logical row 0 moved out, not copied
840        assert_eq!(col0(&g), "bc "); // shifted up; bottom blank, NOT "bcX"
841    }
842
843    #[test]
844    fn take_lines_returns_rows_in_logical_order_after_a_scroll() {
845        // `reflow` assumes logical row order; `take_lines` must deliver it.
846        let mut g = stamped(1, 3); // "abc"
847        g.scroll_up_region(0, 2); // "bc "
848        let lines = g.take_lines();
849        let got: String = lines.iter().map(|r| r[0].c()).collect();
850        assert_eq!(got, "bc ");
851    }
852
853    /// `set_ext_attrs` is "make this column carry **exactly** these attrs". The
854    /// clearing half is invisible through the public API — the flag-gate hides a
855    /// stale entry either way — so it is pinned here, at the primitive that owns
856    /// the guarantee: a caller handing it `None` must leave neither a set presence
857    /// bit nor a readable map entry behind (#521).
858    #[test]
859    fn set_ext_attrs_clears_both_halves_of_the_gate() {
860        let mut row = Row::blank(2);
861        let link = NonZeroU32::new(7).unwrap();
862        row.set_link(0, link);
863        row.set_ucolor(0, Color::Indexed(3));
864        assert_eq!(row.ext_attrs_at(0).link, Some(link));
865        assert_eq!(row.ext_attrs_at(0).ucolor, Some(Color::Indexed(3)));
866
867        row.set_ext_attrs(0, ExtAttrs::default());
868        assert!(!row.cells[0].is_linked(), "presence bit cleared");
869        assert!(!row.cells[0].is_ucolored(), "presence bit cleared");
870        assert!(row.links.is_empty(), "and the map entry with it");
871        assert!(row.ucolors.is_empty());
872        // Re-arming the bit by hand must not resurrect anything.
873        row.cells[0].set_linked(true);
874        row.cells[0].set_ucolored(true);
875        assert_eq!(row.ext_attrs_at(0), ExtAttrs::default());
876    }
877
878    /// The carry itself: reading a column's family and stamping it onto another
879    /// column reproduces both riders together — the one step the promotion paths
880    /// rely on so a future rider needs no new call site (#521).
881    #[test]
882    fn ext_attrs_round_trip_from_one_column_to_another() {
883        let mut row = Row::blank(2);
884        let link = NonZeroU32::new(4).unwrap();
885        row.set_link(0, link);
886        row.set_ucolor(0, Color::Rgb(1, 2, 3));
887        let carried = row.ext_attrs_at(0);
888        row.set_ext_attrs(1, carried);
889        assert_eq!(row.link_at(1), Some(link));
890        assert_eq!(row.ucolor_at(1), Some(Color::Rgb(1, 2, 3)));
891        assert_eq!(row.ext_attrs_at(1), carried);
892    }
893}