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