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