1use serde::{Deserialize, Serialize};
2use vt100::{Color as TerminalColor, Screen};
3
4pub 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}