1#![forbid(unsafe_code)]
2
3use crate::{
29 cell::{CellAttrs, PackedRgba, StyleFlags},
30 char_width,
31};
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct ModelCell {
36 pub text: String,
39 pub fg: PackedRgba,
41 pub bg: PackedRgba,
43 pub attrs: CellAttrs,
45 pub link_id: u32,
47}
48
49impl Default for ModelCell {
50 fn default() -> Self {
51 Self {
52 text: " ".to_string(),
53 fg: PackedRgba::WHITE,
54 bg: PackedRgba::TRANSPARENT,
55 attrs: CellAttrs::NONE,
56 link_id: 0,
57 }
58 }
59}
60
61impl ModelCell {
62 pub fn with_char(ch: char) -> Self {
64 Self {
65 text: ch.to_string(),
66 ..Default::default()
67 }
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct SgrState {
74 pub fg: PackedRgba,
76 pub bg: PackedRgba,
78 pub flags: StyleFlags,
80}
81
82impl Default for SgrState {
83 fn default() -> Self {
84 Self {
85 fg: PackedRgba::WHITE,
86 bg: PackedRgba::TRANSPARENT,
87 flags: StyleFlags::empty(),
88 }
89 }
90}
91
92impl SgrState {
93 pub fn reset(&mut self) {
95 *self = Self::default();
96 }
97}
98
99#[derive(Debug, Clone, Default, PartialEq, Eq)]
101pub struct ModeFlags {
102 pub cursor_visible: bool,
104 pub alt_screen: bool,
106 pub sync_output_level: u32,
108}
109
110impl ModeFlags {
111 pub fn new() -> Self {
113 Self {
114 cursor_visible: true,
115 alt_screen: false,
116 sync_output_level: 0,
117 }
118 }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
123enum ParseState {
124 Ground,
125 Escape,
126 CsiEntry,
127 CsiParam,
128 OscEntry,
129 OscString,
130}
131
132#[derive(Debug)]
137pub struct TerminalModel {
138 width: usize,
139 height: usize,
140 cells: Vec<ModelCell>,
141 cursor_x: usize,
142 cursor_y: usize,
143 sgr: SgrState,
144 modes: ModeFlags,
145 current_link_id: u32,
146 links: Vec<String>,
148 parse_state: ParseState,
150 csi_params: Vec<u32>,
152 csi_intermediate: Vec<u8>,
154 osc_buffer: Vec<u8>,
156 utf8_pending: Vec<u8>,
158 utf8_expected: Option<usize>,
160 bytes_processed: usize,
162}
163
164impl TerminalModel {
165 pub fn new(width: usize, height: usize) -> Self {
170 let width = width.max(1);
171 let height = height.max(1);
172 let cells = vec![ModelCell::default(); width * height];
173 Self {
174 width,
175 height,
176 cells,
177 cursor_x: 0,
178 cursor_y: 0,
179 sgr: SgrState::default(),
180 modes: ModeFlags::new(),
181 current_link_id: 0,
182 links: vec![String::new()], parse_state: ParseState::Ground,
184 csi_params: Vec::with_capacity(16),
185 csi_intermediate: Vec::with_capacity(4),
186 osc_buffer: Vec::with_capacity(256),
187 utf8_pending: Vec::with_capacity(4),
188 utf8_expected: None,
189 bytes_processed: 0,
190 }
191 }
192
193 #[must_use]
195 pub fn width(&self) -> usize {
196 self.width
197 }
198
199 #[must_use]
201 pub fn height(&self) -> usize {
202 self.height
203 }
204
205 #[must_use]
207 pub fn cursor(&self) -> (usize, usize) {
208 (self.cursor_x, self.cursor_y)
209 }
210
211 #[must_use]
213 pub fn sgr_state(&self) -> &SgrState {
214 &self.sgr
215 }
216
217 #[must_use]
219 pub fn modes(&self) -> &ModeFlags {
220 &self.modes
221 }
222
223 #[must_use]
225 pub fn cell(&self, x: usize, y: usize) -> Option<&ModelCell> {
226 if x < self.width && y < self.height {
227 Some(&self.cells[y * self.width + x])
228 } else {
229 None
230 }
231 }
232
233 fn cell_mut(&mut self, x: usize, y: usize) -> Option<&mut ModelCell> {
235 if x < self.width && y < self.height {
236 Some(&mut self.cells[y * self.width + x])
237 } else {
238 None
239 }
240 }
241
242 #[must_use]
244 pub fn current_cell(&self) -> Option<&ModelCell> {
245 self.cell(self.cursor_x, self.cursor_y)
246 }
247
248 pub fn cells(&self) -> &[ModelCell] {
250 &self.cells
251 }
252
253 #[must_use]
255 pub fn row(&self, y: usize) -> Option<&[ModelCell]> {
256 if y < self.height {
257 let start = y * self.width;
258 Some(&self.cells[start..start + self.width])
259 } else {
260 None
261 }
262 }
263
264 #[must_use]
266 pub fn row_text(&self, y: usize) -> Option<String> {
267 self.row(y).map(|cells| {
268 let s: String = cells.iter().map(|c| c.text.as_str()).collect();
269 s.trim_end_matches(' ').to_string()
270 })
271 }
272
273 #[must_use]
275 pub fn link_url(&self, link_id: u32) -> Option<&str> {
276 self.links.get(link_id as usize).map(|s| s.as_str())
277 }
278
279 pub fn has_dangling_link(&self) -> bool {
281 self.current_link_id != 0
282 }
283
284 pub fn sync_output_balanced(&self) -> bool {
286 self.modes.sync_output_level == 0
287 }
288
289 pub fn reset(&mut self) {
291 self.cells.fill(ModelCell::default());
292 self.cursor_x = 0;
293 self.cursor_y = 0;
294 self.sgr = SgrState::default();
295 self.modes = ModeFlags::new();
296 self.current_link_id = 0;
297 self.links.clear();
299 self.links.push(String::new());
300 self.parse_state = ParseState::Ground;
301 self.csi_params.clear();
302 self.csi_intermediate.clear();
303 self.osc_buffer.clear();
304 self.utf8_pending.clear();
305 self.utf8_expected = None;
306 }
307
308 pub fn process(&mut self, bytes: &[u8]) {
310 for &b in bytes {
311 self.process_byte(b);
312 self.bytes_processed += 1;
313 }
314 }
315
316 fn process_byte(&mut self, b: u8) {
318 match self.parse_state {
319 ParseState::Ground => self.ground_state(b),
320 ParseState::Escape => self.escape_state(b),
321 ParseState::CsiEntry => self.csi_entry_state(b),
322 ParseState::CsiParam => self.csi_param_state(b),
323 ParseState::OscEntry => self.osc_entry_state(b),
324 ParseState::OscString => self.osc_string_state(b),
325 }
326 }
327
328 fn ground_state(&mut self, b: u8) {
329 match b {
330 0x1B => {
331 self.flush_pending_utf8_invalid();
333 self.parse_state = ParseState::Escape;
334 }
335 0x00..=0x1A | 0x1C..=0x1F => {
336 self.flush_pending_utf8_invalid();
338 self.handle_c0(b);
339 }
340 _ => {
341 self.handle_printable(b);
343 }
344 }
345 }
346
347 fn escape_state(&mut self, b: u8) {
348 match b {
349 b'[' => {
350 self.csi_params.clear();
352 self.csi_intermediate.clear();
353 self.parse_state = ParseState::CsiEntry;
354 }
355 b']' => {
356 self.osc_buffer.clear();
358 self.parse_state = ParseState::OscEntry;
359 }
360 b'7' => {
361 self.parse_state = ParseState::Ground;
363 }
364 b'8' => {
365 self.parse_state = ParseState::Ground;
367 }
368 b'=' | b'>' => {
369 self.parse_state = ParseState::Ground;
371 }
372 0x1B => {
373 }
375 _ => {
376 self.parse_state = ParseState::Ground;
378 }
379 }
380 }
381
382 fn csi_entry_state(&mut self, b: u8) {
383 match b {
384 b'0'..=b'9' => {
385 self.csi_params.push((b - b'0') as u32);
386 self.parse_state = ParseState::CsiParam;
387 }
388 b';' => {
389 self.csi_params.push(0);
392 self.csi_params.push(0);
393 self.parse_state = ParseState::CsiParam;
394 }
395 b'?' | b'>' | b'!' => {
396 self.csi_intermediate.push(b);
397 self.parse_state = ParseState::CsiParam;
398 }
399 0x40..=0x7E => {
400 self.execute_csi(b);
402 self.parse_state = ParseState::Ground;
403 }
404 _ => {
405 self.parse_state = ParseState::Ground;
406 }
407 }
408 }
409
410 fn csi_param_state(&mut self, b: u8) {
411 match b {
412 b'0'..=b'9' => {
413 if self.csi_params.is_empty() {
414 self.csi_params.push(0);
415 }
416 if let Some(last) = self.csi_params.last_mut() {
417 *last = last.saturating_mul(10).saturating_add((b - b'0') as u32);
418 }
419 }
420 b';' => {
421 self.csi_params.push(0);
422 }
423 b':' => {
424 self.csi_params.push(0);
426 }
427 0x20..=0x2F => {
428 self.csi_intermediate.push(b);
429 }
430 0x40..=0x7E => {
431 self.execute_csi(b);
433 self.parse_state = ParseState::Ground;
434 }
435 _ => {
436 self.parse_state = ParseState::Ground;
437 }
438 }
439 }
440
441 fn osc_entry_state(&mut self, b: u8) {
442 match b {
443 0x07 => {
444 self.execute_osc();
446 self.parse_state = ParseState::Ground;
447 }
448 0x1B => {
449 self.parse_state = ParseState::OscString;
451 }
452 _ => {
453 self.osc_buffer.push(b);
454 }
455 }
456 }
457
458 fn osc_string_state(&mut self, b: u8) {
459 match b {
460 b'\\' => {
461 self.execute_osc();
463 self.parse_state = ParseState::Ground;
464 }
465 _ => {
466 self.osc_buffer.push(0x1B);
468 self.osc_buffer.push(b);
469 self.parse_state = ParseState::OscEntry;
470 }
471 }
472 }
473
474 fn handle_c0(&mut self, b: u8) {
475 match b {
476 0x07 => {} 0x08 if self.cursor_x > 0 => {
478 self.cursor_x -= 1;
480 }
481 0x09 => {
482 self.cursor_x = (self.cursor_x / 8 + 1) * 8;
484 if self.cursor_x >= self.width {
485 self.cursor_x = self.width - 1;
486 }
487 }
488 0x0A if self.cursor_y + 1 < self.height => {
489 self.cursor_y += 1;
491 }
492 0x0D => {
493 self.cursor_x = 0;
495 }
496 _ => {} }
498 }
499
500 fn handle_printable(&mut self, b: u8) {
501 if self.utf8_expected.is_none() {
502 if b < 0x80 {
503 self.put_char(b as char);
504 return;
505 }
506 if let Some(expected) = Self::utf8_expected_len(b) {
507 self.utf8_pending.clear();
508 self.utf8_pending.push(b);
509 self.utf8_expected = Some(expected);
510 if expected == 1 {
511 self.flush_utf8_sequence();
512 }
513 } else {
514 self.put_char('\u{FFFD}');
515 }
516 return;
517 }
518
519 if !Self::is_utf8_continuation(b) {
520 self.flush_pending_utf8_invalid();
521 self.handle_printable(b);
522 return;
523 }
524
525 self.utf8_pending.push(b);
526 if let Some(expected) = self.utf8_expected {
527 if self.utf8_pending.len() == expected {
528 self.flush_utf8_sequence();
529 } else if self.utf8_pending.len() > expected {
530 self.flush_pending_utf8_invalid();
531 }
532 }
533 }
534
535 fn flush_utf8_sequence(&mut self) {
536 let chars: Vec<char> = std::str::from_utf8(&self.utf8_pending)
539 .map(|text| text.chars().collect())
540 .unwrap_or_else(|_| vec!['\u{FFFD}']);
541 self.utf8_pending.clear();
542 self.utf8_expected = None;
543 for ch in chars {
544 self.put_char(ch);
545 }
546 }
547
548 fn flush_pending_utf8_invalid(&mut self) {
549 if self.utf8_expected.is_some() {
550 self.put_char('\u{FFFD}');
551 self.utf8_pending.clear();
552 self.utf8_expected = None;
553 }
554 }
555
556 fn utf8_expected_len(first: u8) -> Option<usize> {
557 if first < 0x80 {
558 Some(1)
559 } else if (0xC2..=0xDF).contains(&first) {
560 Some(2)
561 } else if (0xE0..=0xEF).contains(&first) {
562 Some(3)
563 } else if (0xF0..=0xF4).contains(&first) {
564 Some(4)
565 } else {
566 None
567 }
568 }
569
570 fn is_utf8_continuation(byte: u8) -> bool {
571 (0x80..=0xBF).contains(&byte)
572 }
573
574 fn put_char(&mut self, ch: char) {
575 let width = char_width(ch);
576
577 if width == 0 {
579 if self.cursor_x > 0 {
580 let idx = self.cursor_y * self.width + self.cursor_x - 1;
582 if let Some(cell) = self.cells.get_mut(idx) {
583 cell.text.push(ch);
584 }
585 } else if self.cursor_x < self.width && self.cursor_y < self.height {
586 let idx = self.cursor_y * self.width + self.cursor_x;
588 let cell = &mut self.cells[idx];
589 if cell.text == " " {
590 cell.text = format!(" {}", ch);
592 } else {
593 cell.text.push(ch);
594 }
595 }
596 return;
597 }
598
599 if self.cursor_x < self.width && self.cursor_y < self.height {
600 let cell = &mut self.cells[self.cursor_y * self.width + self.cursor_x];
601 cell.text = ch.to_string();
602 cell.fg = self.sgr.fg;
603 cell.bg = self.sgr.bg;
604 cell.attrs = CellAttrs::new(self.sgr.flags, self.current_link_id);
605 cell.link_id = self.current_link_id;
606
607 if width == 2 && self.cursor_x + 1 < self.width {
609 let next_cell = &mut self.cells[self.cursor_y * self.width + self.cursor_x + 1];
610 next_cell.text = String::new(); next_cell.fg = self.sgr.fg; next_cell.bg = self.sgr.bg;
613 next_cell.attrs = CellAttrs::NONE; next_cell.link_id = 0; }
616 }
617
618 self.cursor_x += width;
619
620 if self.cursor_x >= self.width {
622 self.cursor_x = 0;
623 if self.cursor_y + 1 < self.height {
624 self.cursor_y += 1;
625 }
626 }
631 }
632
633 fn execute_csi(&mut self, final_byte: u8) {
634 let has_question = self.csi_intermediate.contains(&b'?');
635
636 match final_byte {
637 b'H' | b'f' => self.csi_cup(), b'A' => self.csi_cuu(), b'B' => self.csi_cud(), b'C' => self.csi_cuf(), b'D' => self.csi_cub(), b'G' => self.csi_cha(), b'd' => self.csi_vpa(), b'J' => self.csi_ed(), b'K' => self.csi_el(), b'm' => self.csi_sgr(), b'h' if has_question => self.csi_decset(), b'l' if has_question => self.csi_decrst(), b's' => {
650 }
652 b'u' => {
653 }
655 _ => {} }
657 }
658
659 fn csi_cup(&mut self) {
660 let row = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
662 let col = self.csi_params.get(1).copied().unwrap_or(1).max(1) as usize;
663 self.cursor_y = (row - 1).min(self.height - 1);
664 self.cursor_x = (col - 1).min(self.width - 1);
665 }
666
667 fn csi_cuu(&mut self) {
668 let n = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
669 self.cursor_y = self.cursor_y.saturating_sub(n);
670 }
671
672 fn csi_cud(&mut self) {
673 let n = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
674 self.cursor_y = (self.cursor_y + n).min(self.height - 1);
675 }
676
677 fn csi_cuf(&mut self) {
678 let n = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
679 self.cursor_x = (self.cursor_x + n).min(self.width - 1);
680 }
681
682 fn csi_cub(&mut self) {
683 let n = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
684 self.cursor_x = self.cursor_x.saturating_sub(n);
685 }
686
687 fn csi_cha(&mut self) {
688 let col = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
689 self.cursor_x = (col - 1).min(self.width - 1);
690 }
691
692 fn csi_vpa(&mut self) {
693 let row = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
694 self.cursor_y = (row - 1).min(self.height - 1);
695 }
696
697 fn csi_ed(&mut self) {
698 let mode = self.csi_params.first().copied().unwrap_or(0);
699 match mode {
700 0 => {
701 for x in self.cursor_x..self.width {
703 self.erase_cell(x, self.cursor_y);
704 }
705 for y in (self.cursor_y + 1)..self.height {
706 for x in 0..self.width {
707 self.erase_cell(x, y);
708 }
709 }
710 }
711 1 => {
712 for y in 0..self.cursor_y {
714 for x in 0..self.width {
715 self.erase_cell(x, y);
716 }
717 }
718 for x in 0..=self.cursor_x {
719 self.erase_cell(x, self.cursor_y);
720 }
721 }
722 2 | 3 => {
723 for cell in &mut self.cells {
725 *cell = ModelCell::default();
726 }
727 }
728 _ => {}
729 }
730 }
731
732 fn csi_el(&mut self) {
733 let mode = self.csi_params.first().copied().unwrap_or(0);
734 match mode {
735 0 => {
736 for x in self.cursor_x..self.width {
738 self.erase_cell(x, self.cursor_y);
739 }
740 }
741 1 => {
742 for x in 0..=self.cursor_x {
744 self.erase_cell(x, self.cursor_y);
745 }
746 }
747 2 => {
748 for x in 0..self.width {
750 self.erase_cell(x, self.cursor_y);
751 }
752 }
753 _ => {}
754 }
755 }
756
757 fn erase_cell(&mut self, x: usize, y: usize) {
758 let bg = self.sgr.bg;
760 if let Some(cell) = self.cell_mut(x, y) {
761 cell.text = " ".to_string();
762 cell.fg = PackedRgba::WHITE;
764 cell.bg = bg;
765 cell.attrs = CellAttrs::NONE;
766 cell.link_id = 0;
767 }
768 }
769
770 fn csi_sgr(&mut self) {
771 if self.csi_params.is_empty() {
772 self.sgr.reset();
773 return;
774 }
775
776 let mut i = 0;
777 while i < self.csi_params.len() {
778 let code = self.csi_params[i];
779 match code {
780 0 => self.sgr.reset(),
781 1 => self.sgr.flags.insert(StyleFlags::BOLD),
782 2 => self.sgr.flags.insert(StyleFlags::DIM),
783 3 => self.sgr.flags.insert(StyleFlags::ITALIC),
784 4 => self.sgr.flags.insert(StyleFlags::UNDERLINE),
785 5 => self.sgr.flags.insert(StyleFlags::BLINK),
786 7 => self.sgr.flags.insert(StyleFlags::REVERSE),
787 8 => self.sgr.flags.insert(StyleFlags::HIDDEN),
788 9 => self.sgr.flags.insert(StyleFlags::STRIKETHROUGH),
789 21 | 22 => self.sgr.flags.remove(StyleFlags::BOLD | StyleFlags::DIM),
790 23 => self.sgr.flags.remove(StyleFlags::ITALIC),
791 24 => self.sgr.flags.remove(StyleFlags::UNDERLINE),
792 25 => self.sgr.flags.remove(StyleFlags::BLINK),
793 27 => self.sgr.flags.remove(StyleFlags::REVERSE),
794 28 => self.sgr.flags.remove(StyleFlags::HIDDEN),
795 29 => self.sgr.flags.remove(StyleFlags::STRIKETHROUGH),
796 30..=37 => {
798 self.sgr.fg = Self::basic_color(code - 30);
799 }
800 39 => {
802 self.sgr.fg = PackedRgba::WHITE;
803 }
804 40..=47 => {
806 self.sgr.bg = Self::basic_color(code - 40);
807 }
808 49 => {
810 self.sgr.bg = PackedRgba::TRANSPARENT;
811 }
812 90..=97 => {
814 self.sgr.fg = Self::bright_color(code - 90);
815 }
816 100..=107 => {
818 self.sgr.bg = Self::bright_color(code - 100);
819 }
820 38 => {
822 if let Some(color) = self.parse_extended_color(&mut i) {
823 self.sgr.fg = color;
824 }
825 }
826 48 => {
827 if let Some(color) = self.parse_extended_color(&mut i) {
828 self.sgr.bg = color;
829 }
830 }
831 _ => {} }
833 i += 1;
834 }
835 }
836
837 fn parse_extended_color(&self, i: &mut usize) -> Option<PackedRgba> {
838 let mode = self.csi_params.get(*i + 1)?;
839 match *mode {
840 5 => {
841 let idx = self.csi_params.get(*i + 2)?;
843 *i += 2;
844 Some(Self::color_256(*idx as u8))
845 }
846 2 => {
847 let r = *self.csi_params.get(*i + 2)? as u8;
849 let g = *self.csi_params.get(*i + 3)? as u8;
850 let b = *self.csi_params.get(*i + 4)? as u8;
851 *i += 4;
852 Some(PackedRgba::rgb(r, g, b))
853 }
854 _ => None,
855 }
856 }
857
858 fn basic_color(idx: u32) -> PackedRgba {
859 match idx {
860 0 => PackedRgba::rgb(0, 0, 0), 1 => PackedRgba::rgb(128, 0, 0), 2 => PackedRgba::rgb(0, 128, 0), 3 => PackedRgba::rgb(128, 128, 0), 4 => PackedRgba::rgb(0, 0, 128), 5 => PackedRgba::rgb(128, 0, 128), 6 => PackedRgba::rgb(0, 128, 128), 7 => PackedRgba::rgb(192, 192, 192), _ => PackedRgba::WHITE,
869 }
870 }
871
872 fn bright_color(idx: u32) -> PackedRgba {
873 match idx {
874 0 => PackedRgba::rgb(128, 128, 128), 1 => PackedRgba::rgb(255, 0, 0), 2 => PackedRgba::rgb(0, 255, 0), 3 => PackedRgba::rgb(255, 255, 0), 4 => PackedRgba::rgb(0, 0, 255), 5 => PackedRgba::rgb(255, 0, 255), 6 => PackedRgba::rgb(0, 255, 255), 7 => PackedRgba::rgb(255, 255, 255), _ => PackedRgba::WHITE,
883 }
884 }
885
886 fn color_256(idx: u8) -> PackedRgba {
887 match idx {
888 0..=7 => Self::basic_color(idx as u32),
889 8..=15 => Self::bright_color((idx - 8) as u32),
890 16..=231 => {
891 let idx = idx - 16;
893 let r = (idx / 36) % 6;
894 let g = (idx / 6) % 6;
895 let b = idx % 6;
896 let to_channel = |v| if v == 0 { 0 } else { 55 + v * 40 };
897 PackedRgba::rgb(to_channel(r), to_channel(g), to_channel(b))
898 }
899 232..=255 => {
900 let gray = 8 + (idx - 232) * 10;
902 PackedRgba::rgb(gray, gray, gray)
903 }
904 }
905 }
906
907 fn csi_decset(&mut self) {
908 for &code in &self.csi_params {
909 match code {
910 25 => self.modes.cursor_visible = true, 1049 => self.modes.alt_screen = true, 2026 => self.modes.sync_output_level += 1, _ => {}
914 }
915 }
916 }
917
918 fn csi_decrst(&mut self) {
919 for &code in &self.csi_params {
920 match code {
921 25 => self.modes.cursor_visible = false, 1049 => self.modes.alt_screen = false, 2026 => {
924 self.modes.sync_output_level = self.modes.sync_output_level.saturating_sub(1);
926 }
927 _ => {}
928 }
929 }
930 }
931
932 fn execute_osc(&mut self) {
933 let data = String::from_utf8_lossy(&self.osc_buffer).to_string();
936 let mut parts = data.splitn(2, ';');
937 let code: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
938
939 if code == 8
941 && let Some(rest) = parts.next()
942 {
943 let rest = rest.to_string();
944 self.handle_osc8(&rest);
945 }
946 }
947
948 fn handle_osc8(&mut self, data: &str) {
949 let mut parts = data.splitn(2, ';');
952 let _params = parts.next().unwrap_or("");
953 let uri = parts.next().unwrap_or("");
954
955 if uri.is_empty() {
956 self.current_link_id = 0;
958 } else {
959 self.links.push(uri.to_string());
961 self.current_link_id = (self.links.len() - 1) as u32;
962 }
963 }
964
965 #[must_use]
967 pub fn diff_grid(&self, expected: &[ModelCell]) -> Option<String> {
968 if self.cells.len() != expected.len() {
969 return Some(format!(
970 "Grid size mismatch: got {} cells, expected {}",
971 self.cells.len(),
972 expected.len()
973 ));
974 }
975
976 let mut diffs = Vec::new();
977 for (i, (actual, exp)) in self.cells.iter().zip(expected.iter()).enumerate() {
978 if actual != exp {
979 let x = i % self.width;
980 let y = i / self.width;
981 diffs.push(format!(
982 " ({}, {}): got {:?}, expected {:?}",
983 x, y, actual.text, exp.text
984 ));
985 }
986 }
987
988 if diffs.is_empty() {
989 None
990 } else {
991 Some(format!("Grid differences:\n{}", diffs.join("\n")))
992 }
993 }
994
995 pub fn dump_sequences(bytes: &[u8]) -> String {
997 let mut output = String::new();
998 let mut i = 0;
999 while i < bytes.len() {
1000 if bytes[i] == 0x1B {
1001 if i + 1 < bytes.len() {
1002 match bytes[i + 1] {
1003 b'[' => {
1004 output.push_str("\\e[");
1006 i += 2;
1007 while i < bytes.len() && !(0x40..=0x7E).contains(&bytes[i]) {
1008 output.push(bytes[i] as char);
1009 i += 1;
1010 }
1011 if i < bytes.len() {
1012 output.push(bytes[i] as char);
1013 i += 1;
1014 }
1015 }
1016 b']' => {
1017 output.push_str("\\e]");
1019 i += 2;
1020 while i < bytes.len() && bytes[i] != 0x07 {
1021 if bytes[i] == 0x1B && i + 1 < bytes.len() && bytes[i + 1] == b'\\'
1022 {
1023 output.push_str("\\e\\\\");
1024 i += 2;
1025 break;
1026 }
1027 output.push(bytes[i] as char);
1028 i += 1;
1029 }
1030 if i < bytes.len() && bytes[i] == 0x07 {
1031 output.push_str("\\a");
1032 i += 1;
1033 }
1034 }
1035 _ => {
1036 output.push_str(&format!("\\e{}", bytes[i + 1] as char));
1037 i += 2;
1038 }
1039 }
1040 } else {
1041 output.push_str("\\e");
1042 i += 1;
1043 }
1044 } else if bytes[i] < 0x20 {
1045 output.push_str(&format!("\\x{:02x}", bytes[i]));
1046 i += 1;
1047 } else {
1048 output.push(bytes[i] as char);
1049 i += 1;
1050 }
1051 }
1052 output
1053 }
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058 use super::*;
1059 use crate::ansi;
1060
1061 #[test]
1062 fn new_creates_empty_grid() {
1063 let model = TerminalModel::new(80, 24);
1064 assert_eq!(model.width(), 80);
1065 assert_eq!(model.height(), 24);
1066 assert_eq!(model.cursor(), (0, 0));
1067 assert_eq!(model.cells().len(), 80 * 24);
1068 }
1069
1070 #[test]
1071 fn printable_text_writes_to_grid() {
1072 let mut model = TerminalModel::new(10, 5);
1073 model.process(b"Hello");
1074 assert_eq!(model.cursor(), (5, 0));
1075 assert_eq!(model.row_text(0), Some("Hello".to_string()));
1076 }
1077
1078 #[test]
1079 fn cup_moves_cursor() {
1080 let mut model = TerminalModel::new(80, 24);
1081 model.process(b"\x1b[5;10H"); assert_eq!(model.cursor(), (9, 4)); }
1084
1085 #[test]
1086 fn cup_with_defaults() {
1087 let mut model = TerminalModel::new(80, 24);
1088 model.process(b"\x1b[H"); assert_eq!(model.cursor(), (0, 0));
1090 }
1091
1092 #[test]
1093 fn relative_cursor_moves() {
1094 let mut model = TerminalModel::new(80, 24);
1095 model.process(b"\x1b[10;10H"); model.process(b"\x1b[2A"); assert_eq!(model.cursor(), (9, 7));
1098 model.process(b"\x1b[3B"); assert_eq!(model.cursor(), (9, 10));
1100 model.process(b"\x1b[5C"); assert_eq!(model.cursor(), (14, 10));
1102 model.process(b"\x1b[3D"); assert_eq!(model.cursor(), (11, 10));
1104 }
1105
1106 #[test]
1107 fn sgr_sets_style_flags() {
1108 let mut model = TerminalModel::new(20, 5);
1109 model.process(b"\x1b[1mBold\x1b[0m");
1110 assert!(model.cell(0, 0).unwrap().attrs.has_flag(StyleFlags::BOLD));
1111 assert!(!model.cell(4, 0).unwrap().attrs.has_flag(StyleFlags::BOLD)); }
1113
1114 #[test]
1115 fn sgr_sets_colors() {
1116 let mut model = TerminalModel::new(20, 5);
1117 model.process(b"\x1b[31mRed\x1b[0m");
1118 assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(128, 0, 0));
1119 }
1120
1121 #[test]
1122 fn sgr_256_colors() {
1123 let mut model = TerminalModel::new(20, 5);
1124 model.process(b"\x1b[38;5;196mX"); let cell = model.cell(0, 0).unwrap();
1126 assert_eq!(cell.fg, PackedRgba::rgb(255, 0, 0));
1129 }
1130
1131 #[test]
1132 fn sgr_rgb_colors() {
1133 let mut model = TerminalModel::new(20, 5);
1134 model.process(b"\x1b[38;2;100;150;200mX");
1135 assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(100, 150, 200));
1136 }
1137
1138 #[test]
1139 fn erase_line() {
1140 let mut model = TerminalModel::new(10, 5);
1141 model.process(b"ABCDEFGHIJ");
1142 model.process(b"\x1b[1;5H"); model.process(b"\x1b[K"); assert_eq!(model.row_text(0), Some("ABCD".to_string()));
1147 }
1148
1149 #[test]
1150 fn erase_display() {
1151 let mut model = TerminalModel::new(10, 5);
1152 model.process(b"Line1\n");
1153 model.process(b"Line2\n");
1154 model.process(b"\x1b[2J"); for y in 0..5 {
1156 assert_eq!(model.row_text(y), Some(String::new()));
1157 }
1158 }
1159
1160 #[test]
1161 fn osc8_hyperlinks() {
1162 let mut model = TerminalModel::new(20, 5);
1163 model.process(b"\x1b]8;;https://example.com\x07Link\x1b]8;;\x07");
1164
1165 let cell = model.cell(0, 0).unwrap();
1166 assert!(cell.link_id > 0);
1167 assert_eq!(model.link_url(cell.link_id), Some("https://example.com"));
1168
1169 let cell_after = model.cell(4, 0).unwrap();
1171 assert_eq!(cell_after.link_id, 0);
1172 }
1173
1174 #[test]
1175 fn dangling_link_detection() {
1176 let mut model = TerminalModel::new(20, 5);
1177 model.process(b"\x1b]8;;https://example.com\x07Link");
1178 assert!(model.has_dangling_link());
1179
1180 model.process(b"\x1b]8;;\x07");
1181 assert!(!model.has_dangling_link());
1182 }
1183
1184 #[test]
1185 fn sync_output_tracking() {
1186 let mut model = TerminalModel::new(20, 5);
1187 assert!(model.sync_output_balanced());
1188
1189 model.process(b"\x1b[?2026h"); assert!(!model.sync_output_balanced());
1191 assert_eq!(model.modes().sync_output_level, 1);
1192
1193 model.process(b"\x1b[?2026l"); assert!(model.sync_output_balanced());
1195 }
1196
1197 #[test]
1198 fn utf8_multibyte_stream_is_decoded() {
1199 let mut model = TerminalModel::new(10, 1);
1200 let text = "a\u{00E9}\u{4E2D}\u{1F600}";
1201 model.process(text.as_bytes());
1202
1203 assert_eq!(model.row_text(0).as_deref(), Some(text));
1204 assert_eq!(model.cursor(), (6, 0));
1205 }
1206
1207 #[test]
1208 fn utf8_sequence_can_span_process_calls() {
1209 let mut model = TerminalModel::new(10, 1);
1210 let text = "\u{00E9}";
1211 let bytes = text.as_bytes();
1212
1213 model.process(&bytes[..1]);
1214 assert_eq!(model.row_text(0).as_deref(), Some(""));
1215
1216 model.process(&bytes[1..]);
1217 assert_eq!(model.row_text(0).as_deref(), Some(text));
1218 }
1219
1220 #[test]
1221 fn line_wrap() {
1222 let mut model = TerminalModel::new(5, 3);
1223 model.process(b"ABCDEFGH");
1224 assert_eq!(model.row_text(0), Some("ABCDE".to_string()));
1225 assert_eq!(model.row_text(1), Some("FGH".to_string()));
1226 assert_eq!(model.cursor(), (3, 1));
1227 }
1228
1229 #[test]
1230 fn cr_lf_handling() {
1231 let mut model = TerminalModel::new(20, 5);
1232 model.process(b"Hello\r\n");
1233 assert_eq!(model.cursor(), (0, 1));
1234 model.process(b"World");
1235 assert_eq!(model.row_text(0), Some("Hello".to_string()));
1236 assert_eq!(model.row_text(1), Some("World".to_string()));
1237 }
1238
1239 #[test]
1240 fn cursor_visibility() {
1241 let mut model = TerminalModel::new(20, 5);
1242 assert!(model.modes().cursor_visible);
1243
1244 model.process(b"\x1b[?25l"); assert!(!model.modes().cursor_visible);
1246
1247 model.process(b"\x1b[?25h"); assert!(model.modes().cursor_visible);
1249 }
1250
1251 #[test]
1252 fn alt_screen_toggle_is_tracked() {
1253 let mut model = TerminalModel::new(20, 5);
1254 assert!(!model.modes().alt_screen);
1255
1256 model.process(b"\x1b[?1049h");
1257 assert!(model.modes().alt_screen);
1258
1259 model.process(b"\x1b[?1049l");
1260 assert!(!model.modes().alt_screen);
1261 }
1262
1263 #[test]
1264 fn dump_sequences_readable() {
1265 let bytes = b"\x1b[1;1H\x1b[1mHello\x1b[0m";
1266 let dump = TerminalModel::dump_sequences(bytes);
1267 assert!(dump.contains("\\e[1;1H"));
1268 assert!(dump.contains("\\e[1m"));
1269 assert!(dump.contains("Hello"));
1270 assert!(dump.contains("\\e[0m"));
1271 }
1272
1273 #[test]
1274 fn reset_clears_state() {
1275 let mut model = TerminalModel::new(20, 5);
1276 model.process(b"\x1b[10;10HTest\x1b[1m");
1277 model.reset();
1278
1279 assert_eq!(model.cursor(), (0, 0));
1280 assert!(model.sgr_state().flags.is_empty());
1281 for y in 0..5 {
1282 assert_eq!(model.row_text(y), Some(String::new()));
1283 }
1284 }
1285
1286 #[test]
1287 fn erase_scrollback_mode_clears_screen() {
1288 let mut model = TerminalModel::new(10, 3);
1289 model.process(b"Line1\nLine2\nLine3");
1290 model.process(b"\x1b[3J"); for y in 0..3 {
1293 assert_eq!(model.row_text(y), Some(String::new()));
1294 }
1295 }
1296
1297 #[test]
1298 fn scroll_region_sequences_are_ignored_but_safe() {
1299 let mut model = TerminalModel::new(12, 3);
1300 model.process(b"ABCD");
1301 let cursor_before = model.cursor();
1302
1303 let mut buf = Vec::new();
1304 ansi::set_scroll_region(&mut buf, 1, 2).expect("scroll region sequence");
1305 model.process(&buf);
1306 model.process(ansi::RESET_SCROLL_REGION);
1307
1308 assert_eq!(model.cursor(), cursor_before);
1309 model.process(b"EF");
1310 assert_eq!(model.row_text(0).as_deref(), Some("ABCDEF"));
1311 }
1312
1313 #[test]
1314 fn scroll_region_invalid_params_do_not_corrupt_state() {
1315 let mut model = TerminalModel::new(8, 2);
1316 model.process(b"Hi");
1317 let cursor_before = model.cursor();
1318
1319 model.process(b"\x1b[5;2r"); model.process(b"\x1b[0;0r"); model.process(b"\x1b[999;999r"); assert_eq!(model.cursor(), cursor_before);
1324 model.process(b"!");
1325 assert_eq!(model.row_text(0).as_deref(), Some("Hi!"));
1326 }
1327
1328 #[test]
1331 fn model_cell_default_is_space() {
1332 let cell = ModelCell::default();
1333 assert_eq!(cell.text, " ");
1334 assert_eq!(cell.fg, PackedRgba::WHITE);
1335 assert_eq!(cell.bg, PackedRgba::TRANSPARENT);
1336 assert_eq!(cell.attrs, CellAttrs::NONE);
1337 assert_eq!(cell.link_id, 0);
1338 }
1339
1340 #[test]
1341 fn model_cell_with_char() {
1342 let cell = ModelCell::with_char('X');
1343 assert_eq!(cell.text, "X");
1344 assert_eq!(cell.fg, PackedRgba::WHITE);
1345 assert_eq!(cell.link_id, 0);
1346 }
1347
1348 #[test]
1349 fn model_cell_eq() {
1350 let a = ModelCell::default();
1351 let b = ModelCell::default();
1352 assert_eq!(a, b);
1353 let c = ModelCell::with_char('X');
1354 assert_ne!(a, c);
1355 }
1356
1357 #[test]
1358 fn model_cell_clone() {
1359 let a = ModelCell::with_char('Z');
1360 let b = a.clone();
1361 assert_eq!(b.text, "Z");
1362 }
1363
1364 #[test]
1367 fn sgr_state_default_fields() {
1368 let s = SgrState::default();
1369 assert_eq!(s.fg, PackedRgba::WHITE);
1370 assert_eq!(s.bg, PackedRgba::TRANSPARENT);
1371 assert!(s.flags.is_empty());
1372 }
1373
1374 #[test]
1375 fn sgr_state_reset() {
1376 let mut s = SgrState {
1377 fg: PackedRgba::rgb(255, 0, 0),
1378 bg: PackedRgba::rgb(0, 0, 255),
1379 flags: StyleFlags::BOLD | StyleFlags::ITALIC,
1380 };
1381 s.reset();
1382 assert_eq!(s.fg, PackedRgba::WHITE);
1383 assert_eq!(s.bg, PackedRgba::TRANSPARENT);
1384 assert!(s.flags.is_empty());
1385 }
1386
1387 #[test]
1390 fn mode_flags_new_defaults() {
1391 let m = ModeFlags::new();
1392 assert!(m.cursor_visible);
1393 assert!(!m.alt_screen);
1394 assert_eq!(m.sync_output_level, 0);
1395 }
1396
1397 #[test]
1398 fn mode_flags_default_vs_new() {
1399 let d = ModeFlags::default();
1401 assert!(!d.cursor_visible);
1402 let n = ModeFlags::new();
1404 assert!(n.cursor_visible);
1405 }
1406
1407 #[test]
1410 fn new_zero_dimensions_clamped() {
1411 let model = TerminalModel::new(0, 0);
1412 assert_eq!(model.width(), 1);
1413 assert_eq!(model.height(), 1);
1414 assert_eq!(model.cells().len(), 1);
1415 }
1416
1417 #[test]
1418 fn new_1x1() {
1419 let model = TerminalModel::new(1, 1);
1420 assert_eq!(model.width(), 1);
1421 assert_eq!(model.height(), 1);
1422 assert_eq!(model.cursor(), (0, 0));
1423 }
1424
1425 #[test]
1428 fn cell_out_of_bounds_returns_none() {
1429 let model = TerminalModel::new(5, 3);
1430 assert!(model.cell(5, 0).is_none());
1431 assert!(model.cell(0, 3).is_none());
1432 assert!(model.cell(100, 100).is_none());
1433 }
1434
1435 #[test]
1436 fn cell_in_bounds_returns_some() {
1437 let model = TerminalModel::new(5, 3);
1438 assert!(model.cell(0, 0).is_some());
1439 assert!(model.cell(4, 2).is_some());
1440 }
1441
1442 #[test]
1443 fn current_cell_at_cursor() {
1444 let mut model = TerminalModel::new(10, 5);
1445 model.process(b"AB");
1446 let cc = model.current_cell().unwrap();
1448 assert_eq!(cc.text, " "); }
1450
1451 #[test]
1452 fn row_out_of_bounds_returns_none() {
1453 let model = TerminalModel::new(5, 3);
1454 assert!(model.row(3).is_none());
1455 assert!(model.row(100).is_none());
1456 }
1457
1458 #[test]
1459 fn row_text_trims_trailing_spaces() {
1460 let mut model = TerminalModel::new(10, 1);
1461 model.process(b"Hi");
1462 assert_eq!(model.row_text(0), Some("Hi".to_string()));
1463 }
1464
1465 #[test]
1466 fn row_text_preserves_trailing_non_padding_whitespace() {
1467 let mut model = TerminalModel::new(10, 1);
1468 let text = "Hi\u{00A0}";
1469 model.process(text.as_bytes());
1470 assert_eq!(model.row_text(0).as_deref(), Some(text));
1471 }
1472
1473 #[test]
1474 fn link_url_invalid_id_returns_none() {
1475 let model = TerminalModel::new(5, 1);
1476 assert!(model.link_url(999).is_none());
1477 }
1478
1479 #[test]
1480 fn link_url_zero_is_empty() {
1481 let model = TerminalModel::new(5, 1);
1482 assert_eq!(model.link_url(0), Some(""));
1483 }
1484
1485 #[test]
1486 fn has_dangling_link_initially_false() {
1487 let model = TerminalModel::new(5, 1);
1488 assert!(!model.has_dangling_link());
1489 }
1490
1491 #[test]
1494 fn cha_moves_to_column() {
1495 let mut model = TerminalModel::new(80, 24);
1496 model.process(b"\x1b[1;1H"); model.process(b"\x1b[20G"); assert_eq!(model.cursor(), (19, 0));
1499 }
1500
1501 #[test]
1502 fn cha_clamps_to_width() {
1503 let mut model = TerminalModel::new(10, 1);
1504 model.process(b"\x1b[999G");
1505 assert_eq!(model.cursor().0, 9);
1506 }
1507
1508 #[test]
1511 fn vpa_moves_to_row() {
1512 let mut model = TerminalModel::new(80, 24);
1513 model.process(b"\x1b[10d"); assert_eq!(model.cursor(), (0, 9));
1515 }
1516
1517 #[test]
1518 fn vpa_clamps_to_height() {
1519 let mut model = TerminalModel::new(10, 5);
1520 model.process(b"\x1b[999d");
1521 assert_eq!(model.cursor().1, 4);
1522 }
1523
1524 #[test]
1527 fn backspace_moves_cursor_back() {
1528 let mut model = TerminalModel::new(10, 1);
1529 model.process(b"ABC");
1530 assert_eq!(model.cursor(), (3, 0));
1531 model.process(b"\x08"); assert_eq!(model.cursor(), (2, 0));
1533 }
1534
1535 #[test]
1536 fn backspace_at_column_zero_no_move() {
1537 let mut model = TerminalModel::new(10, 1);
1538 model.process(b"\x08");
1539 assert_eq!(model.cursor(), (0, 0));
1540 }
1541
1542 #[test]
1545 fn tab_moves_to_next_tab_stop() {
1546 let mut model = TerminalModel::new(80, 1);
1547 model.process(b"\t");
1548 assert_eq!(model.cursor(), (8, 0));
1549 model.process(b"A\t");
1550 assert_eq!(model.cursor(), (16, 0));
1551 }
1552
1553 #[test]
1554 fn tab_clamps_at_right_edge() {
1555 let mut model = TerminalModel::new(10, 1);
1556 model.process(b"\t"); model.process(b"\t"); assert_eq!(model.cursor(), (9, 0));
1559 }
1560
1561 #[test]
1564 fn esc_7_8_do_not_panic() {
1565 let mut model = TerminalModel::new(10, 1);
1566 model.process(b"\x1b7"); model.process(b"\x1b8"); assert_eq!(model.cursor(), (0, 0));
1569 }
1570
1571 #[test]
1572 fn esc_equals_greater_ignored() {
1573 let mut model = TerminalModel::new(10, 1);
1574 model.process(b"\x1b="); model.process(b"\x1b>"); assert_eq!(model.cursor(), (0, 0));
1577 }
1578
1579 #[test]
1580 fn esc_esc_double_escape_handled() {
1581 let mut model = TerminalModel::new(10, 1);
1582 model.process(b"\x1b\x1b"); model.process(b"AB");
1585 assert_eq!(model.row_text(0).as_deref(), Some("B"));
1587 }
1588
1589 #[test]
1590 fn unknown_escape_returns_to_ground() {
1591 let mut model = TerminalModel::new(10, 1);
1592 model.process(b"\x1bQ"); model.process(b"Hi");
1594 assert_eq!(model.row_text(0).as_deref(), Some("Hi"));
1595 }
1596
1597 #[test]
1600 fn el_mode_1_erases_from_start_to_cursor() {
1601 let mut model = TerminalModel::new(10, 1);
1602 model.process(b"ABCDEFGHIJ");
1603 model.process(b"\x1b[1;5H"); model.process(b"\x1b[1K"); let row = model.row_text(0).unwrap();
1607 assert!(row.starts_with(" ") || row.trim_start().starts_with("FGHIJ"));
1608 }
1609
1610 #[test]
1611 fn el_mode_2_erases_entire_line() {
1612 let mut model = TerminalModel::new(10, 1);
1613 model.process(b"ABCDEFGHIJ");
1614 model.process(b"\x1b[1;5H");
1615 model.process(b"\x1b[2K"); assert_eq!(model.row_text(0), Some(String::new()));
1617 }
1618
1619 #[test]
1622 fn ed_mode_0_erases_from_cursor_to_end() {
1623 let mut model = TerminalModel::new(10, 3);
1624 model.process(b"Line1\nLine2\nLine3");
1625 model.process(b"\x1b[2;1H"); model.process(b"\x1b[0J"); assert_eq!(model.row_text(0), Some("Line1".to_string()));
1628 assert_eq!(model.row_text(1), Some(String::new()));
1629 assert_eq!(model.row_text(2), Some(String::new()));
1630 }
1631
1632 #[test]
1633 fn ed_mode_1_erases_from_start_to_cursor() {
1634 let mut model = TerminalModel::new(10, 3);
1635 model.process(b"Line1\nLine2\nLine3");
1636 model.process(b"\x1b[2;3H"); model.process(b"\x1b[1J"); assert_eq!(model.row_text(0), Some(String::new()));
1639 let row1 = model.row_text(1).unwrap();
1641 assert!(row1.starts_with(" ") || row1.len() <= 10);
1642 }
1643
1644 #[test]
1647 fn sgr_italic() {
1648 let mut model = TerminalModel::new(10, 1);
1649 model.process(b"\x1b[3mI\x1b[0m");
1650 assert!(model.cell(0, 0).unwrap().attrs.has_flag(StyleFlags::ITALIC));
1651 }
1652
1653 #[test]
1654 fn sgr_underline() {
1655 let mut model = TerminalModel::new(10, 1);
1656 model.process(b"\x1b[4mU\x1b[0m");
1657 assert!(
1658 model
1659 .cell(0, 0)
1660 .unwrap()
1661 .attrs
1662 .has_flag(StyleFlags::UNDERLINE)
1663 );
1664 }
1665
1666 #[test]
1667 fn sgr_dim() {
1668 let mut model = TerminalModel::new(10, 1);
1669 model.process(b"\x1b[2mD\x1b[0m");
1670 assert!(model.cell(0, 0).unwrap().attrs.has_flag(StyleFlags::DIM));
1671 }
1672
1673 #[test]
1674 fn sgr_strikethrough() {
1675 let mut model = TerminalModel::new(10, 1);
1676 model.process(b"\x1b[9mS\x1b[0m");
1677 assert!(
1678 model
1679 .cell(0, 0)
1680 .unwrap()
1681 .attrs
1682 .has_flag(StyleFlags::STRIKETHROUGH)
1683 );
1684 }
1685
1686 #[test]
1687 fn sgr_reverse() {
1688 let mut model = TerminalModel::new(10, 1);
1689 model.process(b"\x1b[7mR\x1b[0m");
1690 assert!(
1691 model
1692 .cell(0, 0)
1693 .unwrap()
1694 .attrs
1695 .has_flag(StyleFlags::REVERSE)
1696 );
1697 }
1698
1699 #[test]
1700 fn sgr_remove_bold() {
1701 let mut model = TerminalModel::new(10, 1);
1702 model.process(b"\x1b[1mB\x1b[22mX");
1703 assert!(model.cell(0, 0).unwrap().attrs.has_flag(StyleFlags::BOLD));
1704 assert!(!model.cell(1, 0).unwrap().attrs.has_flag(StyleFlags::BOLD));
1705 }
1706
1707 #[test]
1708 fn sgr_remove_italic() {
1709 let mut model = TerminalModel::new(10, 1);
1710 model.process(b"\x1b[3mI\x1b[23mX");
1711 assert!(!model.cell(1, 0).unwrap().attrs.has_flag(StyleFlags::ITALIC));
1712 }
1713
1714 #[test]
1717 fn sgr_basic_background() {
1718 let mut model = TerminalModel::new(10, 1);
1719 model.process(b"\x1b[42mG"); assert_eq!(model.cell(0, 0).unwrap().bg, PackedRgba::rgb(0, 128, 0));
1721 }
1722
1723 #[test]
1724 fn sgr_default_fg_39() {
1725 let mut model = TerminalModel::new(10, 1);
1726 model.process(b"\x1b[31m\x1b[39mX");
1727 assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::WHITE);
1728 }
1729
1730 #[test]
1731 fn sgr_default_bg_49() {
1732 let mut model = TerminalModel::new(10, 1);
1733 model.process(b"\x1b[41m\x1b[49mX");
1734 assert_eq!(model.cell(0, 0).unwrap().bg, PackedRgba::TRANSPARENT);
1735 }
1736
1737 #[test]
1738 fn sgr_bright_fg() {
1739 let mut model = TerminalModel::new(10, 1);
1740 model.process(b"\x1b[91mX"); assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(255, 0, 0));
1742 }
1743
1744 #[test]
1745 fn sgr_bright_bg() {
1746 let mut model = TerminalModel::new(10, 1);
1747 model.process(b"\x1b[104mX"); assert_eq!(model.cell(0, 0).unwrap().bg, PackedRgba::rgb(0, 0, 255));
1749 }
1750
1751 #[test]
1752 fn sgr_256_grayscale() {
1753 let mut model = TerminalModel::new(10, 1);
1754 model.process(b"\x1b[38;5;232mX"); assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(8, 8, 8));
1756 }
1757
1758 #[test]
1759 fn sgr_256_basic_range() {
1760 let mut model = TerminalModel::new(10, 1);
1761 model.process(b"\x1b[38;5;1mX"); assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(128, 0, 0));
1763 }
1764
1765 #[test]
1766 fn sgr_256_bright_range() {
1767 let mut model = TerminalModel::new(10, 1);
1768 model.process(b"\x1b[38;5;9mX"); assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(255, 0, 0));
1770 }
1771
1772 #[test]
1773 fn sgr_empty_params_resets() {
1774 let mut model = TerminalModel::new(10, 1);
1775 model.process(b"\x1b[1m\x1b[mX"); assert!(!model.cell(0, 0).unwrap().attrs.has_flag(StyleFlags::BOLD));
1777 }
1778
1779 #[test]
1782 fn sync_output_extra_end_saturates() {
1783 let mut model = TerminalModel::new(10, 1);
1784 model.process(b"\x1b[?2026l"); assert_eq!(model.modes().sync_output_level, 0);
1786 assert!(model.sync_output_balanced());
1787 }
1788
1789 #[test]
1790 fn sync_output_nested() {
1791 let mut model = TerminalModel::new(10, 1);
1792 model.process(b"\x1b[?2026h");
1793 model.process(b"\x1b[?2026h");
1794 assert_eq!(model.modes().sync_output_level, 2);
1795 model.process(b"\x1b[?2026l");
1796 assert_eq!(model.modes().sync_output_level, 1);
1797 assert!(!model.sync_output_balanced());
1798 }
1799
1800 #[test]
1803 fn diff_grid_identical_returns_none() {
1804 let model = TerminalModel::new(3, 2);
1805 let expected = vec![ModelCell::default(); 6];
1806 assert!(model.diff_grid(&expected).is_none());
1807 }
1808
1809 #[test]
1810 fn diff_grid_different_returns_some() {
1811 let mut model = TerminalModel::new(3, 1);
1812 model.process(b"ABC");
1813 let expected = vec![ModelCell::default(); 3];
1814 let diff = model.diff_grid(&expected);
1815 assert!(diff.is_some());
1816 let diff_str = diff.unwrap();
1817 assert!(diff_str.contains("Grid differences"));
1818 }
1819
1820 #[test]
1821 fn diff_grid_size_mismatch() {
1822 let model = TerminalModel::new(3, 2);
1823 let expected = vec![ModelCell::default(); 5]; let diff = model.diff_grid(&expected);
1825 assert!(diff.is_some());
1826 assert!(diff.unwrap().contains("Grid size mismatch"));
1827 }
1828
1829 #[test]
1832 fn dump_sequences_osc() {
1833 let bytes = b"\x1b]8;;https://example.com\x07text\x1b]8;;\x07";
1834 let dump = TerminalModel::dump_sequences(bytes);
1835 assert!(dump.contains("\\e]8;;https://example.com\\a"));
1836 }
1837
1838 #[test]
1839 fn dump_sequences_osc_st() {
1840 let bytes = b"\x1b]0;title\x1b\\";
1841 let dump = TerminalModel::dump_sequences(bytes);
1842 assert!(dump.contains("\\e]"));
1843 assert!(dump.contains("\\e\\\\"));
1844 }
1845
1846 #[test]
1847 fn dump_sequences_c0_controls() {
1848 let bytes = b"\x08\x09\x0A";
1849 let dump = TerminalModel::dump_sequences(bytes);
1850 assert!(dump.contains("\\x08"));
1851 assert!(dump.contains("\\x09"));
1852 assert!(dump.contains("\\x0a"));
1853 }
1854
1855 #[test]
1856 fn dump_sequences_trailing_esc() {
1857 let bytes = b"text\x1b";
1858 let dump = TerminalModel::dump_sequences(bytes);
1859 assert!(dump.contains("text"));
1860 assert!(dump.contains("\\e"));
1861 }
1862
1863 #[test]
1864 fn dump_sequences_unknown_escape() {
1865 let bytes = b"\x1bQ";
1866 let dump = TerminalModel::dump_sequences(bytes);
1867 assert!(dump.contains("\\eQ"));
1868 }
1869
1870 #[test]
1873 fn erase_line_uses_current_bg() {
1874 let mut model = TerminalModel::new(5, 1);
1875 model.process(b"Hello");
1876 model.process(b"\x1b[1;1H"); model.process(b"\x1b[41m"); model.process(b"\x1b[K"); let cell = model.cell(0, 0).unwrap();
1880 assert_eq!(cell.text, " ");
1881 assert_eq!(cell.bg, PackedRgba::rgb(128, 0, 0));
1882 }
1883
1884 #[test]
1887 fn multiple_hyperlinks_get_different_ids() {
1888 let mut model = TerminalModel::new(30, 1);
1889 model.process(b"\x1b]8;;https://a.com\x07A\x1b]8;;\x07");
1890 model.process(b"\x1b]8;;https://b.com\x07B\x1b]8;;\x07");
1891 let id_a = model.cell(0, 0).unwrap().link_id;
1892 let id_b = model.cell(1, 0).unwrap().link_id;
1893 assert_ne!(id_a, id_b);
1894 assert_eq!(model.link_url(id_a), Some("https://a.com"));
1895 assert_eq!(model.link_url(id_b), Some("https://b.com"));
1896 }
1897
1898 #[test]
1901 fn osc8_with_st_terminator() {
1902 let mut model = TerminalModel::new(20, 1);
1903 model.process(b"\x1b]8;;https://st.com\x1b\\Link\x1b]8;;\x1b\\");
1904 let cell = model.cell(0, 0).unwrap();
1905 assert!(cell.link_id > 0);
1906 assert_eq!(model.link_url(cell.link_id), Some("https://st.com"));
1907 assert!(!model.has_dangling_link());
1908 }
1909
1910 #[test]
1913 fn terminal_model_debug() {
1914 let model = TerminalModel::new(5, 3);
1915 let dbg = format!("{model:?}");
1916 assert!(dbg.contains("TerminalModel"));
1917 }
1918
1919 #[test]
1922 fn wide_char_occupies_two_cells() {
1923 let mut model = TerminalModel::new(10, 1);
1924 model.process("中".as_bytes());
1926 assert_eq!(model.cell(0, 0).unwrap().text, "中");
1927 assert_eq!(model.cell(1, 0).unwrap().text, "");
1929 assert_eq!(model.cursor(), (2, 0));
1930 }
1931
1932 #[test]
1935 fn cup_with_f_final_byte() {
1936 let mut model = TerminalModel::new(80, 24);
1937 model.process(b"\x1b[3;7f"); assert_eq!(model.cursor(), (6, 2));
1939 }
1940
1941 #[test]
1944 fn csi_unknown_final_byte_ignored() {
1945 let mut model = TerminalModel::new(10, 1);
1946 model.process(b"A");
1947 model.process(b"\x1b[99X"); model.process(b"B");
1949 assert_eq!(model.row_text(0).as_deref(), Some("AB"));
1950 }
1951
1952 #[test]
1955 fn csi_save_restore_cursor_no_panic() {
1956 let mut model = TerminalModel::new(10, 5);
1957 model.process(b"\x1b[5;5H");
1958 model.process(b"\x1b[s"); model.process(b"\x1b[1;1H");
1960 model.process(b"\x1b[u"); let (x, y) = model.cursor();
1963 assert!(x < model.width());
1964 assert!(y < model.height());
1965 }
1966
1967 #[test]
1970 fn bel_in_ground_is_ignored() {
1971 let mut model = TerminalModel::new(10, 1);
1972 model.process(b"\x07Hi");
1973 assert_eq!(model.row_text(0).as_deref(), Some("Hi"));
1974 }
1975
1976 #[test]
1979 fn cup_clamps_large_row_col() {
1980 let mut model = TerminalModel::new(10, 5);
1981 model.process(b"\x1b[999;999H");
1982 assert_eq!(model.cursor(), (9, 4));
1983 }
1984
1985 #[test]
1988 fn cuu_at_top_stays() {
1989 let mut model = TerminalModel::new(10, 5);
1990 model.process(b"\x1b[1;1H");
1991 model.process(b"\x1b[50A"); assert_eq!(model.cursor(), (0, 0));
1993 }
1994
1995 #[test]
1996 fn cud_at_bottom_stays() {
1997 let mut model = TerminalModel::new(10, 5);
1998 model.process(b"\x1b[5;1H");
1999 model.process(b"\x1b[50B"); assert_eq!(model.cursor(), (0, 4));
2001 }
2002
2003 #[test]
2004 fn cuf_at_right_stays() {
2005 let mut model = TerminalModel::new(10, 1);
2006 model.process(b"\x1b[1;10H");
2007 model.process(b"\x1b[50C"); assert_eq!(model.cursor().0, 9);
2009 }
2010
2011 #[test]
2012 fn cub_at_left_stays() {
2013 let mut model = TerminalModel::new(10, 1);
2014 model.process(b"\x1b[50D"); assert_eq!(model.cursor().0, 0);
2016 }
2017
2018 #[test]
2021 fn csi_with_intermediate_no_crash() {
2022 let mut model = TerminalModel::new(10, 1);
2023 model.process(b"\x1b[ q");
2026 model.process(b"OK");
2027 assert_eq!(model.row_text(0).as_deref(), Some("qOK"));
2029 }
2030
2031 #[test]
2034 fn reset_preserves_dimensions() {
2035 let mut model = TerminalModel::new(40, 20);
2036 model.process(b"SomeText");
2037 model.reset();
2038 assert_eq!(model.width(), 40);
2039 assert_eq!(model.height(), 20);
2040 assert_eq!(model.cursor(), (0, 0));
2041 }
2042
2043 #[test]
2046 fn lf_at_bottom_row_stays() {
2047 let mut model = TerminalModel::new(10, 3);
2048 model.process(b"\x1b[3;1H"); model.process(b"\n"); assert_eq!(model.cursor().1, 2); }
2052}
2053
2054#[cfg(test)]
2056mod proptests {
2057 use super::*;
2058 use proptest::prelude::*;
2059
2060 fn cup_sequence(row: u8, col: u8) -> Vec<u8> {
2062 format!("\x1b[{};{}H", row.max(1), col.max(1)).into_bytes()
2063 }
2064
2065 fn sgr_sequence(codes: &[u8]) -> Vec<u8> {
2067 let codes_str: Vec<String> = codes.iter().map(|c| c.to_string()).collect();
2068 format!("\x1b[{}m", codes_str.join(";")).into_bytes()
2069 }
2070
2071 proptest! {
2072 #[test]
2074 fn printable_ascii_no_crash(s in "[A-Za-z0-9 ]{0,100}") {
2075 let mut model = TerminalModel::new(80, 24);
2076 model.process(s.as_bytes());
2077 let (x, y) = model.cursor();
2079 prop_assert!(x < model.width());
2080 prop_assert!(y < model.height());
2081 }
2082
2083 #[test]
2085 fn cup_cursor_in_bounds(row in 0u8..100, col in 0u8..200) {
2086 let mut model = TerminalModel::new(80, 24);
2087 let seq = cup_sequence(row, col);
2088 model.process(&seq);
2089
2090 let (x, y) = model.cursor();
2091 prop_assert!(x < model.width(), "cursor_x {} >= width {}", x, model.width());
2092 prop_assert!(y < model.height(), "cursor_y {} >= height {}", y, model.height());
2093 }
2094
2095 #[test]
2097 fn relative_moves_in_bounds(
2098 start_row in 1u8..24,
2099 start_col in 1u8..80,
2100 up in 0u8..50,
2101 down in 0u8..50,
2102 left in 0u8..100,
2103 right in 0u8..100,
2104 ) {
2105 let mut model = TerminalModel::new(80, 24);
2106
2107 model.process(&cup_sequence(start_row, start_col));
2109
2110 model.process(format!("\x1b[{}A", up).as_bytes());
2112 model.process(format!("\x1b[{}B", down).as_bytes());
2113 model.process(format!("\x1b[{}D", left).as_bytes());
2114 model.process(format!("\x1b[{}C", right).as_bytes());
2115
2116 let (x, y) = model.cursor();
2117 prop_assert!(x < model.width());
2118 prop_assert!(y < model.height());
2119 }
2120
2121 #[test]
2123 fn sgr_reset_clears_flags(attrs in proptest::collection::vec(1u8..9, 0..5)) {
2124 let mut model = TerminalModel::new(80, 24);
2125
2126 if !attrs.is_empty() {
2128 model.process(&sgr_sequence(&attrs));
2129 }
2130
2131 model.process(b"\x1b[0m");
2133
2134 prop_assert!(model.sgr_state().flags.is_empty());
2135 }
2136
2137 #[test]
2139 fn hyperlinks_balance(text in "[a-z]{1,20}") {
2140 let mut model = TerminalModel::new(80, 24);
2141
2142 model.process(b"\x1b]8;;https://example.com\x07");
2144 prop_assert!(model.has_dangling_link());
2145
2146 model.process(text.as_bytes());
2148
2149 model.process(b"\x1b]8;;\x07");
2151 prop_assert!(!model.has_dangling_link());
2152 }
2153
2154 #[test]
2156 fn sync_output_balances(nesting in 1usize..5) {
2157 let mut model = TerminalModel::new(80, 24);
2158
2159 for _ in 0..nesting {
2161 model.process(b"\x1b[?2026h");
2162 }
2163 prop_assert_eq!(model.modes().sync_output_level, nesting as u32);
2164
2165 for _ in 0..nesting {
2167 model.process(b"\x1b[?2026l");
2168 }
2169 prop_assert!(model.sync_output_balanced());
2170 }
2171
2172 #[test]
2174 fn erase_operations_safe(
2175 row in 1u8..24,
2176 col in 1u8..80,
2177 ed_mode in 0u8..4,
2178 el_mode in 0u8..3,
2179 ) {
2180 let mut model = TerminalModel::new(80, 24);
2181
2182 model.process(&cup_sequence(row, col));
2184
2185 model.process(format!("\x1b[{}J", ed_mode).as_bytes());
2187
2188 model.process(&cup_sequence(row, col));
2190 model.process(format!("\x1b[{}K", el_mode).as_bytes());
2191
2192 let (x, y) = model.cursor();
2193 prop_assert!(x < model.width());
2194 prop_assert!(y < model.height());
2195 }
2196
2197 #[test]
2199 fn random_bytes_no_panic(bytes in proptest::collection::vec(any::<u8>(), 0..200)) {
2200 let mut model = TerminalModel::new(80, 24);
2201 model.process(&bytes);
2202
2203 let (x, y) = model.cursor();
2205 prop_assert!(x < model.width());
2206 prop_assert!(y < model.height());
2207 }
2208 }
2209}