Skip to main content

rmux_core/
grid.rs

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