1#![forbid(unsafe_code)]
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub struct Rgb {
32 pub r: u8,
33 pub g: u8,
34 pub b: u8,
35}
36
37impl Rgb {
38 pub const fn new(r: u8, g: u8, b: u8) -> Self {
39 Self { r, g, b }
40 }
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Default)]
45pub struct ModelStyle {
46 pub fg: Option<Rgb>,
47 pub bg: Option<Rgb>,
48 pub bold: bool,
49 pub dim: bool,
50 pub italic: bool,
51 pub underline: bool,
52 pub blink: bool,
53 pub reverse: bool,
54 pub strikethrough: bool,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct ModelCell {
60 pub ch: char,
61 pub style: ModelStyle,
62 pub link: Option<String>,
63}
64
65impl Default for ModelCell {
66 fn default() -> Self {
67 Self {
68 ch: ' ',
69 style: ModelStyle::default(),
70 link: None,
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77enum EraseMode {
78 ToEnd,
79 ToStart,
80 All,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85enum ParserState {
86 Ground,
87 Escape,
88 Csi,
89 Osc,
90}
91
92pub struct TerminalModel {
94 grid: Vec<Vec<ModelCell>>,
95 cursor_x: u16,
96 cursor_y: u16,
97 current_style: ModelStyle,
98 current_link: Option<String>,
99 width: u16,
100 height: u16,
101 state: ParserState,
103 csi_params: Vec<u16>,
104 csi_current: u16,
105 osc_buffer: Vec<u8>,
106}
107
108impl TerminalModel {
109 pub fn new(width: u16, height: u16) -> Self {
111 let grid = (0..height)
112 .map(|_| (0..width).map(|_| ModelCell::default()).collect())
113 .collect();
114 Self {
115 grid,
116 cursor_x: 0,
117 cursor_y: 0,
118 current_style: ModelStyle::default(),
119 current_link: None,
120 width,
121 height,
122 state: ParserState::Ground,
123 csi_params: Vec::new(),
124 csi_current: 0,
125 osc_buffer: Vec::new(),
126 }
127 }
128
129 #[inline]
131 pub fn width(&self) -> u16 {
132 self.width
133 }
134
135 #[inline]
137 pub fn height(&self) -> u16 {
138 self.height
139 }
140
141 #[inline]
143 pub fn cursor(&self) -> (u16, u16) {
144 (self.cursor_x, self.cursor_y)
145 }
146
147 pub fn char_at(&self, x: u16, y: u16) -> char {
149 self.cell_at(x, y).map_or(' ', |c| c.ch)
150 }
151
152 pub fn style_at(&self, x: u16, y: u16) -> ModelStyle {
154 self.cell_at(x, y)
155 .map_or_else(ModelStyle::default, |c| c.style.clone())
156 }
157
158 pub fn link_at(&self, x: u16, y: u16) -> Option<String> {
160 self.cell_at(x, y).and_then(|c| c.link.clone())
161 }
162
163 fn cell_at(&self, x: u16, y: u16) -> Option<&ModelCell> {
165 self.grid
166 .get(y as usize)
167 .and_then(|row| row.get(x as usize))
168 }
169
170 pub fn row_text(&self, y: u16) -> String {
172 if let Some(row) = self.grid.get(y as usize) {
173 let s: String = row.iter().map(|c| c.ch).collect();
174 s.trim_end().to_string()
175 } else {
176 String::new()
177 }
178 }
179
180 pub fn screen_text(&self) -> String {
182 let mut lines: Vec<String> = (0..self.height).map(|y| self.row_text(y)).collect();
183 while lines.last().is_some_and(|l| l.is_empty()) {
185 lines.pop();
186 }
187 lines.join("\n")
188 }
189
190 pub fn dump(&self) -> String {
192 let mut out = String::new();
193 for (y, row) in self.grid.iter().enumerate() {
194 out.push_str(&format!("{y:3}| "));
195 for cell in row {
196 out.push(cell.ch);
197 }
198 out.push('\n');
199 }
200 out.push_str(&format!("Cursor: ({}, {})\n", self.cursor_x, self.cursor_y));
201 out.push_str(&format!("Style: {:?}\n", self.current_style));
202 out
203 }
204
205 pub fn feed(&mut self, bytes: &[u8]) {
207 for &byte in bytes {
208 self.advance(byte);
209 }
210 }
211
212 pub fn feed_str(&mut self, s: &str) {
214 self.feed(s.as_bytes());
215 }
216
217 fn advance(&mut self, byte: u8) {
218 match self.state {
219 ParserState::Ground => self.ground(byte),
220 ParserState::Escape => self.escape(byte),
221 ParserState::Csi => self.csi(byte),
222 ParserState::Osc => self.osc(byte),
223 }
224 }
225
226 fn ground(&mut self, byte: u8) {
227 match byte {
228 0x1b => {
229 self.state = ParserState::Escape;
230 }
231 0x0a => {
232 if self.cursor_y + 1 < self.height {
234 self.cursor_y += 1;
235 }
236 }
237 0x0d => {
238 self.cursor_x = 0;
240 }
241 0x08 => {
242 self.cursor_x = self.cursor_x.saturating_sub(1);
244 }
245 0x09 => {
246 self.cursor_x = ((self.cursor_x / 8) + 1) * 8;
248 if self.cursor_x >= self.width {
249 self.cursor_x = self.width.saturating_sub(1);
250 }
251 }
252 0x20..=0x7e => {
253 self.put_char(byte as char);
254 }
255 0xc0..=0xff => {
256 self.put_char('?');
258 }
259 _ => {
260 }
262 }
263 }
264
265 fn escape(&mut self, byte: u8) {
266 match byte {
267 b'[' => {
268 self.state = ParserState::Csi;
269 self.csi_params.clear();
270 self.csi_current = 0;
271 }
272 b']' => {
273 self.state = ParserState::Osc;
274 self.osc_buffer.clear();
275 }
276 _ => {
277 self.state = ParserState::Ground;
279 }
280 }
281 }
282
283 fn csi(&mut self, byte: u8) {
284 match byte {
285 b'0'..=b'9' => {
286 self.csi_current = self.csi_current.saturating_mul(10) + (byte - b'0') as u16;
287 }
288 b';' => {
289 self.csi_params.push(self.csi_current);
290 self.csi_current = 0;
291 }
292 b'?' => {
293 }
295 b'A' => {
296 self.csi_params.push(self.csi_current);
297 let n = self.param(0, 1);
298 self.cursor_y = self.cursor_y.saturating_sub(n);
299 self.state = ParserState::Ground;
300 }
301 b'B' => {
302 self.csi_params.push(self.csi_current);
303 let n = self.param(0, 1);
304 self.cursor_y = (self.cursor_y + n).min(self.height.saturating_sub(1));
305 self.state = ParserState::Ground;
306 }
307 b'C' => {
308 self.csi_params.push(self.csi_current);
309 let n = self.param(0, 1);
310 self.cursor_x = (self.cursor_x + n).min(self.width.saturating_sub(1));
311 self.state = ParserState::Ground;
312 }
313 b'D' => {
314 self.csi_params.push(self.csi_current);
315 let n = self.param(0, 1);
316 self.cursor_x = self.cursor_x.saturating_sub(n);
317 self.state = ParserState::Ground;
318 }
319 b'H' | b'f' => {
320 self.csi_params.push(self.csi_current);
322 let row = self.param(0, 1);
323 let col = self.param(1, 1);
324 self.cursor_y = row.saturating_sub(1).min(self.height.saturating_sub(1));
325 self.cursor_x = col.saturating_sub(1).min(self.width.saturating_sub(1));
326 self.state = ParserState::Ground;
327 }
328 b'J' => {
329 self.csi_params.push(self.csi_current);
331 let mode = match self.param(0, 0) {
332 0 => EraseMode::ToEnd,
333 1 => EraseMode::ToStart,
334 _ => EraseMode::All,
335 };
336 self.erase_display(mode);
337 self.state = ParserState::Ground;
338 }
339 b'K' => {
340 self.csi_params.push(self.csi_current);
342 let mode = match self.param(0, 0) {
343 0 => EraseMode::ToEnd,
344 1 => EraseMode::ToStart,
345 _ => EraseMode::All,
346 };
347 self.erase_line(mode);
348 self.state = ParserState::Ground;
349 }
350 b'm' => {
351 self.csi_params.push(self.csi_current);
353 self.apply_sgr();
354 self.state = ParserState::Ground;
355 }
356 b'h' | b'l' | b's' | b'u' => {
357 self.state = ParserState::Ground;
359 }
360 _ => {
361 self.state = ParserState::Ground;
363 }
364 }
365 }
366
367 fn osc(&mut self, byte: u8) {
368 match byte {
369 0x07 => {
370 self.process_osc();
372 self.state = ParserState::Ground;
373 }
374 0x1b => {
375 self.process_osc();
377 self.state = ParserState::Escape;
378 }
379 _ => {
380 self.osc_buffer.push(byte);
381 }
382 }
383 }
384
385 fn process_osc(&mut self) {
386 let osc_str = String::from_utf8_lossy(&self.osc_buffer).to_string();
387 if let Some(rest) = osc_str.strip_prefix("8;") {
389 if let Some((_params, url)) = rest.split_once(';') {
391 if url.is_empty() {
392 self.current_link = None;
393 } else {
394 self.current_link = Some(url.to_string());
395 }
396 }
397 }
398 }
399
400 fn param(&self, index: usize, default: u16) -> u16 {
401 self.csi_params.get(index).copied().unwrap_or(default)
402 }
403
404 fn put_char(&mut self, ch: char) {
405 let x = self.cursor_x as usize;
406 let y = self.cursor_y as usize;
407 if y < self.grid.len() && x < self.grid[y].len() {
408 self.grid[y][x] = ModelCell {
409 ch,
410 style: self.current_style.clone(),
411 link: self.current_link.clone(),
412 };
413 }
414 self.cursor_x += 1;
415 if self.cursor_x >= self.width {
416 self.cursor_x = 0;
417 if self.cursor_y + 1 < self.height {
418 self.cursor_y += 1;
419 }
420 }
421 }
422
423 fn erase_line(&mut self, mode: EraseMode) {
424 let y = self.cursor_y as usize;
425 if y >= self.grid.len() {
426 return;
427 }
428 let (start, end) = match mode {
429 EraseMode::ToEnd => (self.cursor_x as usize, self.width as usize),
430 EraseMode::ToStart => (0, self.cursor_x as usize + 1),
431 EraseMode::All => (0, self.width as usize),
432 };
433 for x in start..end.min(self.grid[y].len()) {
434 self.grid[y][x] = ModelCell::default();
435 }
436 }
437
438 fn erase_display(&mut self, mode: EraseMode) {
439 match mode {
440 EraseMode::ToEnd => {
441 self.erase_line(EraseMode::ToEnd);
443 for y in (self.cursor_y + 1) as usize..self.height as usize {
444 for cell in &mut self.grid[y] {
445 *cell = ModelCell::default();
446 }
447 }
448 }
449 EraseMode::ToStart => {
450 for y in 0..self.cursor_y as usize {
452 for cell in &mut self.grid[y] {
453 *cell = ModelCell::default();
454 }
455 }
456 self.erase_line(EraseMode::ToStart);
457 }
458 EraseMode::All => {
459 for row in &mut self.grid {
460 for cell in row {
461 *cell = ModelCell::default();
462 }
463 }
464 }
465 }
466 }
467
468 fn apply_sgr(&mut self) {
469 if self.csi_params.is_empty() || (self.csi_params.len() == 1 && self.csi_params[0] == 0) {
470 self.current_style = ModelStyle::default();
471 return;
472 }
473
474 let mut i = 0;
475 while i < self.csi_params.len() {
476 match self.csi_params[i] {
477 0 => self.current_style = ModelStyle::default(),
478 1 => self.current_style.bold = true,
479 2 => self.current_style.dim = true,
480 3 => self.current_style.italic = true,
481 4 => self.current_style.underline = true,
482 5 => self.current_style.blink = true,
483 7 => self.current_style.reverse = true,
484 9 => self.current_style.strikethrough = true,
485 22 => {
486 self.current_style.bold = false;
487 self.current_style.dim = false;
488 }
489 23 => self.current_style.italic = false,
490 24 => self.current_style.underline = false,
491 25 => self.current_style.blink = false,
492 27 => self.current_style.reverse = false,
493 29 => self.current_style.strikethrough = false,
494 38 => {
495 if i + 4 < self.csi_params.len() && self.csi_params[i + 1] == 2 {
497 self.current_style.fg = Some(Rgb::new(
498 self.csi_params[i + 2] as u8,
499 self.csi_params[i + 3] as u8,
500 self.csi_params[i + 4] as u8,
501 ));
502 i += 4;
503 }
504 }
505 39 => self.current_style.fg = None,
506 48 => {
507 if i + 4 < self.csi_params.len() && self.csi_params[i + 1] == 2 {
509 self.current_style.bg = Some(Rgb::new(
510 self.csi_params[i + 2] as u8,
511 self.csi_params[i + 3] as u8,
512 self.csi_params[i + 4] as u8,
513 ));
514 i += 4;
515 }
516 }
517 49 => self.current_style.bg = None,
518 _ => {}
519 }
520 i += 1;
521 }
522 }
523}
524
525#[derive(Debug, Clone)]
527pub struct CellDiff {
528 pub x: u16,
529 pub y: u16,
530 pub expected: ModelCell,
531 pub actual: ModelCell,
532}
533
534impl std::fmt::Display for CellDiff {
535 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
536 write!(
537 f,
538 "({}, {}): expected '{}' got '{}'",
539 self.x, self.y, self.expected.ch, self.actual.ch
540 )
541 }
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547
548 #[test]
549 fn new_model_empty() {
550 let m = TerminalModel::new(10, 5);
551 assert_eq!(m.width(), 10);
552 assert_eq!(m.height(), 5);
553 assert_eq!(m.cursor(), (0, 0));
554 assert_eq!(m.char_at(0, 0), ' ');
555 }
556
557 #[test]
558 fn print_text() {
559 let mut m = TerminalModel::new(20, 5);
560 m.feed(b"Hello");
561 assert_eq!(m.char_at(0, 0), 'H');
562 assert_eq!(m.char_at(1, 0), 'e');
563 assert_eq!(m.char_at(2, 0), 'l');
564 assert_eq!(m.char_at(3, 0), 'l');
565 assert_eq!(m.char_at(4, 0), 'o');
566 assert_eq!(m.cursor(), (5, 0));
567 }
568
569 #[test]
570 fn cursor_wraps_at_edge() {
571 let mut m = TerminalModel::new(5, 3);
572 m.feed(b"ABCDE");
573 assert_eq!(m.cursor(), (0, 1));
575 assert_eq!(m.char_at(0, 0), 'A');
576 assert_eq!(m.char_at(4, 0), 'E');
577 }
578
579 #[test]
580 fn newline() {
581 let mut m = TerminalModel::new(20, 5);
582 m.feed(b"AB\nCD");
583 assert_eq!(m.char_at(0, 0), 'A');
584 assert_eq!(m.char_at(1, 0), 'B');
585 assert_eq!(m.char_at(2, 1), 'C');
587 assert_eq!(m.char_at(3, 1), 'D');
588 }
589
590 #[test]
591 fn carriage_return() {
592 let mut m = TerminalModel::new(20, 5);
593 m.feed(b"Hello\r");
594 assert_eq!(m.cursor(), (0, 0));
595 m.feed(b"World");
596 assert_eq!(m.row_text(0), "World");
597 }
598
599 #[test]
600 fn crlf() {
601 let mut m = TerminalModel::new(20, 5);
602 m.feed(b"Line1\r\nLine2");
603 assert_eq!(m.row_text(0), "Line1");
604 assert_eq!(m.row_text(1), "Line2");
605 }
606
607 #[test]
608 fn cursor_position_cup() {
609 let mut m = TerminalModel::new(20, 10);
610 m.feed(b"\x1b[5;10H");
611 assert_eq!(m.cursor(), (9, 4));
613 }
614
615 #[test]
616 fn cursor_position_default() {
617 let mut m = TerminalModel::new(20, 10);
618 m.feed(b"\x1b[H");
619 assert_eq!(m.cursor(), (0, 0));
620 }
621
622 #[test]
623 fn cursor_movement() {
624 let mut m = TerminalModel::new(20, 10);
625 m.feed(b"\x1b[5;10H"); m.feed(b"\x1b[2A"); assert_eq!(m.cursor(), (9, 2));
628 m.feed(b"\x1b[3B"); assert_eq!(m.cursor(), (9, 5));
630 m.feed(b"\x1b[4C"); assert_eq!(m.cursor(), (13, 5));
632 m.feed(b"\x1b[2D"); assert_eq!(m.cursor(), (11, 5));
634 }
635
636 #[test]
637 fn cursor_movement_clamps() {
638 let mut m = TerminalModel::new(10, 5);
639 m.feed(b"\x1b[100A"); assert_eq!(m.cursor(), (0, 0));
641 m.feed(b"\x1b[100B"); assert_eq!(m.cursor(), (0, 4));
643 m.feed(b"\x1b[100D"); assert_eq!(m.cursor(), (0, 4));
645 m.feed(b"\x1b[100C"); assert_eq!(m.cursor(), (9, 4));
647 }
648
649 #[test]
650 fn sgr_bold() {
651 let mut m = TerminalModel::new(20, 5);
652 m.feed(b"\x1b[1mBold\x1b[0m");
653 assert!(m.style_at(0, 0).bold);
654 assert!(m.style_at(3, 0).bold);
655 }
656
657 #[test]
658 fn sgr_reset() {
659 let mut m = TerminalModel::new(20, 5);
660 m.feed(b"\x1b[1;3mBI\x1b[0mN");
661 assert!(m.style_at(0, 0).bold);
662 assert!(m.style_at(0, 0).italic);
663 assert!(!m.style_at(2, 0).bold);
664 assert!(!m.style_at(2, 0).italic);
665 }
666
667 #[test]
668 fn sgr_truecolor_fg() {
669 let mut m = TerminalModel::new(20, 5);
670 m.feed(b"\x1b[38;2;255;0;128mX\x1b[0m");
671 let style = m.style_at(0, 0);
672 assert_eq!(style.fg, Some(Rgb::new(255, 0, 128)));
673 }
674
675 #[test]
676 fn sgr_truecolor_bg() {
677 let mut m = TerminalModel::new(20, 5);
678 m.feed(b"\x1b[48;2;10;20;30mX\x1b[0m");
679 let style = m.style_at(0, 0);
680 assert_eq!(style.bg, Some(Rgb::new(10, 20, 30)));
681 }
682
683 #[test]
684 fn sgr_combined() {
685 let mut m = TerminalModel::new(20, 5);
686 m.feed(b"\x1b[1;3;4;38;2;255;128;0mX\x1b[0m");
687 let style = m.style_at(0, 0);
688 assert!(style.bold);
689 assert!(style.italic);
690 assert!(style.underline);
691 assert_eq!(style.fg, Some(Rgb::new(255, 128, 0)));
692 }
693
694 #[test]
695 fn sgr_selective_reset() {
696 let mut m = TerminalModel::new(20, 5);
697 m.feed(b"\x1b[1;3mX\x1b[23mY");
698 let x_style = m.style_at(0, 0);
699 assert!(x_style.bold);
700 assert!(x_style.italic);
701 let y_style = m.style_at(1, 0);
702 assert!(y_style.bold);
703 assert!(!y_style.italic);
704 }
705
706 #[test]
707 fn erase_line_to_end() {
708 let mut m = TerminalModel::new(10, 3);
709 m.feed(b"ABCDEFGHIJ");
710 m.feed(b"\x1b[1;4H"); m.feed(b"\x1b[0K"); assert_eq!(m.row_text(0), "ABC");
713 }
714
715 #[test]
716 fn erase_line_to_start() {
717 let mut m = TerminalModel::new(10, 3);
718 m.feed(b"ABCDEFGHIJ");
719 m.feed(b"\x1b[1;4H"); m.feed(b"\x1b[1K"); assert_eq!(m.char_at(0, 0), ' ');
722 assert_eq!(m.char_at(1, 0), ' ');
723 assert_eq!(m.char_at(2, 0), ' ');
724 assert_eq!(m.char_at(3, 0), ' ');
725 assert_eq!(m.char_at(4, 0), 'E');
726 }
727
728 #[test]
729 fn erase_line_all() {
730 let mut m = TerminalModel::new(10, 3);
731 m.feed(b"ABCDEFGHIJ");
732 m.feed(b"\x1b[1;4H");
733 m.feed(b"\x1b[2K"); assert_eq!(m.row_text(0), "");
735 }
736
737 #[test]
738 fn erase_display_to_end() {
739 let mut m = TerminalModel::new(10, 3);
740 m.feed(b"Line1 ");
741 m.feed(b"Line2 ");
742 m.feed(b"Line3 ");
743 m.feed(b"\x1b[2;1H"); m.feed(b"\x1b[0J"); assert_eq!(m.row_text(0), "Line1");
746 assert_eq!(m.row_text(1), "");
747 assert_eq!(m.row_text(2), "");
748 }
749
750 #[test]
751 fn erase_display_all() {
752 let mut m = TerminalModel::new(10, 3);
753 m.feed(b"XXXXXXXXXX");
754 m.feed(b"YYYYYYYYYY");
755 m.feed(b"\x1b[2J");
756 assert_eq!(m.screen_text(), "");
757 }
758
759 #[test]
760 fn osc8_hyperlink() {
761 let mut m = TerminalModel::new(30, 3);
762 m.feed(b"\x1b]8;;https://example.com\x07Link\x1b]8;;\x07");
764 assert_eq!(m.char_at(0, 0), 'L');
765 assert_eq!(m.link_at(0, 0), Some("https://example.com".to_string()));
766 assert_eq!(m.link_at(3, 0), Some("https://example.com".to_string()));
767 assert_eq!(m.link_at(4, 0), None);
769 }
770
771 #[test]
772 fn osc8_with_st_terminator() {
773 let mut m = TerminalModel::new(30, 3);
774 m.feed(b"\x1b]8;;http://test.com\x1b\\Link\x1b]8;;\x1b\\");
776 assert_eq!(m.link_at(0, 0), Some("http://test.com".to_string()));
777 }
778
779 #[test]
780 fn screen_text_trims() {
781 let mut m = TerminalModel::new(10, 3);
782 m.feed(b"Hello");
783 let text = m.screen_text();
784 assert_eq!(text, "Hello");
785 }
786
787 #[test]
788 fn dump_format() {
789 let mut m = TerminalModel::new(5, 2);
790 m.feed(b"Hi");
791 let dump = m.dump();
792 assert!(dump.contains("Hi"));
793 assert!(dump.contains("Cursor:"));
794 }
795
796 #[test]
797 fn tab_stop() {
798 let mut m = TerminalModel::new(20, 3);
799 m.feed(b"A\tB");
800 assert_eq!(m.char_at(0, 0), 'A');
801 assert_eq!(m.char_at(8, 0), 'B');
802 }
803
804 #[test]
805 fn backspace() {
806 let mut m = TerminalModel::new(20, 3);
807 m.feed(b"AB\x08C");
808 assert_eq!(m.char_at(0, 0), 'A');
810 assert_eq!(m.char_at(1, 0), 'C');
811 }
812
813 #[test]
814 fn feed_str_convenience() {
815 let mut m = TerminalModel::new(20, 3);
816 m.feed_str("Hello");
817 assert_eq!(m.row_text(0), "Hello");
818 }
819
820 #[test]
821 fn sgr_all_attributes() {
822 let mut m = TerminalModel::new(20, 3);
823 m.feed(b"\x1b[1;2;3;4;5;7;9mX\x1b[0m");
824 let s = m.style_at(0, 0);
825 assert!(s.bold);
826 assert!(s.dim);
827 assert!(s.italic);
828 assert!(s.underline);
829 assert!(s.blink);
830 assert!(s.reverse);
831 assert!(s.strikethrough);
832 }
833
834 #[test]
835 fn sgr_reset_individual() {
836 let mut m = TerminalModel::new(20, 3);
837 m.feed(b"\x1b[1;3;4;9mX\x1b[22;23;24;29mY");
838 let x = m.style_at(0, 0);
839 assert!(x.bold);
840 assert!(x.italic);
841 assert!(x.underline);
842 assert!(x.strikethrough);
843 let y = m.style_at(1, 0);
844 assert!(!y.bold);
845 assert!(!y.italic);
846 assert!(!y.underline);
847 assert!(!y.strikethrough);
848 }
849
850 #[test]
851 fn sgr_default_fg_bg() {
852 let mut m = TerminalModel::new(20, 3);
853 m.feed(b"\x1b[38;2;255;0;0mR\x1b[39mX");
854 assert_eq!(m.style_at(0, 0).fg, Some(Rgb::new(255, 0, 0)));
855 assert_eq!(m.style_at(1, 0).fg, None);
856
857 m.feed(b"\x1b[48;2;0;255;0mG\x1b[49mX");
858 assert_eq!(m.style_at(2, 0).bg, Some(Rgb::new(0, 255, 0)));
859 assert_eq!(m.style_at(3, 0).bg, None);
860 }
861
862 #[test]
863 fn multiple_lines_rendering() {
864 let mut m = TerminalModel::new(20, 5);
865 m.feed(b"\x1b[1;1HLine 1");
867 m.feed(b"\x1b[2;1HLine 2");
868 m.feed(b"\x1b[3;1HLine 3");
869 assert_eq!(m.row_text(0), "Line 1");
870 assert_eq!(m.row_text(1), "Line 2");
871 assert_eq!(m.row_text(2), "Line 3");
872 }
873
874 #[test]
875 fn styled_text_rendering() {
876 let mut m = TerminalModel::new(30, 3);
877 m.feed(b"\x1b[1;38;2;255;0;0mERROR\x1b[0m: something");
879 assert!(m.style_at(0, 0).bold);
880 assert_eq!(m.style_at(0, 0).fg, Some(Rgb::new(255, 0, 0)));
881 assert!(!m.style_at(5, 0).bold);
882 assert_eq!(m.style_at(5, 0).fg, None);
883 assert_eq!(m.row_text(0), "ERROR: something");
884 }
885
886 #[test]
889 fn cell_diff_display() {
890 let diff = CellDiff {
891 x: 3,
892 y: 7,
893 expected: ModelCell {
894 ch: 'A',
895 style: ModelStyle::default(),
896 link: None,
897 },
898 actual: ModelCell {
899 ch: 'B',
900 style: ModelStyle::default(),
901 link: None,
902 },
903 };
904 let s = format!("{diff}");
905 assert!(s.contains("(3, 7)"));
906 assert!(s.contains("expected 'A'"));
907 assert!(s.contains("got 'B'"));
908 }
909
910 #[test]
911 fn cell_diff_debug_clone() {
912 let diff = CellDiff {
913 x: 0,
914 y: 0,
915 expected: ModelCell::default(),
916 actual: ModelCell::default(),
917 };
918 let debug = format!("{diff:?}");
919 assert!(debug.contains("CellDiff"));
920
921 let cloned = diff.clone();
922 assert_eq!(cloned.x, 0);
923 assert_eq!(cloned.y, 0);
924 }
925
926 #[test]
927 fn rgb_new_and_default() {
928 let rgb = Rgb::new(10, 20, 30);
929 assert_eq!(rgb.r, 10);
930 assert_eq!(rgb.g, 20);
931 assert_eq!(rgb.b, 30);
932
933 let def = Rgb::default();
934 assert_eq!(def, Rgb::new(0, 0, 0));
935 }
936
937 #[test]
938 fn rgb_debug_copy_eq() {
939 let a = Rgb::new(255, 128, 0);
940 let b = a; assert_eq!(a, b);
942
943 let debug = format!("{a:?}");
944 assert!(debug.contains("Rgb"));
945 }
946
947 #[test]
948 fn model_style_default() {
949 let s = ModelStyle::default();
950 assert!(s.fg.is_none());
951 assert!(s.bg.is_none());
952 assert!(!s.bold);
953 assert!(!s.dim);
954 assert!(!s.italic);
955 assert!(!s.underline);
956 assert!(!s.blink);
957 assert!(!s.reverse);
958 assert!(!s.strikethrough);
959 }
960
961 #[test]
962 fn model_style_debug_clone_eq() {
963 let s = ModelStyle {
964 bold: true,
965 fg: Some(Rgb::new(1, 2, 3)),
966 ..ModelStyle::default()
967 };
968
969 let cloned = s.clone();
970 assert_eq!(s, cloned);
971
972 let debug = format!("{s:?}");
973 assert!(debug.contains("ModelStyle"));
974 }
975
976 #[test]
977 fn model_cell_default() {
978 let c = ModelCell::default();
979 assert_eq!(c.ch, ' ');
980 assert_eq!(c.style, ModelStyle::default());
981 assert!(c.link.is_none());
982 }
983
984 #[test]
985 fn model_cell_debug_clone_eq() {
986 let c = ModelCell {
987 ch: 'X',
988 style: ModelStyle::default(),
989 link: Some("http://test.com".to_string()),
990 };
991 let cloned = c.clone();
992 assert_eq!(c, cloned);
993
994 let debug = format!("{c:?}");
995 assert!(debug.contains("ModelCell"));
996 }
997
998 #[test]
999 fn cursor_wrap_at_bottom_edge() {
1000 let mut m = TerminalModel::new(3, 2);
1001 m.feed(b"ABCDE");
1003 assert_eq!(m.cursor(), (2, 1));
1005 m.feed(b"F");
1007 assert_eq!(m.cursor(), (0, 1));
1009 assert_eq!(m.char_at(2, 1), 'F');
1010 }
1011
1012 #[test]
1013 fn lf_at_bottom_of_screen() {
1014 let mut m = TerminalModel::new(10, 2);
1015 m.feed(b"\x1b[2;1H"); assert_eq!(m.cursor(), (0, 1));
1017 m.feed(b"\n"); assert_eq!(m.cursor(), (0, 1));
1019 }
1020
1021 #[test]
1022 fn bs_at_column_zero() {
1023 let mut m = TerminalModel::new(10, 3);
1024 m.feed(b"\x08"); assert_eq!(m.cursor(), (0, 0));
1026 }
1027
1028 #[test]
1029 fn tab_near_end_of_line() {
1030 let mut m = TerminalModel::new(10, 3);
1031 m.feed(b"1234567\t"); assert_eq!(m.cursor(), (8, 0));
1033 m.feed(b"\r12345678\t"); assert_eq!(m.cursor(), (9, 0));
1035 }
1036
1037 #[test]
1038 fn tab_already_at_end() {
1039 let mut m = TerminalModel::new(8, 3);
1040 m.feed(b"12345678"); m.feed(b"\x1b[1;8H"); m.feed(b"\t"); assert_eq!(m.cursor().0, 7);
1044 }
1045
1046 #[test]
1047 fn cup_f_variant() {
1048 let mut m = TerminalModel::new(20, 10);
1049 m.feed(b"\x1b[3;5f"); assert_eq!(m.cursor(), (4, 2));
1051 }
1052
1053 #[test]
1054 fn cup_clamps_to_screen_bounds() {
1055 let mut m = TerminalModel::new(10, 5);
1056 m.feed(b"\x1b[100;200H");
1057 assert_eq!(m.cursor(), (9, 4));
1058 }
1059
1060 #[test]
1061 fn cup_zero_params_default_to_1_1() {
1062 let mut m = TerminalModel::new(10, 5);
1063 m.feed(b"\x1b[5;5H"); m.feed(b"\x1b[0;0H"); assert_eq!(m.cursor(), (0, 0));
1067 }
1068
1069 #[test]
1070 fn erase_display_to_start() {
1071 let mut m = TerminalModel::new(10, 3);
1072 m.feed(b"Line1 ");
1073 m.feed(b"Line2 ");
1074 m.feed(b"Line3 ");
1075 m.feed(b"\x1b[2;5H"); m.feed(b"\x1b[1J"); assert_eq!(m.row_text(0), "");
1080 assert_eq!(m.char_at(0, 1), ' ');
1082 assert_eq!(m.char_at(3, 1), ' ');
1083 assert_eq!(m.char_at(4, 1), ' ');
1084 assert_eq!(m.row_text(2), "Line3");
1086 }
1087
1088 #[test]
1089 fn sgr_truecolor_insufficient_params_fg() {
1090 let mut m = TerminalModel::new(10, 3);
1091 m.feed(b"\x1b[38;2;255mX");
1093 assert!(m.style_at(0, 0).fg.is_none());
1095 }
1096
1097 #[test]
1098 fn sgr_truecolor_insufficient_params_bg() {
1099 let mut m = TerminalModel::new(10, 3);
1100 m.feed(b"\x1b[48;2mX");
1102 assert!(m.style_at(0, 0).bg.is_none());
1103 }
1104
1105 #[test]
1106 fn sgr_empty_is_reset() {
1107 let mut m = TerminalModel::new(10, 3);
1108 m.feed(b"\x1b[1mA\x1b[mB"); assert!(m.style_at(0, 0).bold);
1110 assert!(!m.style_at(1, 0).bold);
1111 }
1112
1113 #[test]
1114 fn sgr_unknown_code_ignored() {
1115 let mut m = TerminalModel::new(10, 3);
1116 m.feed(b"\x1b[1;99;3mX"); let s = m.style_at(0, 0);
1118 assert!(s.bold);
1119 assert!(s.italic);
1120 }
1121
1122 #[test]
1123 fn multi_byte_utf8_treated_as_question() {
1124 let mut m = TerminalModel::new(10, 3);
1125 m.feed(&[0xC3, 0xA9]); assert_eq!(m.char_at(0, 0), '?');
1128 }
1129
1130 #[test]
1131 fn char_at_out_of_bounds() {
1132 let m = TerminalModel::new(5, 3);
1133 assert_eq!(m.char_at(10, 0), ' ');
1135 assert_eq!(m.char_at(0, 10), ' ');
1136 assert_eq!(m.char_at(100, 100), ' ');
1137 }
1138
1139 #[test]
1140 fn style_at_out_of_bounds() {
1141 let m = TerminalModel::new(5, 3);
1142 let s = m.style_at(100, 100);
1143 assert_eq!(s, ModelStyle::default());
1144 }
1145
1146 #[test]
1147 fn link_at_out_of_bounds() {
1148 let m = TerminalModel::new(5, 3);
1149 assert!(m.link_at(100, 100).is_none());
1150 }
1151
1152 #[test]
1153 fn row_text_out_of_bounds() {
1154 let m = TerminalModel::new(5, 3);
1155 assert_eq!(m.row_text(100), "");
1156 }
1157
1158 #[test]
1159 fn screen_text_all_empty() {
1160 let m = TerminalModel::new(5, 3);
1161 assert_eq!(m.screen_text(), "");
1162 }
1163
1164 #[test]
1165 fn screen_text_trailing_empty_lines_trimmed() {
1166 let mut m = TerminalModel::new(10, 5);
1167 m.feed(b"Hello");
1168 m.feed(b"\x1b[2;1HWorld");
1169 let text = m.screen_text();
1170 assert_eq!(text, "Hello\nWorld");
1171 }
1172
1173 #[test]
1174 fn unknown_escape_sequence_returns_to_ground() {
1175 let mut m = TerminalModel::new(10, 3);
1176 m.feed(b"\x1b)A"); assert_eq!(m.char_at(0, 0), 'A');
1179 }
1180
1181 #[test]
1182 fn unknown_csi_final_byte_returns_to_ground() {
1183 let mut m = TerminalModel::new(10, 3);
1184 m.feed(b"\x1b[5ZA"); assert_eq!(m.char_at(0, 0), 'A');
1187 }
1188
1189 #[test]
1190 fn csi_private_mode_prefix_ignored() {
1191 let mut m = TerminalModel::new(10, 3);
1192 m.feed(b"\x1b[?2026hA");
1194 assert_eq!(m.char_at(0, 0), 'A');
1196 }
1197
1198 #[test]
1199 fn csi_save_restore_cursor_ignored() {
1200 let mut m = TerminalModel::new(10, 3);
1201 m.feed(b"AB");
1202 m.feed(b"\x1b[s"); m.feed(b"CD");
1204 m.feed(b"\x1b[u"); m.feed(b"EF");
1206 assert_eq!(m.row_text(0), "ABCDEF");
1208 }
1209
1210 #[test]
1211 fn osc8_link_toggle() {
1212 let mut m = TerminalModel::new(30, 3);
1213 m.feed(b"\x1b]8;;http://a.com\x07A\x1b]8;;\x07B\x1b]8;;http://b.com\x07C\x1b]8;;\x07");
1215 assert_eq!(m.link_at(0, 0), Some("http://a.com".to_string()));
1216 assert!(m.link_at(1, 0).is_none());
1217 assert_eq!(m.link_at(2, 0), Some("http://b.com".to_string()));
1218 }
1219
1220 #[test]
1221 fn cr_lf_sequence() {
1222 let mut m = TerminalModel::new(10, 5);
1223 m.feed(b"ABC\r\nDEF\r\nGHI");
1224 assert_eq!(m.row_text(0), "ABC");
1225 assert_eq!(m.row_text(1), "DEF");
1226 assert_eq!(m.row_text(2), "GHI");
1227 }
1228
1229 #[test]
1230 fn multiple_backspaces() {
1231 let mut m = TerminalModel::new(10, 3);
1232 m.feed(b"ABCDE\x08\x08\x08XY");
1233 assert_eq!(m.row_text(0), "ABXYE");
1235 }
1236
1237 #[test]
1238 fn cursor_movement_explicit_one() {
1239 let mut m = TerminalModel::new(20, 10);
1240 m.feed(b"\x1b[5;10H"); m.feed(b"\x1b[1A"); assert_eq!(m.cursor(), (9, 3));
1243 m.feed(b"\x1b[1B"); assert_eq!(m.cursor(), (9, 4));
1245 m.feed(b"\x1b[1C"); assert_eq!(m.cursor(), (10, 4));
1247 m.feed(b"\x1b[1D"); assert_eq!(m.cursor(), (9, 4));
1249 }
1250
1251 #[test]
1252 fn cursor_movement_no_param_is_zero() {
1253 let mut m = TerminalModel::new(20, 10);
1257 m.feed(b"\x1b[5;10H"); m.feed(b"\x1b[A"); assert_eq!(m.cursor(), (9, 4));
1260 }
1261
1262 #[test]
1263 fn sgr_22_resets_both_bold_and_dim() {
1264 let mut m = TerminalModel::new(10, 3);
1265 m.feed(b"\x1b[1;2mA\x1b[22mB");
1266 let a = m.style_at(0, 0);
1267 assert!(a.bold);
1268 assert!(a.dim);
1269 let b = m.style_at(1, 0);
1270 assert!(!b.bold);
1271 assert!(!b.dim);
1272 }
1273
1274 #[test]
1275 fn sgr_blink_and_reverse_reset() {
1276 let mut m = TerminalModel::new(10, 3);
1277 m.feed(b"\x1b[5;7mA\x1b[25;27mB");
1278 let a = m.style_at(0, 0);
1279 assert!(a.blink);
1280 assert!(a.reverse);
1281 let b = m.style_at(1, 0);
1282 assert!(!b.blink);
1283 assert!(!b.reverse);
1284 }
1285
1286 #[test]
1287 fn erase_line_at_row_zero() {
1288 let mut m = TerminalModel::new(10, 3);
1289 m.feed(b"ABCDEFGHIJ");
1290 m.feed(b"\x1b[1;1H\x1b[2K");
1291 assert_eq!(m.row_text(0), "");
1292 }
1293
1294 #[test]
1295 fn erase_display_all_preserves_cursor() {
1296 let mut m = TerminalModel::new(10, 3);
1297 m.feed(b"XXXXXXXXXX");
1298 m.feed(b"\x1b[1;5H"); m.feed(b"\x1b[2J");
1300 assert_eq!(m.screen_text(), "");
1301 assert_eq!(m.cursor(), (4, 0));
1303 }
1304
1305 #[test]
1306 fn feed_empty_bytes() {
1307 let mut m = TerminalModel::new(10, 3);
1308 m.feed(b"");
1309 assert_eq!(m.cursor(), (0, 0));
1310 assert_eq!(m.screen_text(), "");
1311 }
1312
1313 #[test]
1314 fn feed_str_empty() {
1315 let mut m = TerminalModel::new(10, 3);
1316 m.feed_str("");
1317 assert_eq!(m.cursor(), (0, 0));
1318 }
1319
1320 #[test]
1321 fn put_char_at_full_grid_bottom_right() {
1322 let mut m = TerminalModel::new(3, 2);
1323 m.feed(b"\x1b[2;3H"); m.feed(b"Z");
1326 assert_eq!(m.char_at(2, 1), 'Z');
1327 assert_eq!(m.cursor(), (0, 1));
1329 }
1330
1331 #[test]
1332 fn control_chars_ignored() {
1333 let mut m = TerminalModel::new(10, 3);
1334 m.feed(&[0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x0b, 0x0c]);
1336 assert_eq!(m.cursor(), (0, 0));
1338 assert_eq!(m.screen_text(), "");
1339 }
1340
1341 #[test]
1342 fn printable_ascii_range() {
1343 let mut m = TerminalModel::new(95, 1);
1344 let printable: Vec<u8> = (0x20..=0x7eu8).collect();
1346 m.feed(&printable);
1347 assert_eq!(m.char_at(0, 0), ' ');
1348 assert_eq!(m.char_at(94, 0), '~');
1349 }
1350
1351 #[test]
1352 fn dump_shows_cursor_and_style() {
1353 let mut m = TerminalModel::new(5, 2);
1354 m.feed(b"\x1b[1mBold\x1b[0m");
1355 let dump = m.dump();
1356 assert!(dump.contains("Bold"));
1357 assert!(dump.contains("Cursor:"));
1358 assert!(dump.contains("Style:"));
1359 }
1360
1361 #[test]
1362 fn multiple_sgr_sequences_accumulate() {
1363 let mut m = TerminalModel::new(10, 3);
1364 m.feed(b"\x1b[1m\x1b[3m\x1b[4mX");
1365 let s = m.style_at(0, 0);
1366 assert!(s.bold);
1367 assert!(s.italic);
1368 assert!(s.underline);
1369 }
1370
1371 #[test]
1372 fn sgr_zero_in_middle_resets_all() {
1373 let mut m = TerminalModel::new(10, 3);
1374 m.feed(b"\x1b[1;0;3mX");
1375 let s = m.style_at(0, 0);
1377 assert!(!s.bold);
1378 assert!(s.italic);
1379 }
1380
1381 #[test]
1382 fn width_1_terminal() {
1383 let mut m = TerminalModel::new(1, 3);
1384 m.feed(b"ABC");
1385 assert_eq!(m.char_at(0, 0), 'A');
1386 assert_eq!(m.char_at(0, 1), 'B');
1387 assert_eq!(m.char_at(0, 2), 'C');
1388 }
1389
1390 #[test]
1391 fn height_1_terminal() {
1392 let mut m = TerminalModel::new(10, 1);
1393 m.feed(b"Hello");
1394 assert_eq!(m.row_text(0), "Hello");
1395 m.feed(b"\n"); assert_eq!(m.cursor(), (5, 0));
1397 }
1398}