Skip to main content

rmux_core/
grid.rs

1//! Safe grid and scrollback storage for pane screen contents.
2
3use std::collections::VecDeque;
4
5use rmux_proto::TerminalSize;
6
7use crate::hyperlinks::Hyperlinks;
8use crate::input::{Colour, COLOUR_DEFAULT};
9
10#[path = "grid/cell.rs"]
11mod cell;
12#[path = "grid/render.rs"]
13mod render;
14
15pub(crate) use cell::{GridCell, GridCellFlags, GridLine, GridLineFlags};
16use render::{append_cell_text, append_grid_string_code, append_hyperlink};
17
18/// Captured grid content rendered as logical lines.
19#[derive(Debug, Clone, PartialEq, Eq, Default)]
20#[cfg_attr(not(test), allow(dead_code))]
21pub(crate) struct GridCapture {
22    /// Captured lines ordered from oldest to newest.
23    pub lines: Vec<String>,
24}
25
26/// Rendering flags for tmux-style grid capture.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct GridRenderOptions {
29    /// Whether wrapped rows should omit separating newlines.
30    pub join_wrapped: bool,
31    /// Whether to emit ANSI SGR and OSC sequences inline.
32    pub with_sequences: bool,
33    /// Whether control sequences should be octal-escaped.
34    pub escape_sequences: bool,
35    /// Whether trailing empty cells should be included.
36    pub include_empty_cells: bool,
37    /// Whether trailing spaces should be trimmed from the rendered line.
38    pub trim_spaces: bool,
39}
40
41impl Default for GridRenderOptions {
42    fn default() -> Self {
43        Self {
44            join_wrapped: false,
45            with_sequences: false,
46            escape_sequences: false,
47            include_empty_cells: true,
48            trim_spaces: true,
49        }
50    }
51}
52
53/// Per-capture ANSI state matching tmux's carried `lastgc`.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct GridStringState {
56    last_cell: GridCell,
57}
58
59impl Default for GridStringState {
60    fn default() -> Self {
61        Self {
62            last_cell: GridCell::blank_with_bg(COLOUR_DEFAULT),
63        }
64    }
65}
66
67/// Absolute grid storage split into history and visible rows.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub(crate) struct Grid {
70    sx: u32,
71    sy: u32,
72    hlimit: usize,
73    hscrolled: usize,
74    history_enabled: bool,
75    history: VecDeque<GridLine>,
76    visible: Vec<GridLine>,
77}
78
79impl Grid {
80    /// Creates a new grid with the given geometry and history limit.
81    #[must_use]
82    pub fn new(size: TerminalSize, hlimit: usize) -> Self {
83        let sx = u32::from(size.cols.max(1));
84        let sy = u32::from(size.rows.max(1));
85        Self {
86            sx,
87            sy,
88            hlimit,
89            hscrolled: 0,
90            history_enabled: true,
91            history: VecDeque::new(),
92            visible: (0..sy).map(|_| GridLine::new(sx)).collect(),
93        }
94    }
95
96    /// Returns the grid size.
97    #[must_use]
98    pub fn size(&self) -> TerminalSize {
99        TerminalSize {
100            cols: u16::try_from(self.sx).unwrap_or(u16::MAX),
101            rows: u16::try_from(self.sy).unwrap_or(u16::MAX),
102        }
103    }
104
105    /// Returns the visible width in columns.
106    #[must_use]
107    pub const fn sx(&self) -> u32 {
108        self.sx
109    }
110
111    /// Returns the visible height in rows.
112    #[must_use]
113    pub const fn sy(&self) -> u32 {
114        self.sy
115    }
116
117    /// Returns the history size in rows.
118    #[must_use]
119    pub fn hsize(&self) -> usize {
120        self.history.len()
121    }
122
123    /// Returns the configured history limit.
124    #[must_use]
125    pub const fn hlimit(&self) -> usize {
126        self.hlimit
127    }
128
129    /// Returns whether history collection is enabled.
130    #[must_use]
131    pub const fn history_enabled(&self) -> bool {
132        self.history_enabled
133    }
134
135    /// Updates the history limit and evicts old rows if needed.
136    pub fn set_hlimit(&mut self, hlimit: usize) {
137        self.hlimit = hlimit;
138        while self.history.len() > self.hlimit {
139            let _ = self.history.pop_front();
140        }
141        self.hscrolled = self.hscrolled.min(self.history.len());
142    }
143
144    /// Enables or disables scrollback collection.
145    pub fn set_history_enabled(&mut self, enabled: bool) {
146        self.history_enabled = enabled;
147    }
148
149    /// Returns the number of history rows that can be pulled back by growth.
150    #[allow(dead_code)]
151    #[must_use]
152    pub const fn hscrolled(&self) -> usize {
153        self.hscrolled
154    }
155
156    /// Returns one visible line by row.
157    #[must_use]
158    pub fn visible_line(&self, y: u32) -> Option<&GridLine> {
159        self.visible.get(y as usize)
160    }
161
162    pub(crate) fn visible_line_mut(&mut self, y: u32) -> Option<&mut GridLine> {
163        self.visible.get_mut(y as usize)
164    }
165
166    /// Returns one absolute line where rows `0..hsize` are history and
167    /// `hsize..hsize+sy` are the visible screen.
168    #[allow(dead_code)]
169    #[must_use]
170    pub fn absolute_line(&self, absolute_y: usize) -> Option<&GridLine> {
171        if absolute_y < self.history.len() {
172            self.history.get(absolute_y)
173        } else {
174            self.visible.get(absolute_y - self.history.len())
175        }
176    }
177
178    /// Removes one absolute line from history or the visible viewport.
179    ///
180    /// Visible removals keep the viewport height stable by pushing a blank row
181    /// at the bottom.
182    pub fn remove_absolute_line(&mut self, absolute_y: usize) -> bool {
183        if absolute_y < self.history.len() {
184            let _ = self.history.remove(absolute_y);
185            self.hscrolled = self.hscrolled.min(self.history.len());
186            return true;
187        }
188
189        let visible_index = absolute_y.saturating_sub(self.history.len());
190        if visible_index >= self.visible.len() {
191            return false;
192        }
193
194        let _ = self.visible.remove(visible_index);
195        self.visible.push(GridLine::new(self.sx));
196        true
197    }
198
199    /// Returns whether the absolute line is marked as wrapped.
200    #[must_use]
201    pub fn absolute_line_wrapped(&self, absolute_y: usize) -> Option<bool> {
202        self.absolute_line(absolute_y)
203            .map(|line| line.flags.contains(GridLineFlags::WRAPPED))
204    }
205
206    /// Clears every history row.
207    pub fn clear_history(&mut self) {
208        self.history.clear();
209        self.hscrolled = 0;
210    }
211
212    /// Clears the visible grid.
213    pub fn clear_visible(&mut self, bg: Colour) {
214        for line in &mut self.visible {
215            line.clear(bg);
216        }
217    }
218
219    /// Replaces the visible rows with a saved copy.
220    pub fn replace_visible(&mut self, lines: Vec<GridLine>) {
221        self.sy = lines.len() as u32;
222        self.visible = lines;
223        for line in &mut self.visible {
224            line.resize_width_preserving_wrap(self.sx, COLOUR_DEFAULT);
225        }
226    }
227
228    /// Captures the grid as rendered lines. Wrapped rows are optionally joined.
229    #[cfg_attr(not(test), allow(dead_code))]
230    #[must_use]
231    pub fn capture(&self, join_wrapped: bool) -> GridCapture {
232        let mut lines = Vec::new();
233        let mut pending = String::new();
234
235        for line in self.history.iter().chain(self.visible.iter()) {
236            let rendered = line.render_text();
237            if join_wrapped {
238                pending.push_str(&rendered);
239                if !line.flags.contains(GridLineFlags::WRAPPED) {
240                    lines.push(std::mem::take(&mut pending));
241                }
242                continue;
243            }
244
245            lines.push(rendered);
246        }
247
248        if join_wrapped && !pending.is_empty() {
249            lines.push(pending);
250        }
251
252        GridCapture { lines }
253    }
254
255    /// Renders one absolute line using tmux-style capture options.
256    #[must_use]
257    pub fn render_absolute_line(
258        &self,
259        absolute_y: usize,
260        options: GridRenderOptions,
261        state: &mut GridStringState,
262        hyperlinks: Option<&Hyperlinks>,
263    ) -> Option<String> {
264        self.absolute_line(absolute_y)
265            .map(|line| line.render_with_options(options, state, hyperlinks))
266    }
267
268    /// Returns the retained history size in bytes including newlines.
269    #[must_use]
270    pub fn history_byte_size(&self) -> usize {
271        self.history
272            .iter()
273            .map(|line| line.render_text().len() + 1)
274            .sum()
275    }
276
277    /// Captures only the visible rows.
278    #[must_use]
279    pub fn visible_lines(&self) -> Vec<GridLine> {
280        self.visible.clone()
281    }
282
283    pub(crate) fn scroll_region_up(
284        &mut self,
285        upper: u32,
286        lower: u32,
287        bg: Colour,
288        to_history: bool,
289    ) {
290        if !self.valid_region(upper, lower) {
291            return;
292        }
293
294        let removed = self.visible.remove(upper as usize);
295        if to_history && self.history_enabled {
296            self.push_history(removed);
297        }
298        self.visible
299            .insert(lower as usize, GridLine::blank_with_bg(self.sx, bg));
300    }
301
302    pub(crate) fn scroll_region_down(&mut self, upper: u32, lower: u32, bg: Colour) {
303        if !self.valid_region(upper, lower) {
304            return;
305        }
306
307        let _ = self.visible.remove(lower as usize);
308        self.visible
309            .insert(upper as usize, GridLine::blank_with_bg(self.sx, bg));
310    }
311
312    pub(crate) fn resize_width(&mut self, sx: u32, bg: Colour) {
313        let sx = sx.max(1);
314        if sx == self.sx {
315            return;
316        }
317
318        let visible_rows = self.sy as usize;
319        let lines = self
320            .history
321            .iter()
322            .chain(self.visible.iter())
323            .cloned()
324            .collect::<Vec<_>>();
325        let mut reflowed = reflow_wrapped_lines(lines, sx, bg);
326        while reflowed.len() < visible_rows {
327            reflowed.push(GridLine::blank_with_bg(sx, bg));
328        }
329
330        let history_rows = reflowed.len().saturating_sub(visible_rows);
331        let visible = reflowed.split_off(history_rows);
332        self.history = reflowed.into();
333        while self.history.len() > self.hlimit {
334            let _ = self.history.pop_front();
335        }
336        self.visible = visible;
337        self.hscrolled = self.history.len();
338        self.sx = sx;
339    }
340
341    pub(crate) fn resize_height(&mut self, sy: u32, cursor_y: &mut u32, bg: Colour) {
342        let sy = sy.max(1);
343        let oldy = self.sy;
344
345        if sy < oldy {
346            let mut needed = oldy - sy;
347
348            let available_bottom = oldy.saturating_sub(1).saturating_sub(*cursor_y);
349            let remove_bottom = available_bottom.min(needed);
350            for _ in 0..remove_bottom {
351                let _ = self.visible.pop();
352            }
353            needed -= remove_bottom;
354
355            if self.history_enabled {
356                for _ in 0..needed {
357                    let Some(line) = self.visible.first().cloned() else {
358                        break;
359                    };
360                    let _ = self.visible.remove(0);
361                    self.push_history(line);
362                }
363            } else {
364                let remove_top = (*cursor_y).min(needed);
365                for _ in 0..remove_top {
366                    if !self.visible.is_empty() {
367                        let _ = self.visible.remove(0);
368                    }
369                }
370                *cursor_y = cursor_y.saturating_sub(remove_top);
371            }
372        } else if sy > oldy {
373            let mut needed = sy - oldy;
374            let pull = self.hscrolled.min(needed as usize).min(self.history.len()) as u32;
375            if self.history_enabled && pull > 0 {
376                let mut restored = Vec::with_capacity(pull as usize);
377                for _ in 0..pull {
378                    if let Some(line) = self.history.pop_back() {
379                        restored.push(line);
380                    }
381                }
382                restored.reverse();
383                for line in restored.into_iter().rev() {
384                    self.visible.insert(0, line);
385                }
386                *cursor_y = cursor_y.saturating_add(pull).min(sy.saturating_sub(1));
387                self.hscrolled -= pull as usize;
388                needed -= pull;
389            }
390
391            for _ in 0..needed {
392                self.visible.push(GridLine::blank_with_bg(self.sx, bg));
393            }
394        }
395
396        self.sy = sy;
397        self.visible
398            .resize_with(self.sy as usize, || GridLine::blank_with_bg(self.sx, bg));
399        *cursor_y = (*cursor_y).min(self.sy.saturating_sub(1));
400    }
401
402    fn valid_region(&self, upper: u32, lower: u32) -> bool {
403        upper < self.sy && lower < self.sy && upper <= lower
404    }
405
406    fn push_history(&mut self, mut line: GridLine) {
407        if self.hlimit == 0 {
408            return;
409        }
410
411        line.touch();
412        if self.history.len() == self.hlimit {
413            let _ = self.history.pop_front();
414        }
415        self.history.push_back(line);
416        self.hscrolled = (self.hscrolled + 1).min(self.history.len());
417    }
418}
419
420fn reflow_wrapped_lines(lines: Vec<GridLine>, width: u32, bg: Colour) -> Vec<GridLine> {
421    let mut output = Vec::new();
422    let mut logical_cells = Vec::new();
423    let mut logical_flags = None;
424
425    for line in lines {
426        let wrapped = line.flags.contains(GridLineFlags::WRAPPED);
427        if logical_flags.is_none() {
428            let mut flags = line.flags;
429            flags.remove(GridLineFlags::WRAPPED);
430            logical_flags = Some(flags);
431        }
432
433        let end = if wrapped {
434            line.cells.len()
435        } else {
436            line.used_end()
437        };
438        logical_cells.extend(
439            line.cells
440                .iter()
441                .take(end)
442                .filter(|cell| !cell.is_padding())
443                .cloned(),
444        );
445
446        if !wrapped {
447            output.extend(reflow_logical_line(
448                &logical_cells,
449                logical_flags.take().unwrap_or_default(),
450                width,
451                bg,
452            ));
453            logical_cells.clear();
454        }
455    }
456
457    if logical_flags.is_some() || !logical_cells.is_empty() {
458        output.extend(reflow_logical_line(
459            &logical_cells,
460            logical_flags.unwrap_or_default(),
461            width,
462            bg,
463        ));
464    }
465
466    output
467}
468
469fn reflow_logical_line(
470    cells: &[GridCell],
471    first_flags: GridLineFlags,
472    width: u32,
473    bg: Colour,
474) -> Vec<GridLine> {
475    if cells.is_empty() {
476        let mut line = GridLine::blank_with_bg(width, bg);
477        line.flags = first_flags;
478        return vec![line];
479    }
480
481    let mut output = Vec::new();
482    let mut current = GridLine::blank_with_bg(width, bg);
483    current.flags = first_flags;
484    let mut x: u32 = 0;
485
486    for cell in cells {
487        let mut cell = cell.clone();
488        let mut cell_width = u32::from(cell.width().max(1));
489        if cell_width > width {
490            cell_width = 1;
491            cell.set_width(1);
492        }
493        if x > 0 && x.saturating_add(cell_width) > width {
494            current.set_wrapped(true);
495            output.push(current);
496            current = GridLine::blank_with_bg(width, bg);
497            x = 0;
498        }
499
500        if let Some(target) = current.cells.get_mut(x as usize) {
501            *target = cell.clone();
502        }
503        for offset in 1..cell_width {
504            if let Some(padding_cell) = current.cells.get_mut((x + offset) as usize) {
505                let mut padding = cell.clone();
506                padding.set_text(" ".to_owned());
507                padding.set_width(0);
508                padding.set_flags(GridCellFlags::PADDING);
509                *padding_cell = padding;
510            }
511        }
512        current.touch();
513        x += cell_width;
514    }
515
516    output.push(current);
517    output
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523    use crate::input::CellState;
524
525    #[test]
526    fn render_without_trimming_preserves_explicit_trailing_spaces_but_not_cleared_cells() {
527        let mut line = GridLine::new(6);
528        let state = CellState::default();
529        for (x, ch) in "A  ".chars().enumerate() {
530            *line.cell_mut(x as u32).expect("cell exists") =
531                GridCell::from_state(ch, 1, &state, GridCellFlags::default());
532        }
533
534        let mut render_state = GridStringState::default();
535        let rendered = line.render_with_options(
536            GridRenderOptions {
537                trim_spaces: false,
538                include_empty_cells: false,
539                ..GridRenderOptions::default()
540            },
541            &mut render_state,
542            None,
543        );
544        assert_eq!(rendered, "A  ");
545
546        let mut render_state = GridStringState::default();
547        let trimmed = line.render_with_options(
548            GridRenderOptions {
549                trim_spaces: true,
550                include_empty_cells: false,
551                ..GridRenderOptions::default()
552            },
553            &mut render_state,
554            None,
555        );
556        assert_eq!(trimmed, "A");
557    }
558
559    #[test]
560    fn capture_join_wrapped_keeps_spaces_at_wrapped_boundaries() {
561        let mut grid = Grid::new(TerminalSize { cols: 6, rows: 2 }, 0);
562        let state = CellState::default();
563        let first = grid.visible_line_mut(0).expect("line exists");
564        for (x, ch) in "user ".chars().enumerate() {
565            *first.cell_mut(x as u32).expect("cell exists") =
566                GridCell::from_state(ch, 1, &state, GridCellFlags::default());
567        }
568        first.set_wrapped(true);
569        let second = grid.visible_line_mut(1).expect("line exists");
570        for (x, ch) in "root".chars().enumerate() {
571            *second.cell_mut(x as u32).expect("cell exists") =
572                GridCell::from_state(ch, 1, &state, GridCellFlags::default());
573        }
574
575        let mut render_state = GridStringState::default();
576        let mut output = Vec::new();
577        for absolute_y in 0..2 {
578            let line = grid
579                .render_absolute_line(
580                    absolute_y,
581                    GridRenderOptions {
582                        join_wrapped: true,
583                        trim_spaces: false,
584                        include_empty_cells: false,
585                        ..GridRenderOptions::default()
586                    },
587                    &mut render_state,
588                    None,
589                )
590                .expect("line renders");
591            output.extend_from_slice(line.as_bytes());
592            if !grid.absolute_line_wrapped(absolute_y).unwrap_or(false) {
593                output.push(b'\n');
594            }
595        }
596
597        assert_eq!(String::from_utf8(output).expect("utf8"), "user root\n");
598    }
599}