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, GridPhysicalCursor};
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 capture::{RecoveryRow, RecoveryRowRenderer};
26pub use view::{ScreenCellRef, ScreenCellView, ScreenLineView};
27
28pub(crate) const MAX_TERMINAL_PASSTHROUGH_EVENTS: usize = 256;
29const TITLE_STACK_MAX: usize = 100;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32struct SavedGrid {
33    grid: Grid,
34    history_enabled: bool,
35}
36
37/// One pane screen, including scrollback, alternate-screen state, cursor
38/// position, modes, tab stops, and hyperlink storage.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Screen {
41    grid: Grid,
42    cursor_x: u32,
43    cursor_y: u32,
44    pending_wrap: bool,
45    saved_cursor_x: Option<u32>,
46    saved_cursor_y: Option<u32>,
47    saved_cursor_pending_wrap: bool,
48    saved_state: SavedState,
49    saved_grid: Option<SavedGrid>,
50    rupper: u32,
51    rlower: u32,
52    mode: u32,
53    cursor_style: u32,
54    title: String,
55    window_name: String,
56    path: String,
57    title_stack: Vec<String>,
58    title_rename_enabled: bool,
59    tabs: Vec<bool>,
60    hyperlinks: Hyperlinks,
61    active_hyperlink: u32,
62    metadata_revision: u64,
63    bell_count: u64,
64    terminal_passthrough: Vec<TerminalPassthrough>,
65    dropped_terminal_passthrough_count: u64,
66    has_selected_cells: bool,
67    utf8_config: Utf8Config,
68    alternate_screen_enabled: bool,
69    preserve_alternate_screen_cursor: bool,
70}
71
72impl Screen {
73    pub(crate) fn render_cell_state_ansi(&self, state: &CellState) -> Vec<u8> {
74        crate::grid::render_cell_state_ansi(state, &self.hyperlinks)
75    }
76
77    pub(crate) fn render_cell_state_ansi_bounded(
78        &self,
79        state: &CellState,
80        max_hyperlink_bytes: usize,
81    ) -> (Vec<u8>, bool) {
82        let mut bounded = state.clone();
83        let complete = self
84            .hyperlinks
85            .entry_fits(bounded.link(), max_hyperlink_bytes);
86        if !complete {
87            bounded.cell.link = 0;
88        }
89        (
90            crate::grid::render_cell_state_ansi(&bounded, &self.hyperlinks),
91            complete,
92        )
93    }
94
95    /// Creates a new screen with the given geometry and history limit.
96    #[must_use]
97    pub fn new(size: TerminalSize, history_limit: usize) -> Self {
98        let grid = Grid::new(size, history_limit);
99        let mut screen = Self {
100            grid,
101            cursor_x: 0,
102            cursor_y: 0,
103            pending_wrap: false,
104            saved_cursor_x: None,
105            saved_cursor_y: None,
106            saved_cursor_pending_wrap: false,
107            saved_state: SavedState::default(),
108            saved_grid: None,
109            rupper: 0,
110            rlower: u32::from(size.rows.max(1)).saturating_sub(1),
111            mode: mode::MODE_CURSOR | mode::MODE_WRAP,
112            cursor_style: 0,
113            title: String::new(),
114            window_name: String::new(),
115            path: String::new(),
116            title_stack: Vec::new(),
117            title_rename_enabled: true,
118            tabs: Vec::new(),
119            hyperlinks: Hyperlinks::new(),
120            active_hyperlink: 0,
121            metadata_revision: 0,
122            bell_count: 0,
123            terminal_passthrough: Vec::new(),
124            dropped_terminal_passthrough_count: 0,
125            has_selected_cells: false,
126            utf8_config: Utf8Config::default(),
127            alternate_screen_enabled: true,
128            preserve_alternate_screen_cursor: false,
129        };
130        screen.reset_tabs();
131        screen
132    }
133
134    /// Returns the current terminal mode flags.
135    #[must_use]
136    pub const fn mode(&self) -> u32 {
137        self.mode
138    }
139
140    /// Returns the most recent DECSCUSR cursor style parameter.
141    #[must_use]
142    pub const fn cursor_style(&self) -> u32 {
143        self.cursor_style
144    }
145
146    /// Returns the DECSTBM scroll region as `(top, bottom)` rows, 0-based and
147    /// inclusive. A full-screen region is `(0, rows - 1)`.
148    #[must_use]
149    pub const fn scroll_region(&self) -> (u32, u32) {
150        (self.rupper, self.rlower)
151    }
152
153    pub(crate) const fn plain_output_forwarding_safe(&self) -> bool {
154        let unsafe_modes = mode::MODE_INSERT | mode::MODE_CRLF | mode::MODE_SYNC;
155        !self.pending_wrap
156            && self.mode & mode::MODE_WRAP != 0
157            && self.mode & unsafe_modes == 0
158            && self.rupper == 0
159            && self.rlower == self.grid.sy().saturating_sub(1)
160    }
161
162    /// Returns the screen size.
163    #[must_use]
164    pub fn size(&self) -> TerminalSize {
165        self.grid.size()
166    }
167
168    #[cfg_attr(not(test), allow(dead_code))]
169    #[must_use]
170    pub(crate) fn grid(&self) -> &Grid {
171        &self.grid
172    }
173
174    /// Returns the current screen title.
175    #[must_use]
176    pub fn title(&self) -> &str {
177        &self.title
178    }
179
180    /// Returns the title stack from oldest to newest.
181    #[must_use]
182    pub fn title_stack(&self) -> &[String] {
183        &self.title_stack
184    }
185
186    /// Maximum title stack depth accepted by the terminal model.
187    #[must_use]
188    pub const fn title_stack_limit() -> usize {
189        TITLE_STACK_MAX
190    }
191
192    /// Sets the current screen title.
193    pub fn set_title(&mut self, title: impl Into<String>) {
194        let title = title.into();
195        if self.title != title {
196            self.title = title;
197            self.bump_metadata_revision();
198        }
199    }
200
201    /// Returns the revision of terminal-controlled metadata.
202    #[must_use]
203    pub const fn metadata_revision(&self) -> u64 {
204        self.metadata_revision
205    }
206
207    fn bump_metadata_revision(&mut self) {
208        self.metadata_revision = self.metadata_revision.saturating_add(1);
209    }
210
211    /// Enables or disables title changes requested by pane output.
212    pub fn set_title_rename_enabled(&mut self, enabled: bool) {
213        self.title_rename_enabled = enabled;
214    }
215
216    /// Enables or disables DEC alternate-screen entry for this screen.
217    ///
218    /// Exit sequences are still honored by the writer so disabling the option
219    /// while a pane is already in alternate screen does not trap it there.
220    pub fn set_alternate_screen_enabled(&mut self, enabled: bool) {
221        self.alternate_screen_enabled = enabled;
222    }
223
224    pub(crate) fn set_preserve_alternate_screen_cursor(&mut self, enabled: bool) {
225        self.preserve_alternate_screen_cursor = enabled;
226    }
227
228    /// Returns the most recent OSC 7 path.
229    #[must_use]
230    pub fn path(&self) -> &str {
231        &self.path
232    }
233
234    /// Returns whether the alternate screen is active.
235    #[must_use]
236    pub fn is_alternate(&self) -> bool {
237        self.saved_grid.is_some()
238    }
239
240    /// Returns the cursor saved by DEC alternate-screen entry, when present.
241    #[must_use]
242    pub fn alternate_saved_cursor(&self) -> Option<(u32, u32, bool)> {
243        self.saved_cursor_x.zip(self.saved_cursor_y).map(|(x, y)| {
244            (
245                x,
246                y,
247                self.saved_cursor_pending_wrap && self.mode & mode::MODE_WRAP != 0,
248            )
249        })
250    }
251
252    /// Returns whether the next printable character first performs autowrap.
253    #[must_use]
254    pub const fn pending_wrap(&self) -> bool {
255        self.pending_wrap
256    }
257
258    /// Returns the active tab-stop bitmap.
259    #[must_use]
260    pub fn tab_stops(&self) -> &[bool] {
261        &self.tabs
262    }
263
264    /// Clones all state needed to reconstruct a renderer, including bounded
265    /// scrollback and the saved main buffer beneath alternate screen.
266    ///
267    /// Ephemeral notifications and passthrough side effects are discarded:
268    /// replaying a recovery keyframe must not replay clipboard, graphics, bell,
269    /// or other host-side effects.
270    #[must_use]
271    pub(crate) fn clone_recovery_state(&self) -> Self {
272        let mut recovery = self.clone();
273        recovery.bell_count = 0;
274        recovery.terminal_passthrough.clear();
275        recovery.dropped_terminal_passthrough_count = 0;
276        recovery.has_selected_cells = false;
277        recovery
278    }
279
280    /// Returns the configured history limit.
281    #[must_use]
282    pub fn history_limit(&self) -> usize {
283        self.grid.hlimit()
284    }
285
286    /// Returns the current history size in rows.
287    #[must_use]
288    pub fn history_size(&self) -> usize {
289        self.grid.hsize()
290    }
291
292    /// Returns the current cursor position within the visible viewport.
293    #[must_use]
294    pub const fn cursor_position(&self) -> (u32, u32) {
295        (self.cursor_x, self.cursor_y)
296    }
297
298    /// Returns the absolute cursor row including history.
299    #[must_use]
300    pub fn cursor_absolute_y(&self) -> usize {
301        self.grid.hsize() + self.cursor_y as usize
302    }
303
304    /// Returns the total number of absolute lines retained by the screen.
305    #[must_use]
306    pub fn absolute_line_count(&self) -> usize {
307        self.grid.hsize() + self.grid.sy() as usize
308    }
309
310    /// Deletes one visible line and scrolls the remaining viewport content up.
311    ///
312    /// This clears any pending wrap state because deleting a visible row
313    /// invalidates the previous cursor edge condition.
314    pub fn delete_visible_line(&mut self, y: u32) -> bool {
315        if y >= self.grid.sy() {
316            return false;
317        }
318
319        let cursor_x = self.cursor_x;
320        let cursor_y = self.cursor_y;
321        let rupper = self.rupper;
322        let rlower = self.rlower;
323
324        self.cursor_x = 0;
325        self.cursor_y = y;
326        self.pending_wrap = false;
327        self.rupper = 0;
328        self.rlower = self.grid.sy().saturating_sub(1);
329        self.delete_line(1, COLOUR_DEFAULT);
330
331        self.cursor_y = if cursor_y > y {
332            cursor_y.saturating_sub(1)
333        } else {
334            cursor_y
335        }
336        .min(self.grid.sy().saturating_sub(1));
337        self.cursor_x = cursor_x.min(self.grid.sx().saturating_sub(1));
338        self.pending_wrap = false;
339        self.rupper = rupper;
340        self.rlower = rlower;
341        true
342    }
343
344    /// Deletes one absolute line from history or the visible viewport.
345    pub fn delete_absolute_line(&mut self, absolute_y: usize) -> bool {
346        let history_size = self.grid.hsize();
347        let visible_y = absolute_y.saturating_sub(history_size);
348        let removed = self.grid.remove_absolute_line(absolute_y);
349        if !removed {
350            return false;
351        }
352
353        if absolute_y >= history_size {
354            let visible_y = visible_y as u32;
355            if visible_y < self.cursor_y {
356                self.cursor_y = self.cursor_y.saturating_sub(1);
357            }
358        }
359        self.pending_wrap = false;
360        true
361    }
362
363    /// Trims all lines below the cursor and pulls history into the viewport.
364    pub fn trim_below_cursor(&mut self) -> bool {
365        let cursor_absolute_y = self.cursor_absolute_y();
366        if !self.grid.truncate_after_absolute_line(cursor_absolute_y) {
367            return false;
368        }
369
370        let history_size = self.grid.hsize();
371        self.cursor_y = cursor_absolute_y
372            .saturating_sub(history_size)
373            .min(self.grid.sy().saturating_sub(1) as usize) as u32;
374        self.cursor_x = self.cursor_x.min(self.max_cursor_x());
375        self.pending_wrap = false;
376        true
377    }
378
379    /// Returns the current retained history size in bytes.
380    #[must_use]
381    pub fn history_bytes(&self) -> usize {
382        self.grid.history_byte_size()
383    }
384
385    /// Drains and returns the number of BEL notifications observed since the last drain.
386    pub fn take_bell_count(&mut self) -> u64 {
387        let bell_count = self.bell_count;
388        self.bell_count = 0;
389        bell_count
390    }
391
392    /// Drains terminal passthrough events observed since the last drain.
393    pub fn take_terminal_passthrough(&mut self) -> Vec<TerminalPassthrough> {
394        std::mem::take(&mut self.terminal_passthrough)
395    }
396
397    /// Drains the count of terminal passthrough events dropped by safety limits.
398    pub fn take_terminal_passthrough_dropped_count(&mut self) -> u64 {
399        let dropped = self.dropped_terminal_passthrough_count;
400        self.dropped_terminal_passthrough_count = 0;
401        dropped
402    }
403
404    fn push_terminal_passthrough(&mut self, passthrough: TerminalPassthrough) {
405        if passthrough.payload().len() > MAX_TERMINAL_PASSTHROUGH_PAYLOAD_BYTES {
406            self.dropped_terminal_passthrough_count =
407                self.dropped_terminal_passthrough_count.saturating_add(1);
408            return;
409        }
410
411        let overflow = self
412            .terminal_passthrough
413            .len()
414            .saturating_add(1)
415            .saturating_sub(MAX_TERMINAL_PASSTHROUGH_EVENTS);
416        if overflow > 0 {
417            self.terminal_passthrough.drain(..overflow);
418            self.dropped_terminal_passthrough_count = self
419                .dropped_terminal_passthrough_count
420                .saturating_add(overflow as u64);
421        }
422
423        self.terminal_passthrough.push(passthrough);
424    }
425
426    /// Returns the stored OSC 8 URI for a hyperlink inner ID.
427    #[must_use]
428    pub fn hyperlink_uri(&self, inner_id: u32) -> Option<&str> {
429        self.hyperlinks
430            .get(inner_id)
431            .map(|entry| entry.uri.as_str())
432    }
433
434    /// Updates the history limit.
435    pub fn set_history_limit(&mut self, limit: usize) {
436        self.grid.set_hlimit(limit);
437    }
438
439    /// Updates the tmux-style UTF-8 width and combining configuration.
440    pub fn set_utf8_config(&mut self, utf8_config: Utf8Config) {
441        self.utf8_config = utf8_config;
442    }
443
444    /// Resizes the screen and resets the scroll region.
445    pub fn resize(&mut self, size: TerminalSize) {
446        self.clear_selected_cells();
447        let cols = u32::from(size.cols.max(1));
448        let rows = u32::from(size.rows.max(1));
449        if cols != self.grid.sx() {
450            let remapped = if self.is_alternate() {
451                self.grid.resize_visible_width_preserving_cursor(
452                    cols,
453                    COLOUR_DEFAULT,
454                    self.cursor_y,
455                    self.cursor_x,
456                    self.pending_wrap,
457                )
458            } else {
459                let cursor =
460                    self.grid
461                        .logical_cursor(self.cursor_y, self.cursor_x, self.pending_wrap);
462                self.grid
463                    .resize_width_remapping_cursor(cols, COLOUR_DEFAULT, cursor)
464            };
465            self.apply_grid_cursor(remapped);
466            self.reset_tabs();
467        }
468        if rows != self.grid.sy() {
469            self.grid
470                .resize_height(rows, &mut self.cursor_y, COLOUR_DEFAULT);
471        }
472        self.rupper = 0;
473        self.rlower = rows.saturating_sub(1);
474        self.cursor_x = self.cursor_x.min(self.max_cursor_x());
475        self.pending_wrap = self.pending_wrap
476            && (self.mode & mode::MODE_WRAP) != 0
477            && self.cursor_x == self.max_cursor_x();
478    }
479
480    /// Clears history and optionally resets stored hyperlinks.
481    pub fn clear_history_and_hyperlinks(&mut self, reset_hyperlinks: bool) {
482        self.clear_selected_cells();
483        self.grid.clear_history();
484        if reset_hyperlinks {
485            self.hyperlinks.reset();
486            self.bump_metadata_revision();
487        }
488    }
489
490    fn reset_tabs(&mut self) {
491        self.tabs = vec![false; self.grid.sx() as usize];
492        for column in (8..self.grid.sx()).step_by(8) {
493            self.tabs[column as usize] = true;
494        }
495    }
496
497    fn max_cursor_x(&self) -> u32 {
498        self.grid.sx().saturating_sub(1)
499    }
500
501    fn cursor_column(&self) -> u32 {
502        self.cursor_x.min(self.max_cursor_x())
503    }
504
505    fn logical_insert_column(&self) -> u32 {
506        let x = self.cursor_column();
507        let Some(line) = self.grid.visible_line(self.cursor_y) else {
508            return x;
509        };
510        let Some(owner_x) = line.owning_cell_x(x).filter(|owner_x| *owner_x != x) else {
511            return x;
512        };
513        let width = line
514            .cell(owner_x)
515            .map_or(1, |cell| u32::from(cell.width()).max(1));
516        owner_x.saturating_add(width)
517    }
518
519    fn current_line_mut(&mut self) -> Option<&mut GridLine> {
520        self.grid.visible_line_mut(self.cursor_y)
521    }
522
523    fn clear_pending_wrap(&mut self) {
524        self.pending_wrap = false;
525    }
526
527    fn restore_cursor_position(&mut self, x: u32, y: u32, pending_wrap: bool) {
528        self.cursor_x = x.min(self.max_cursor_x());
529        self.cursor_y = y.min(self.grid.sy().saturating_sub(1));
530        self.pending_wrap = pending_wrap
531            && (self.mode & mode::MODE_WRAP) != 0
532            && self.cursor_x == self.max_cursor_x();
533    }
534
535    fn apply_grid_cursor(&mut self, cursor: GridPhysicalCursor) {
536        let history_size = self.grid.hsize();
537        self.cursor_x = cursor.x.min(self.max_cursor_x());
538        self.cursor_y = cursor
539            .absolute_y
540            .saturating_sub(history_size)
541            .min(self.grid.sy().saturating_sub(1) as usize) as u32;
542        self.pending_wrap = cursor.pending_wrap
543            && (self.mode & mode::MODE_WRAP) != 0
544            && self.cursor_x == self.max_cursor_x();
545    }
546
547    fn apply_pending_wrap(&mut self) {
548        if !self.pending_wrap || (self.mode & mode::MODE_WRAP) == 0 {
549            self.pending_wrap = false;
550            return;
551        }
552
553        if let Some(line) = self.current_line_mut() {
554            line.set_wrapped(true);
555        }
556        self.pending_wrap = false;
557        self.linefeed(false, COLOUR_DEFAULT);
558        self.cursor_x = 0;
559    }
560
561    fn blank_cell(&self, bg: i32) -> GridCell {
562        GridCell::blank_with_bg(bg)
563    }
564
565    fn repair_wide_cells_on_line(line: &mut GridLine, sx: u32, bg: i32) {
566        let blank = GridCell::blank_with_bg(bg);
567        let mut changed = false;
568        let mut x = 0;
569
570        while x < sx {
571            let Some(cell) = line.cell(x) else {
572                x += 1;
573                continue;
574            };
575
576            if cell.is_padding() {
577                if line.owning_cell_x(x).is_none() {
578                    if let Some(target) = line.cell_mut(x) {
579                        *target = blank.clone();
580                        changed = true;
581                    }
582                }
583                x += 1;
584                continue;
585            }
586
587            let width = u32::from(cell.width());
588            if width <= 1 {
589                x += 1;
590                continue;
591            }
592
593            let mut valid = x.saturating_add(width) <= sx;
594            if valid {
595                for offset in 1..width {
596                    let column = x + offset;
597                    let valid_padding = line
598                        .cell(column)
599                        .is_some_and(|candidate| candidate.is_padding())
600                        && line.owning_cell_x(column) == Some(x);
601                    if !valid_padding {
602                        valid = false;
603                        break;
604                    }
605                }
606            }
607
608            if valid {
609                x += width;
610                continue;
611            }
612
613            if let Some(target) = line.cell_mut(x) {
614                *target = blank.clone();
615                changed = true;
616            }
617            x += 1;
618        }
619
620        if changed {
621            line.touch();
622        }
623    }
624
625    fn overwrite_for_write(&mut self, x: u32, width: u32) {
626        let sx = self.grid.sx();
627        let blank = GridCell::blank_with_bg(COLOUR_DEFAULT);
628        let Some(line) = self.current_line_mut() else {
629            return;
630        };
631
632        let current_is_padding = line.is_padding_cell(x);
633        if current_is_padding {
634            if let Some(owner_x) = line.owning_cell_x(x).filter(|owner_x| *owner_x != x) {
635                if let Some(owner) = line.cell_mut(owner_x) {
636                    *owner = blank.clone();
637                }
638            }
639        }
640
641        let clear_following_padding = width != 1
642            || line
643                .cell(x)
644                .is_some_and(|cell| cell.width() != 1 || cell.is_padding());
645        if clear_following_padding {
646            let mut clear_x = x.saturating_add(width);
647            while clear_x < sx && line.is_padding_cell(clear_x) {
648                if let Some(cell) = line.cell_mut(clear_x) {
649                    *cell = blank.clone();
650                }
651                clear_x += 1;
652            }
653        }
654
655        line.touch();
656    }
657
658    fn clear_line_range(&mut self, y: u32, start: u32, end_inclusive: u32, bg: i32) {
659        self.clear_selected_cells();
660        let sx = self.grid.sx();
661        let end = end_inclusive.min(sx.saturating_sub(1));
662        let clears_whole_line = start == 0 && end == sx.saturating_sub(1);
663        if clears_whole_line {
664            self.grid.break_wrap_before_visible_line(y);
665        }
666        let Some(line) = self.grid.visible_line_mut(y) else {
667            return;
668        };
669        for x in start.min(sx)..=end {
670            if let Some(cell) = line.cell_mut(x) {
671                *cell = GridCell::blank_with_bg(bg);
672            }
673        }
674        Self::repair_wide_cells_on_line(line, sx, bg);
675        if clears_whole_line {
676            line.set_wrapped(false);
677        }
678        line.touch();
679    }
680
681    fn clear_screen_region(&mut self, start_y: u32, end_y_inclusive: u32, bg: i32) {
682        self.clear_selected_cells();
683        for y in start_y..=end_y_inclusive.min(self.grid.sy().saturating_sub(1)) {
684            self.grid.break_wrap_before_visible_line(y);
685            if let Some(line) = self.grid.visible_line_mut(y) {
686                line.clear(bg);
687            }
688        }
689    }
690
691    fn write_char(&mut self, ch: char, cell: &CellState, acs: bool) {
692        if self.grid.sx() == 0 || self.grid.sy() == 0 {
693            return;
694        }
695        self.clear_selected_cells();
696
697        let ch = if acs { acs::translate_acs(ch) } else { ch };
698        let requested_width = u32::from(self.utf8_config.width(ch));
699        if self.combine_char(ch) {
700            return;
701        }
702        // A cell wider than the entire viewport cannot be represented with
703        // its normal padding. Preserve the glyph as a one-column cell rather
704        // than creating an out-of-bounds wide owner.
705        let width = requested_width.clamp(1, self.grid.sx());
706
707        let wrap_enabled = (self.mode & mode::MODE_WRAP) != 0;
708        let mut automatic_wrap_continuation = self.pending_wrap && wrap_enabled;
709        self.apply_pending_wrap();
710
711        if (self.mode & mode::MODE_INSERT) != 0 {
712            let insert_x = self.logical_insert_column();
713            if insert_x >= self.grid.sx() {
714                if !wrap_enabled {
715                    return;
716                }
717                if let Some(line) = self.current_line_mut() {
718                    line.set_wrapped(true);
719                }
720                self.linefeed(false, COLOUR_DEFAULT);
721                self.cursor_x = 0;
722                automatic_wrap_continuation = true;
723            } else {
724                self.cursor_x = insert_x;
725            }
726        }
727
728        let write_would_cross_right_edge =
729            width > self.grid.sx() || self.cursor_x > self.grid.sx().saturating_sub(width);
730        if !wrap_enabled && width > 1 && write_would_cross_right_edge {
731            return;
732        }
733
734        // tmux shifts the current row before an auto-wrapped wide glyph moves
735        // to the next row; the no-wrap rejection above must leave it untouched.
736        if (self.mode & mode::MODE_INSERT) != 0 {
737            <Self as ScreenWriter>::insert_character(self, width, cell.bg());
738        }
739
740        if wrap_enabled && write_would_cross_right_edge {
741            let gap_start = self.cursor_column();
742            if let Some(line) = self.current_line_mut() {
743                line.mark_unused_suffix_as_reflow_gap(gap_start);
744                line.set_wrapped(true);
745            }
746            self.linefeed(false, COLOUR_DEFAULT);
747            self.cursor_x = 0;
748            automatic_wrap_continuation = true;
749        }
750
751        if self.cursor_y >= self.grid.sy()
752            || self.cursor_column() > self.grid.sx().saturating_sub(width)
753        {
754            return;
755        }
756
757        let x = self.cursor_column();
758        if x == 0 && !automatic_wrap_continuation {
759            self.break_previous_wrapped_line();
760        }
761        self.overwrite_for_write(x, width);
762        if let Some(line) = self.current_line_mut() {
763            if let Some(target) = line.cell_mut(x) {
764                *target = GridCell::from_state(
765                    ch,
766                    u8::try_from(width).unwrap_or(1),
767                    cell,
768                    GridCellFlags::default(),
769                );
770            }
771            for offset in 1..width {
772                if let Some(padding) = line.cell_mut(x + offset) {
773                    *padding = GridCell::from_state(' ', 0, cell, GridCellFlags::PADDING);
774                }
775            }
776            line.touch();
777        }
778
779        if wrap_enabled && x + width >= self.grid.sx() {
780            self.cursor_x = self.max_cursor_x();
781            self.pending_wrap = true;
782        } else {
783            self.cursor_x = x.saturating_add(width).min(self.max_cursor_x());
784            self.pending_wrap = false;
785        }
786    }
787
788    fn write_plain_ascii_run(&mut self, mut bytes: &[u8], cell: &CellState, acs: bool) -> bool {
789        if bytes.is_empty() {
790            return true;
791        }
792        if acs
793            || (self.mode & mode::MODE_INSERT) != 0
794            || cell.attr() != 0
795            || cell.fg() != COLOUR_DEFAULT
796            || cell.bg() != COLOUR_DEFAULT
797            || cell.us() != COLOUR_DEFAULT
798            || cell.link() != 0
799            || self.grid.sx() == 0
800            || self.grid.sy() == 0
801        {
802            return false;
803        }
804        self.clear_selected_cells();
805
806        while !bytes.is_empty() {
807            let automatic_wrap_continuation =
808                self.pending_wrap && (self.mode & mode::MODE_WRAP) != 0;
809            self.apply_pending_wrap();
810            if self.cursor_y >= self.grid.sy() {
811                self.write_ascii_run_slow(bytes, cell, acs);
812                return true;
813            }
814
815            let sx = self.grid.sx();
816            let x = self.cursor_column();
817            if x == 0 && !automatic_wrap_continuation {
818                self.break_previous_wrapped_line();
819            }
820
821            if (self.mode & mode::MODE_WRAP) == 0 {
822                let available = sx.saturating_sub(x) as usize;
823                if bytes.len() > available {
824                    self.write_ascii_run_slow(bytes, cell, acs);
825                    return true;
826                }
827            }
828
829            let writable = sx.saturating_sub(x) as usize;
830            if writable == 0 {
831                self.write_ascii_run_slow(bytes, cell, acs);
832                return true;
833            }
834            let chunk_len = bytes.len().min(writable);
835            let (chunk, rest) = bytes.split_at(chunk_len);
836            let wrote_chunk = self
837                .current_line_mut()
838                .is_some_and(|line| line.write_plain_ascii_run(x, chunk));
839            if !wrote_chunk {
840                // Earlier chunks in this run may already have changed prior
841                // rows. Consume only the untouched suffix through the general
842                // writer so the caller never replays bytes already written.
843                self.write_ascii_run_slow(bytes, cell, acs);
844                return true;
845            }
846
847            if (self.mode & mode::MODE_WRAP) != 0 && x + chunk_len as u32 >= sx {
848                self.cursor_x = self.max_cursor_x();
849                self.pending_wrap = true;
850            } else {
851                self.cursor_x = x.saturating_add(chunk_len as u32).min(self.max_cursor_x());
852                self.pending_wrap = false;
853            }
854            bytes = rest;
855        }
856        true
857    }
858
859    fn write_ascii_run_slow(&mut self, bytes: &[u8], cell: &CellState, acs: bool) {
860        for &byte in bytes {
861            self.write_char(char::from(byte), cell, acs);
862        }
863    }
864
865    fn break_previous_wrapped_line(&mut self) {
866        if self.cursor_y == 0 {
867            return;
868        }
869        if let Some(previous) = self.grid.visible_line_mut(self.cursor_y - 1) {
870            previous.set_wrapped(false);
871        }
872    }
873
874    fn combine_char(&mut self, ch: char) -> bool {
875        let mut x = self.cursor_column();
876        if self.pending_wrap {
877            x = self.max_cursor_x();
878        } else if x == 0 {
879            return matches!(
880                utf8_combine_char(None, ch, &self.utf8_config),
881                CombineResult::Discard
882            );
883        } else {
884            x -= 1;
885        }
886
887        let Some((target_x, previous)) = self.grid.visible_line(self.cursor_y).map(|line| {
888            let target_x = line.owning_cell_x(x).unwrap_or(x);
889            let previous = line
890                .cell(target_x)
891                .map(|cell| (cell.text().to_owned(), cell.width()));
892            (target_x, previous)
893        }) else {
894            return matches!(
895                utf8_combine_char(None, ch, &self.utf8_config),
896                CombineResult::Discard
897            );
898        };
899        let result = utf8_combine_char(
900            previous
901                .as_ref()
902                .map(|(text, width)| (text.as_str(), *width)),
903            ch,
904            &self.utf8_config,
905        );
906
907        match result {
908            CombineResult::Standalone { .. } => false,
909            CombineResult::Discard => true,
910            CombineResult::Combined { text, width } => {
911                let previous_width = previous.as_ref().map_or(0, |(_, width)| *width);
912                let available_width = self.grid.sx().saturating_sub(target_x).max(1);
913                let width = width.min(u8::try_from(available_width).unwrap_or(u8::MAX));
914                if width != previous_width {
915                    // Width promotion may replace the owner of an adjacent
916                    // wide cell. Clear that owner's displaced padding before
917                    // installing the new owner/padding pair.
918                    self.overwrite_for_write(target_x, u32::from(width));
919                }
920                let Some(line) = self.grid.visible_line_mut(self.cursor_y) else {
921                    return true;
922                };
923                if let Some(cell) = line.cell_mut(target_x) {
924                    cell.set_text(text);
925                    cell.set_width(width);
926                    if width == 2 {
927                        let mut padding = cell.clone();
928                        padding.set_text(" ".to_owned());
929                        padding.set_width(0);
930                        padding.set_flags(GridCellFlags::PADDING);
931                        if let Some(padding_cell) = line.cell_mut(target_x + 1) {
932                            *padding_cell = padding;
933                        }
934                    }
935                    line.touch();
936                }
937                if previous_width == 1 && width == 2 && !self.pending_wrap {
938                    let next_cursor = target_x.saturating_add(2);
939                    if next_cursor >= self.grid.sx() {
940                        self.cursor_x = self.max_cursor_x();
941                        self.pending_wrap = (self.mode & mode::MODE_WRAP) != 0;
942                    } else {
943                        self.cursor_x = next_cursor;
944                    }
945                }
946                true
947            }
948        }
949    }
950
951    fn parse_hyperlink(data: &str) -> (Option<String>, String) {
952        let (params, uri) = data.split_once(';').unwrap_or((data, ""));
953        let mut internal_id = None;
954        for part in params.split(':') {
955            if let Some(value) = part.strip_prefix("id=") {
956                internal_id = Some(value.to_owned());
957            }
958        }
959        (internal_id, uri.to_owned())
960    }
961}
962
963#[cfg(test)]
964#[path = "screen/capture_ascii_space_tests.rs"]
965mod capture_ascii_space_tests;
966#[cfg(test)]
967#[path = "screen/capture_mutation_tests.rs"]
968mod capture_mutation_tests;
969#[cfg(test)]
970#[path = "screen/erase_tests.rs"]
971mod erase_tests;
972#[cfg(test)]
973#[path = "screen/tests.rs"]
974mod tests;