1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
//! Virtual screen model using vt100.
/// Virtual screen backed by vt100.
pub struct Screen {
parser: vt100::Parser,
}
impl Screen {
/// Create a new screen with the given dimensions.
#[must_use]
pub fn new(rows: u16, cols: u16) -> Self {
Self {
parser: vt100::Parser::new(rows, cols, 0),
}
}
/// Process output bytes through the terminal parser.
pub fn process(&mut self, data: &[u8]) {
self.parser.process(data);
}
/// Get the current screen contents as a string.
/// Each row is separated by a newline.
#[must_use]
pub fn contents(&self) -> String {
self.parser.screen().contents()
}
/// Get the screen contents with ANSI formatting codes preserved.
/// This returns the screen text with color/style escape codes but without
/// cursor positioning or screen-clearing sequences.
#[must_use]
#[allow(clippy::similar_names)] // fg/bg are intentionally similar
#[allow(clippy::too_many_lines)] // Complex function, splitting would reduce clarity
pub fn contents_formatted(&self) -> String {
use std::fmt::Write;
let screen = self.parser.screen();
let (rows, cols) = screen.size();
let mut result = String::new();
let mut current_fg: Option<vt100::Color> = None;
let mut current_bg: Option<vt100::Color> = None;
let mut current_bold = false;
let mut current_dim = false;
let mut current_italic = false;
let mut current_underline = false;
let mut current_inverse = false;
let mut trailing_empty_rows = 0;
for row in 0..rows {
let mut row_text = String::new();
let mut row_has_content = false;
let mut trailing_spaces = 0;
for col in 0..cols {
if let Some(cell) = screen.cell(row, col) {
// Skip wide character continuations
if cell.is_wide_continuation() {
continue;
}
let contents = cell.contents();
// Track if we need to emit formatting changes
let fg = cell.fgcolor();
let bg = cell.bgcolor();
let bold = cell.bold();
let dim = cell.dim();
let italic = cell.italic();
let underline = cell.underline();
let inverse = cell.inverse();
// Check if attributes changed
let attrs_changed = current_fg != Some(fg)
|| current_bg != Some(bg)
|| current_bold != bold
|| current_dim != dim
|| current_italic != italic
|| current_underline != underline
|| current_inverse != inverse;
if attrs_changed && cell.has_contents() {
// Emit reset and new attributes
let mut sgr = vec!["0".to_string()]; // Reset
// Foreground color
match fg {
vt100::Color::Default => {}
vt100::Color::Idx(n) => {
if n < 8 {
sgr.push(format!("{}", 30 + n));
} else if n < 16 {
sgr.push(format!("{}", 90 + n - 8));
} else {
sgr.push(format!("38;5;{n}"));
}
}
vt100::Color::Rgb(r, g, b) => {
sgr.push(format!("38;2;{r};{g};{b}"));
}
}
// Background color
match bg {
vt100::Color::Default => {}
vt100::Color::Idx(n) => {
if n < 8 {
sgr.push(format!("{}", 40 + n));
} else if n < 16 {
sgr.push(format!("{}", 100 + n - 8));
} else {
sgr.push(format!("48;5;{n}"));
}
}
vt100::Color::Rgb(r, g, b) => {
sgr.push(format!("48;2;{r};{g};{b}"));
}
}
if bold {
sgr.push("1".to_string());
}
if dim {
sgr.push("2".to_string());
}
if italic {
sgr.push("3".to_string());
}
if underline {
sgr.push("4".to_string());
}
if inverse {
sgr.push("7".to_string());
}
// Only emit if we have non-default attributes
if sgr.len() > 1 || current_fg.is_some() {
// First flush any trailing spaces before the escape
row_text.push_str(&" ".repeat(trailing_spaces));
trailing_spaces = 0;
let _ = write!(row_text, "\x1b[{}m", sgr.join(";"));
}
current_fg = Some(fg);
current_bg = Some(bg);
current_bold = bold;
current_dim = dim;
current_italic = italic;
current_underline = underline;
current_inverse = inverse;
}
if contents.is_empty() || contents == " " {
trailing_spaces += 1;
} else {
// Flush trailing spaces
row_text.push_str(&" ".repeat(trailing_spaces));
trailing_spaces = 0;
row_text.push_str(contents);
row_has_content = true;
}
}
}
// Don't include trailing spaces on lines
if row_has_content {
// Flush any pending empty rows
for _ in 0..trailing_empty_rows {
result.push('\n');
}
trailing_empty_rows = 0;
result.push_str(&row_text);
result.push('\n');
} else {
trailing_empty_rows += 1;
}
}
// Remove trailing newline if present
if result.ends_with('\n') {
result.pop();
}
// Reset attributes at the end if we changed any
if current_fg.is_some()
&& (current_fg != Some(vt100::Color::Default)
|| current_bg != Some(vt100::Color::Default)
|| current_bold
|| current_dim
|| current_italic
|| current_underline
|| current_inverse)
{
result.push_str("\x1b[0m");
}
result
}
/// Render the full screen as escape sequences suitable for initializing a terminal.
/// This outputs: clear screen, draw content with colors, position cursor.
/// Used by attach to initialize the display before streaming live updates.
#[must_use]
pub fn render_full_screen(&self) -> Vec<u8> {
use std::fmt::Write;
let mut result = String::new();
// Clear screen and position cursor home
// \e[2J = clear entire screen
// \e[H = cursor home (1,1)
result.push_str("\x1b[2J\x1b[H");
// Use contents_formatted() which correctly handles all formatting
// including wide characters, colors, etc.
result.push_str(&self.contents_formatted());
// Ensure we end with reset attributes
result.push_str("\x1b[0m");
// Position cursor where it should be
let (cursor_row, cursor_col) = self.parser.screen().cursor_position();
let _ = write!(result, "\x1b[{};{}H", cursor_row + 1, cursor_col + 1);
result.into_bytes()
}
/// Get the cursor position (row, col), 0-indexed.
#[must_use]
pub fn cursor_position(&self) -> (u16, u16) {
self.parser.screen().cursor_position()
}
/// Get the screen size (rows, cols).
#[must_use]
pub fn size(&self) -> (u16, u16) {
self.parser.screen().size()
}
/// Check if the alternate screen is active.
#[must_use]
pub fn alternate_screen(&self) -> bool {
self.parser.screen().alternate_screen()
}
/// Resize the screen.
pub fn resize(&mut self, rows: u16, cols: u16) {
// vt100::Parser doesn't have a resize method, so we create a new parser
// and copy the contents. This is a limitation we may need to work around.
self.parser = vt100::Parser::new(rows, cols, 0);
}
/// Get a snapshot of the screen as normalized text.
/// Strips ANSI codes and trailing whitespace.
#[must_use]
pub fn snapshot(&self) -> String {
// contents() already strips formatting
let contents = self.parser.screen().contents();
let mut lines: Vec<&str> = contents.lines().collect();
// Trim trailing empty lines
while lines.last().is_some_and(|l| l.trim().is_empty()) {
lines.pop();
}
// Trim trailing whitespace from each line
lines
.iter()
.map(|l| l.trim_end())
.collect::<Vec<_>>()
.join("\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_output() {
let mut screen = Screen::new(24, 80);
screen.process(b"Hello, World!");
assert!(screen.contents().contains("Hello, World!"));
}
#[test]
fn test_cursor_movement() {
let mut screen = Screen::new(24, 80);
screen.process(b"ABC\rX");
// \r moves cursor to beginning of line, X overwrites A
assert!(screen.contents().starts_with("XBC"));
}
#[test]
fn test_newlines() {
let mut screen = Screen::new(24, 80);
screen.process(b"line1\nline2\nline3");
let snapshot = screen.snapshot();
assert!(snapshot.contains("line1"));
assert!(snapshot.contains("line2"));
assert!(snapshot.contains("line3"));
}
#[test]
fn test_ansi_colors_stripped() {
let mut screen = Screen::new(24, 80);
// Red text: ESC[31m Hello ESC[0m
screen.process(b"\x1b[31mHello\x1b[0m");
let snapshot = screen.snapshot();
assert_eq!(snapshot.trim(), "Hello");
assert!(!snapshot.contains("\x1b"));
}
#[test]
fn test_contents_formatted_preserves_colors() {
let mut screen = Screen::new(24, 80);
// Red "RED", reset, space, green "GREEN"
screen.process(b"\x1b[31mRED\x1b[0m \x1b[32mGREEN\x1b[0m");
let formatted = screen.contents_formatted();
eprintln!("formatted output: {:?}", formatted);
// Should contain the text
assert!(formatted.contains("RED"));
assert!(formatted.contains("GREEN"));
// Should contain ANSI codes
assert!(formatted.contains("\x1b["));
// Should have red color code (31)
assert!(formatted.contains("31"));
// Should have green color code (32)
assert!(formatted.contains("32"));
// Should NOT have cursor positioning (like ESC[H or ESC[J or ESC[row;colH)
assert!(!formatted.contains("\x1b[H"));
assert!(!formatted.contains("\x1b[J"));
assert!(!formatted.contains("\x1b[?25h")); // show cursor
// Text should be on one line (no spurious newlines in the middle)
let lines: Vec<&str> = formatted.lines().collect();
assert_eq!(lines.len(), 1, "Expected 1 line, got: {:?}", lines);
}
}