Skip to main content

rmux_core/screen/
capture.rs

1use crate::grid::{Grid, GridCapture, GridRenderOptions, GridStringState, RenderedLineSpan};
2use crate::hyperlinks::Hyperlinks;
3use crate::input::ScreenWriter;
4use crate::style::Style;
5use crate::transcript::{resolve_screen_capture_range, ScreenCaptureRange};
6
7use super::Screen;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10enum CaptureSurfaceBoundary {
11    Continuous,
12    HistoryToAlternateViewport,
13}
14
15impl CaptureSurfaceBoundary {
16    fn separates_after(self, absolute_y: usize, history_size: usize, capture_end: usize) -> bool {
17        matches!(self, Self::HistoryToAlternateViewport)
18            && history_size > 0
19            && absolute_y.saturating_add(1) == history_size
20            && capture_end >= history_size
21    }
22}
23
24/// One rendered recovery row: the bytes to replay, the soft-wrap flag, and the
25/// columns those bytes paint.
26///
27/// A replay places the next row either by autowrap or by an explicit line
28/// break, so the emitting side needs the painted span, not the byte length.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct RecoveryRow {
31    /// Bytes that repaint the row from a fresh ANSI state.
32    pub bytes: Vec<u8>,
33    /// Whether the row soft-wraps onto the following row.
34    pub wrapped: bool,
35    /// Columns painted by the row.
36    pub span: RenderedLineSpan,
37    /// Columns a pre-wrapped wide glyph left unused at the end of the row.
38    pub trailing_reflow_gap: u32,
39}
40
41/// Bounded row renderer used to construct transport-safe recovery frames.
42///
43/// The renderer owns a size-limited hyperlink table, so rendering one row
44/// cannot clone an unbounded OSC 8 URI from terminal-controlled state.
45pub struct RecoveryRowRenderer<'a> {
46    screen: &'a Screen,
47    saved_grid: Option<Grid>,
48    hyperlinks: Hyperlinks,
49    metadata_complete: bool,
50}
51
52impl RecoveryRowRenderer<'_> {
53    /// Returns whether every retained hyperlink fitted the metadata budget.
54    #[must_use]
55    pub const fn metadata_complete(&self) -> bool {
56        self.metadata_complete
57    }
58
59    /// Renders one row of the active grid from a fresh ANSI state.
60    #[must_use]
61    pub fn active_row(&self, absolute_y: usize, options: GridRenderOptions) -> Option<RecoveryRow> {
62        render_grid_row_independent(&self.screen.grid, &self.hyperlinks, absolute_y, options)
63    }
64
65    /// Returns one reconstructed main-history row's soft-wrap flag.
66    #[must_use]
67    pub fn recovery_history_row_wrapped(&self, absolute_y: usize) -> Option<bool> {
68        let grid = self.saved_grid.as_ref().unwrap_or(&self.screen.grid);
69        (absolute_y < grid.hsize())
70            .then(|| grid.absolute_line_wrapped(absolute_y))
71            .flatten()
72    }
73
74    /// Renders one row of the saved pre-alternate viewport.
75    #[must_use]
76    pub fn saved_row(&self, absolute_y: usize, options: GridRenderOptions) -> Option<RecoveryRow> {
77        let saved = self.saved_grid.as_ref()?;
78        render_grid_row_independent(
79            saved,
80            &self.hyperlinks,
81            saved.hsize().saturating_add(absolute_y),
82            options,
83        )
84    }
85
86    /// Returns the history rows that belong to the reconstructed main screen.
87    #[must_use]
88    pub fn recovery_history_size(&self) -> usize {
89        self.saved_grid
90            .as_ref()
91            .map_or_else(|| self.screen.grid.hsize(), Grid::hsize)
92    }
93
94    /// Renders one history row from the reconstructed main screen.
95    #[must_use]
96    pub fn recovery_history_row(
97        &self,
98        absolute_y: usize,
99        options: GridRenderOptions,
100    ) -> Option<RecoveryRow> {
101        let grid = self.saved_grid.as_ref().unwrap_or(&self.screen.grid);
102        (absolute_y < grid.hsize())
103            .then(|| render_grid_row_independent(grid, &self.hyperlinks, absolute_y, options))
104            .flatten()
105    }
106}
107
108impl Screen {
109    /// Creates a recovery renderer with bounded terminal-controlled metadata.
110    #[must_use]
111    pub fn recovery_row_renderer(
112        &self,
113        max_hyperlink_entry_bytes: usize,
114        max_hyperlink_total_bytes: usize,
115    ) -> RecoveryRowRenderer<'_> {
116        let (hyperlinks, metadata_complete) = self
117            .hyperlinks
118            .clone_bounded(max_hyperlink_entry_bytes, max_hyperlink_total_bytes);
119        let saved_grid = self.recovery_saved_grid();
120        RecoveryRowRenderer {
121            screen: self,
122            saved_grid,
123            hyperlinks,
124            metadata_complete,
125        }
126    }
127
128    fn recovery_saved_grid(&self) -> Option<Grid> {
129        let saved_rows = usize::try_from(self.saved_grid.as_ref()?.grid.sy()).unwrap_or(usize::MAX);
130        let restore_cursor = self.alternate_saved_cursor().is_some();
131        let mut restored = self.clone();
132        restored
133            .grid
134            .set_hlimit(restored.grid.hsize().saturating_add(saved_rows));
135        restored.alternate_off(crate::input::COLOUR_DEFAULT, restore_cursor);
136        Some(restored.grid)
137    }
138
139    #[cfg_attr(not(test), allow(dead_code))]
140    #[must_use]
141    pub(crate) fn capture_grid(&self, join_wrapped: bool) -> GridCapture {
142        self.grid.capture(join_wrapped)
143    }
144
145    /// Captures a tmux-style line range over the current grid contents.
146    #[must_use]
147    pub fn capture_transcript(
148        &self,
149        range: ScreenCaptureRange,
150        options: GridRenderOptions,
151    ) -> Vec<u8> {
152        let boundary = if self.is_alternate() {
153            CaptureSurfaceBoundary::HistoryToAlternateViewport
154        } else {
155            CaptureSurfaceBoundary::Continuous
156        };
157        capture_grid_bytes(&self.grid, &self.hyperlinks, range, options, boundary)
158    }
159
160    /// Captures tmux-style per-line format flags for the selected physical rows.
161    #[must_use]
162    pub fn capture_line_format_flags(&self, range: ScreenCaptureRange) -> Vec<u8> {
163        capture_grid_line_format_flags(&self.grid, range)
164    }
165
166    /// Captures physical lines with each line rendered from a fresh ANSI state.
167    ///
168    /// This is intended for renderers that repaint individual terminal rows:
169    /// a row must carry its own SGR state instead of depending on a previous
170    /// captured row having been emitted first.
171    #[must_use]
172    pub fn capture_transcript_lines_independent(
173        &self,
174        range: ScreenCaptureRange,
175        options: GridRenderOptions,
176    ) -> Vec<Vec<u8>> {
177        capture_grid_rows_independent(&self.grid, &self.hyperlinks, range, options)
178            .into_iter()
179            .map(|(bytes, _wrapped)| bytes)
180            .collect()
181    }
182
183    /// Captures physical rows from fresh ANSI states together with each row's
184    /// soft-wrap flag.
185    ///
186    /// Recovery renderers use the flag to preserve scrollback reflow: a
187    /// wrapped row is followed immediately by the next row, while a hard line
188    /// break is represented by CRLF.
189    #[must_use]
190    pub fn capture_transcript_rows_independent(
191        &self,
192        range: ScreenCaptureRange,
193        options: GridRenderOptions,
194    ) -> Vec<(Vec<u8>, bool)> {
195        capture_grid_rows_independent(&self.grid, &self.hyperlinks, range, options)
196    }
197
198    #[must_use]
199    /// Returns the monotonic mutation revision for one visible row.
200    pub fn visible_line_revision(&self, row: usize) -> Option<u64> {
201        self.grid
202            .visible_line(u32::try_from(row).ok()?)
203            .map(|line| line.revision())
204    }
205
206    #[must_use]
207    /// Renders one visible row from a fresh ANSI state.
208    pub fn render_visible_line_independent(
209        &self,
210        row: usize,
211        options: GridRenderOptions,
212    ) -> Option<Vec<u8>> {
213        let absolute_y = self.grid.hsize().checked_add(row)?;
214        let mut state = GridStringState::default();
215        self.grid
216            .render_absolute_line(absolute_y, options, &mut state, Some(&self.hyperlinks))
217            .map(String::into_bytes)
218    }
219
220    #[must_use]
221    /// Renders one visible row from a fresh ANSI state after applying pane
222    /// default-style to default cells only.
223    pub fn render_visible_line_independent_with_default_style(
224        &self,
225        row: usize,
226        options: GridRenderOptions,
227        style: &Style,
228    ) -> Option<Vec<u8>> {
229        let mut state = GridStringState::default();
230        self.grid
231            .render_visible_line_with_default_style(
232                row,
233                options,
234                &mut state,
235                Some(&self.hyperlinks),
236                style,
237            )
238            .map(String::into_bytes)
239    }
240
241    /// Captures the saved pre-alternate-screen copy when alternate mode is active.
242    #[must_use]
243    pub fn capture_saved_transcript(
244        &self,
245        range: ScreenCaptureRange,
246        options: GridRenderOptions,
247    ) -> Option<Vec<u8>> {
248        self.saved_grid.as_ref().map(|saved| {
249            capture_grid_bytes(
250                &saved.grid,
251                &self.hyperlinks,
252                range,
253                options,
254                CaptureSurfaceBoundary::Continuous,
255            )
256        })
257    }
258
259    /// Captures saved pre-alternate-screen rows from independent ANSI states.
260    #[must_use]
261    pub fn capture_saved_transcript_lines_independent(
262        &self,
263        range: ScreenCaptureRange,
264        options: GridRenderOptions,
265    ) -> Option<Vec<Vec<u8>>> {
266        self.saved_grid.as_ref().map(|saved| {
267            capture_grid_rows_independent(&saved.grid, &self.hyperlinks, range, options)
268                .into_iter()
269                .map(|(bytes, _wrapped)| bytes)
270                .collect()
271        })
272    }
273
274    /// Captures saved pre-alternate-screen rows and their soft-wrap flags.
275    #[must_use]
276    pub fn capture_saved_transcript_rows_independent(
277        &self,
278        range: ScreenCaptureRange,
279        options: GridRenderOptions,
280    ) -> Option<Vec<(Vec<u8>, bool)>> {
281        self.saved_grid.as_ref().map(|saved| {
282            capture_grid_rows_independent(&saved.grid, &self.hyperlinks, range, options)
283        })
284    }
285
286    /// Captures the complete saved main screen while alternate mode is active.
287    ///
288    /// The live grid retains main-screen history while the saved grid retains
289    /// its visible rows, so recovery must join those two stores explicitly.
290    #[must_use]
291    pub fn capture_saved_recovery_rows_independent(
292        &self,
293        options: GridRenderOptions,
294    ) -> Option<Vec<(Vec<u8>, bool)>> {
295        let saved = self.saved_grid.as_ref()?;
296        let mut rows = capture_grid_absolute_rows_independent(
297            &self.grid,
298            &self.hyperlinks,
299            0..self.grid.hsize(),
300            options,
301        );
302        let saved_total =
303            saved.grid.hsize() + usize::try_from(saved.grid.sy()).unwrap_or(usize::MAX);
304        rows.extend(capture_grid_absolute_rows_independent(
305            &saved.grid,
306            &self.hyperlinks,
307            saved.grid.hsize()..saved_total,
308            options,
309        ));
310        Some(rows)
311    }
312
313    /// Captures tmux-style per-line format flags from the saved alternate screen.
314    #[must_use]
315    pub fn capture_saved_line_format_flags(&self, range: ScreenCaptureRange) -> Option<Vec<u8>> {
316        self.saved_grid
317            .as_ref()
318            .map(|saved| capture_grid_line_format_flags(&saved.grid, range))
319    }
320}
321
322fn render_grid_row_independent(
323    grid: &Grid,
324    hyperlinks: &Hyperlinks,
325    absolute_y: usize,
326    options: GridRenderOptions,
327) -> Option<RecoveryRow> {
328    let mut state = GridStringState::default();
329    let (line, span) =
330        grid.render_absolute_line_measured(absolute_y, options, &mut state, Some(hyperlinks))?;
331    Some(RecoveryRow {
332        bytes: line.into_bytes(),
333        wrapped: grid.absolute_line_wrapped(absolute_y).unwrap_or(false),
334        span,
335        trailing_reflow_gap: grid
336            .absolute_line_trailing_reflow_gap(absolute_y)
337            .unwrap_or(0),
338    })
339}
340
341fn capture_grid_rows_independent(
342    grid: &Grid,
343    hyperlinks: &Hyperlinks,
344    range: ScreenCaptureRange,
345    options: GridRenderOptions,
346) -> Vec<(Vec<u8>, bool)> {
347    let total_lines = grid.hsize() + usize::try_from(grid.sy()).unwrap_or(usize::MAX);
348    let Some(range) = resolve_screen_capture_range(range, grid.hsize(), total_lines) else {
349        return Vec::new();
350    };
351
352    capture_grid_absolute_rows_independent(grid, hyperlinks, range, options)
353}
354
355fn capture_grid_absolute_rows_independent(
356    grid: &Grid,
357    hyperlinks: &Hyperlinks,
358    rows: impl IntoIterator<Item = usize>,
359    options: GridRenderOptions,
360) -> Vec<(Vec<u8>, bool)> {
361    let mut output = Vec::new();
362    for absolute_y in rows {
363        let mut state = GridStringState::default();
364        let Some(line) =
365            grid.render_absolute_line(absolute_y, options, &mut state, Some(hyperlinks))
366        else {
367            continue;
368        };
369        output.push((
370            line.into_bytes(),
371            grid.absolute_line_wrapped(absolute_y).unwrap_or(false),
372        ));
373    }
374    output
375}
376
377fn capture_grid_bytes(
378    grid: &Grid,
379    hyperlinks: &Hyperlinks,
380    range: ScreenCaptureRange,
381    options: GridRenderOptions,
382    boundary: CaptureSurfaceBoundary,
383) -> Vec<u8> {
384    let total_lines = grid.hsize() + usize::try_from(grid.sy()).unwrap_or(usize::MAX);
385    let Some(range) = resolve_screen_capture_range(range, grid.hsize(), total_lines) else {
386        return Vec::new();
387    };
388    let capture_end = *range.end();
389
390    let line_count = range.end().saturating_sub(*range.start()).saturating_add(1);
391    let mut output = Vec::with_capacity(capture_capacity_hint(
392        line_count,
393        usize::try_from(grid.sx()).unwrap_or(usize::MAX),
394    ));
395    let mut state = GridStringState::default();
396    for absolute_y in range {
397        if grid
398            .append_rendered_absolute_line(
399                absolute_y,
400                options,
401                &mut state,
402                Some(hyperlinks),
403                &mut output,
404            )
405            .is_none()
406        {
407            continue;
408        };
409        let wrapped = grid.absolute_line_wrapped(absolute_y).unwrap_or(false);
410        if !options.join_wrapped
411            || !wrapped
412            || boundary.separates_after(absolute_y, grid.hsize(), capture_end)
413        {
414            state.reset_to_default_line_style(options, Some(hyperlinks), &mut output);
415            output.push(b'\n');
416        }
417    }
418    output
419}
420
421fn capture_grid_line_format_flags(grid: &Grid, range: ScreenCaptureRange) -> Vec<u8> {
422    let total_lines = grid.hsize() + usize::try_from(grid.sy()).unwrap_or(usize::MAX);
423    let Some(range) = resolve_screen_capture_range(range, grid.hsize(), total_lines) else {
424        return Vec::new();
425    };
426
427    let mut flags = Vec::new();
428    for absolute_y in range {
429        if grid.absolute_line(absolute_y).is_none() {
430            continue;
431        }
432        flags.push(if grid.absolute_line_wrapped(absolute_y).unwrap_or(false) {
433            b'W'
434        } else {
435            b'-'
436        });
437    }
438    flags
439}
440
441fn capture_capacity_hint(line_count: usize, line_width: usize) -> usize {
442    line_count
443        .saturating_mul(line_width.saturating_add(1))
444        .min(64 * 1024 * 1024)
445}