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, CellFlags};
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/// Re-key a sparse column map to follow a `copy_within(src, dst)` cell shift: the
32/// live entry for a moved cell travels to the cell's new column. Vacated source
33/// keys whose cell loses its gate bit are left stale — harmless under the
34/// flag-gate — so only the live carry is done. Generic over the value type so the
35/// combining and link maps share one implementation.
36fn move_map<V>(map: &mut BTreeMap<usize, V>, src: std::ops::Range<usize>, dst: usize) {
37 if map.is_empty() {
38 return;
39 }
40 let start = src.start;
41 let moved: Vec<(usize, V)> = src
42 .filter_map(|s| map.remove(&s).map(|v| (dst + (s - start), v)))
43 .collect();
44 for (col, v) in moved {
45 map.insert(col, v);
46 }
47}
48
49/// One row of cells **plus** its per-row, column-keyed combining, link, and
50/// underline-colour maps.
51///
52/// The maps ride with the row through scroll / scrollback / reflow for free (the
53/// row is the unit that moves), which is why combining (#45), hyperlinks (#46),
54/// and underline colours (#520) live here rather than in global per-cell indices —
55/// no leak, cleared on row reuse. `Row` derefs to `[Cell]`, so index/iterate/slice
56/// sites are unchanged; the maps are reached through the dedicated methods so the
57/// flag-gate (read iff the cell's `COMBINED_PRESENT` / `LINK_PRESENT` /
58/// `UCOLOR_PRESENT` bit is set) is never bypassed.
59#[derive(Clone, Debug, PartialEq, Eq, Default)]
60pub struct Row {
61 cells: Vec<Cell>,
62 combining: Combining,
63 links: Links,
64 ucolors: UColors,
65}
66
67impl Row {
68 /// A row of `cols` blank cells.
69 pub(crate) fn blank(cols: usize) -> Row {
70 Row {
71 cells: vec![Cell::default(); cols],
72 combining: Combining::new(),
73 links: Links::new(),
74 ucolors: UColors::new(),
75 }
76 }
77
78 /// Wrap a cell vector as a row with no combining marks, links, or ucolors.
79 pub(crate) fn from_cells(cells: Vec<Cell>) -> Row {
80 Row {
81 cells,
82 combining: Combining::new(),
83 links: Links::new(),
84 ucolors: UColors::new(),
85 }
86 }
87
88 /// Build a row from cells and its maps (the reflow re-split path).
89 pub(crate) fn new(
90 cells: Vec<Cell>,
91 combining: Combining,
92 links: Links,
93 ucolors: UColors,
94 ) -> Row {
95 Row {
96 cells,
97 combining,
98 links,
99 ucolors,
100 }
101 }
102
103 /// Consume the row into its cells, combining map, link map, and ucolor map
104 /// (the reflow join path).
105 pub(crate) fn into_parts(self) -> (Vec<Cell>, Combining, Links, UColors) {
106 (self.cells, self.combining, self.links, self.ucolors)
107 }
108
109 /// Resize to `cols`, padding with blanks or truncating; map entries for
110 /// dropped columns are pruned (xterm's shrink-prune).
111 pub(crate) fn resize(&mut self, cols: usize) {
112 self.cells.resize(cols, Cell::default());
113 if self
114 .combining
115 .keys()
116 .next_back()
117 .is_some_and(|&m| m >= cols)
118 {
119 self.combining.retain(|&col, _| col < cols);
120 }
121 if self.links.keys().next_back().is_some_and(|&m| m >= cols) {
122 self.links.retain(|&col, _| col < cols);
123 }
124 if self.ucolors.keys().next_back().is_some_and(|&m| m >= cols) {
125 self.ucolors.retain(|&col, _| col < cols);
126 }
127 }
128
129 /// Empty the row, keeping the cell allocation — for recycling a row buffer
130 /// (`scroll_up_recycle`). Clears cells and both maps so a reused row never
131 /// surfaces a previous occupant's marks or links.
132 pub(crate) fn clear(&mut self) {
133 self.cells.clear();
134 self.combining.clear();
135 self.links.clear();
136 self.ucolors.clear();
137 }
138
139 /// The combining marks at `col`, or `None`. Flag-gated: returns `Some` only
140 /// when the cell carries the `COMBINED_PRESENT` bit, so a stale map entry is
141 /// never surfaced.
142 pub(crate) fn combining_at(&self, col: usize) -> Option<&[char]> {
143 if self.cells[col].is_combined() {
144 self.combining.get(&col).map(Vec::as_slice)
145 } else {
146 None
147 }
148 }
149
150 /// The hyperlink-pool index at `col`, or `None`. Flag-gated by the cell's
151 /// `LINK_PRESENT` bit (mirror of [`Row::combining_at`]).
152 pub(crate) fn link_at(&self, col: usize) -> Option<NonZeroU32> {
153 if self.cells[col].is_linked() {
154 self.links.get(&col).copied()
155 } else {
156 None
157 }
158 }
159
160 /// The non-default underline colour at `col`, or `None` (which the caller reads
161 /// as `Default` — follow the fg). Flag-gated by the cell's `UCOLOR_PRESENT` bit,
162 /// so a stale map entry an overwrite left behind is never surfaced (#520).
163 pub(crate) fn ucolor_at(&self, col: usize) -> Option<Color> {
164 if self.cells[col].is_ucolored() {
165 self.ucolors.get(&col).copied()
166 } else {
167 None
168 }
169 }
170
171 /// Attach a combining mark to `col`'s glyph. The first mark on a cell starts a
172 /// fresh cluster — dropping any stale entry an overwrite left behind (the bit
173 /// was clear) — and sets the presence bit; subsequent marks append. Mirrors
174 /// xterm's `addCodepointToCell`.
175 pub(crate) fn push_combining(&mut self, col: usize, mark: char) {
176 if self.cells[col].is_combined() {
177 self.combining.entry(col).or_default().push(mark);
178 } else {
179 self.cells[col].set_combined(true);
180 self.combining.insert(col, vec![mark]);
181 }
182 }
183
184 /// Stamp `col`'s glyph with a hyperlink-pool index, setting the presence bit
185 /// (the print path calls this on every cell written while a link is open).
186 pub(crate) fn set_link(&mut self, col: usize, link: NonZeroU32) {
187 self.cells[col].set_linked(true);
188 self.links.insert(col, link);
189 }
190
191 /// Stamp `col`'s glyph with a non-default underline colour, setting the presence
192 /// bit (the print path calls this on every cell written while the pen's underline
193 /// colour is non-default, #520). Mirror of [`Row::set_link`].
194 pub(crate) fn set_ucolor(&mut self, col: usize, color: Color) {
195 self.cells[col].set_ucolored(true);
196 self.ucolors.insert(col, color);
197 }
198
199 /// Re-key every map to follow a `copy_within(src, dst)` cell shift (ICH/DCH),
200 /// so a cluster, link, or underline colour stays attached to its glyph at the
201 /// new column.
202 pub(crate) fn move_maps(&mut self, src: std::ops::Range<usize>, dst: usize) {
203 move_map(&mut self.combining, src.clone(), dst);
204 move_map(&mut self.links, src.clone(), dst);
205 move_map(&mut self.ucolors, src, dst);
206 }
207}
208
209impl Deref for Row {
210 type Target = [Cell];
211 fn deref(&self) -> &[Cell] {
212 &self.cells
213 }
214}
215
216impl DerefMut for Row {
217 fn deref_mut(&mut self) -> &mut [Cell] {
218 &mut self.cells
219 }
220}
221
222/// Re-wrap physical `rows` to `new_cols`. Soft-wrapped rows (WRAPLINE on the
223/// last cell) are joined into logical lines, then each logical line is re-split
224/// at `new_cols` with WRAPLINE set on every segment but the last. Trailing blank
225/// rows are absorbed (re-created by the caller's row-count fit). See #7.
226///
227/// `points` are `(row, col)` coordinates to track through the reflow (the cursor
228/// and any selection anchors); the returned `Vec` maps each to its new position,
229/// index-aligned with the input.
230///
231/// Common-90%: trailing blanks on a hard-ended row are trimmed, and a wide-char
232/// split across the new boundary is not yet special-cased.
233pub(crate) fn reflow(
234 rows: Vec<Row>,
235 new_cols: usize,
236 points: &[(usize, usize)],
237) -> (Vec<Row>, Vec<(usize, usize)>) {
238 // 1. Join soft-wrapped rows into logical lines, recording each tracked
239 // point's logical coordinate (line index + offset within the line). The
240 // combining map is carried alongside: a row's entries are re-keyed by the
241 // join offset so a cluster stays attached to its glyph across the wrap.
242 let mut logical: Vec<Vec<Cell>> = Vec::new();
243 let mut logical_comb: Vec<Combining> = Vec::new();
244 let mut logical_links: Vec<Links> = Vec::new();
245 let mut logical_ucolors: Vec<UColors> = Vec::new();
246 let mut current: Vec<Cell> = Vec::new();
247 let mut current_comb: Combining = Combining::new();
248 let mut current_links: Links = Links::new();
249 let mut current_ucolors: UColors = UColors::new();
250 // Per point: (logical line, offset, found-yet).
251 let mut tracked: Vec<(usize, usize, bool)> = vec![(0, 0, false); points.len()];
252 for (i, row) in rows.into_iter().enumerate() {
253 for (pi, &(pr, pc)) in points.iter().enumerate() {
254 if i == pr && !tracked[pi].2 {
255 tracked[pi] = (logical.len(), current.len() + pc, true);
256 }
257 }
258 let soft = row.last().is_some_and(|c| c.is_wrapline());
259 let base = current.len();
260 let (cells, comb, links, ucolors) = row.into_parts();
261 // Carry live map entries, re-keyed to the logical-line offset (flag-gated:
262 // a stale entry whose cell lost its bit is dropped).
263 for (col, marks) in comb {
264 if cells[col].is_combined() {
265 current_comb.insert(base + col, marks);
266 }
267 }
268 for (col, link) in links {
269 if cells[col].is_linked() {
270 current_links.insert(base + col, link);
271 }
272 }
273 for (col, color) in ucolors {
274 if cells[col].is_ucolored() {
275 current_ucolors.insert(base + col, color);
276 }
277 }
278 if soft {
279 let mut cells = cells;
280 // A wide char that wrapped at the boundary (write_glyph / relocate_cluster_wide) left a
281 // leading-spacer placeholder in the vacated last column. It is a wrap artefact, not
282 // content — drop it on the join so the logical line (and re-split) never carries a
283 // phantom blank into accessible_text / search / copy (#303). The `soft` flag was already
284 // read from this cell above, so removing it now is safe.
285 if cells.last().is_some_and(Cell::is_leading_spacer) {
286 cells.pop();
287 }
288 current.extend(cells.into_iter().map(|mut c| {
289 c.remove_flags(CellFlags::WRAPLINE);
290 c
291 }));
292 } else {
293 let mut cells = cells;
294 while cells.last() == Some(&Cell::default()) {
295 cells.pop();
296 }
297 current.extend(cells);
298 logical.push(std::mem::take(&mut current));
299 logical_comb.push(std::mem::take(&mut current_comb));
300 logical_links.push(std::mem::take(&mut current_links));
301 logical_ucolors.push(std::mem::take(&mut current_ucolors));
302 }
303 }
304 if !current.is_empty() {
305 logical.push(current);
306 logical_comb.push(current_comb);
307 logical_links.push(current_links);
308 logical_ucolors.push(current_ucolors);
309 }
310 // Trailing blank lines are absorbed, not preserved as rows (the maps are
311 // trimmed in lockstep so all four stay index-aligned).
312 while logical.last().is_some_and(|l| l.is_empty()) {
313 logical.pop();
314 logical_comb.pop();
315 logical_links.pop();
316 logical_ucolors.pop();
317 }
318
319 // 2. Re-split each logical line into `new_cols`-wide rows, mapping each
320 // tracked point to its new (row, col).
321 let mut out: Vec<Row> = Vec::new();
322 let mut new_points = vec![(0usize, 0usize); points.len()];
323 for (li, line) in logical.iter().enumerate() {
324 let comb = &logical_comb[li];
325 let links = &logical_links[li];
326 let ucolors = &logical_ucolors[li];
327 let start = out.len();
328 if line.is_empty() {
329 out.push(Row::blank(new_cols));
330 } else {
331 let mut i = 0;
332 while i < line.len() {
333 let mut take = (line.len() - i).min(new_cols);
334 // Don't split a wide char from its spacer: if the row would end
335 // on a WIDE_CHAR lead, drop it to the next row (xterm's newCols-1).
336 if i + take < line.len() && line[i + take - 1].is_wide() {
337 take -= 1;
338 }
339 let take = take.max(1); // guard the 1-col degenerate case
340 // Segment maps: entries in [i, i+take) re-keyed to col - i.
341 let seg_comb: Combining = comb
342 .range(i..i + take)
343 .map(|(&col, marks)| (col - i, marks.clone()))
344 .collect();
345 let seg_links: Links = links
346 .range(i..i + take)
347 .map(|(&col, &link)| (col - i, link))
348 .collect();
349 let seg_ucolors: UColors = ucolors
350 .range(i..i + take)
351 .map(|(&col, &color)| (col - i, color))
352 .collect();
353 let mut row =
354 Row::new(line[i..i + take].to_vec(), seg_comb, seg_links, seg_ucolors);
355 row.resize(new_cols);
356 i += take;
357 if i < line.len() {
358 row[new_cols - 1].insert_flags(CellFlags::WRAPLINE);
359 }
360 out.push(row);
361 }
362 }
363 for (pi, &(pl, poff, _)) in tracked.iter().enumerate() {
364 if pl == li {
365 let off = poff.min(line.len());
366 new_points[pi] = (start + off / new_cols, off % new_cols);
367 }
368 }
369 }
370 // A point whose logical line was trimmed (trailing blank) clamps to the end.
371 for (pi, &(pl, _, _)) in tracked.iter().enumerate() {
372 if pl >= logical.len() {
373 new_points[pi] = (out.len().saturating_sub(1), 0);
374 }
375 }
376
377 (out, new_points)
378}
379
380/// The current screen: `rows` × `cols` cells.
381#[derive(Clone, Debug)]
382pub struct Grid {
383 cols: usize,
384 rows: usize,
385 lines: Vec<Row>,
386}
387
388impl Grid {
389 /// A blank grid of the given size.
390 pub fn new(cols: usize, rows: usize) -> Self {
391 let lines = vec![Row::blank(cols); rows];
392 Grid { cols, rows, lines }
393 }
394
395 pub fn cols(&self) -> usize {
396 self.cols
397 }
398
399 pub fn rows(&self) -> usize {
400 self.rows
401 }
402
403 /// Read a cell. Panics on out-of-bounds (callers clamp to the grid).
404 pub fn cell(&self, row: usize, col: usize) -> &Cell {
405 &self.lines[row][col]
406 }
407
408 /// Mutable access to a cell.
409 pub fn cell_mut(&mut self, row: usize, col: usize) -> &mut Cell {
410 &mut self.lines[row][col]
411 }
412
413 /// Read a whole row.
414 pub fn row(&self, row: usize) -> &[Cell] {
415 &self.lines[row]
416 }
417
418 /// Read a whole row including its combining map — for combining-aware reads
419 /// (text extraction, serialization).
420 pub(crate) fn row_ref(&self, row: usize) -> &Row {
421 &self.lines[row]
422 }
423
424 /// Mutable access to a whole row (cells + combining map) — for in-row cell
425 /// shifts (ICH/DCH), which must re-key combining alongside the cell move.
426 pub(crate) fn row_mut(&mut self, row: usize) -> &mut Row {
427 &mut self.lines[row]
428 }
429
430 /// A clone of a whole row (cells + combining map) — for the sub-region scroll
431 /// eviction, which copies row 0 out to scrollback (the full-screen path moves
432 /// the row instead, via `scroll_up_recycle`).
433 pub(crate) fn row_owned(&self, row: usize) -> Row {
434 self.lines[row].clone()
435 }
436
437 /// Scroll the rows `[top..=bottom]` up by one line: the top line of the
438 /// region is dropped and a blank line appears at `bottom`. Rows outside the
439 /// region are untouched.
440 ///
441 /// `rotate_left` moves whole-row `Vec` *handles* (24 bytes each), not cell
442 /// data — cheap even at the screen's bounded row count, so the per-newline
443 /// scrollback cost lives in the *eviction*, not here (see `scroll_up_recycle`
444 /// and ADR-0009).
445 pub fn scroll_up_region(&mut self, top: usize, bottom: usize) {
446 // Rotate the region's top line to its bottom, then blank it: every line
447 // in the region shifts up one and the region's bottom becomes empty.
448 self.lines[top..=bottom].rotate_left(1);
449 for cell in self.lines[bottom].iter_mut() {
450 cell.reset();
451 }
452 }
453
454 /// Full-screen scroll up that **moves** the evicted top row out instead of
455 /// copying it (`Term::linefeed`'s hot path): `rotate_left` puts logical row 0
456 /// in the bottom slot, then a recycled `blank` is swapped into that slot and
457 /// the evicted row returned by value (the caller pushes it into scrollback).
458 /// The grid clears + fits `blank` to `cols`, so the caller may hand it a
459 /// dirty recycled row — reusing its allocation, so a steady-state flood does
460 /// no per-line alloc/copy (ADR-0009). No ring: the win is recycling the row
461 /// buffer, not making the cheap handle-rotate O(1).
462 pub(crate) fn scroll_up_recycle(&mut self, mut blank: Row) -> Row {
463 blank.clear(); // drop any recycled content (keeps the allocation)
464 blank.resize(self.cols);
465 self.lines.rotate_left(1); // logical row 0 -> the bottom slot
466 let last = self.rows - 1;
467 std::mem::replace(&mut self.lines[last], blank)
468 }
469
470 /// Extract all rows, leaving the grid empty. Used by `Term::resize` to
471 /// reflow the screen together with scrollback as one stream.
472 pub(crate) fn take_lines(&mut self) -> Vec<Row> {
473 std::mem::take(&mut self.lines)
474 }
475
476 /// Replace the screen with `lines` at `cols` x `rows`: each row is fit to
477 /// `cols` and the screen is padded with blank rows / truncated to `rows`.
478 pub(crate) fn set_screen(&mut self, mut lines: Vec<Row>, cols: usize, rows: usize) {
479 for row in &mut lines {
480 row.resize(cols);
481 }
482 while lines.len() < rows {
483 lines.push(Row::blank(cols));
484 }
485 lines.truncate(rows);
486 self.lines = lines;
487 self.cols = cols;
488 self.rows = rows;
489 }
490
491 /// Reset every cell to a blank default. Used when switching to the alt
492 /// screen (which always starts cleared).
493 pub fn clear(&mut self) {
494 for row in &mut self.lines {
495 for cell in row.iter_mut() {
496 cell.reset();
497 }
498 }
499 }
500
501 /// Scroll the rows `[top..=bottom]` down by one line: a blank line appears at
502 /// `top` and the bottom region line is dropped. Rows outside are untouched.
503 /// Used by RI (reverse index) at the top margin.
504 pub fn scroll_down_region(&mut self, top: usize, bottom: usize) {
505 // Rotate the region's bottom line to its top, then blank it: every line
506 // in the region shifts down one and the region's top becomes empty.
507 self.lines[top..=bottom].rotate_right(1);
508 for cell in self.lines[top].iter_mut() {
509 cell.reset();
510 }
511 }
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517
518 /// A grid whose row `r` carries the char `'a' + r` in column 0 — a distinct
519 /// marker per logical row so a scroll's row mapping is observable.
520 fn stamped(cols: usize, rows: usize) -> Grid {
521 let mut g = Grid::new(cols, rows);
522 for r in 0..rows {
523 g.cell_mut(r, 0).set_c(char::from(b'a' + r as u8));
524 }
525 g
526 }
527
528 /// Column-0 chars read top-to-bottom in *logical* row order.
529 fn col0(g: &Grid) -> String {
530 (0..g.rows()).map(|r| g.cell(r, 0).c()).collect()
531 }
532
533 #[test]
534 fn full_screen_scroll_up_shifts_content_and_blanks_bottom() {
535 let mut g = stamped(2, 3); // logical col0 = "abc"
536 g.scroll_up_region(0, 2);
537 assert_eq!(col0(&g), "bc "); // shifted up, bottom blanked
538 }
539
540 #[test]
541 fn full_screen_scroll_down_shifts_content_and_blanks_top() {
542 // RI at the top margin: blank appears at the top, the bottom line is lost.
543 let mut g = stamped(2, 3); // "abc"
544 g.scroll_down_region(0, 2);
545 assert_eq!(col0(&g), " ab");
546 }
547
548 #[test]
549 fn sub_region_scroll_leaves_rows_outside_the_region_untouched() {
550 let mut g = stamped(2, 4); // "abcd"
551 g.scroll_up_region(0, 1); // sub-region [0..=1] only
552 // rows 0..=1 ("ab") scroll up → "b" then blank; rows 2,3 ("c","d") stay.
553 assert_eq!(col0(&g), "b cd");
554 }
555
556 #[test]
557 fn scroll_up_recycle_moves_out_row0_and_blanks_a_dirty_recycled_row() {
558 let mut g = stamped(2, 3); // "abc"
559 // Hand it a *dirty* recycled row (full width, stale content) — the new
560 // bottom must come out blank, not carrying the recycled row's text.
561 let mut x = Cell::default();
562 x.set_c('X');
563 let dirty = Row::from_cells(vec![x; 2]);
564 let evicted = g.scroll_up_recycle(dirty);
565 assert_eq!(evicted[0].c(), 'a'); // logical row 0 moved out, not copied
566 assert_eq!(col0(&g), "bc "); // shifted up; bottom blank, NOT "bcX"
567 }
568
569 #[test]
570 fn take_lines_returns_rows_in_logical_order_after_a_scroll() {
571 // `reflow` assumes logical row order; `take_lines` must deliver it.
572 let mut g = stamped(1, 3); // "abc"
573 g.scroll_up_region(0, 2); // "bc "
574 let lines = g.take_lines();
575 let got: String = lines.iter().map(|r| r[0].c()).collect();
576 assert_eq!(got, "bc ");
577 }
578}