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