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