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