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