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