1use crossterm::style::{Color, force_color_output};
2use std::io::{self, Write};
3
4use crate::Style;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct Cell {
9 pub ch: char,
10 pub style: Style,
11}
12
13impl Default for Cell {
14 fn default() -> Self {
15 Self { ch: ' ', style: Style::default() }
16 }
17}
18
19impl Cell {
20 fn new(ch: char, style: Style) -> Self {
21 Self { ch, style }
22 }
23}
24
25#[derive(Debug, Clone)]
33pub struct TestTerminal {
34 buffer: Vec<Vec<Cell>>,
36 scrollback: Vec<Vec<Cell>>,
38 cursor: (u16, u16),
40 saved_cursor: Option<(u16, u16)>,
42 size: (u16, u16),
44 escape_buffer: Vec<u8>,
46 pending_wrap: bool,
48 current_style: Style,
50}
51
52impl TestTerminal {
53 pub fn new(columns: u16, rows: u16) -> Self {
55 force_color_output(true);
56 let buffer = vec![vec![Cell::default(); columns as usize]; rows as usize];
57 Self {
58 buffer,
59 scrollback: Vec::new(),
60 cursor: (0, 0),
61 saved_cursor: None,
62 size: (columns, rows),
63 escape_buffer: Vec::new(),
64 pending_wrap: false,
65 current_style: Style::default(),
66 }
67 }
68
69 pub fn resize(&mut self, columns: u16, rows: u16) {
71 let columns = columns.max(1);
72 let rows = rows.max(1);
73 self.buffer = vec![vec![Cell::default(); columns as usize]; rows as usize];
74 self.scrollback.clear();
75 self.size = (columns, rows);
76 self.cursor = (0, rows.saturating_sub(1));
77 self.saved_cursor = None;
78 self.pending_wrap = false;
79 }
80
81 pub fn resize_preserving_transcript(&mut self, columns: u16, rows: u16) {
83 let transcript = self.get_transcript_lines();
84 let wrapped = Self::reflow_lines(&transcript, columns);
85 self.apply_reflowed_lines(columns, rows, &wrapped);
86 }
87
88 fn reflow_lines(lines: &[String], columns: u16) -> Vec<String> {
89 let mut wrapped = Vec::new();
90 let width = columns.max(1) as usize;
91
92 for line in lines {
93 if line.is_empty() {
94 wrapped.push(String::new());
95 continue;
96 }
97
98 let chars: Vec<char> = line.chars().collect();
99 for chunk in chars.chunks(width) {
100 wrapped.push(chunk.iter().collect());
101 }
102 }
103
104 if wrapped.is_empty() {
105 wrapped.push(String::new());
106 }
107
108 wrapped
109 }
110
111 fn apply_reflowed_lines(&mut self, columns: u16, rows: u16, wrapped: &[String]) {
112 let rows_usize = rows.max(1) as usize;
113 let split_at = wrapped.len().saturating_sub(rows_usize);
114 let (scrollback, visible) = wrapped.split_at(split_at);
115
116 self.scrollback = scrollback.iter().map(|line| Self::line_to_row(line, columns)).collect();
117
118 self.buffer = visible.iter().map(|line| Self::line_to_row(line, columns)).collect();
119
120 while self.buffer.len() < rows_usize {
121 self.buffer.push(vec![Cell::default(); columns as usize]);
122 }
123
124 self.size = (columns, rows);
125 self.cursor = (0, rows.saturating_sub(1));
126 self.saved_cursor = None;
127 self.pending_wrap = false;
128 }
129
130 fn line_to_row(line: &str, columns: u16) -> Vec<Cell> {
131 let mut row: Vec<Cell> =
132 line.chars().take(columns as usize).map(|ch| Cell::new(ch, Style::default())).collect();
133 row.resize(columns as usize, Cell::default());
134 row
135 }
136
137 pub fn get_lines(&self) -> Vec<String> {
139 self.buffer.iter().map(|cells| cells.iter().map(|c| c.ch).collect::<String>().trim_end().to_string()).collect()
140 }
141
142 pub fn get_transcript_lines(&self) -> Vec<String> {
144 self.scrollback
145 .iter()
146 .chain(self.buffer.iter())
147 .map(|cells| cells.iter().map(|c| c.ch).collect::<String>().trim_end().to_string())
148 .collect()
149 }
150
151 #[allow(dead_code)]
153 pub fn cursor_position(&self) -> (u16, u16) {
154 self.cursor
155 }
156
157 pub fn get_style_at(&self, row: usize, col: usize) -> Style {
159 self.buffer.get(row).and_then(|r| r.get(col)).map_or(Style::default(), |c| c.style)
160 }
161
162 pub fn style_of_text(&self, row: usize, text: &str) -> Option<Style> {
166 let row_data = self.buffer.get(row)?;
167 let row_text: String = row_data.iter().map(|c| c.ch).collect();
168 let byte_offset = row_text.find(text)?;
169 let char_index = row_text[..byte_offset].chars().count();
171 Some(row_data[char_index].style)
172 }
173
174 pub fn clear(&mut self) {
176 for row in &mut self.buffer {
177 for cell in row {
178 *cell = Cell::default();
179 }
180 }
181 }
182
183 pub fn clear_line(&mut self) {
185 if let Some(row) = self.buffer.get_mut(self.cursor.1 as usize) {
186 for cell in row {
187 *cell = Cell::default();
188 }
189 }
190 }
191
192 pub fn move_to(&mut self, col: u16, row: u16) {
194 self.cursor = (col.min(self.size.0.saturating_sub(1)), row.min(self.size.1.saturating_sub(1)));
195 self.pending_wrap = false;
196 }
197
198 pub fn move_to_column(&mut self, col: u16) {
200 self.cursor.0 = col.min(self.size.0.saturating_sub(1));
201 self.pending_wrap = false;
202 }
203
204 pub fn move_left(&mut self, n: u16) {
206 self.cursor.0 = self.cursor.0.saturating_sub(n);
207 self.pending_wrap = false;
208 }
209
210 pub fn move_right(&mut self, n: u16) {
212 self.cursor.0 = (self.cursor.0 + n).min(self.size.0.saturating_sub(1));
213 self.pending_wrap = false;
214 }
215
216 fn write_char(&mut self, ch: char) {
218 match ch {
219 '\n' => {
220 self.pending_wrap = false;
221 if self.cursor.1 >= self.size.1.saturating_sub(1) {
222 let removed = self.buffer.remove(0);
223 self.scrollback.push(removed);
224 self.buffer.push(vec![Cell::default(); self.size.0 as usize]);
225 } else {
226 self.cursor.1 += 1;
227 }
228 self.cursor.0 = 0;
229 }
230 '\r' => {
231 self.cursor.0 = 0;
232 self.pending_wrap = false;
233 }
234 '\t' => {
235 for _ in 0..4 {
236 self.write_char_at_cursor(' ');
237 }
238 }
239 _ => {
240 self.write_char_at_cursor(ch);
241 }
242 }
243 }
244
245 fn write_char_at_cursor(&mut self, ch: char) {
250 if self.pending_wrap {
251 self.pending_wrap = false;
252 self.cursor.0 = 0;
253 if self.cursor.1 >= self.size.1.saturating_sub(1) {
254 let removed = self.buffer.remove(0);
255 self.scrollback.push(removed);
256 self.buffer.push(vec![Cell::default(); self.size.0 as usize]);
257 } else {
258 self.cursor.1 += 1;
259 }
260 }
261
262 if let Some(row) = self.buffer.get_mut(self.cursor.1 as usize)
263 && let Some(cell) = row.get_mut(self.cursor.0 as usize)
264 {
265 *cell = Cell::new(ch, self.current_style);
266 self.cursor.0 += 1;
267 if self.cursor.0 >= self.size.0 {
268 self.cursor.0 = self.size.0 - 1;
269 self.pending_wrap = true;
270 }
271 }
272 }
273
274 fn process_bytes(&mut self, buf: &[u8]) {
276 let s = String::from_utf8_lossy(buf);
277 let mut chars = s.chars().peekable();
278
279 while let Some(ch) = chars.next() {
280 if ch == '\x1b' {
281 if chars.peek() == Some(&'[') {
282 chars.next();
283 self.process_csi_sequence(&mut chars);
284 } else if chars.peek() == Some(&'7') {
285 chars.next();
286 self.saved_cursor = Some(self.cursor);
287 } else if chars.peek() == Some(&'8') {
288 chars.next();
289 if let Some(saved) = self.saved_cursor {
290 self.cursor = saved;
291 }
292 }
293 } else {
294 self.write_char(ch);
295 }
296 }
297 }
298
299 #[allow(clippy::too_many_lines)]
301 fn process_csi_sequence(&mut self, chars: &mut std::iter::Peekable<std::str::Chars>) {
302 let private_mode = if chars.peek() == Some(&'?') {
303 chars.next();
304 true
305 } else {
306 false
307 };
308
309 let mut params = String::new();
310
311 while let Some(&ch) = chars.peek() {
312 if ch.is_ascii_digit() || ch == ';' || ch == ':' {
313 params.push(ch);
314 chars.next();
315 } else {
316 break;
317 }
318 }
319
320 if private_mode {
321 chars.next();
322 return;
323 }
324
325 if let Some(cmd) = chars.next() {
326 match cmd {
327 'H' | 'f' => {
328 let parts: Vec<u16> = params.split(';').filter_map(|s| s.parse().ok()).collect();
329 let row = parts.first().copied().unwrap_or(1).saturating_sub(1);
330 let col = parts.get(1).copied().unwrap_or(1).saturating_sub(1);
331 self.move_to(col, row);
332 }
333 'A' => {
334 let n = params.parse().unwrap_or(1);
335 self.cursor.1 = self.cursor.1.saturating_sub(n);
336 self.pending_wrap = false;
337 }
338 'B' => {
339 let n = params.parse().unwrap_or(1);
340 self.cursor.1 = (self.cursor.1 + n).min(self.size.1.saturating_sub(1));
341 self.pending_wrap = false;
342 }
343 'C' => {
344 let n = params.parse().unwrap_or(1);
345 self.move_right(n);
346 }
347 'D' => {
348 let n = params.parse().unwrap_or(1);
349 self.move_left(n);
350 }
351 'G' => {
352 let col = params.parse::<u16>().unwrap_or(1).saturating_sub(1);
353 self.move_to_column(col);
354 }
355 'J' => {
356 let n = params.parse().unwrap_or(0);
357 match n {
358 0 => {
359 for row in self.cursor.1..self.size.1 {
360 if let Some(r) = self.buffer.get_mut(row as usize) {
361 let start = if row == self.cursor.1 { self.cursor.0 as usize } else { 0 };
362 for cell in r.iter_mut().skip(start) {
363 *cell = Cell::default();
364 }
365 }
366 }
367 }
368 2 => {
369 self.clear();
370 }
371 3 => {
372 self.scrollback.clear();
373 }
374 _ => {}
375 }
376 }
377 'K' => {
378 let n = params.parse().unwrap_or(0);
379 match n {
380 0 => {
381 if let Some(row) = self.buffer.get_mut(self.cursor.1 as usize) {
382 for cell in row.iter_mut().skip(self.cursor.0 as usize) {
383 *cell = Cell::default();
384 }
385 }
386 }
387 2 => {
388 self.clear_line();
389 }
390 _ => {}
391 }
392 }
393 's' => {
394 self.saved_cursor = Some(self.cursor);
395 }
396 'u' => {
397 if let Some(saved) = self.saved_cursor {
398 self.cursor = saved;
399 self.pending_wrap = false;
400 }
401 }
402 'm' => {
403 self.apply_sgr(¶ms);
404 }
405 _ => {}
406 }
407 }
408 }
409
410 #[allow(clippy::cast_possible_truncation)]
412 fn apply_sgr(&mut self, params: &str) {
413 if params.is_empty() {
414 self.current_style = Style::default();
415 return;
416 }
417
418 let codes: Vec<u16> = params
421 .split(';')
422 .filter_map(|s| {
423 let primary = s.split(':').next().unwrap_or(s);
424 primary.parse().ok()
425 })
426 .collect();
427 let mut i = 0;
428 while i < codes.len() {
429 match codes[i] {
430 0 => self.current_style = Style::default(),
431 1 => self.current_style.bold = true,
432 2 => self.current_style.dim = true,
433 3 => self.current_style.italic = true,
434 4 => self.current_style.underline = true,
435 9 => self.current_style.strikethrough = true,
436 22 => {
437 self.current_style.bold = false;
438 self.current_style.dim = false;
439 }
440 23 => self.current_style.italic = false,
441 24 => self.current_style.underline = false,
442 29 => self.current_style.strikethrough = false,
443 30..=37 => {
444 self.current_style.fg = Some(standard_color(codes[i] as u8 - 30));
445 }
446 38 => {
447 i += 1;
448 if i < codes.len() {
449 match codes[i] {
450 5 if i + 1 < codes.len() => {
451 i += 1;
452 self.current_style.fg = Some(Color::AnsiValue(codes[i] as u8));
453 }
454 2 if i + 3 < codes.len() => {
455 self.current_style.fg = Some(Color::Rgb {
456 r: codes[i + 1] as u8,
457 g: codes[i + 2] as u8,
458 b: codes[i + 3] as u8,
459 });
460 i += 3;
461 }
462 _ => {}
463 }
464 }
465 }
466 39 => self.current_style.fg = None,
467 40..=47 => {
468 self.current_style.bg = Some(standard_color(codes[i] as u8 - 40));
469 }
470 48 => {
471 i += 1;
472 if i < codes.len() {
473 match codes[i] {
474 5 if i + 1 < codes.len() => {
475 i += 1;
476 self.current_style.bg = Some(Color::AnsiValue(codes[i] as u8));
477 }
478 2 if i + 3 < codes.len() => {
479 self.current_style.bg = Some(Color::Rgb {
480 r: codes[i + 1] as u8,
481 g: codes[i + 2] as u8,
482 b: codes[i + 3] as u8,
483 });
484 i += 3;
485 }
486 _ => {}
487 }
488 }
489 }
490 49 => self.current_style.bg = None,
491 90..=97 => {
492 self.current_style.fg = Some(bright_color(codes[i] as u8 - 90));
493 }
494 100..=107 => {
495 self.current_style.bg = Some(bright_color(codes[i] as u8 - 100));
496 }
497 _ => {}
498 }
499 i += 1;
500 }
501 }
502}
503
504fn standard_color(index: u8) -> Color {
506 match index {
507 0 => Color::Black,
508 1 => Color::DarkRed,
509 2 => Color::DarkGreen,
510 3 => Color::DarkYellow,
511 4 => Color::DarkBlue,
512 5 => Color::DarkMagenta,
513 6 => Color::DarkCyan,
514 7 => Color::Grey,
515 _ => Color::Reset,
516 }
517}
518
519fn bright_color(index: u8) -> Color {
521 match index {
522 0 => Color::DarkGrey,
523 1 => Color::Red,
524 2 => Color::Green,
525 3 => Color::Yellow,
526 4 => Color::Blue,
527 5 => Color::Magenta,
528 6 => Color::Cyan,
529 7 => Color::White,
530 _ => Color::Reset,
531 }
532}
533
534impl Write for TestTerminal {
535 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
536 self.escape_buffer.extend_from_slice(buf);
537 Ok(buf.len())
538 }
539
540 fn flush(&mut self) -> io::Result<()> {
541 if !self.escape_buffer.is_empty() {
542 let bytes = std::mem::take(&mut self.escape_buffer);
543 self.process_bytes(&bytes);
544 }
545 Ok(())
546 }
547}
548
549pub fn assert_buffer_eq<S: AsRef<str>>(terminal: &TestTerminal, expected: &[S]) {
553 let actual_lines = terminal.get_lines();
554 let max_lines = expected.len().max(actual_lines.len());
555
556 for i in 0..max_lines {
557 let expected_line = expected.get(i).map_or("", AsRef::as_ref);
558 let actual_line = actual_lines.get(i).map_or("", String::as_str);
559
560 assert_eq!(
561 actual_line,
562 expected_line,
563 "Line {i} mismatch:\n Expected: '{expected_line}'\n Got: '{actual_line}'\n\nFull buffer:\n{}",
564 actual_lines.join("\n")
565 );
566 }
567}
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572
573 #[test]
574 fn test_basic_write() {
575 let mut term = TestTerminal::new(80, 24);
576 write!(term, "Hello").unwrap();
577 term.flush().unwrap();
578 let lines = term.get_lines();
579 assert_eq!(lines[0], "Hello");
580 }
581
582 #[test]
583 fn test_newline() {
584 let mut term = TestTerminal::new(80, 24);
585 write!(term, "Line 1\nLine 2").unwrap();
586 term.flush().unwrap();
587 assert_buffer_eq(&term, &["Line 1", "Line 2"]);
588 }
589
590 #[test]
591 fn test_carriage_return() {
592 let mut term = TestTerminal::new(80, 24);
593 write!(term, "Hello\rWorld").unwrap();
594 term.flush().unwrap();
595 let lines = term.get_lines();
596 assert_eq!(lines[0], "World");
597 }
598
599 #[test]
600 fn test_ansi_cursor_position() {
601 let mut term = TestTerminal::new(80, 24);
602 write!(term, "\x1b[3;5HX").unwrap();
603 term.flush().unwrap();
604 let lines = term.get_lines();
605 assert_eq!(&lines[2][4..5], "X");
606 }
607
608 #[test]
609 fn test_ansi_clear_line() {
610 let mut term = TestTerminal::new(80, 24);
611 write!(term, "Hello World").unwrap();
612 write!(term, "\x1b[1G\x1b[K").unwrap();
613 term.flush().unwrap();
614 let lines = term.get_lines();
615 assert_eq!(lines[0], "");
616 }
617
618 #[test]
619 fn test_assert_buffer_eq() {
620 let mut term = TestTerminal::new(80, 24);
621 write!(term, "Line 1\nLine 2\nLine 3").unwrap();
622 term.flush().unwrap();
623
624 assert_buffer_eq(&term, &["Line 1", "Line 2", "Line 3"]);
625 }
626
627 #[test]
628 #[should_panic(expected = "Line 0 mismatch")]
629 fn test_assert_buffer_eq_fails() {
630 let mut term = TestTerminal::new(80, 24);
631 write!(term, "Wrong").unwrap();
632 term.flush().unwrap();
633
634 assert_buffer_eq(&term, &["Expected"]);
635 }
636
637 #[test]
638 fn test_private_mode_sequences_ignored() {
639 let mut term = TestTerminal::new(80, 24);
640 write!(term, "\x1b[?2026hHello\x1b[?2026l").unwrap();
641 term.flush().unwrap();
642 let lines = term.get_lines();
643 assert_eq!(lines[0], "Hello");
644 }
645
646 #[test]
647 fn test_cursor_save_restore() {
648 let mut term = TestTerminal::new(80, 24);
649
650 write!(term, "\x1b[6;11HFirst").unwrap();
651 write!(term, "\x1b7").unwrap();
652 write!(term, "\x1b[1;1HSecond").unwrap();
653 write!(term, "\x1b8Third").unwrap();
654
655 term.flush().unwrap();
656
657 let lines = term.get_lines();
658 assert_eq!(lines[0], "Second");
659 assert_eq!(lines[5], " FirstThird");
660 }
661
662 #[test]
663 fn test_transcript_includes_scrolled_off_lines() {
664 let mut term = TestTerminal::new(6, 2);
665 write!(term, "L1\nL2\nL3").unwrap();
666 term.flush().unwrap();
667
668 let visible = term.get_lines();
669 assert_eq!(visible[0], "L2");
670 assert_eq!(visible[1], "L3");
671
672 let transcript = term.get_transcript_lines();
673 assert_eq!(transcript, vec!["L1", "L2", "L3"]);
674 }
675
676 #[test]
677 fn test_sgr_bold() {
678 let mut term = TestTerminal::new(80, 24);
679 write!(term, "\x1b[1mbold\x1b[0m").unwrap();
680 term.flush().unwrap();
681 let lines = term.get_lines();
682 assert_eq!(lines[0], "bold");
683 assert!(term.get_style_at(0, 0).bold);
684 assert!(!term.get_style_at(0, 4).bold);
685 }
686
687 #[test]
688 fn test_sgr_fg_color() {
689 let mut term = TestTerminal::new(80, 24);
690 write!(term, "\x1b[31mred\x1b[0m").unwrap();
691 term.flush().unwrap();
692 assert_eq!(term.get_style_at(0, 0).fg, Some(Color::DarkRed));
693 assert_eq!(term.get_style_at(0, 3).fg, None);
694 }
695
696 #[test]
697 fn test_sgr_rgb_color() {
698 let mut term = TestTerminal::new(80, 24);
699 write!(term, "\x1b[38;2;255;128;0mrgb\x1b[0m").unwrap();
700 term.flush().unwrap();
701 assert_eq!(term.get_style_at(0, 0).fg, Some(Color::Rgb { r: 255, g: 128, b: 0 }));
702 }
703
704 #[test]
705 fn test_style_of_text() {
706 let mut term = TestTerminal::new(80, 24);
707 write!(term, "plain \x1b[1mbold\x1b[0m rest").unwrap();
708 term.flush().unwrap();
709 let style = term.style_of_text(0, "bold").unwrap();
710 assert!(style.bold);
711 let style = term.style_of_text(0, "plain").unwrap();
712 assert!(!style.bold);
713 }
714}