Skip to main content

forge_terminal/
repaint.rs

1//! The attach repaint: the visible screen as a VT stream a client can draw.
2//!
3//! `alacritty_terminal` has no formatted dump, so forge writes one. A client
4//! that attaches late, reconnects or falls behind is sent this instead of the
5//! bytes it missed: the grid's characters, their colours and attributes, and
6//! the cursor, as the escape sequences xterm.js already parses.
7//!
8//! **The repaint is the screen, not the history.** A reattaching client gets
9//! what is visible; the scrollback is the server's, and `terminal__read` is
10//! how an agent reads it.
11
12use alacritty_terminal::event::EventListener;
13use alacritty_terminal::grid::Dimensions;
14use alacritty_terminal::index::{Column, Line};
15use alacritty_terminal::term::cell::Flags;
16use alacritty_terminal::term::{Term, TermMode};
17use alacritty_terminal::vte::ansi::{Color, NamedColor};
18
19/// The visible screen as a VT stream, ending with the cursor where the
20/// program left it.
21///
22/// **The modes go with the cells.** A client attaching while vim holds the
23/// alternate screen must be told that before the cells land, or it draws them
24/// onto its normal buffer and the program's next frame corrupts both.
25pub fn repaint<T: EventListener>(term: &Term<T>) -> Vec<u8> {
26    let grid = term.grid();
27    let rows = grid.screen_lines();
28    let cols = grid.columns();
29    let mut out = Vec::with_capacity(rows * cols * 2);
30    let mode = term.mode();
31    out.extend_from_slice(b"\x1b[0m");
32    // The buffer is first: it decides where every cell lands.
33    toggle(&mut out, mode.contains(TermMode::ALT_SCREEN), "?1049");
34    toggle(&mut out, mode.contains(TermMode::BRACKETED_PASTE), "?2004");
35    toggle(&mut out, mode.contains(TermMode::APP_CURSOR), "?1");
36    toggle(&mut out, mode.contains(TermMode::MOUSE_REPORT_CLICK), "?1000");
37    toggle(&mut out, mode.contains(TermMode::MOUSE_MOTION), "?1002");
38    toggle(&mut out, mode.contains(TermMode::SGR_MOUSE), "?1006");
39    toggle(&mut out, mode.contains(TermMode::ALTERNATE_SCROLL), "?1007");
40    toggle(&mut out, mode.contains(TermMode::INSERT), "4");
41    toggle(&mut out, mode.contains(TermMode::LINE_FEED_NEW_LINE), "20");
42    // App keypad is its own pair, not a private mode.
43    out.extend_from_slice(if mode.contains(TermMode::APP_KEYPAD) { b"\x1b=" } else { b"\x1b>" });
44    out.extend_from_slice(b"\x1b[H\x1b[2J");
45    // **Origin mode off for the draw**: every row below is an absolute
46    // position, and left on it would place them inside the app's scroll
47    // region instead.
48    toggle(&mut out, false, "?6");
49    let mut current: Option<Style> = None;
50    for row in 0..rows {
51        // Position each row explicitly: a full-width row would otherwise
52        // wrap on its own and shift every row after it.
53        out.extend_from_slice(format!("\x1b[{};1H", row + 1).as_bytes());
54        let line = Line(i32::try_from(row).unwrap_or(i32::MAX));
55        for col in 0..cols {
56            let cell = &grid[line][Column(col)];
57            if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) {
58                continue;
59            }
60            let style = Style { fg: cell.fg, bg: cell.bg, flags: cell.flags };
61            if current != Some(style) {
62                out.extend_from_slice(b"\x1b[0m");
63                out.extend_from_slice(style.sgr().as_bytes());
64                current = Some(style);
65            }
66            let mut buf = [0u8; 4];
67            out.extend_from_slice(cell.c.encode_utf8(&mut buf).as_bytes());
68        }
69    }
70    out.extend_from_slice(b"\x1b[0m");
71    // **The app's origin mode is restored before the cursor, never after.**
72    // xterm.js homes the cursor when it reads the toggle, so a placement
73    // emitted before it is lost: the repaint ends at the top-left and the
74    // shell's next prompt redraw erases the screen from there.
75    toggle(&mut out, mode.contains(TermMode::ORIGIN), "?6");
76    let point = grid.cursor.point;
77    let last_row = i32::try_from(rows.saturating_sub(1)).unwrap_or(i32::MAX);
78    let row = point.line.0.clamp(0, last_row) + 1;
79    let col = point.column.0 + 1;
80    out.extend_from_slice(format!("\x1b[{row};{col}H").as_bytes());
81    toggle(&mut out, mode.contains(TermMode::SHOW_CURSOR), "?25");
82    out
83}
84
85/// One DEC private mode, set or reset.
86fn toggle(out: &mut Vec<u8>, on: bool, mode: &str) {
87    out.extend_from_slice(format!("\x1b[{mode}{}", if on { 'h' } else { 'l' }).as_bytes());
88}
89
90/// One cell's drawing attributes, compared to decide when to emit an SGR.
91#[derive(Clone, Copy, PartialEq, Eq)]
92struct Style {
93    fg: Color,
94    bg: Color,
95    flags: Flags,
96}
97
98impl Style {
99    fn sgr(self) -> String {
100        let mut parts = Vec::new();
101        if self.flags.contains(Flags::BOLD) {
102            parts.push("1".to_owned());
103        }
104        if self.flags.contains(Flags::DIM) {
105            parts.push("2".to_owned());
106        }
107        if self.flags.contains(Flags::ITALIC) {
108            parts.push("3".to_owned());
109        }
110        if self.flags.intersects(Flags::ALL_UNDERLINES) {
111            parts.push("4".to_owned());
112        }
113        if self.flags.contains(Flags::INVERSE) {
114            parts.push("7".to_owned());
115        }
116        if self.flags.contains(Flags::HIDDEN) {
117            parts.push("8".to_owned());
118        }
119        if self.flags.contains(Flags::STRIKEOUT) {
120            parts.push("9".to_owned());
121        }
122        parts.push(fg_sgr(self.fg));
123        parts.push(bg_sgr(self.bg));
124        format!("\x1b[{}m", parts.join(";"))
125    }
126}
127
128fn fg_sgr(color: Color) -> String {
129    match color {
130        Color::Named(named) => match named {
131            NamedColor::Foreground
132            | NamedColor::Background
133            | NamedColor::Cursor
134            | NamedColor::BrightForeground
135            | NamedColor::DimForeground => "39".to_owned(),
136            other => {
137                let index = named_index(other);
138                if index < 8 { format!("{}", 30 + index) } else { format!("{}", 90 + index - 8) }
139            }
140        },
141        Color::Indexed(index) => format!("38;5;{index}"),
142        Color::Spec(rgb) => format!("38;2;{};{};{}", rgb.r, rgb.g, rgb.b),
143    }
144}
145
146fn bg_sgr(color: Color) -> String {
147    match color {
148        Color::Named(named) => match named {
149            NamedColor::Background
150            | NamedColor::Foreground
151            | NamedColor::Cursor
152            | NamedColor::BrightForeground
153            | NamedColor::DimForeground => "49".to_owned(),
154            other => {
155                let index = named_index(other);
156                if index < 8 { format!("{}", 40 + index) } else { format!("{}", 100 + index - 8) }
157            }
158        },
159        Color::Indexed(index) => format!("48;5;{index}"),
160        Color::Spec(rgb) => format!("48;2;{};{};{}", rgb.r, rgb.g, rgb.b),
161    }
162}
163
164/// The 0..15 index of a named colour, folding the dim variants onto their
165/// base colour.
166fn named_index(named: NamedColor) -> u8 {
167    match named {
168        NamedColor::Black | NamedColor::DimBlack => 0,
169        NamedColor::Red | NamedColor::DimRed => 1,
170        NamedColor::Green | NamedColor::DimGreen => 2,
171        NamedColor::Yellow | NamedColor::DimYellow => 3,
172        NamedColor::Blue | NamedColor::DimBlue => 4,
173        NamedColor::Magenta | NamedColor::DimMagenta => 5,
174        NamedColor::Cyan | NamedColor::DimCyan => 6,
175        NamedColor::White
176        | NamedColor::DimWhite
177        | NamedColor::Foreground
178        | NamedColor::Background
179        | NamedColor::Cursor
180        | NamedColor::BrightForeground
181        | NamedColor::DimForeground => 7,
182        NamedColor::BrightBlack => 8,
183        NamedColor::BrightRed => 9,
184        NamedColor::BrightGreen => 10,
185        NamedColor::BrightYellow => 11,
186        NamedColor::BrightBlue => 12,
187        NamedColor::BrightMagenta => 13,
188        NamedColor::BrightCyan => 14,
189        NamedColor::BrightWhite => 15,
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use alacritty_terminal::event::VoidListener;
197    use alacritty_terminal::grid::Dimensions;
198    use alacritty_terminal::term::Config;
199    use alacritty_terminal::vte::ansi::Processor;
200
201    struct Size {
202        cols: usize,
203        rows: usize,
204    }
205
206    impl Dimensions for Size {
207        fn total_lines(&self) -> usize {
208            self.rows
209        }
210        fn screen_lines(&self) -> usize {
211            self.rows
212        }
213        fn columns(&self) -> usize {
214            self.cols
215        }
216    }
217
218    fn term_with(bytes: &[u8], cols: usize, rows: usize) -> Term<VoidListener> {
219        let mut term = Term::new(
220            Config { scrolling_history: 100, ..Default::default() },
221            &Size { cols, rows },
222            VoidListener,
223        );
224        let mut parser: Processor = Processor::new();
225        parser.advance(&mut term, bytes);
226        term
227    }
228
229    /// **The repaint reproduces the screen.** Feeding it into a fresh
230    /// terminal yields the same text and cursor, which is what a reattaching
231    /// client needs.
232    #[test]
233    fn the_repaint_reproduces_the_screen() {
234        let source = term_with(b"hello\r\n\x1b[31mred\x1b[0m world", 20, 5);
235        let bytes = repaint(&source);
236        let repainted = term_with(&bytes, 20, 5);
237        let before = crate::screen::extract(&source);
238        let after = crate::screen::extract(&repainted);
239        assert_eq!(after.text, before.text, "the text survives the repaint");
240        assert_eq!(after.cursor, before.cursor, "and so does the cursor");
241    }
242
243    /// A coloured cell carries its colour, so a reattaching client draws the
244    /// same screen rather than a monochrome one.
245    #[test]
246    fn a_coloured_cell_carries_its_colour() {
247        let term = term_with(b"\x1b[31mred", 20, 5);
248        let bytes = repaint(&term);
249        let text = String::from_utf8_lossy(&bytes);
250        assert!(text.contains("\x1b[0m\x1b[31;49m"), "the red foreground is emitted: {text:?}");
251    }
252
253    /// A truecolour cell carries its RGB, not a nearest named colour.
254    #[test]
255    fn a_truecolour_cell_carries_its_rgb() {
256        let term = term_with(b"\x1b[38;2;1;2;3mX", 20, 5);
257        let bytes = repaint(&term);
258        let text = String::from_utf8_lossy(&bytes);
259        assert!(text.contains("38;2;1;2;3"), "{text:?}");
260    }
261
262    /// **The modes ride the repaint.** A client attaching into a full-screen
263    /// program is put on the alternate screen, with its cursor hidden and its
264    /// mouse and paste modes as the program set them.
265    #[test]
266    fn the_repaint_carries_the_terminal_modes() {
267        let source =
268            term_with(b"\x1b[?1049h\x1b[?25l\x1b[?2004h\x1b[?1000h\x1b[?1h\x1b[4hX", 20, 5);
269        let repainted = term_with(&repaint(&source), 20, 5);
270        let mode = *repainted.mode();
271        assert!(mode.contains(TermMode::ALT_SCREEN), "alternate screen: {mode:?}");
272        assert!(!mode.contains(TermMode::SHOW_CURSOR), "hidden cursor: {mode:?}");
273        assert!(mode.contains(TermMode::BRACKETED_PASTE), "bracketed paste: {mode:?}");
274        assert!(mode.contains(TermMode::MOUSE_REPORT_CLICK), "mouse clicks: {mode:?}");
275        assert!(mode.contains(TermMode::APP_CURSOR), "application cursor: {mode:?}");
276        assert!(mode.contains(TermMode::INSERT), "insert mode: {mode:?}");
277    }
278
279    /// **Origin mode is set before the cursor, and cleared before the rows.**
280    /// xterm.js homes the cursor when it reads the toggle, so a placement
281    /// emitted before it is lost: the repaint ends at the top-left and the
282    /// shell's next prompt redraw erases the whole screen from there. And the
283    /// rows are absolute positions, which only hold with the mode off.
284    #[test]
285    fn the_repaint_sets_origin_mode_around_the_draw() {
286        let term = term_with(b"\x1b[?6hREADY", 20, 5);
287        let bytes = repaint(&term);
288        let text = String::from_utf8_lossy(&bytes);
289        let off = text.find("\x1b[?6l").expect("origin mode is cleared for the draw");
290        let first_row = text.find("\x1b[1;1H").expect("the first row is positioned");
291        assert!(off < first_row, "the rows are positioned with the mode off: {text:?}");
292        let on = text.rfind("\x1b[?6h").expect("the app's origin mode is restored");
293        let cursor = text.rfind("\x1b[1;6H").expect("the cursor is placed after the text");
294        assert!(on < cursor, "the cursor placement follows the origin toggle: {text:?}");
295    }
296
297    /// The repaint ends with the cursor where the program left it, so a
298    /// client that attaches mid-line types in the right place.
299    #[test]
300    fn the_repaint_ends_at_the_cursor() {
301        let term = term_with(b"abc", 20, 5);
302        let bytes = repaint(&term);
303        let text = String::from_utf8_lossy(&bytes);
304        let at = text.find("\x1b[1;4H");
305        assert!(
306            at.is_some_and(|at| text[..at].contains("abc")),
307            "the cursor is placed after 'abc': {text:?}",
308        );
309    }
310}