Skip to main content

rmux_core/screen/
view.rs

1use crate::grid::{Grid, GridLine, GridLineFlags};
2use crate::input::{Colour, COLOUR_DEFAULT};
3
4use super::{SavedGrid, Screen};
5
6/// Borrowed read-only view of one rendered screen cell.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub struct ScreenCellRef<'a> {
9    text: &'a str,
10    width: u8,
11    padding: bool,
12    attr: u16,
13    fg: Colour,
14    bg: Colour,
15    us: Colour,
16    link: u32,
17}
18
19impl<'a> ScreenCellRef<'a> {
20    /// Returns the stored cell text.
21    #[must_use]
22    pub const fn text(&self) -> &'a str {
23        self.text
24    }
25
26    /// Returns the display width of the cell.
27    #[must_use]
28    pub const fn width(&self) -> u8 {
29        self.width
30    }
31
32    /// Returns whether this cell is padding for a wide glyph.
33    #[must_use]
34    pub const fn is_padding(&self) -> bool {
35        self.padding
36    }
37
38    /// Returns the cell attributes.
39    #[must_use]
40    pub const fn attr(&self) -> u16 {
41        self.attr
42    }
43
44    /// Returns the foreground colour.
45    #[must_use]
46    pub const fn fg(&self) -> Colour {
47        self.fg
48    }
49
50    /// Returns the background colour.
51    #[must_use]
52    pub const fn bg(&self) -> Colour {
53        self.bg
54    }
55
56    /// Returns the underline colour.
57    #[must_use]
58    pub const fn us(&self) -> Colour {
59        self.us
60    }
61
62    /// Returns the hyperlink inner ID for the cell.
63    #[must_use]
64    pub const fn link(&self) -> u32 {
65        self.link
66    }
67}
68
69fn blank_cell_ref() -> ScreenCellRef<'static> {
70    ScreenCellRef {
71        text: " ",
72        width: 1,
73        padding: false,
74        attr: 0,
75        fg: COLOUR_DEFAULT,
76        bg: COLOUR_DEFAULT,
77        us: COLOUR_DEFAULT,
78        link: 0,
79    }
80}
81
82/// Read-only copy of one rendered screen cell for copy-mode consumers.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct ScreenCellView {
85    pub(super) text: String,
86    pub(super) width: u8,
87    pub(super) padding: bool,
88    pub(super) attr: u16,
89    pub(super) fg: crate::input::Colour,
90    pub(super) bg: crate::input::Colour,
91    pub(super) us: crate::input::Colour,
92    pub(super) link: u32,
93}
94
95impl ScreenCellView {
96    /// Returns the stored cell text.
97    #[must_use]
98    pub fn text(&self) -> &str {
99        &self.text
100    }
101
102    /// Returns the display width of the cell.
103    #[must_use]
104    pub const fn width(&self) -> u8 {
105        self.width
106    }
107
108    /// Returns whether the cell is padding for a wide glyph.
109    #[must_use]
110    pub const fn is_padding(&self) -> bool {
111        self.padding
112    }
113
114    /// Returns the cell attributes.
115    #[must_use]
116    pub const fn attr(&self) -> u16 {
117        self.attr
118    }
119
120    /// Returns the cell foreground colour.
121    #[must_use]
122    pub const fn fg(&self) -> crate::input::Colour {
123        self.fg
124    }
125
126    /// Returns the cell background colour.
127    #[must_use]
128    pub const fn bg(&self) -> crate::input::Colour {
129        self.bg
130    }
131
132    /// Returns the cell underline colour.
133    #[must_use]
134    pub const fn us(&self) -> crate::input::Colour {
135        self.us
136    }
137
138    /// Returns the hyperlink inner ID for the cell.
139    #[must_use]
140    pub const fn link(&self) -> u32 {
141        self.link
142    }
143}
144
145/// Read-only copy of one absolute screen line for copy-mode consumers.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct ScreenLineView {
148    pub(super) cells: Vec<ScreenCellView>,
149    width: u32,
150    pub(super) wrapped: bool,
151    pub(super) start_prompt: bool,
152    pub(super) start_output: bool,
153    pub(super) time: i64,
154}
155
156impl ScreenLineView {
157    /// Returns the stored cells for the line.
158    #[must_use]
159    pub fn cells(&self) -> &[ScreenCellView] {
160        &self.cells
161    }
162
163    /// Returns the terminal-width column span represented by this line.
164    #[must_use]
165    pub const fn width(&self) -> u32 {
166        self.width
167    }
168
169    /// Returns one cell by column.
170    #[must_use]
171    pub fn cell(&self, x: u32) -> Option<&ScreenCellView> {
172        self.cells.get(x as usize)
173    }
174
175    /// Returns whether the line wraps onto the following row.
176    #[must_use]
177    pub const fn wrapped(&self) -> bool {
178        self.wrapped
179    }
180
181    /// Returns whether the line starts a shell prompt block.
182    #[must_use]
183    pub const fn start_prompt(&self) -> bool {
184        self.start_prompt
185    }
186
187    /// Returns whether the line starts a shell output block.
188    #[must_use]
189    pub const fn start_output(&self) -> bool {
190        self.start_output
191    }
192
193    /// Returns the line timestamp.
194    #[must_use]
195    pub const fn time(&self) -> i64 {
196        self.time
197    }
198
199    /// Resolves the owning non-padding cell for a column.
200    #[must_use]
201    pub fn owning_cell_x(&self, x: u32) -> Option<u32> {
202        if x >= self.width {
203            return None;
204        }
205        let Some(cell) = self.cell(x) else {
206            return Some(x);
207        };
208        if !cell.is_padding() {
209            return Some(x);
210        }
211
212        let mut owner = x;
213        while owner > 0 {
214            owner -= 1;
215            let candidate = self.cell(owner)?;
216            if !candidate.is_padding() {
217                let width = u32::from(candidate.width().max(1));
218                if owner.saturating_add(width) > x {
219                    return Some(owner);
220                }
221                return None;
222            }
223        }
224        None
225    }
226}
227
228impl Screen {
229    /// Reports whether recovery metadata fits the supplied bounds without
230    /// cloning viewport or scrollback rows.
231    #[must_use]
232    pub fn recovery_metadata_fits(
233        &self,
234        max_string_bytes: usize,
235        max_title_stack_bytes: usize,
236        max_hyperlink_entry_bytes: usize,
237        max_hyperlink_total_bytes: usize,
238    ) -> bool {
239        if self.title.len() > max_string_bytes
240            || self.window_name.len() > max_string_bytes
241            || self.path.len() > max_string_bytes
242            || self
243                .title_stack
244                .iter()
245                .any(|title| title.len() > max_string_bytes)
246            || self
247                .title_stack
248                .iter()
249                .try_fold(0_usize, |total, title| total.checked_add(title.len()))
250                .is_none_or(|total| total > max_title_stack_bytes)
251        {
252            return false;
253        }
254        let (hyperlinks, complete) = self
255            .hyperlinks
256            .clone_bounded(max_hyperlink_entry_bytes, max_hyperlink_total_bytes);
257        complete && (self.active_hyperlink == 0 || hyperlinks.get(self.active_hyperlink).is_some())
258    }
259
260    /// Clones only the visible viewport and bounded terminal metadata.
261    ///
262    /// Recovery callers validate geometry before invoking this method. Unlike
263    /// [`Screen::clone`], this path never copies scrollback, saved alternate
264    /// grids, passthrough payloads, or unbounded title/link strings.
265    #[must_use]
266    pub fn clone_recovery_viewport_bounded(
267        &self,
268        max_string_bytes: usize,
269        max_title_stack_bytes: usize,
270        max_hyperlink_entry_bytes: usize,
271        max_hyperlink_total_bytes: usize,
272    ) -> (Self, bool) {
273        let (mut viewport, metadata_complete) = self.recovery_viewport_shell(
274            max_string_bytes,
275            max_title_stack_bytes,
276            max_hyperlink_entry_bytes,
277            max_hyperlink_total_bytes,
278        );
279
280        viewport.grid.replace_visible(self.grid.visible_lines());
281        viewport.cursor_x = self.cursor_x.min(viewport.max_cursor_x());
282        viewport.cursor_y = self.cursor_y.min(viewport.grid.sy().saturating_sub(1));
283        viewport.pending_wrap = self.pending_wrap;
284        viewport.saved_cursor_x = None;
285        viewport.saved_cursor_y = None;
286        viewport.saved_cursor_pending_wrap = false;
287        viewport.rupper = self.rupper.min(viewport.grid.sy().saturating_sub(1));
288        viewport.rlower = self.rlower.min(viewport.grid.sy().saturating_sub(1));
289        viewport.tabs = self.tabs.clone();
290        viewport.title_rename_enabled = self.title_rename_enabled;
291        viewport.alternate_screen_enabled = self.alternate_screen_enabled;
292        viewport.preserve_alternate_screen_cursor = self.preserve_alternate_screen_cursor;
293
294        (viewport, metadata_complete)
295    }
296
297    /// Clones a transport-bounded recovery projection without rendering it.
298    ///
299    /// The active viewport and saved alternate viewport are retained. Scrollback
300    /// is limited to the newest complete logical-line suffix whose structural
301    /// clone fits `max_history_bytes`.
302    #[must_use]
303    pub fn clone_recovery_projection_bounded(
304        &self,
305        max_string_bytes: usize,
306        max_title_stack_bytes: usize,
307        max_hyperlink_entry_bytes: usize,
308        max_hyperlink_total_bytes: usize,
309        max_history_bytes: usize,
310    ) -> (Self, bool) {
311        let (mut projection, metadata_complete) = self.recovery_viewport_shell(
312            max_string_bytes,
313            max_title_stack_bytes,
314            max_hyperlink_entry_bytes,
315            max_hyperlink_total_bytes,
316        );
317
318        projection.grid = self.grid.clone_recovery_projection(max_history_bytes);
319        projection.cursor_x = self.cursor_x.min(projection.max_cursor_x());
320        projection.cursor_y = self.cursor_y.min(projection.grid.sy().saturating_sub(1));
321        projection.pending_wrap = self.pending_wrap;
322        projection.saved_cursor_x = self.saved_cursor_x;
323        projection.saved_cursor_y = self.saved_cursor_y;
324        projection.saved_cursor_pending_wrap = self.saved_cursor_pending_wrap;
325        projection.saved_grid = self.saved_grid.as_ref().map(|saved| SavedGrid {
326            grid: saved.grid.clone_recovery_projection(0),
327            history_enabled: saved.history_enabled,
328        });
329        projection.rupper = self.rupper.min(projection.grid.sy().saturating_sub(1));
330        projection.rlower = self.rlower.min(projection.grid.sy().saturating_sub(1));
331        projection.tabs = self.tabs.clone();
332        projection.title_rename_enabled = self.title_rename_enabled;
333        projection.alternate_screen_enabled = self.alternate_screen_enabled;
334        projection.preserve_alternate_screen_cursor = self.preserve_alternate_screen_cursor;
335
336        (projection, metadata_complete)
337    }
338
339    /// Clones a bounded viewport over absolute rows for Web scroll/copy-mode
340    /// recovery without first cloning the backing history.
341    #[must_use]
342    #[allow(clippy::too_many_arguments)]
343    pub fn clone_recovery_viewport_at_bounded(
344        &self,
345        top_line: usize,
346        cursor_x: u32,
347        cursor_absolute_y: usize,
348        max_string_bytes: usize,
349        max_title_stack_bytes: usize,
350        max_hyperlink_entry_bytes: usize,
351        max_hyperlink_total_bytes: usize,
352    ) -> (Self, bool) {
353        let size = self.grid.size();
354        let rows = usize::from(size.rows.max(1));
355        let cols = u32::from(size.cols.max(1));
356        let total_lines = self.absolute_line_count();
357        let top_line = top_line.min(total_lines.saturating_sub(rows));
358        let (mut viewport, metadata_complete) = self.recovery_viewport_shell(
359            max_string_bytes,
360            max_title_stack_bytes,
361            max_hyperlink_entry_bytes,
362            max_hyperlink_total_bytes,
363        );
364        let lines = (0..rows)
365            .map(|offset| {
366                self.grid
367                    .absolute_line(top_line + offset)
368                    .cloned()
369                    .unwrap_or_else(|| GridLine::new(cols))
370            })
371            .collect();
372        viewport.grid.replace_visible(lines);
373        viewport.cursor_x = cursor_x.min(viewport.max_cursor_x());
374        viewport.cursor_y = if (top_line..top_line + rows).contains(&cursor_absolute_y) {
375            (cursor_absolute_y - top_line) as u32
376        } else {
377            0
378        };
379        viewport.pending_wrap = false;
380        viewport.rupper = 0;
381        viewport.rlower = u32::from(size.rows.max(1)).saturating_sub(1);
382        viewport.reset_tabs();
383        (viewport, metadata_complete)
384    }
385
386    #[allow(clippy::too_many_arguments)]
387    fn recovery_viewport_shell(
388        &self,
389        max_string_bytes: usize,
390        max_title_stack_bytes: usize,
391        max_hyperlink_entry_bytes: usize,
392        max_hyperlink_total_bytes: usize,
393    ) -> (Self, bool) {
394        let mut viewport = Self::new(self.grid.size(), 0);
395        let mut metadata_complete = true;
396        viewport.mode = self.mode;
397        viewport.cursor_style = self.cursor_style;
398        viewport.title = bounded_utf8_string(&self.title, max_string_bytes, &mut metadata_complete);
399        viewport.window_name =
400            bounded_utf8_string(&self.window_name, max_string_bytes, &mut metadata_complete);
401        viewport.path = bounded_utf8_string(&self.path, max_string_bytes, &mut metadata_complete);
402        viewport.title_stack = bounded_title_stack(
403            &self.title_stack,
404            max_string_bytes,
405            max_title_stack_bytes,
406            &mut metadata_complete,
407        );
408        let (hyperlinks, hyperlinks_complete) = self
409            .hyperlinks
410            .clone_bounded(max_hyperlink_entry_bytes, max_hyperlink_total_bytes);
411        metadata_complete &= hyperlinks_complete;
412        viewport.active_hyperlink = if hyperlinks.get(self.active_hyperlink).is_some() {
413            self.active_hyperlink
414        } else {
415            metadata_complete &= self.active_hyperlink == 0;
416            0
417        };
418        viewport.hyperlinks = hyperlinks;
419        viewport.metadata_revision = self.metadata_revision;
420        viewport.utf8_config = self.utf8_config.clone();
421        viewport.saved_cursor_x = None;
422        viewport.saved_cursor_y = None;
423        viewport.saved_cursor_pending_wrap = false;
424        viewport.saved_grid = self.saved_grid.as_ref().map(|saved| SavedGrid {
425            grid: Grid::new(self.grid.size(), 0),
426            history_enabled: saved.history_enabled,
427        });
428        viewport.title_rename_enabled = self.title_rename_enabled;
429        viewport.alternate_screen_enabled = self.alternate_screen_enabled;
430        viewport.preserve_alternate_screen_cursor = self.preserve_alternate_screen_cursor;
431        (viewport, metadata_complete)
432    }
433
434    /// Visits borrowed cells for one visible row, padding to `cols` cells.
435    ///
436    /// Returns `false` when `row` is outside the visible viewport. Plain ASCII
437    /// compact rows are visited directly from their compact text storage, so
438    /// callers that only need the visible viewport can avoid the owned
439    /// [`ScreenLineView`] allocation path.
440    pub fn visit_visible_line_cells(
441        &self,
442        row: usize,
443        cols: usize,
444        mut visit: impl FnMut(ScreenCellRef<'_>),
445    ) -> bool {
446        let Some(line) = self
447            .grid
448            .visible_line(u32::try_from(row).unwrap_or(u32::MAX))
449        else {
450            return false;
451        };
452        if let Some(text) = line.plain_text() {
453            let text_cols = text.len().min(cols);
454            for col in 0..text_cols {
455                visit(ScreenCellRef {
456                    text: &text[col..col + 1],
457                    width: 1,
458                    padding: false,
459                    attr: 0,
460                    fg: COLOUR_DEFAULT,
461                    bg: COLOUR_DEFAULT,
462                    us: COLOUR_DEFAULT,
463                    link: 0,
464                });
465            }
466            for _ in text_cols..cols {
467                visit(blank_cell_ref());
468            }
469            return true;
470        }
471
472        let mut emitted = 0_usize;
473        for cell in line.cells().iter().take(cols) {
474            visit(ScreenCellRef {
475                text: cell.text(),
476                width: cell.width(),
477                padding: cell.is_padding(),
478                attr: cell.attr(),
479                fg: cell.fg(),
480                bg: cell.bg(),
481                us: cell.us(),
482                link: cell.link(),
483            });
484            emitted += 1;
485        }
486        for _ in emitted..cols {
487            visit(blank_cell_ref());
488        }
489        true
490    }
491
492    /// Returns a read-only copy of one absolute line.
493    #[must_use]
494    pub fn absolute_line_view(&self, absolute_y: usize) -> Option<ScreenLineView> {
495        let line = self.grid.absolute_line(absolute_y)?;
496        let width = u32::from(self.grid.size().cols.max(1));
497        let cells = if let Some(text) = line.plain_text() {
498            let mut cells = text
499                .bytes()
500                .map(|byte| ScreenCellView {
501                    text: char::from(byte).to_string(),
502                    width: 1,
503                    padding: false,
504                    attr: 0,
505                    fg: crate::input::COLOUR_DEFAULT,
506                    bg: crate::input::COLOUR_DEFAULT,
507                    us: crate::input::COLOUR_DEFAULT,
508                    link: 0,
509                })
510                .collect::<Vec<_>>();
511            cells.resize_with(width as usize, || ScreenCellView {
512                text: " ".to_owned(),
513                width: 1,
514                padding: false,
515                attr: 0,
516                fg: crate::input::COLOUR_DEFAULT,
517                bg: crate::input::COLOUR_DEFAULT,
518                us: crate::input::COLOUR_DEFAULT,
519                link: 0,
520            });
521            cells
522        } else {
523            line.cells()
524                .iter()
525                .map(|cell| ScreenCellView {
526                    text: cell.text().to_owned(),
527                    width: cell.width(),
528                    padding: cell.is_padding(),
529                    attr: cell.attr(),
530                    fg: cell.fg(),
531                    bg: cell.bg(),
532                    us: cell.us(),
533                    link: cell.link(),
534                })
535                .collect()
536        };
537        Some(ScreenLineView {
538            cells,
539            width,
540            wrapped: line.flags().contains(GridLineFlags::WRAPPED),
541            start_prompt: line.flags().contains(GridLineFlags::START_PROMPT),
542            start_output: line.flags().contains(GridLineFlags::START_OUTPUT),
543            time: line.time(),
544        })
545    }
546
547    /// Clones the screen as a standalone viewport over its absolute lines.
548    #[must_use]
549    pub fn clone_viewport(&self, top_line: usize, cursor_x: u32, cursor_absolute_y: usize) -> Self {
550        let size = self.grid.size();
551        let rows = usize::from(size.rows.max(1));
552        let cols = u32::from(size.cols.max(1));
553        let total_lines = self.absolute_line_count();
554        let top_line = top_line.min(total_lines.saturating_sub(rows));
555        let mut viewport = Self::new(size, 0);
556
557        viewport.mode = self.mode;
558        viewport.cursor_style = self.cursor_style;
559        viewport.title = self.title.clone();
560        viewport.window_name = self.window_name.clone();
561        viewport.path = self.path.clone();
562        viewport.title_stack = self.title_stack.clone();
563        viewport.hyperlinks = self.hyperlinks.clone();
564        viewport.active_hyperlink = self.active_hyperlink;
565        viewport.metadata_revision = self.metadata_revision;
566        viewport.bell_count = 0;
567        viewport.utf8_config = self.utf8_config.clone();
568
569        let lines = (0..rows)
570            .map(|offset| {
571                self.grid
572                    .absolute_line(top_line + offset)
573                    .cloned()
574                    .unwrap_or_else(|| GridLine::new(cols))
575            })
576            .collect();
577        viewport.grid.replace_visible(lines);
578
579        viewport.cursor_x = cursor_x.min(viewport.max_cursor_x());
580        viewport.cursor_y = if (top_line..top_line + rows).contains(&cursor_absolute_y) {
581            (cursor_absolute_y - top_line) as u32
582        } else {
583            0
584        };
585        viewport.pending_wrap = false;
586        viewport.saved_cursor_x = None;
587        viewport.saved_cursor_y = None;
588        viewport.saved_cursor_pending_wrap = false;
589        viewport.saved_grid = None;
590        viewport.rupper = 0;
591        viewport.rlower = u32::from(size.rows.max(1)).saturating_sub(1);
592        viewport.reset_tabs();
593        viewport
594    }
595}
596
597fn bounded_utf8_string(value: &str, max_bytes: usize, complete: &mut bool) -> String {
598    if value.len() <= max_bytes {
599        return value.to_owned();
600    }
601    *complete = false;
602    let mut end = max_bytes.min(value.len());
603    while end > 0 && !value.is_char_boundary(end) {
604        end -= 1;
605    }
606    value[..end].to_owned()
607}
608
609fn bounded_title_stack(
610    titles: &[String],
611    max_entry_bytes: usize,
612    max_total_bytes: usize,
613    complete: &mut bool,
614) -> Vec<String> {
615    let mut retained_reversed = Vec::new();
616    let mut retained_bytes = 0_usize;
617    for title in titles.iter().rev() {
618        let title = bounded_utf8_string(title, max_entry_bytes, complete);
619        let next = retained_bytes.saturating_add(title.len());
620        if next > max_total_bytes {
621            *complete = false;
622            break;
623        }
624        retained_reversed.push(title);
625        retained_bytes = next;
626    }
627    if retained_reversed.len() != titles.len() {
628        *complete = false;
629    }
630    retained_reversed.reverse();
631    retained_reversed
632}