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