Skip to main content

rmux_core/
screen.rs

1//! Screen state and `ScreenWriter` implementation backed by [`Grid`].
2
3use crate::grid::{Grid, GridCell, GridCellFlags, GridLine};
4use crate::hyperlinks::Hyperlinks;
5use crate::input::{mode, CellState, SavedState, ScreenWriter, COLOUR_DEFAULT};
6use crate::terminal_passthrough::{TerminalPassthrough, MAX_TERMINAL_PASSTHROUGH_PAYLOAD_BYTES};
7use crate::utf8::{combine_char as utf8_combine_char, CombineResult, Utf8Config};
8use rmux_proto::TerminalSize;
9
10#[path = "screen/acs.rs"]
11mod acs;
12#[path = "screen/capture.rs"]
13mod capture;
14#[path = "screen/cell_nav.rs"]
15mod cell_nav;
16#[path = "screen/history_bytes.rs"]
17mod history_bytes;
18#[path = "screen/selection.rs"]
19mod selection;
20#[path = "screen/style_overlay.rs"]
21mod style_overlay;
22#[path = "screen/view.rs"]
23mod view;
24#[path = "screen/writer.rs"]
25mod writer;
26
27pub use view::{ScreenCellView, ScreenLineView};
28
29pub(crate) const MAX_TERMINAL_PASSTHROUGH_EVENTS: usize = 256;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32struct SavedGrid {
33    grid: Grid,
34    history_enabled: bool,
35}
36
37/// One pane screen, including scrollback, alternate-screen state, cursor
38/// position, modes, tab stops, and hyperlink storage.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Screen {
41    grid: Grid,
42    cursor_x: u32,
43    cursor_y: u32,
44    pending_wrap: bool,
45    saved_cursor_x: Option<u32>,
46    saved_cursor_y: Option<u32>,
47    saved_cursor_pending_wrap: bool,
48    saved_state: SavedState,
49    saved_grid: Option<SavedGrid>,
50    rupper: u32,
51    rlower: u32,
52    mode: u32,
53    cursor_style: u32,
54    title: String,
55    window_name: String,
56    path: String,
57    title_stack: Vec<String>,
58    tabs: Vec<bool>,
59    hyperlinks: Hyperlinks,
60    active_hyperlink: u32,
61    bell_count: u64,
62    terminal_passthrough: Vec<TerminalPassthrough>,
63    dropped_terminal_passthrough_count: u64,
64    has_selected_cells: bool,
65    utf8_config: Utf8Config,
66    alternate_screen_enabled: bool,
67    preserve_alternate_screen_cursor: bool,
68}
69
70impl Screen {
71    /// Creates a new screen with the given geometry and history limit.
72    #[must_use]
73    pub fn new(size: TerminalSize, history_limit: usize) -> Self {
74        let grid = Grid::new(size, history_limit);
75        let mut screen = Self {
76            grid,
77            cursor_x: 0,
78            cursor_y: 0,
79            pending_wrap: false,
80            saved_cursor_x: None,
81            saved_cursor_y: None,
82            saved_cursor_pending_wrap: false,
83            saved_state: SavedState::default(),
84            saved_grid: None,
85            rupper: 0,
86            rlower: u32::from(size.rows.max(1)).saturating_sub(1),
87            mode: mode::MODE_CURSOR | mode::MODE_WRAP,
88            cursor_style: 0,
89            title: String::new(),
90            window_name: String::new(),
91            path: String::new(),
92            title_stack: Vec::new(),
93            tabs: Vec::new(),
94            hyperlinks: Hyperlinks::new(),
95            active_hyperlink: 0,
96            bell_count: 0,
97            terminal_passthrough: Vec::new(),
98            dropped_terminal_passthrough_count: 0,
99            has_selected_cells: false,
100            utf8_config: Utf8Config::default(),
101            alternate_screen_enabled: true,
102            preserve_alternate_screen_cursor: false,
103        };
104        screen.reset_tabs();
105        screen
106    }
107
108    /// Returns the current terminal mode flags.
109    #[must_use]
110    pub const fn mode(&self) -> u32 {
111        self.mode
112    }
113
114    /// Returns the most recent DECSCUSR cursor style parameter.
115    #[must_use]
116    pub const fn cursor_style(&self) -> u32 {
117        self.cursor_style
118    }
119
120    /// Returns the DECSTBM scroll region as `(top, bottom)` rows, 0-based and
121    /// inclusive. A full-screen region is `(0, rows - 1)`.
122    #[must_use]
123    pub const fn scroll_region(&self) -> (u32, u32) {
124        (self.rupper, self.rlower)
125    }
126
127    /// Returns the screen size.
128    #[must_use]
129    pub fn size(&self) -> TerminalSize {
130        self.grid.size()
131    }
132
133    #[cfg_attr(not(test), allow(dead_code))]
134    #[must_use]
135    pub(crate) fn grid(&self) -> &Grid {
136        &self.grid
137    }
138
139    /// Returns the current screen title.
140    #[must_use]
141    pub fn title(&self) -> &str {
142        &self.title
143    }
144
145    /// Sets the current screen title.
146    pub fn set_title(&mut self, title: impl Into<String>) {
147        self.title = title.into();
148    }
149
150    /// Enables or disables DEC alternate-screen entry for this screen.
151    ///
152    /// Exit sequences are still honored by the writer so disabling the option
153    /// while a pane is already in alternate screen does not trap it there.
154    pub fn set_alternate_screen_enabled(&mut self, enabled: bool) {
155        self.alternate_screen_enabled = enabled;
156    }
157
158    pub(crate) fn set_preserve_alternate_screen_cursor(&mut self, enabled: bool) {
159        self.preserve_alternate_screen_cursor = enabled;
160    }
161
162    /// Returns the most recent OSC 7 path.
163    #[must_use]
164    pub fn path(&self) -> &str {
165        &self.path
166    }
167
168    /// Returns whether the alternate screen is active.
169    #[must_use]
170    pub fn is_alternate(&self) -> bool {
171        self.saved_grid.is_some()
172    }
173
174    /// Returns the configured history limit.
175    #[must_use]
176    pub fn history_limit(&self) -> usize {
177        self.grid.hlimit()
178    }
179
180    /// Returns the current history size in rows.
181    #[must_use]
182    pub fn history_size(&self) -> usize {
183        self.grid.hsize()
184    }
185
186    /// Returns the current cursor position within the visible viewport.
187    #[must_use]
188    pub const fn cursor_position(&self) -> (u32, u32) {
189        (self.cursor_x, self.cursor_y)
190    }
191
192    /// Returns the absolute cursor row including history.
193    #[must_use]
194    pub fn cursor_absolute_y(&self) -> usize {
195        self.grid.hsize() + self.cursor_y as usize
196    }
197
198    /// Returns the total number of absolute lines retained by the screen.
199    #[must_use]
200    pub fn absolute_line_count(&self) -> usize {
201        self.grid.hsize() + self.grid.sy() as usize
202    }
203
204    /// Deletes one visible line and scrolls the remaining viewport content up.
205    ///
206    /// This clears any pending wrap state because deleting a visible row
207    /// invalidates the previous cursor edge condition.
208    pub fn delete_visible_line(&mut self, y: u32) -> bool {
209        if y >= self.grid.sy() {
210            return false;
211        }
212
213        let cursor_x = self.cursor_x;
214        let cursor_y = self.cursor_y;
215        let rupper = self.rupper;
216        let rlower = self.rlower;
217
218        self.cursor_x = 0;
219        self.cursor_y = y;
220        self.pending_wrap = false;
221        self.rupper = 0;
222        self.rlower = self.grid.sy().saturating_sub(1);
223        self.delete_line(1, COLOUR_DEFAULT);
224
225        self.cursor_y = if cursor_y > y {
226            cursor_y.saturating_sub(1)
227        } else {
228            cursor_y
229        }
230        .min(self.grid.sy().saturating_sub(1));
231        self.cursor_x = cursor_x.min(self.grid.sx().saturating_sub(1));
232        self.pending_wrap = false;
233        self.rupper = rupper;
234        self.rlower = rlower;
235        true
236    }
237
238    /// Deletes one absolute line from history or the visible viewport.
239    pub fn delete_absolute_line(&mut self, absolute_y: usize) -> bool {
240        let history_size = self.grid.hsize();
241        let visible_y = absolute_y.saturating_sub(history_size);
242        let removed = self.grid.remove_absolute_line(absolute_y);
243        if !removed {
244            return false;
245        }
246
247        if absolute_y >= history_size {
248            let visible_y = visible_y as u32;
249            if visible_y < self.cursor_y {
250                self.cursor_y = self.cursor_y.saturating_sub(1);
251            }
252        }
253        self.pending_wrap = false;
254        true
255    }
256
257    /// Trims all lines below the cursor and pulls history into the viewport.
258    pub fn trim_below_cursor(&mut self) -> bool {
259        let cursor_absolute_y = self.cursor_absolute_y();
260        if !self.grid.truncate_after_absolute_line(cursor_absolute_y) {
261            return false;
262        }
263
264        let history_size = self.grid.hsize();
265        self.cursor_y = cursor_absolute_y
266            .saturating_sub(history_size)
267            .min(self.grid.sy().saturating_sub(1) as usize) as u32;
268        self.cursor_x = self.cursor_x.min(self.max_cursor_x());
269        self.pending_wrap = false;
270        true
271    }
272
273    /// Returns the current retained history size in bytes.
274    #[must_use]
275    pub fn history_bytes(&self) -> usize {
276        self.grid.history_byte_size()
277    }
278
279    /// Drains and returns the number of BEL notifications observed since the last drain.
280    pub fn take_bell_count(&mut self) -> u64 {
281        let bell_count = self.bell_count;
282        self.bell_count = 0;
283        bell_count
284    }
285
286    /// Drains terminal passthrough events observed since the last drain.
287    pub fn take_terminal_passthrough(&mut self) -> Vec<TerminalPassthrough> {
288        std::mem::take(&mut self.terminal_passthrough)
289    }
290
291    /// Drains the count of terminal passthrough events dropped by safety limits.
292    pub fn take_terminal_passthrough_dropped_count(&mut self) -> u64 {
293        let dropped = self.dropped_terminal_passthrough_count;
294        self.dropped_terminal_passthrough_count = 0;
295        dropped
296    }
297
298    fn push_terminal_passthrough(&mut self, passthrough: TerminalPassthrough) {
299        if passthrough.payload().len() > MAX_TERMINAL_PASSTHROUGH_PAYLOAD_BYTES {
300            self.dropped_terminal_passthrough_count =
301                self.dropped_terminal_passthrough_count.saturating_add(1);
302            return;
303        }
304
305        let overflow = self
306            .terminal_passthrough
307            .len()
308            .saturating_add(1)
309            .saturating_sub(MAX_TERMINAL_PASSTHROUGH_EVENTS);
310        if overflow > 0 {
311            self.terminal_passthrough.drain(..overflow);
312            self.dropped_terminal_passthrough_count = self
313                .dropped_terminal_passthrough_count
314                .saturating_add(overflow as u64);
315        }
316
317        self.terminal_passthrough.push(passthrough);
318    }
319
320    /// Returns the stored OSC 8 URI for a hyperlink inner ID.
321    #[must_use]
322    pub fn hyperlink_uri(&self, inner_id: u32) -> Option<&str> {
323        self.hyperlinks
324            .get(inner_id)
325            .map(|entry| entry.uri.as_str())
326    }
327
328    /// Updates the history limit.
329    pub fn set_history_limit(&mut self, limit: usize) {
330        self.grid.set_hlimit(limit);
331    }
332
333    /// Updates the tmux-style UTF-8 width and combining configuration.
334    pub fn set_utf8_config(&mut self, utf8_config: Utf8Config) {
335        self.utf8_config = utf8_config;
336    }
337
338    /// Resizes the screen and resets the scroll region.
339    pub fn resize(&mut self, size: TerminalSize) {
340        self.clear_selected_cells();
341        let cols = u32::from(size.cols.max(1));
342        let rows = u32::from(size.rows.max(1));
343        if cols != self.grid.sx() {
344            self.grid.resize_width(cols, COLOUR_DEFAULT);
345            self.reset_tabs();
346        }
347        if rows != self.grid.sy() {
348            self.grid
349                .resize_height(rows, &mut self.cursor_y, COLOUR_DEFAULT);
350        }
351        self.rupper = 0;
352        self.rlower = rows.saturating_sub(1);
353        self.cursor_x = self.cursor_x.min(self.max_cursor_x());
354        self.pending_wrap &= self.cursor_x == self.max_cursor_x();
355    }
356
357    /// Clears history and optionally resets stored hyperlinks.
358    pub fn clear_history_and_hyperlinks(&mut self, reset_hyperlinks: bool) {
359        self.clear_selected_cells();
360        self.grid.clear_history();
361        if reset_hyperlinks {
362            self.hyperlinks.reset();
363        }
364    }
365
366    fn reset_tabs(&mut self) {
367        self.tabs = vec![false; self.grid.sx() as usize];
368        for column in (8..self.grid.sx()).step_by(8) {
369            self.tabs[column as usize] = true;
370        }
371    }
372
373    fn max_cursor_x(&self) -> u32 {
374        self.grid.sx().saturating_sub(1)
375    }
376
377    fn cursor_column(&self) -> u32 {
378        self.cursor_x.min(self.max_cursor_x())
379    }
380
381    fn current_line_mut(&mut self) -> Option<&mut GridLine> {
382        self.grid.visible_line_mut(self.cursor_y)
383    }
384
385    fn clear_pending_wrap(&mut self) {
386        self.pending_wrap = false;
387    }
388
389    fn restore_cursor_position(&mut self, x: u32, y: u32, pending_wrap: bool) {
390        self.cursor_x = x.min(self.max_cursor_x());
391        self.cursor_y = y.min(self.grid.sy().saturating_sub(1));
392        self.pending_wrap = pending_wrap
393            && (self.mode & mode::MODE_WRAP) != 0
394            && self.cursor_x == self.max_cursor_x();
395    }
396
397    fn apply_pending_wrap(&mut self) {
398        if !self.pending_wrap || (self.mode & mode::MODE_WRAP) == 0 {
399            self.pending_wrap = false;
400            return;
401        }
402
403        if let Some(line) = self.current_line_mut() {
404            line.set_wrapped(true);
405        }
406        self.pending_wrap = false;
407        self.linefeed(false, COLOUR_DEFAULT);
408        self.cursor_x = 0;
409    }
410
411    fn blank_cell(&self, bg: i32) -> GridCell {
412        GridCell::blank_with_bg(bg)
413    }
414
415    fn overwrite_for_write(&mut self, x: u32, width: u32) {
416        let sx = self.grid.sx();
417        let blank = GridCell::blank_with_bg(COLOUR_DEFAULT);
418        let Some(line) = self.current_line_mut() else {
419            return;
420        };
421
422        let current_is_padding = line.is_padding_cell(x);
423        if current_is_padding {
424            if let Some(owner_x) = line.owning_cell_x(x).filter(|owner_x| *owner_x != x) {
425                if let Some(owner) = line.cell_mut(owner_x) {
426                    *owner = blank.clone();
427                }
428            }
429        }
430
431        let clear_following_padding = width != 1
432            || line
433                .cell(x)
434                .is_some_and(|cell| cell.width() != 1 || cell.is_padding());
435        if clear_following_padding {
436            let mut clear_x = x.saturating_add(width);
437            while clear_x < sx && line.is_padding_cell(clear_x) {
438                if let Some(cell) = line.cell_mut(clear_x) {
439                    *cell = blank.clone();
440                }
441                clear_x += 1;
442            }
443        }
444
445        line.touch();
446    }
447
448    fn clear_line_range(&mut self, y: u32, start: u32, end_inclusive: u32, bg: i32) {
449        self.clear_selected_cells();
450        let sx = self.grid.sx();
451        let end = end_inclusive.min(sx.saturating_sub(1));
452        let Some(line) = self.grid.visible_line_mut(y) else {
453            return;
454        };
455        for x in start.min(sx)..=end {
456            if let Some(cell) = line.cell_mut(x) {
457                *cell = GridCell::blank_with_bg(bg);
458            }
459        }
460        line.set_wrapped(false);
461        line.touch();
462    }
463
464    fn clear_screen_region(&mut self, start_y: u32, end_y_inclusive: u32, bg: i32) {
465        self.clear_selected_cells();
466        for y in start_y..=end_y_inclusive.min(self.grid.sy().saturating_sub(1)) {
467            if let Some(line) = self.grid.visible_line_mut(y) {
468                line.clear(bg);
469            }
470        }
471    }
472
473    fn write_char(&mut self, ch: char, cell: &CellState, acs: bool) {
474        if self.grid.sx() == 0 || self.grid.sy() == 0 {
475            return;
476        }
477        self.clear_selected_cells();
478
479        let ch = if acs { acs::translate_acs(ch) } else { ch };
480        let width = u32::from(self.utf8_config.width(ch));
481        if self.combine_char(ch) {
482            return;
483        }
484
485        let automatic_wrap_continuation = self.pending_wrap && (self.mode & mode::MODE_WRAP) != 0;
486        self.apply_pending_wrap();
487
488        if (self.mode & mode::MODE_WRAP) != 0
489            && self.cursor_x > self.grid.sx().saturating_sub(width)
490        {
491            if let Some(line) = self.current_line_mut() {
492                line.set_wrapped(true);
493            }
494            self.linefeed(false, COLOUR_DEFAULT);
495            self.cursor_x = 0;
496        }
497
498        if (self.mode & mode::MODE_WRAP) == 0
499            && width > 1
500            && (width > self.grid.sx() || self.cursor_x > self.grid.sx().saturating_sub(width))
501        {
502            return;
503        }
504
505        if self.cursor_y >= self.grid.sy()
506            || self.cursor_column() > self.grid.sx().saturating_sub(width)
507        {
508            return;
509        }
510
511        let x = self.cursor_column();
512        if x == 0 && !automatic_wrap_continuation {
513            self.break_previous_wrapped_line();
514        }
515        self.overwrite_for_write(x, width);
516        if let Some(line) = self.current_line_mut() {
517            if let Some(target) = line.cell_mut(x) {
518                *target = GridCell::from_state(
519                    ch,
520                    u8::try_from(width).unwrap_or(1),
521                    cell,
522                    GridCellFlags::default(),
523                );
524            }
525            for offset in 1..width {
526                if let Some(padding) = line.cell_mut(x + offset) {
527                    *padding = GridCell::from_state(' ', 0, cell, GridCellFlags::PADDING);
528                }
529            }
530            line.touch();
531        }
532
533        if (self.mode & mode::MODE_WRAP) != 0 && x + width >= self.grid.sx() {
534            self.cursor_x = self.max_cursor_x();
535            self.pending_wrap = true;
536        } else {
537            self.cursor_x = x.saturating_add(width).min(self.max_cursor_x());
538            self.pending_wrap = false;
539        }
540    }
541
542    fn write_plain_ascii_run(&mut self, mut bytes: &[u8], cell: &CellState, acs: bool) -> bool {
543        if bytes.is_empty() {
544            return true;
545        }
546        if acs
547            || cell.attr() != 0
548            || cell.fg() != COLOUR_DEFAULT
549            || cell.bg() != COLOUR_DEFAULT
550            || cell.us() != COLOUR_DEFAULT
551            || cell.link() != 0
552            || self.grid.sx() == 0
553            || self.grid.sy() == 0
554        {
555            return false;
556        }
557        self.clear_selected_cells();
558
559        while !bytes.is_empty() {
560            let automatic_wrap_continuation =
561                self.pending_wrap && (self.mode & mode::MODE_WRAP) != 0;
562            self.apply_pending_wrap();
563            if self.cursor_y >= self.grid.sy() {
564                return false;
565            }
566
567            let sx = self.grid.sx();
568            let x = self.cursor_column();
569            if x == 0 && !automatic_wrap_continuation {
570                self.break_previous_wrapped_line();
571            }
572
573            if (self.mode & mode::MODE_WRAP) == 0 {
574                let available = sx.saturating_sub(x) as usize;
575                if bytes.len() > available {
576                    return false;
577                }
578            }
579
580            let writable = sx.saturating_sub(x) as usize;
581            if writable == 0 {
582                return false;
583            }
584            let chunk_len = bytes.len().min(writable);
585            let (chunk, rest) = bytes.split_at(chunk_len);
586            let Some(line) = self.current_line_mut() else {
587                return false;
588            };
589            if !line.write_plain_ascii_run(x, chunk) {
590                return false;
591            }
592
593            if (self.mode & mode::MODE_WRAP) != 0 && x + chunk_len as u32 >= sx {
594                self.cursor_x = self.max_cursor_x();
595                self.pending_wrap = true;
596            } else {
597                self.cursor_x = x.saturating_add(chunk_len as u32).min(self.max_cursor_x());
598                self.pending_wrap = false;
599            }
600            bytes = rest;
601        }
602        true
603    }
604
605    fn break_previous_wrapped_line(&mut self) {
606        if self.cursor_y == 0 {
607            return;
608        }
609        if let Some(previous) = self.grid.visible_line_mut(self.cursor_y - 1) {
610            previous.set_wrapped(false);
611        }
612    }
613
614    fn combine_char(&mut self, ch: char) -> bool {
615        let mut x = self.cursor_column();
616        if self.pending_wrap {
617            x = self.max_cursor_x();
618        } else if x == 0 {
619            return matches!(
620                utf8_combine_char(None, ch, &self.utf8_config),
621                CombineResult::Discard
622            );
623        } else {
624            x -= 1;
625        }
626
627        let Some(line) = self.grid.visible_line_mut(self.cursor_y) else {
628            return matches!(
629                utf8_combine_char(None, ch, &self.utf8_config),
630                CombineResult::Discard
631            );
632        };
633        let target_x = line.owning_cell_x(x).unwrap_or(x);
634        let previous = line
635            .cell(target_x)
636            .map(|cell| (cell.text().to_owned(), cell.width()));
637        let result = utf8_combine_char(
638            previous
639                .as_ref()
640                .map(|(text, width)| (text.as_str(), *width)),
641            ch,
642            &self.utf8_config,
643        );
644
645        match result {
646            CombineResult::Standalone { .. } => false,
647            CombineResult::Discard => true,
648            CombineResult::Combined { text, width } => {
649                let previous_width = previous.as_ref().map_or(0, |(_, width)| *width);
650                if let Some(cell) = line.cell_mut(target_x) {
651                    cell.set_text(text);
652                    cell.set_width(width);
653                    if width == 2 {
654                        let mut padding = cell.clone();
655                        padding.set_text(" ".to_owned());
656                        padding.set_width(0);
657                        padding.set_flags(GridCellFlags::PADDING);
658                        if let Some(padding_cell) = line.cell_mut(target_x + 1) {
659                            *padding_cell = padding;
660                        }
661                    }
662                    line.touch();
663                }
664                if previous_width == 1 && width == 2 && !self.pending_wrap {
665                    let next_cursor = target_x.saturating_add(2);
666                    if next_cursor >= self.grid.sx() {
667                        self.cursor_x = self.max_cursor_x();
668                        self.pending_wrap = (self.mode & mode::MODE_WRAP) != 0;
669                    } else {
670                        self.cursor_x = next_cursor;
671                    }
672                }
673                true
674            }
675        }
676    }
677
678    fn parse_hyperlink(data: &str) -> (Option<String>, String) {
679        let (params, uri) = data.split_once(';').unwrap_or((data, ""));
680        let mut internal_id = None;
681        for part in params.split(':') {
682            if let Some(value) = part.strip_prefix("id=") {
683                internal_id = Some(value.to_owned());
684            }
685        }
686        (internal_id, uri.to_owned())
687    }
688}
689
690#[cfg(test)]
691#[path = "screen/tests.rs"]
692mod tests;