Skip to main content

terminal_control/
frame.rs

1use serde::{Deserialize, Serialize};
2use vt100::{Color as TerminalColor, Screen};
3
4/// Schema version written in every structured terminal frame.
5pub const FORMAT_VERSION: u8 = 1;
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Deserialize, Serialize)]
8pub struct Color {
9    pub r: u8,
10    pub g: u8,
11    pub b: u8,
12}
13
14pub const DEFAULT_FOREGROUND: Color = Color {
15    r: 201,
16    g: 209,
17    b: 217,
18};
19pub const DEFAULT_BACKGROUND: Color = Color {
20    r: 13,
21    g: 17,
22    b: 23,
23};
24
25impl Color {
26    pub fn css(self) -> String {
27        format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
28    }
29}
30
31#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Deserialize, Serialize)]
32pub struct Attributes {
33    pub bold: bool,
34    pub italic: bool,
35    pub faint: bool,
36    pub invisible: bool,
37    pub strikethrough: bool,
38    pub overline: bool,
39    pub underline: Option<Underline>,
40}
41
42#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Deserialize, Serialize)]
43#[serde(rename_all = "lowercase")]
44pub enum Underline {
45    Single,
46}
47
48#[derive(Clone, Debug, PartialEq, Eq, Hash, Deserialize, Serialize)]
49pub struct Cell {
50    pub x: u16,
51    pub y: u16,
52    pub text: String,
53    pub width: u16,
54    pub foreground: Color,
55    pub background: Color,
56    pub attributes: Attributes,
57}
58
59#[derive(Clone, Debug, PartialEq, Eq, Hash, Deserialize, Serialize)]
60pub struct Cursor {
61    pub x: u16,
62    pub y: u16,
63    pub color: Color,
64    pub blinking: bool,
65}
66
67#[derive(Clone, Debug, PartialEq, Eq, Hash, Deserialize, Serialize)]
68pub struct Frame {
69    pub version: u8,
70    pub cols: u16,
71    pub rows: u16,
72    pub foreground: Color,
73    pub background: Color,
74    pub cursor: Option<Cursor>,
75    pub cells: Vec<Cell>,
76}
77
78impl Frame {
79    pub fn has_visible_content(&self) -> bool {
80        self.cells
81            .iter()
82            .any(|cell| !cell.text.trim().is_empty() || cell.background != self.background)
83    }
84
85    pub fn text(&self) -> String {
86        let mut rows =
87            vec![vec![String::from(" "); usize::from(self.cols)]; usize::from(self.rows)];
88        for cell in &self.cells {
89            if cell.text.is_empty() || cell.x >= self.cols || cell.y >= self.rows {
90                continue;
91            }
92            rows[usize::from(cell.y)][usize::from(cell.x)] = cell.text.clone();
93            if cell.width == 2 && cell.x + 1 < self.cols {
94                rows[usize::from(cell.y)][usize::from(cell.x + 1)].clear();
95            }
96        }
97        rows.into_iter()
98            .map(|line| line.join("").trim_end().to_owned())
99            .collect::<Vec<_>>()
100            .join("\n")
101            .trim_end()
102            .to_owned()
103    }
104}
105
106pub fn from_screen(screen: &Screen) -> Frame {
107    let (rows, cols) = screen.size();
108    let foreground = DEFAULT_FOREGROUND;
109    let background = DEFAULT_BACKGROUND;
110    let mut cells = Vec::new();
111    for y in 0..rows {
112        for x in 0..cols {
113            let Some(cell) = screen.cell(y, x) else {
114                continue;
115            };
116            if cell.is_wide_continuation() {
117                continue;
118            }
119            let mut cell_foreground = resolve_color(cell.fgcolor(), foreground);
120            let mut cell_background = resolve_color(cell.bgcolor(), background);
121            if cell.inverse() {
122                std::mem::swap(&mut cell_foreground, &mut cell_background);
123            }
124            let attributes = Attributes {
125                bold: cell.bold(),
126                italic: cell.italic(),
127                faint: cell.dim(),
128                invisible: false,
129                strikethrough: false,
130                overline: false,
131                underline: cell.underline().then_some(Underline::Single),
132            };
133            let text = cell.contents().to_owned();
134            if !text.is_empty() || cell_background != background || has_attributes(&attributes) {
135                cells.push(Cell {
136                    x,
137                    y,
138                    text,
139                    width: if cell.is_wide() { 2 } else { 1 },
140                    foreground: cell_foreground,
141                    background: cell_background,
142                    attributes,
143                });
144            }
145        }
146    }
147    let (cursor_y, cursor_x) = screen.cursor_position();
148    Frame {
149        version: FORMAT_VERSION,
150        cols,
151        rows,
152        foreground,
153        background,
154        cursor: (!screen.hide_cursor()).then_some(Cursor {
155            x: cursor_x,
156            y: cursor_y,
157            color: foreground,
158            blinking: false,
159        }),
160        cells,
161    }
162}
163
164fn has_attributes(attributes: &Attributes) -> bool {
165    attributes.bold || attributes.italic || attributes.faint || attributes.underline.is_some()
166}
167
168fn resolve_color(color: TerminalColor, default: Color) -> Color {
169    match color {
170        TerminalColor::Default => default,
171        TerminalColor::Rgb(r, g, b) => Color { r, g, b },
172        TerminalColor::Idx(index) => indexed_color(index),
173    }
174}
175
176fn indexed_color(index: u8) -> Color {
177    const ANSI: [Color; 16] = [
178        Color { r: 0, g: 0, b: 0 },
179        Color {
180            r: 205,
181            g: 49,
182            b: 49,
183        },
184        Color {
185            r: 13,
186            g: 188,
187            b: 121,
188        },
189        Color {
190            r: 229,
191            g: 229,
192            b: 16,
193        },
194        Color {
195            r: 36,
196            g: 114,
197            b: 200,
198        },
199        Color {
200            r: 188,
201            g: 63,
202            b: 188,
203        },
204        Color {
205            r: 17,
206            g: 168,
207            b: 205,
208        },
209        Color {
210            r: 229,
211            g: 229,
212            b: 229,
213        },
214        Color {
215            r: 102,
216            g: 102,
217            b: 102,
218        },
219        Color {
220            r: 241,
221            g: 76,
222            b: 76,
223        },
224        Color {
225            r: 35,
226            g: 209,
227            b: 139,
228        },
229        Color {
230            r: 245,
231            g: 245,
232            b: 67,
233        },
234        Color {
235            r: 59,
236            g: 142,
237            b: 234,
238        },
239        Color {
240            r: 214,
241            g: 112,
242            b: 214,
243        },
244        Color {
245            r: 41,
246            g: 184,
247            b: 219,
248        },
249        Color {
250            r: 255,
251            g: 255,
252            b: 255,
253        },
254    ];
255    if index < 16 {
256        return ANSI[usize::from(index)];
257    }
258    if index >= 232 {
259        let value = 8 + (index - 232) * 10;
260        return Color {
261            r: value,
262            g: value,
263            b: value,
264        };
265    }
266    let value = index - 16;
267    let channel = |component: u8| {
268        if component == 0 {
269            0
270        } else {
271            55 + component * 40
272        }
273    };
274    Color {
275        r: channel(value / 36),
276        g: channel((value % 36) / 6),
277        b: channel(value % 6),
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn extracts_truecolor_backgrounds_and_text() {
287        let mut parser = vt100::Parser::new(3, 20, 0);
288        parser.process(b"\x1b[48;2;30;34;42m\x1b[38;2;196;215;240m Hi \x1b[0m");
289
290        let frame = from_screen(parser.screen());
291
292        assert_eq!(frame.text(), " Hi");
293        assert_eq!(
294            frame.cells[0].background,
295            Color {
296                r: 30,
297                g: 34,
298                b: 42
299            }
300        );
301        assert_eq!(
302            frame.cells[0].foreground,
303            Color {
304                r: 196,
305                g: 215,
306                b: 240
307            }
308        );
309    }
310
311    #[test]
312    fn maps_xterm_color_cube_values() {
313        assert_eq!(
314            indexed_color(1),
315            Color {
316                r: 205,
317                g: 49,
318                b: 49
319            }
320        );
321        assert_eq!(
322            indexed_color(214),
323            Color {
324                r: 255,
325                g: 175,
326                b: 0
327            }
328        );
329        assert_eq!(
330            indexed_color(244),
331            Color {
332                r: 128,
333                g: 128,
334                b: 128
335            }
336        );
337    }
338
339    #[test]
340    fn background_paint_is_visible_content() {
341        let mut parser = vt100::Parser::new(1, 2, 0);
342        parser.process(b"\x1b[48;2;30;34;42m ");
343
344        assert!(from_screen(parser.screen()).has_visible_content());
345    }
346
347    #[test]
348    fn text_ignores_out_of_bounds_external_cells() {
349        let mut frame = from_screen(vt100::Parser::new(1, 1, 0).screen());
350        frame.cells.push(Cell {
351            x: 2,
352            y: 0,
353            text: "x".to_owned(),
354            width: 1,
355            foreground: DEFAULT_FOREGROUND,
356            background: DEFAULT_BACKGROUND,
357            attributes: Attributes::default(),
358        });
359
360        assert_eq!(frame.text(), "");
361    }
362}