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/history_bytes.rs"]
15mod history_bytes;
16#[path = "screen/selection.rs"]
17mod selection;
18#[path = "screen/style_overlay.rs"]
19mod style_overlay;
20#[path = "screen/view.rs"]
21mod view;
22#[path = "screen/writer.rs"]
23mod writer;
24
25pub use view::{ScreenCellRef, ScreenCellView, ScreenLineView};
26
27pub(crate) const MAX_TERMINAL_PASSTHROUGH_EVENTS: usize = 256;
28const TITLE_STACK_MAX: usize = 100;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31struct SavedGrid {
32    grid: Grid,
33    history_enabled: bool,
34}
35
36/// One pane screen, including scrollback, alternate-screen state, cursor
37/// position, modes, tab stops, and hyperlink storage.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Screen {
40    grid: Grid,
41    cursor_x: u32,
42    cursor_y: u32,
43    pending_wrap: bool,
44    saved_cursor_x: Option<u32>,
45    saved_cursor_y: Option<u32>,
46    saved_cursor_pending_wrap: bool,
47    saved_state: SavedState,
48    saved_grid: Option<SavedGrid>,
49    rupper: u32,
50    rlower: u32,
51    mode: u32,
52    cursor_style: u32,
53    title: String,
54    window_name: String,
55    path: String,
56    title_stack: Vec<String>,
57    title_rename_enabled: bool,
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    has_selected_cells: bool,
65    utf8_config: Utf8Config,
66    alternate_screen_enabled: bool,
67    preserve_alternate_screen_cursor: bool,
68}
69
70impl Screen {
71    /// Creates a new screen with the given geometry and history limit.
72    #[must_use]
73    pub fn new(size: TerminalSize, history_limit: usize) -> Self {
74        let grid = Grid::new(size, history_limit);
75        let mut screen = Self {
76            grid,
77            cursor_x: 0,
78            cursor_y: 0,
79            pending_wrap: false,
80            saved_cursor_x: None,
81            saved_cursor_y: None,
82            saved_cursor_pending_wrap: false,
83            saved_state: SavedState::default(),
84            saved_grid: None,
85            rupper: 0,
86            rlower: u32::from(size.rows.max(1)).saturating_sub(1),
87            mode: mode::MODE_CURSOR | mode::MODE_WRAP,
88            cursor_style: 0,
89            title: String::new(),
90            window_name: String::new(),
91            path: String::new(),
92            title_stack: Vec::new(),
93            title_rename_enabled: true,
94            tabs: Vec::new(),
95            hyperlinks: Hyperlinks::new(),
96            active_hyperlink: 0,
97            bell_count: 0,
98            terminal_passthrough: Vec::new(),
99            dropped_terminal_passthrough_count: 0,
100            has_selected_cells: false,
101            utf8_config: Utf8Config::default(),
102            alternate_screen_enabled: true,
103            preserve_alternate_screen_cursor: false,
104        };
105        screen.reset_tabs();
106        screen
107    }
108
109    /// Returns the current terminal mode flags.
110    #[must_use]
111    pub const fn mode(&self) -> u32 {
112        self.mode
113    }
114
115    /// Returns the most recent DECSCUSR cursor style parameter.
116    #[must_use]
117    pub const fn cursor_style(&self) -> u32 {
118        self.cursor_style
119    }
120
121    /// Returns the DECSTBM scroll region as `(top, bottom)` rows, 0-based and
122    /// inclusive. A full-screen region is `(0, rows - 1)`.
123    #[must_use]
124    pub const fn scroll_region(&self) -> (u32, u32) {
125        (self.rupper, self.rlower)
126    }
127
128    /// Returns the screen size.
129    #[must_use]
130    pub fn size(&self) -> TerminalSize {
131        self.grid.size()
132    }
133
134    #[cfg_attr(not(test), allow(dead_code))]
135    #[must_use]
136    pub(crate) fn grid(&self) -> &Grid {
137        &self.grid
138    }
139
140    /// Returns the current screen title.
141    #[must_use]
142    pub fn title(&self) -> &str {
143        &self.title
144    }
145
146    /// Sets the current screen title.
147    pub fn set_title(&mut self, title: impl Into<String>) {
148        self.title = title.into();
149    }
150
151    /// Enables or disables title changes requested by pane output.
152    pub fn set_title_rename_enabled(&mut self, enabled: bool) {
153        self.title_rename_enabled = enabled;
154    }
155
156    /// Enables or disables DEC alternate-screen entry for this screen.
157    ///
158    /// Exit sequences are still honored by the writer so disabling the option
159    /// while a pane is already in alternate screen does not trap it there.
160    pub fn set_alternate_screen_enabled(&mut self, enabled: bool) {
161        self.alternate_screen_enabled = enabled;
162    }
163
164    pub(crate) fn set_preserve_alternate_screen_cursor(&mut self, enabled: bool) {
165        self.preserve_alternate_screen_cursor = enabled;
166    }
167
168    /// Returns the most recent OSC 7 path.
169    #[must_use]
170    pub fn path(&self) -> &str {
171        &self.path
172    }
173
174    /// Returns whether the alternate screen is active.
175    #[must_use]
176    pub fn is_alternate(&self) -> bool {
177        self.saved_grid.is_some()
178    }
179
180    /// Returns the configured history limit.
181    #[must_use]
182    pub fn history_limit(&self) -> usize {
183        self.grid.hlimit()
184    }
185
186    /// Returns the current history size in rows.
187    #[must_use]
188    pub fn history_size(&self) -> usize {
189        self.grid.hsize()
190    }
191
192    /// Returns the current cursor position within the visible viewport.
193    #[must_use]
194    pub const fn cursor_position(&self) -> (u32, u32) {
195        (self.cursor_x, self.cursor_y)
196    }
197
198    /// Returns the absolute cursor row including history.
199    #[must_use]
200    pub fn cursor_absolute_y(&self) -> usize {
201        self.grid.hsize() + self.cursor_y as usize
202    }
203
204    /// Returns the total number of absolute lines retained by the screen.
205    #[must_use]
206    pub fn absolute_line_count(&self) -> usize {
207        self.grid.hsize() + self.grid.sy() as usize
208    }
209
210    /// Deletes one visible line and scrolls the remaining viewport content up.
211    ///
212    /// This clears any pending wrap state because deleting a visible row
213    /// invalidates the previous cursor edge condition.
214    pub fn delete_visible_line(&mut self, y: u32) -> bool {
215        if y >= self.grid.sy() {
216            return false;
217        }
218
219        let cursor_x = self.cursor_x;
220        let cursor_y = self.cursor_y;
221        let rupper = self.rupper;
222        let rlower = self.rlower;
223
224        self.cursor_x = 0;
225        self.cursor_y = y;
226        self.pending_wrap = false;
227        self.rupper = 0;
228        self.rlower = self.grid.sy().saturating_sub(1);
229        self.delete_line(1, COLOUR_DEFAULT);
230
231        self.cursor_y = if cursor_y > y {
232            cursor_y.saturating_sub(1)
233        } else {
234            cursor_y
235        }
236        .min(self.grid.sy().saturating_sub(1));
237        self.cursor_x = cursor_x.min(self.grid.sx().saturating_sub(1));
238        self.pending_wrap = false;
239        self.rupper = rupper;
240        self.rlower = rlower;
241        true
242    }
243
244    /// Deletes one absolute line from history or the visible viewport.
245    pub fn delete_absolute_line(&mut self, absolute_y: usize) -> bool {
246        let history_size = self.grid.hsize();
247        let visible_y = absolute_y.saturating_sub(history_size);
248        let removed = self.grid.remove_absolute_line(absolute_y);
249        if !removed {
250            return false;
251        }
252
253        if absolute_y >= history_size {
254            let visible_y = visible_y as u32;
255            if visible_y < self.cursor_y {
256                self.cursor_y = self.cursor_y.saturating_sub(1);
257            }
258        }
259        self.pending_wrap = false;
260        true
261    }
262
263    /// Trims all lines below the cursor and pulls history into the viewport.
264    pub fn trim_below_cursor(&mut self) -> bool {
265        let cursor_absolute_y = self.cursor_absolute_y();
266        if !self.grid.truncate_after_absolute_line(cursor_absolute_y) {
267            return false;
268        }
269
270        let history_size = self.grid.hsize();
271        self.cursor_y = cursor_absolute_y
272            .saturating_sub(history_size)
273            .min(self.grid.sy().saturating_sub(1) as usize) as u32;
274        self.cursor_x = self.cursor_x.min(self.max_cursor_x());
275        self.pending_wrap = false;
276        true
277    }
278
279    /// Returns the current retained history size in bytes.
280    #[must_use]
281    pub fn history_bytes(&self) -> usize {
282        self.grid.history_byte_size()
283    }
284
285    /// Drains and returns the number of BEL notifications observed since the last drain.
286    pub fn take_bell_count(&mut self) -> u64 {
287        let bell_count = self.bell_count;
288        self.bell_count = 0;
289        bell_count
290    }
291
292    /// Drains terminal passthrough events observed since the last drain.
293    pub fn take_terminal_passthrough(&mut self) -> Vec<TerminalPassthrough> {
294        std::mem::take(&mut self.terminal_passthrough)
295    }
296
297    /// Drains the count of terminal passthrough events dropped by safety limits.
298    pub fn take_terminal_passthrough_dropped_count(&mut self) -> u64 {
299        let dropped = self.dropped_terminal_passthrough_count;
300        self.dropped_terminal_passthrough_count = 0;
301        dropped
302    }
303
304    fn push_terminal_passthrough(&mut self, passthrough: TerminalPassthrough) {
305        if passthrough.payload().len() > MAX_TERMINAL_PASSTHROUGH_PAYLOAD_BYTES {
306            self.dropped_terminal_passthrough_count =
307                self.dropped_terminal_passthrough_count.saturating_add(1);
308            return;
309        }
310
311        let overflow = self
312            .terminal_passthrough
313            .len()
314            .saturating_add(1)
315            .saturating_sub(MAX_TERMINAL_PASSTHROUGH_EVENTS);
316        if overflow > 0 {
317            self.terminal_passthrough.drain(..overflow);
318            self.dropped_terminal_passthrough_count = self
319                .dropped_terminal_passthrough_count
320                .saturating_add(overflow as u64);
321        }
322
323        self.terminal_passthrough.push(passthrough);
324    }
325
326    /// Returns the stored OSC 8 URI for a hyperlink inner ID.
327    #[must_use]
328    pub fn hyperlink_uri(&self, inner_id: u32) -> Option<&str> {
329        self.hyperlinks
330            .get(inner_id)
331            .map(|entry| entry.uri.as_str())
332    }
333
334    /// Updates the history limit.
335    pub fn set_history_limit(&mut self, limit: usize) {
336        self.grid.set_hlimit(limit);
337    }
338
339    /// Updates the tmux-style UTF-8 width and combining configuration.
340    pub fn set_utf8_config(&mut self, utf8_config: Utf8Config) {
341        self.utf8_config = utf8_config;
342    }
343
344    /// Resizes the screen and resets the scroll region.
345    pub fn resize(&mut self, size: TerminalSize) {
346        self.clear_selected_cells();
347        let cols = u32::from(size.cols.max(1));
348        let rows = u32::from(size.rows.max(1));
349        if cols != self.grid.sx() {
350            self.grid.resize_width(cols, COLOUR_DEFAULT);
351            self.reset_tabs();
352        }
353        if rows != self.grid.sy() {
354            self.grid
355                .resize_height(rows, &mut self.cursor_y, COLOUR_DEFAULT);
356        }
357        self.rupper = 0;
358        self.rlower = rows.saturating_sub(1);
359        self.cursor_x = self.cursor_x.min(self.max_cursor_x());
360        self.pending_wrap &= self.cursor_x == self.max_cursor_x();
361    }
362
363    /// Clears history and optionally resets stored hyperlinks.
364    pub fn clear_history_and_hyperlinks(&mut self, reset_hyperlinks: bool) {
365        self.clear_selected_cells();
366        self.grid.clear_history();
367        if reset_hyperlinks {
368            self.hyperlinks.reset();
369        }
370    }
371
372    fn reset_tabs(&mut self) {
373        self.tabs = vec![false; self.grid.sx() as usize];
374        for column in (8..self.grid.sx()).step_by(8) {
375            self.tabs[column as usize] = true;
376        }
377    }
378
379    fn max_cursor_x(&self) -> u32 {
380        self.grid.sx().saturating_sub(1)
381    }
382
383    fn cursor_column(&self) -> u32 {
384        self.cursor_x.min(self.max_cursor_x())
385    }
386
387    fn current_line_mut(&mut self) -> Option<&mut GridLine> {
388        self.grid.visible_line_mut(self.cursor_y)
389    }
390
391    fn clear_pending_wrap(&mut self) {
392        self.pending_wrap = false;
393    }
394
395    fn restore_cursor_position(&mut self, x: u32, y: u32, pending_wrap: bool) {
396        self.cursor_x = x.min(self.max_cursor_x());
397        self.cursor_y = y.min(self.grid.sy().saturating_sub(1));
398        self.pending_wrap = pending_wrap
399            && (self.mode & mode::MODE_WRAP) != 0
400            && self.cursor_x == self.max_cursor_x();
401    }
402
403    fn apply_pending_wrap(&mut self) {
404        if !self.pending_wrap || (self.mode & mode::MODE_WRAP) == 0 {
405            self.pending_wrap = false;
406            return;
407        }
408
409        if let Some(line) = self.current_line_mut() {
410            line.set_wrapped(true);
411        }
412        self.pending_wrap = false;
413        self.linefeed(false, COLOUR_DEFAULT);
414        self.cursor_x = 0;
415    }
416
417    fn blank_cell(&self, bg: i32) -> GridCell {
418        GridCell::blank_with_bg(bg)
419    }
420
421    fn repair_wide_cells_on_line(line: &mut GridLine, sx: u32, bg: i32) {
422        let blank = GridCell::blank_with_bg(bg);
423        let mut changed = false;
424        let mut x = 0;
425
426        while x < sx {
427            let Some(cell) = line.cell(x) else {
428                x += 1;
429                continue;
430            };
431
432            if cell.is_padding() {
433                if line.owning_cell_x(x).is_none() {
434                    if let Some(target) = line.cell_mut(x) {
435                        *target = blank.clone();
436                        changed = true;
437                    }
438                }
439                x += 1;
440                continue;
441            }
442
443            let width = u32::from(cell.width());
444            if width <= 1 {
445                x += 1;
446                continue;
447            }
448
449            let mut valid = x.saturating_add(width) <= sx;
450            if valid {
451                for offset in 1..width {
452                    let column = x + offset;
453                    let valid_padding = line
454                        .cell(column)
455                        .is_some_and(|candidate| candidate.is_padding())
456                        && line.owning_cell_x(column) == Some(x);
457                    if !valid_padding {
458                        valid = false;
459                        break;
460                    }
461                }
462            }
463
464            if valid {
465                x += width;
466                continue;
467            }
468
469            if let Some(target) = line.cell_mut(x) {
470                *target = blank.clone();
471                changed = true;
472            }
473            x += 1;
474        }
475
476        if changed {
477            line.touch();
478        }
479    }
480
481    fn overwrite_for_write(&mut self, x: u32, width: u32) {
482        let sx = self.grid.sx();
483        let blank = GridCell::blank_with_bg(COLOUR_DEFAULT);
484        let Some(line) = self.current_line_mut() else {
485            return;
486        };
487
488        let current_is_padding = line.is_padding_cell(x);
489        if current_is_padding {
490            if let Some(owner_x) = line.owning_cell_x(x).filter(|owner_x| *owner_x != x) {
491                if let Some(owner) = line.cell_mut(owner_x) {
492                    *owner = blank.clone();
493                }
494            }
495        }
496
497        let clear_following_padding = width != 1
498            || line
499                .cell(x)
500                .is_some_and(|cell| cell.width() != 1 || cell.is_padding());
501        if clear_following_padding {
502            let mut clear_x = x.saturating_add(width);
503            while clear_x < sx && line.is_padding_cell(clear_x) {
504                if let Some(cell) = line.cell_mut(clear_x) {
505                    *cell = blank.clone();
506                }
507                clear_x += 1;
508            }
509        }
510
511        line.touch();
512    }
513
514    fn clear_line_range(&mut self, y: u32, start: u32, end_inclusive: u32, bg: i32) {
515        self.clear_selected_cells();
516        let sx = self.grid.sx();
517        let end = end_inclusive.min(sx.saturating_sub(1));
518        let Some(line) = self.grid.visible_line_mut(y) else {
519            return;
520        };
521        for x in start.min(sx)..=end {
522            if let Some(cell) = line.cell_mut(x) {
523                *cell = GridCell::blank_with_bg(bg);
524            }
525        }
526        Self::repair_wide_cells_on_line(line, sx, bg);
527        line.set_wrapped(false);
528        line.touch();
529    }
530
531    fn clear_screen_region(&mut self, start_y: u32, end_y_inclusive: u32, bg: i32) {
532        self.clear_selected_cells();
533        for y in start_y..=end_y_inclusive.min(self.grid.sy().saturating_sub(1)) {
534            if let Some(line) = self.grid.visible_line_mut(y) {
535                line.clear(bg);
536            }
537        }
538    }
539
540    fn write_char(&mut self, ch: char, cell: &CellState, acs: bool) {
541        if self.grid.sx() == 0 || self.grid.sy() == 0 {
542            return;
543        }
544        self.clear_selected_cells();
545
546        let ch = if acs { acs::translate_acs(ch) } else { ch };
547        let width = u32::from(self.utf8_config.width(ch));
548        if self.combine_char(ch) {
549            return;
550        }
551
552        let automatic_wrap_continuation = self.pending_wrap && (self.mode & mode::MODE_WRAP) != 0;
553        self.apply_pending_wrap();
554
555        if (self.mode & mode::MODE_WRAP) != 0
556            && self.cursor_x > self.grid.sx().saturating_sub(width)
557        {
558            if let Some(line) = self.current_line_mut() {
559                line.set_wrapped(true);
560            }
561            self.linefeed(false, COLOUR_DEFAULT);
562            self.cursor_x = 0;
563        }
564
565        if (self.mode & mode::MODE_WRAP) == 0
566            && width > 1
567            && (width > self.grid.sx() || self.cursor_x > self.grid.sx().saturating_sub(width))
568        {
569            return;
570        }
571
572        if self.cursor_y >= self.grid.sy()
573            || self.cursor_column() > self.grid.sx().saturating_sub(width)
574        {
575            return;
576        }
577
578        let x = self.cursor_column();
579        if x == 0 && !automatic_wrap_continuation {
580            self.break_previous_wrapped_line();
581        }
582        self.overwrite_for_write(x, width);
583        if let Some(line) = self.current_line_mut() {
584            if let Some(target) = line.cell_mut(x) {
585                *target = GridCell::from_state(
586                    ch,
587                    u8::try_from(width).unwrap_or(1),
588                    cell,
589                    GridCellFlags::default(),
590                );
591            }
592            for offset in 1..width {
593                if let Some(padding) = line.cell_mut(x + offset) {
594                    *padding = GridCell::from_state(' ', 0, cell, GridCellFlags::PADDING);
595                }
596            }
597            line.touch();
598        }
599
600        if (self.mode & mode::MODE_WRAP) != 0 && x + width >= self.grid.sx() {
601            self.cursor_x = self.max_cursor_x();
602            self.pending_wrap = true;
603        } else {
604            self.cursor_x = x.saturating_add(width).min(self.max_cursor_x());
605            self.pending_wrap = false;
606        }
607    }
608
609    fn write_plain_ascii_run(&mut self, mut bytes: &[u8], cell: &CellState, acs: bool) -> bool {
610        if bytes.is_empty() {
611            return true;
612        }
613        if acs
614            || cell.attr() != 0
615            || cell.fg() != COLOUR_DEFAULT
616            || cell.bg() != COLOUR_DEFAULT
617            || cell.us() != COLOUR_DEFAULT
618            || cell.link() != 0
619            || self.grid.sx() == 0
620            || self.grid.sy() == 0
621        {
622            return false;
623        }
624        self.clear_selected_cells();
625
626        while !bytes.is_empty() {
627            let automatic_wrap_continuation =
628                self.pending_wrap && (self.mode & mode::MODE_WRAP) != 0;
629            self.apply_pending_wrap();
630            if self.cursor_y >= self.grid.sy() {
631                return false;
632            }
633
634            let sx = self.grid.sx();
635            let x = self.cursor_column();
636            if x == 0 && !automatic_wrap_continuation {
637                self.break_previous_wrapped_line();
638            }
639
640            if (self.mode & mode::MODE_WRAP) == 0 {
641                let available = sx.saturating_sub(x) as usize;
642                if bytes.len() > available {
643                    return false;
644                }
645            }
646
647            let writable = sx.saturating_sub(x) as usize;
648            if writable == 0 {
649                return false;
650            }
651            let chunk_len = bytes.len().min(writable);
652            let (chunk, rest) = bytes.split_at(chunk_len);
653            let Some(line) = self.current_line_mut() else {
654                return false;
655            };
656            if !line.write_plain_ascii_run(x, chunk) {
657                return false;
658            }
659
660            if (self.mode & mode::MODE_WRAP) != 0 && x + chunk_len as u32 >= sx {
661                self.cursor_x = self.max_cursor_x();
662                self.pending_wrap = true;
663            } else {
664                self.cursor_x = x.saturating_add(chunk_len as u32).min(self.max_cursor_x());
665                self.pending_wrap = false;
666            }
667            bytes = rest;
668        }
669        true
670    }
671
672    fn break_previous_wrapped_line(&mut self) {
673        if self.cursor_y == 0 {
674            return;
675        }
676        if let Some(previous) = self.grid.visible_line_mut(self.cursor_y - 1) {
677            previous.set_wrapped(false);
678        }
679    }
680
681    fn combine_char(&mut self, ch: char) -> bool {
682        let mut x = self.cursor_column();
683        if self.pending_wrap {
684            x = self.max_cursor_x();
685        } else if x == 0 {
686            return matches!(
687                utf8_combine_char(None, ch, &self.utf8_config),
688                CombineResult::Discard
689            );
690        } else {
691            x -= 1;
692        }
693
694        let Some(line) = self.grid.visible_line_mut(self.cursor_y) else {
695            return matches!(
696                utf8_combine_char(None, ch, &self.utf8_config),
697                CombineResult::Discard
698            );
699        };
700        let target_x = line.owning_cell_x(x).unwrap_or(x);
701        let previous = line
702            .cell(target_x)
703            .map(|cell| (cell.text().to_owned(), cell.width()));
704        let result = utf8_combine_char(
705            previous
706                .as_ref()
707                .map(|(text, width)| (text.as_str(), *width)),
708            ch,
709            &self.utf8_config,
710        );
711
712        match result {
713            CombineResult::Standalone { .. } => false,
714            CombineResult::Discard => true,
715            CombineResult::Combined { text, width } => {
716                let previous_width = previous.as_ref().map_or(0, |(_, width)| *width);
717                if let Some(cell) = line.cell_mut(target_x) {
718                    cell.set_text(text);
719                    cell.set_width(width);
720                    if width == 2 {
721                        let mut padding = cell.clone();
722                        padding.set_text(" ".to_owned());
723                        padding.set_width(0);
724                        padding.set_flags(GridCellFlags::PADDING);
725                        if let Some(padding_cell) = line.cell_mut(target_x + 1) {
726                            *padding_cell = padding;
727                        }
728                    }
729                    line.touch();
730                }
731                if previous_width == 1 && width == 2 && !self.pending_wrap {
732                    let next_cursor = target_x.saturating_add(2);
733                    if next_cursor >= self.grid.sx() {
734                        self.cursor_x = self.max_cursor_x();
735                        self.pending_wrap = (self.mode & mode::MODE_WRAP) != 0;
736                    } else {
737                        self.cursor_x = next_cursor;
738                    }
739                }
740                true
741            }
742        }
743    }
744
745    fn parse_hyperlink(data: &str) -> (Option<String>, String) {
746        let (params, uri) = data.split_once(';').unwrap_or((data, ""));
747        let mut internal_id = None;
748        for part in params.split(':') {
749            if let Some(value) = part.strip_prefix("id=") {
750                internal_id = Some(value.to_owned());
751            }
752        }
753        (internal_id, uri.to_owned())
754    }
755}
756
757#[cfg(test)]
758#[path = "screen/tests.rs"]
759mod tests;