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