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