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().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.parse_state = ParseState::Ground;
298 self.csi_params.clear();
299 self.csi_intermediate.clear();
300 self.osc_buffer.clear();
301 self.utf8_pending.clear();
302 self.utf8_expected = None;
303 }
304
305 pub fn process(&mut self, bytes: &[u8]) {
307 for &b in bytes {
308 self.process_byte(b);
309 self.bytes_processed += 1;
310 }
311 }
312
313 fn process_byte(&mut self, b: u8) {
315 match self.parse_state {
316 ParseState::Ground => self.ground_state(b),
317 ParseState::Escape => self.escape_state(b),
318 ParseState::CsiEntry => self.csi_entry_state(b),
319 ParseState::CsiParam => self.csi_param_state(b),
320 ParseState::OscEntry => self.osc_entry_state(b),
321 ParseState::OscString => self.osc_string_state(b),
322 }
323 }
324
325 fn ground_state(&mut self, b: u8) {
326 match b {
327 0x1B => {
328 self.flush_pending_utf8_invalid();
330 self.parse_state = ParseState::Escape;
331 }
332 0x00..=0x1A | 0x1C..=0x1F => {
333 self.flush_pending_utf8_invalid();
335 self.handle_c0(b);
336 }
337 _ => {
338 self.handle_printable(b);
340 }
341 }
342 }
343
344 fn escape_state(&mut self, b: u8) {
345 match b {
346 b'[' => {
347 self.csi_params.clear();
349 self.csi_intermediate.clear();
350 self.parse_state = ParseState::CsiEntry;
351 }
352 b']' => {
353 self.osc_buffer.clear();
355 self.parse_state = ParseState::OscEntry;
356 }
357 b'7' => {
358 self.parse_state = ParseState::Ground;
360 }
361 b'8' => {
362 self.parse_state = ParseState::Ground;
364 }
365 b'=' | b'>' => {
366 self.parse_state = ParseState::Ground;
368 }
369 0x1B => {
370 }
372 _ => {
373 self.parse_state = ParseState::Ground;
375 }
376 }
377 }
378
379 fn csi_entry_state(&mut self, b: u8) {
380 match b {
381 b'0'..=b'9' => {
382 self.csi_params.push((b - b'0') as u32);
383 self.parse_state = ParseState::CsiParam;
384 }
385 b';' => {
386 self.csi_params.push(0);
387 self.parse_state = ParseState::CsiParam;
388 }
389 b'?' | b'>' | b'!' => {
390 self.csi_intermediate.push(b);
391 self.parse_state = ParseState::CsiParam;
392 }
393 0x40..=0x7E => {
394 self.execute_csi(b);
396 self.parse_state = ParseState::Ground;
397 }
398 _ => {
399 self.parse_state = ParseState::Ground;
400 }
401 }
402 }
403
404 fn csi_param_state(&mut self, b: u8) {
405 match b {
406 b'0'..=b'9' => {
407 if self.csi_params.is_empty() {
408 self.csi_params.push(0);
409 }
410 if let Some(last) = self.csi_params.last_mut() {
411 *last = last.saturating_mul(10).saturating_add((b - b'0') as u32);
412 }
413 }
414 b';' => {
415 self.csi_params.push(0);
416 }
417 b':' => {
418 self.csi_params.push(0);
420 }
421 0x20..=0x2F => {
422 self.csi_intermediate.push(b);
423 }
424 0x40..=0x7E => {
425 self.execute_csi(b);
427 self.parse_state = ParseState::Ground;
428 }
429 _ => {
430 self.parse_state = ParseState::Ground;
431 }
432 }
433 }
434
435 fn osc_entry_state(&mut self, b: u8) {
436 match b {
437 0x07 => {
438 self.execute_osc();
440 self.parse_state = ParseState::Ground;
441 }
442 0x1B => {
443 self.parse_state = ParseState::OscString;
445 }
446 _ => {
447 self.osc_buffer.push(b);
448 }
449 }
450 }
451
452 fn osc_string_state(&mut self, b: u8) {
453 match b {
454 b'\\' => {
455 self.execute_osc();
457 self.parse_state = ParseState::Ground;
458 }
459 _ => {
460 self.osc_buffer.push(0x1B);
462 self.osc_buffer.push(b);
463 self.parse_state = ParseState::OscEntry;
464 }
465 }
466 }
467
468 fn handle_c0(&mut self, b: u8) {
469 match b {
470 0x07 => {} 0x08 => {
472 if self.cursor_x > 0 {
474 self.cursor_x -= 1;
475 }
476 }
477 0x09 => {
478 self.cursor_x = (self.cursor_x / 8 + 1) * 8;
480 if self.cursor_x >= self.width {
481 self.cursor_x = self.width - 1;
482 }
483 }
484 0x0A => {
485 if self.cursor_y + 1 < self.height {
487 self.cursor_y += 1;
488 }
489 }
490 0x0D => {
491 self.cursor_x = 0;
493 }
494 _ => {} }
496 }
497
498 fn handle_printable(&mut self, b: u8) {
499 if self.utf8_expected.is_none() {
500 if b < 0x80 {
501 self.put_char(b as char);
502 return;
503 }
504 if let Some(expected) = Self::utf8_expected_len(b) {
505 self.utf8_pending.clear();
506 self.utf8_pending.push(b);
507 self.utf8_expected = Some(expected);
508 if expected == 1 {
509 self.flush_utf8_sequence();
510 }
511 } else {
512 self.put_char('\u{FFFD}');
513 }
514 return;
515 }
516
517 if !Self::is_utf8_continuation(b) {
518 self.flush_pending_utf8_invalid();
519 self.handle_printable(b);
520 return;
521 }
522
523 self.utf8_pending.push(b);
524 if let Some(expected) = self.utf8_expected {
525 if self.utf8_pending.len() == expected {
526 self.flush_utf8_sequence();
527 } else if self.utf8_pending.len() > expected {
528 self.flush_pending_utf8_invalid();
529 }
530 }
531 }
532
533 fn flush_utf8_sequence(&mut self) {
534 let chars: Vec<char> = std::str::from_utf8(&self.utf8_pending)
537 .map(|text| text.chars().collect())
538 .unwrap_or_else(|_| vec!['\u{FFFD}']);
539 self.utf8_pending.clear();
540 self.utf8_expected = None;
541 for ch in chars {
542 self.put_char(ch);
543 }
544 }
545
546 fn flush_pending_utf8_invalid(&mut self) {
547 if self.utf8_expected.is_some() {
548 self.put_char('\u{FFFD}');
549 self.utf8_pending.clear();
550 self.utf8_expected = None;
551 }
552 }
553
554 fn utf8_expected_len(first: u8) -> Option<usize> {
555 if first < 0x80 {
556 Some(1)
557 } else if (0xC2..=0xDF).contains(&first) {
558 Some(2)
559 } else if (0xE0..=0xEF).contains(&first) {
560 Some(3)
561 } else if (0xF0..=0xF4).contains(&first) {
562 Some(4)
563 } else {
564 None
565 }
566 }
567
568 fn is_utf8_continuation(byte: u8) -> bool {
569 (0x80..=0xBF).contains(&byte)
570 }
571
572 fn put_char(&mut self, ch: char) {
573 let width = char_width(ch);
574
575 if width == 0 {
577 if self.cursor_x > 0 {
578 let idx = self.cursor_y * self.width + self.cursor_x - 1;
580 if let Some(cell) = self.cells.get_mut(idx) {
581 cell.text.push(ch);
582 }
583 } else if self.cursor_x < self.width && self.cursor_y < self.height {
584 let idx = self.cursor_y * self.width + self.cursor_x;
586 let cell = &mut self.cells[idx];
587 if cell.text == " " {
588 cell.text = format!(" {}", ch);
590 } else {
591 cell.text.push(ch);
592 }
593 }
594 return;
595 }
596
597 if self.cursor_x < self.width && self.cursor_y < self.height {
598 let cell = &mut self.cells[self.cursor_y * self.width + self.cursor_x];
599 cell.text = ch.to_string();
600 cell.fg = self.sgr.fg;
601 cell.bg = self.sgr.bg;
602 cell.attrs = CellAttrs::new(self.sgr.flags, self.current_link_id);
603 cell.link_id = self.current_link_id;
604
605 if width == 2 && self.cursor_x + 1 < self.width {
607 let next_cell = &mut self.cells[self.cursor_y * self.width + self.cursor_x + 1];
608 next_cell.text = String::new(); next_cell.fg = self.sgr.fg; next_cell.bg = self.sgr.bg;
611 next_cell.attrs = CellAttrs::NONE; next_cell.link_id = 0; }
614 }
615
616 self.cursor_x += width;
617
618 if self.cursor_x >= self.width {
620 self.cursor_x = 0;
621 if self.cursor_y + 1 < self.height {
622 self.cursor_y += 1;
623 }
624 }
625 }
626
627 fn execute_csi(&mut self, final_byte: u8) {
628 let has_question = self.csi_intermediate.contains(&b'?');
629
630 match final_byte {
631 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' => {
644 }
646 b'u' => {
647 }
649 _ => {} }
651 }
652
653 fn csi_cup(&mut self) {
654 let row = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
656 let col = self.csi_params.get(1).copied().unwrap_or(1).max(1) as usize;
657 self.cursor_y = (row - 1).min(self.height - 1);
658 self.cursor_x = (col - 1).min(self.width - 1);
659 }
660
661 fn csi_cuu(&mut self) {
662 let n = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
663 self.cursor_y = self.cursor_y.saturating_sub(n);
664 }
665
666 fn csi_cud(&mut self) {
667 let n = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
668 self.cursor_y = (self.cursor_y + n).min(self.height - 1);
669 }
670
671 fn csi_cuf(&mut self) {
672 let n = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
673 self.cursor_x = (self.cursor_x + n).min(self.width - 1);
674 }
675
676 fn csi_cub(&mut self) {
677 let n = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
678 self.cursor_x = self.cursor_x.saturating_sub(n);
679 }
680
681 fn csi_cha(&mut self) {
682 let col = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
683 self.cursor_x = (col - 1).min(self.width - 1);
684 }
685
686 fn csi_vpa(&mut self) {
687 let row = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
688 self.cursor_y = (row - 1).min(self.height - 1);
689 }
690
691 fn csi_ed(&mut self) {
692 let mode = self.csi_params.first().copied().unwrap_or(0);
693 match mode {
694 0 => {
695 for x in self.cursor_x..self.width {
697 self.erase_cell(x, self.cursor_y);
698 }
699 for y in (self.cursor_y + 1)..self.height {
700 for x in 0..self.width {
701 self.erase_cell(x, y);
702 }
703 }
704 }
705 1 => {
706 for y in 0..self.cursor_y {
708 for x in 0..self.width {
709 self.erase_cell(x, y);
710 }
711 }
712 for x in 0..=self.cursor_x {
713 self.erase_cell(x, self.cursor_y);
714 }
715 }
716 2 | 3 => {
717 for cell in &mut self.cells {
719 *cell = ModelCell::default();
720 }
721 }
722 _ => {}
723 }
724 }
725
726 fn csi_el(&mut self) {
727 let mode = self.csi_params.first().copied().unwrap_or(0);
728 match mode {
729 0 => {
730 for x in self.cursor_x..self.width {
732 self.erase_cell(x, self.cursor_y);
733 }
734 }
735 1 => {
736 for x in 0..=self.cursor_x {
738 self.erase_cell(x, self.cursor_y);
739 }
740 }
741 2 => {
742 for x in 0..self.width {
744 self.erase_cell(x, self.cursor_y);
745 }
746 }
747 _ => {}
748 }
749 }
750
751 fn erase_cell(&mut self, x: usize, y: usize) {
752 let bg = self.sgr.bg;
754 if let Some(cell) = self.cell_mut(x, y) {
755 cell.text = " ".to_string();
756 cell.fg = PackedRgba::WHITE;
758 cell.bg = bg;
759 cell.attrs = CellAttrs::NONE;
760 cell.link_id = 0;
761 }
762 }
763
764 fn csi_sgr(&mut self) {
765 if self.csi_params.is_empty() {
766 self.sgr.reset();
767 return;
768 }
769
770 let mut i = 0;
771 while i < self.csi_params.len() {
772 let code = self.csi_params[i];
773 match code {
774 0 => self.sgr.reset(),
775 1 => self.sgr.flags.insert(StyleFlags::BOLD),
776 2 => self.sgr.flags.insert(StyleFlags::DIM),
777 3 => self.sgr.flags.insert(StyleFlags::ITALIC),
778 4 => self.sgr.flags.insert(StyleFlags::UNDERLINE),
779 5 => self.sgr.flags.insert(StyleFlags::BLINK),
780 7 => self.sgr.flags.insert(StyleFlags::REVERSE),
781 8 => self.sgr.flags.insert(StyleFlags::HIDDEN),
782 9 => self.sgr.flags.insert(StyleFlags::STRIKETHROUGH),
783 21 | 22 => self.sgr.flags.remove(StyleFlags::BOLD | StyleFlags::DIM),
784 23 => self.sgr.flags.remove(StyleFlags::ITALIC),
785 24 => self.sgr.flags.remove(StyleFlags::UNDERLINE),
786 25 => self.sgr.flags.remove(StyleFlags::BLINK),
787 27 => self.sgr.flags.remove(StyleFlags::REVERSE),
788 28 => self.sgr.flags.remove(StyleFlags::HIDDEN),
789 29 => self.sgr.flags.remove(StyleFlags::STRIKETHROUGH),
790 30..=37 => {
792 self.sgr.fg = Self::basic_color(code - 30);
793 }
794 39 => {
796 self.sgr.fg = PackedRgba::WHITE;
797 }
798 40..=47 => {
800 self.sgr.bg = Self::basic_color(code - 40);
801 }
802 49 => {
804 self.sgr.bg = PackedRgba::TRANSPARENT;
805 }
806 90..=97 => {
808 self.sgr.fg = Self::bright_color(code - 90);
809 }
810 100..=107 => {
812 self.sgr.bg = Self::bright_color(code - 100);
813 }
814 38 => {
816 if let Some(color) = self.parse_extended_color(&mut i) {
817 self.sgr.fg = color;
818 }
819 }
820 48 => {
821 if let Some(color) = self.parse_extended_color(&mut i) {
822 self.sgr.bg = color;
823 }
824 }
825 _ => {} }
827 i += 1;
828 }
829 }
830
831 fn parse_extended_color(&self, i: &mut usize) -> Option<PackedRgba> {
832 let mode = self.csi_params.get(*i + 1)?;
833 match *mode {
834 5 => {
835 let idx = self.csi_params.get(*i + 2)?;
837 *i += 2;
838 Some(Self::color_256(*idx as u8))
839 }
840 2 => {
841 let r = *self.csi_params.get(*i + 2)? as u8;
843 let g = *self.csi_params.get(*i + 3)? as u8;
844 let b = *self.csi_params.get(*i + 4)? as u8;
845 *i += 4;
846 Some(PackedRgba::rgb(r, g, b))
847 }
848 _ => None,
849 }
850 }
851
852 fn basic_color(idx: u32) -> PackedRgba {
853 match idx {
854 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,
863 }
864 }
865
866 fn bright_color(idx: u32) -> PackedRgba {
867 match idx {
868 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,
877 }
878 }
879
880 fn color_256(idx: u8) -> PackedRgba {
881 match idx {
882 0..=7 => Self::basic_color(idx as u32),
883 8..=15 => Self::bright_color((idx - 8) as u32),
884 16..=231 => {
885 let idx = idx - 16;
887 let r = (idx / 36) % 6;
888 let g = (idx / 6) % 6;
889 let b = idx % 6;
890 let to_channel = |v| if v == 0 { 0 } else { 55 + v * 40 };
891 PackedRgba::rgb(to_channel(r), to_channel(g), to_channel(b))
892 }
893 232..=255 => {
894 let gray = 8 + (idx - 232) * 10;
896 PackedRgba::rgb(gray, gray, gray)
897 }
898 }
899 }
900
901 fn csi_decset(&mut self) {
902 for &code in &self.csi_params {
903 match code {
904 25 => self.modes.cursor_visible = true, 1049 => self.modes.alt_screen = true, 2026 => self.modes.sync_output_level += 1, _ => {}
908 }
909 }
910 }
911
912 fn csi_decrst(&mut self) {
913 for &code in &self.csi_params {
914 match code {
915 25 => self.modes.cursor_visible = false, 1049 => self.modes.alt_screen = false, 2026 => {
918 self.modes.sync_output_level = self.modes.sync_output_level.saturating_sub(1);
920 }
921 _ => {}
922 }
923 }
924 }
925
926 fn execute_osc(&mut self) {
927 let data = String::from_utf8_lossy(&self.osc_buffer).to_string();
930 let mut parts = data.splitn(2, ';');
931 let code: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
932
933 if code == 8
935 && let Some(rest) = parts.next()
936 {
937 let rest = rest.to_string();
938 self.handle_osc8(&rest);
939 }
940 }
941
942 fn handle_osc8(&mut self, data: &str) {
943 let mut parts = data.splitn(2, ';');
946 let _params = parts.next().unwrap_or("");
947 let uri = parts.next().unwrap_or("");
948
949 if uri.is_empty() {
950 self.current_link_id = 0;
952 } else {
953 self.links.push(uri.to_string());
955 self.current_link_id = (self.links.len() - 1) as u32;
956 }
957 }
958
959 #[must_use]
961 pub fn diff_grid(&self, expected: &[ModelCell]) -> Option<String> {
962 if self.cells.len() != expected.len() {
963 return Some(format!(
964 "Grid size mismatch: got {} cells, expected {}",
965 self.cells.len(),
966 expected.len()
967 ));
968 }
969
970 let mut diffs = Vec::new();
971 for (i, (actual, exp)) in self.cells.iter().zip(expected.iter()).enumerate() {
972 if actual != exp {
973 let x = i % self.width;
974 let y = i / self.width;
975 diffs.push(format!(
976 " ({}, {}): got {:?}, expected {:?}",
977 x, y, actual.text, exp.text
978 ));
979 }
980 }
981
982 if diffs.is_empty() {
983 None
984 } else {
985 Some(format!("Grid differences:\n{}", diffs.join("\n")))
986 }
987 }
988
989 pub fn dump_sequences(bytes: &[u8]) -> String {
991 let mut output = String::new();
992 let mut i = 0;
993 while i < bytes.len() {
994 if bytes[i] == 0x1B {
995 if i + 1 < bytes.len() {
996 match bytes[i + 1] {
997 b'[' => {
998 output.push_str("\\e[");
1000 i += 2;
1001 while i < bytes.len() && !(0x40..=0x7E).contains(&bytes[i]) {
1002 output.push(bytes[i] as char);
1003 i += 1;
1004 }
1005 if i < bytes.len() {
1006 output.push(bytes[i] as char);
1007 i += 1;
1008 }
1009 }
1010 b']' => {
1011 output.push_str("\\e]");
1013 i += 2;
1014 while i < bytes.len() && bytes[i] != 0x07 {
1015 if bytes[i] == 0x1B && i + 1 < bytes.len() && bytes[i + 1] == b'\\'
1016 {
1017 output.push_str("\\e\\\\");
1018 i += 2;
1019 break;
1020 }
1021 output.push(bytes[i] as char);
1022 i += 1;
1023 }
1024 if i < bytes.len() && bytes[i] == 0x07 {
1025 output.push_str("\\a");
1026 i += 1;
1027 }
1028 }
1029 _ => {
1030 output.push_str(&format!("\\e{}", bytes[i + 1] as char));
1031 i += 2;
1032 }
1033 }
1034 } else {
1035 output.push_str("\\e");
1036 i += 1;
1037 }
1038 } else if bytes[i] < 0x20 {
1039 output.push_str(&format!("\\x{:02x}", bytes[i]));
1040 i += 1;
1041 } else {
1042 output.push(bytes[i] as char);
1043 i += 1;
1044 }
1045 }
1046 output
1047 }
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052 use super::*;
1053 use crate::ansi;
1054
1055 #[test]
1056 fn new_creates_empty_grid() {
1057 let model = TerminalModel::new(80, 24);
1058 assert_eq!(model.width(), 80);
1059 assert_eq!(model.height(), 24);
1060 assert_eq!(model.cursor(), (0, 0));
1061 assert_eq!(model.cells().len(), 80 * 24);
1062 }
1063
1064 #[test]
1065 fn printable_text_writes_to_grid() {
1066 let mut model = TerminalModel::new(10, 5);
1067 model.process(b"Hello");
1068 assert_eq!(model.cursor(), (5, 0));
1069 assert_eq!(model.row_text(0), Some("Hello".to_string()));
1070 }
1071
1072 #[test]
1073 fn cup_moves_cursor() {
1074 let mut model = TerminalModel::new(80, 24);
1075 model.process(b"\x1b[5;10H"); assert_eq!(model.cursor(), (9, 4)); }
1078
1079 #[test]
1080 fn cup_with_defaults() {
1081 let mut model = TerminalModel::new(80, 24);
1082 model.process(b"\x1b[H"); assert_eq!(model.cursor(), (0, 0));
1084 }
1085
1086 #[test]
1087 fn relative_cursor_moves() {
1088 let mut model = TerminalModel::new(80, 24);
1089 model.process(b"\x1b[10;10H"); model.process(b"\x1b[2A"); assert_eq!(model.cursor(), (9, 7));
1092 model.process(b"\x1b[3B"); assert_eq!(model.cursor(), (9, 10));
1094 model.process(b"\x1b[5C"); assert_eq!(model.cursor(), (14, 10));
1096 model.process(b"\x1b[3D"); assert_eq!(model.cursor(), (11, 10));
1098 }
1099
1100 #[test]
1101 fn sgr_sets_style_flags() {
1102 let mut model = TerminalModel::new(20, 5);
1103 model.process(b"\x1b[1mBold\x1b[0m");
1104 assert!(model.cell(0, 0).unwrap().attrs.has_flag(StyleFlags::BOLD));
1105 assert!(!model.cell(4, 0).unwrap().attrs.has_flag(StyleFlags::BOLD)); }
1107
1108 #[test]
1109 fn sgr_sets_colors() {
1110 let mut model = TerminalModel::new(20, 5);
1111 model.process(b"\x1b[31mRed\x1b[0m");
1112 assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(128, 0, 0));
1113 }
1114
1115 #[test]
1116 fn sgr_256_colors() {
1117 let mut model = TerminalModel::new(20, 5);
1118 model.process(b"\x1b[38;5;196mX"); let cell = model.cell(0, 0).unwrap();
1120 assert_eq!(cell.fg, PackedRgba::rgb(255, 0, 0));
1123 }
1124
1125 #[test]
1126 fn sgr_rgb_colors() {
1127 let mut model = TerminalModel::new(20, 5);
1128 model.process(b"\x1b[38;2;100;150;200mX");
1129 assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(100, 150, 200));
1130 }
1131
1132 #[test]
1133 fn erase_line() {
1134 let mut model = TerminalModel::new(10, 5);
1135 model.process(b"ABCDEFGHIJ");
1136 model.process(b"\x1b[1;5H"); model.process(b"\x1b[K"); assert_eq!(model.row_text(0), Some("ABCD".to_string()));
1141 }
1142
1143 #[test]
1144 fn erase_display() {
1145 let mut model = TerminalModel::new(10, 5);
1146 model.process(b"Line1\n");
1147 model.process(b"Line2\n");
1148 model.process(b"\x1b[2J"); for y in 0..5 {
1150 assert_eq!(model.row_text(y), Some(String::new()));
1151 }
1152 }
1153
1154 #[test]
1155 fn osc8_hyperlinks() {
1156 let mut model = TerminalModel::new(20, 5);
1157 model.process(b"\x1b]8;;https://example.com\x07Link\x1b]8;;\x07");
1158
1159 let cell = model.cell(0, 0).unwrap();
1160 assert!(cell.link_id > 0);
1161 assert_eq!(model.link_url(cell.link_id), Some("https://example.com"));
1162
1163 let cell_after = model.cell(4, 0).unwrap();
1165 assert_eq!(cell_after.link_id, 0);
1166 }
1167
1168 #[test]
1169 fn dangling_link_detection() {
1170 let mut model = TerminalModel::new(20, 5);
1171 model.process(b"\x1b]8;;https://example.com\x07Link");
1172 assert!(model.has_dangling_link());
1173
1174 model.process(b"\x1b]8;;\x07");
1175 assert!(!model.has_dangling_link());
1176 }
1177
1178 #[test]
1179 fn sync_output_tracking() {
1180 let mut model = TerminalModel::new(20, 5);
1181 assert!(model.sync_output_balanced());
1182
1183 model.process(b"\x1b[?2026h"); assert!(!model.sync_output_balanced());
1185 assert_eq!(model.modes().sync_output_level, 1);
1186
1187 model.process(b"\x1b[?2026l"); assert!(model.sync_output_balanced());
1189 }
1190
1191 #[test]
1192 fn utf8_multibyte_stream_is_decoded() {
1193 let mut model = TerminalModel::new(10, 1);
1194 let text = "a\u{00E9}\u{4E2D}\u{1F600}";
1195 model.process(text.as_bytes());
1196
1197 assert_eq!(model.row_text(0).as_deref(), Some(text));
1198 assert_eq!(model.cursor(), (6, 0));
1199 }
1200
1201 #[test]
1202 fn utf8_sequence_can_span_process_calls() {
1203 let mut model = TerminalModel::new(10, 1);
1204 let text = "\u{00E9}";
1205 let bytes = text.as_bytes();
1206
1207 model.process(&bytes[..1]);
1208 assert_eq!(model.row_text(0).as_deref(), Some(""));
1209
1210 model.process(&bytes[1..]);
1211 assert_eq!(model.row_text(0).as_deref(), Some(text));
1212 }
1213
1214 #[test]
1215 fn line_wrap() {
1216 let mut model = TerminalModel::new(5, 3);
1217 model.process(b"ABCDEFGH");
1218 assert_eq!(model.row_text(0), Some("ABCDE".to_string()));
1219 assert_eq!(model.row_text(1), Some("FGH".to_string()));
1220 assert_eq!(model.cursor(), (3, 1));
1221 }
1222
1223 #[test]
1224 fn cr_lf_handling() {
1225 let mut model = TerminalModel::new(20, 5);
1226 model.process(b"Hello\r\n");
1227 assert_eq!(model.cursor(), (0, 1));
1228 model.process(b"World");
1229 assert_eq!(model.row_text(0), Some("Hello".to_string()));
1230 assert_eq!(model.row_text(1), Some("World".to_string()));
1231 }
1232
1233 #[test]
1234 fn cursor_visibility() {
1235 let mut model = TerminalModel::new(20, 5);
1236 assert!(model.modes().cursor_visible);
1237
1238 model.process(b"\x1b[?25l"); assert!(!model.modes().cursor_visible);
1240
1241 model.process(b"\x1b[?25h"); assert!(model.modes().cursor_visible);
1243 }
1244
1245 #[test]
1246 fn alt_screen_toggle_is_tracked() {
1247 let mut model = TerminalModel::new(20, 5);
1248 assert!(!model.modes().alt_screen);
1249
1250 model.process(b"\x1b[?1049h");
1251 assert!(model.modes().alt_screen);
1252
1253 model.process(b"\x1b[?1049l");
1254 assert!(!model.modes().alt_screen);
1255 }
1256
1257 #[test]
1258 fn dump_sequences_readable() {
1259 let bytes = b"\x1b[1;1H\x1b[1mHello\x1b[0m";
1260 let dump = TerminalModel::dump_sequences(bytes);
1261 assert!(dump.contains("\\e[1;1H"));
1262 assert!(dump.contains("\\e[1m"));
1263 assert!(dump.contains("Hello"));
1264 assert!(dump.contains("\\e[0m"));
1265 }
1266
1267 #[test]
1268 fn reset_clears_state() {
1269 let mut model = TerminalModel::new(20, 5);
1270 model.process(b"\x1b[10;10HTest\x1b[1m");
1271 model.reset();
1272
1273 assert_eq!(model.cursor(), (0, 0));
1274 assert!(model.sgr_state().flags.is_empty());
1275 for y in 0..5 {
1276 assert_eq!(model.row_text(y), Some(String::new()));
1277 }
1278 }
1279
1280 #[test]
1281 fn erase_scrollback_mode_clears_screen() {
1282 let mut model = TerminalModel::new(10, 3);
1283 model.process(b"Line1\nLine2\nLine3");
1284 model.process(b"\x1b[3J"); for y in 0..3 {
1287 assert_eq!(model.row_text(y), Some(String::new()));
1288 }
1289 }
1290
1291 #[test]
1292 fn scroll_region_sequences_are_ignored_but_safe() {
1293 let mut model = TerminalModel::new(12, 3);
1294 model.process(b"ABCD");
1295 let cursor_before = model.cursor();
1296
1297 let mut buf = Vec::new();
1298 ansi::set_scroll_region(&mut buf, 1, 2).expect("scroll region sequence");
1299 model.process(&buf);
1300 model.process(ansi::RESET_SCROLL_REGION);
1301
1302 assert_eq!(model.cursor(), cursor_before);
1303 model.process(b"EF");
1304 assert_eq!(model.row_text(0).as_deref(), Some("ABCDEF"));
1305 }
1306
1307 #[test]
1308 fn scroll_region_invalid_params_do_not_corrupt_state() {
1309 let mut model = TerminalModel::new(8, 2);
1310 model.process(b"Hi");
1311 let cursor_before = model.cursor();
1312
1313 model.process(b"\x1b[5;2r"); model.process(b"\x1b[0;0r"); model.process(b"\x1b[999;999r"); assert_eq!(model.cursor(), cursor_before);
1318 model.process(b"!");
1319 assert_eq!(model.row_text(0).as_deref(), Some("Hi!"));
1320 }
1321
1322 #[test]
1325 fn model_cell_default_is_space() {
1326 let cell = ModelCell::default();
1327 assert_eq!(cell.text, " ");
1328 assert_eq!(cell.fg, PackedRgba::WHITE);
1329 assert_eq!(cell.bg, PackedRgba::TRANSPARENT);
1330 assert_eq!(cell.attrs, CellAttrs::NONE);
1331 assert_eq!(cell.link_id, 0);
1332 }
1333
1334 #[test]
1335 fn model_cell_with_char() {
1336 let cell = ModelCell::with_char('X');
1337 assert_eq!(cell.text, "X");
1338 assert_eq!(cell.fg, PackedRgba::WHITE);
1339 assert_eq!(cell.link_id, 0);
1340 }
1341
1342 #[test]
1343 fn model_cell_eq() {
1344 let a = ModelCell::default();
1345 let b = ModelCell::default();
1346 assert_eq!(a, b);
1347 let c = ModelCell::with_char('X');
1348 assert_ne!(a, c);
1349 }
1350
1351 #[test]
1352 fn model_cell_clone() {
1353 let a = ModelCell::with_char('Z');
1354 let b = a.clone();
1355 assert_eq!(b.text, "Z");
1356 }
1357
1358 #[test]
1361 fn sgr_state_default_fields() {
1362 let s = SgrState::default();
1363 assert_eq!(s.fg, PackedRgba::WHITE);
1364 assert_eq!(s.bg, PackedRgba::TRANSPARENT);
1365 assert!(s.flags.is_empty());
1366 }
1367
1368 #[test]
1369 fn sgr_state_reset() {
1370 let mut s = SgrState {
1371 fg: PackedRgba::rgb(255, 0, 0),
1372 bg: PackedRgba::rgb(0, 0, 255),
1373 flags: StyleFlags::BOLD | StyleFlags::ITALIC,
1374 };
1375 s.reset();
1376 assert_eq!(s.fg, PackedRgba::WHITE);
1377 assert_eq!(s.bg, PackedRgba::TRANSPARENT);
1378 assert!(s.flags.is_empty());
1379 }
1380
1381 #[test]
1384 fn mode_flags_new_defaults() {
1385 let m = ModeFlags::new();
1386 assert!(m.cursor_visible);
1387 assert!(!m.alt_screen);
1388 assert_eq!(m.sync_output_level, 0);
1389 }
1390
1391 #[test]
1392 fn mode_flags_default_vs_new() {
1393 let d = ModeFlags::default();
1395 assert!(!d.cursor_visible);
1396 let n = ModeFlags::new();
1398 assert!(n.cursor_visible);
1399 }
1400
1401 #[test]
1404 fn new_zero_dimensions_clamped() {
1405 let model = TerminalModel::new(0, 0);
1406 assert_eq!(model.width(), 1);
1407 assert_eq!(model.height(), 1);
1408 assert_eq!(model.cells().len(), 1);
1409 }
1410
1411 #[test]
1412 fn new_1x1() {
1413 let model = TerminalModel::new(1, 1);
1414 assert_eq!(model.width(), 1);
1415 assert_eq!(model.height(), 1);
1416 assert_eq!(model.cursor(), (0, 0));
1417 }
1418
1419 #[test]
1422 fn cell_out_of_bounds_returns_none() {
1423 let model = TerminalModel::new(5, 3);
1424 assert!(model.cell(5, 0).is_none());
1425 assert!(model.cell(0, 3).is_none());
1426 assert!(model.cell(100, 100).is_none());
1427 }
1428
1429 #[test]
1430 fn cell_in_bounds_returns_some() {
1431 let model = TerminalModel::new(5, 3);
1432 assert!(model.cell(0, 0).is_some());
1433 assert!(model.cell(4, 2).is_some());
1434 }
1435
1436 #[test]
1437 fn current_cell_at_cursor() {
1438 let mut model = TerminalModel::new(10, 5);
1439 model.process(b"AB");
1440 let cc = model.current_cell().unwrap();
1442 assert_eq!(cc.text, " "); }
1444
1445 #[test]
1446 fn row_out_of_bounds_returns_none() {
1447 let model = TerminalModel::new(5, 3);
1448 assert!(model.row(3).is_none());
1449 assert!(model.row(100).is_none());
1450 }
1451
1452 #[test]
1453 fn row_text_trims_trailing_spaces() {
1454 let mut model = TerminalModel::new(10, 1);
1455 model.process(b"Hi");
1456 assert_eq!(model.row_text(0), Some("Hi".to_string()));
1457 }
1458
1459 #[test]
1460 fn link_url_invalid_id_returns_none() {
1461 let model = TerminalModel::new(5, 1);
1462 assert!(model.link_url(999).is_none());
1463 }
1464
1465 #[test]
1466 fn link_url_zero_is_empty() {
1467 let model = TerminalModel::new(5, 1);
1468 assert_eq!(model.link_url(0), Some(""));
1469 }
1470
1471 #[test]
1472 fn has_dangling_link_initially_false() {
1473 let model = TerminalModel::new(5, 1);
1474 assert!(!model.has_dangling_link());
1475 }
1476
1477 #[test]
1480 fn cha_moves_to_column() {
1481 let mut model = TerminalModel::new(80, 24);
1482 model.process(b"\x1b[1;1H"); model.process(b"\x1b[20G"); assert_eq!(model.cursor(), (19, 0));
1485 }
1486
1487 #[test]
1488 fn cha_clamps_to_width() {
1489 let mut model = TerminalModel::new(10, 1);
1490 model.process(b"\x1b[999G");
1491 assert_eq!(model.cursor().0, 9);
1492 }
1493
1494 #[test]
1497 fn vpa_moves_to_row() {
1498 let mut model = TerminalModel::new(80, 24);
1499 model.process(b"\x1b[10d"); assert_eq!(model.cursor(), (0, 9));
1501 }
1502
1503 #[test]
1504 fn vpa_clamps_to_height() {
1505 let mut model = TerminalModel::new(10, 5);
1506 model.process(b"\x1b[999d");
1507 assert_eq!(model.cursor().1, 4);
1508 }
1509
1510 #[test]
1513 fn backspace_moves_cursor_back() {
1514 let mut model = TerminalModel::new(10, 1);
1515 model.process(b"ABC");
1516 assert_eq!(model.cursor(), (3, 0));
1517 model.process(b"\x08"); assert_eq!(model.cursor(), (2, 0));
1519 }
1520
1521 #[test]
1522 fn backspace_at_column_zero_no_move() {
1523 let mut model = TerminalModel::new(10, 1);
1524 model.process(b"\x08");
1525 assert_eq!(model.cursor(), (0, 0));
1526 }
1527
1528 #[test]
1531 fn tab_moves_to_next_tab_stop() {
1532 let mut model = TerminalModel::new(80, 1);
1533 model.process(b"\t");
1534 assert_eq!(model.cursor(), (8, 0));
1535 model.process(b"A\t");
1536 assert_eq!(model.cursor(), (16, 0));
1537 }
1538
1539 #[test]
1540 fn tab_clamps_at_right_edge() {
1541 let mut model = TerminalModel::new(10, 1);
1542 model.process(b"\t"); model.process(b"\t"); assert_eq!(model.cursor(), (9, 0));
1545 }
1546
1547 #[test]
1550 fn esc_7_8_do_not_panic() {
1551 let mut model = TerminalModel::new(10, 1);
1552 model.process(b"\x1b7"); model.process(b"\x1b8"); assert_eq!(model.cursor(), (0, 0));
1555 }
1556
1557 #[test]
1558 fn esc_equals_greater_ignored() {
1559 let mut model = TerminalModel::new(10, 1);
1560 model.process(b"\x1b="); model.process(b"\x1b>"); assert_eq!(model.cursor(), (0, 0));
1563 }
1564
1565 #[test]
1566 fn esc_esc_double_escape_handled() {
1567 let mut model = TerminalModel::new(10, 1);
1568 model.process(b"\x1b\x1b"); model.process(b"AB");
1571 assert_eq!(model.row_text(0).as_deref(), Some("B"));
1573 }
1574
1575 #[test]
1576 fn unknown_escape_returns_to_ground() {
1577 let mut model = TerminalModel::new(10, 1);
1578 model.process(b"\x1bQ"); model.process(b"Hi");
1580 assert_eq!(model.row_text(0).as_deref(), Some("Hi"));
1581 }
1582
1583 #[test]
1586 fn el_mode_1_erases_from_start_to_cursor() {
1587 let mut model = TerminalModel::new(10, 1);
1588 model.process(b"ABCDEFGHIJ");
1589 model.process(b"\x1b[1;5H"); model.process(b"\x1b[1K"); let row = model.row_text(0).unwrap();
1593 assert!(row.starts_with(" ") || row.trim_start().starts_with("FGHIJ"));
1594 }
1595
1596 #[test]
1597 fn el_mode_2_erases_entire_line() {
1598 let mut model = TerminalModel::new(10, 1);
1599 model.process(b"ABCDEFGHIJ");
1600 model.process(b"\x1b[1;5H");
1601 model.process(b"\x1b[2K"); assert_eq!(model.row_text(0), Some(String::new()));
1603 }
1604
1605 #[test]
1608 fn ed_mode_0_erases_from_cursor_to_end() {
1609 let mut model = TerminalModel::new(10, 3);
1610 model.process(b"Line1\nLine2\nLine3");
1611 model.process(b"\x1b[2;1H"); model.process(b"\x1b[0J"); assert_eq!(model.row_text(0), Some("Line1".to_string()));
1614 assert_eq!(model.row_text(1), Some(String::new()));
1615 assert_eq!(model.row_text(2), Some(String::new()));
1616 }
1617
1618 #[test]
1619 fn ed_mode_1_erases_from_start_to_cursor() {
1620 let mut model = TerminalModel::new(10, 3);
1621 model.process(b"Line1\nLine2\nLine3");
1622 model.process(b"\x1b[2;3H"); model.process(b"\x1b[1J"); assert_eq!(model.row_text(0), Some(String::new()));
1625 let row1 = model.row_text(1).unwrap();
1627 assert!(row1.starts_with(" ") || row1.len() <= 10);
1628 }
1629
1630 #[test]
1633 fn sgr_italic() {
1634 let mut model = TerminalModel::new(10, 1);
1635 model.process(b"\x1b[3mI\x1b[0m");
1636 assert!(model.cell(0, 0).unwrap().attrs.has_flag(StyleFlags::ITALIC));
1637 }
1638
1639 #[test]
1640 fn sgr_underline() {
1641 let mut model = TerminalModel::new(10, 1);
1642 model.process(b"\x1b[4mU\x1b[0m");
1643 assert!(
1644 model
1645 .cell(0, 0)
1646 .unwrap()
1647 .attrs
1648 .has_flag(StyleFlags::UNDERLINE)
1649 );
1650 }
1651
1652 #[test]
1653 fn sgr_dim() {
1654 let mut model = TerminalModel::new(10, 1);
1655 model.process(b"\x1b[2mD\x1b[0m");
1656 assert!(model.cell(0, 0).unwrap().attrs.has_flag(StyleFlags::DIM));
1657 }
1658
1659 #[test]
1660 fn sgr_strikethrough() {
1661 let mut model = TerminalModel::new(10, 1);
1662 model.process(b"\x1b[9mS\x1b[0m");
1663 assert!(
1664 model
1665 .cell(0, 0)
1666 .unwrap()
1667 .attrs
1668 .has_flag(StyleFlags::STRIKETHROUGH)
1669 );
1670 }
1671
1672 #[test]
1673 fn sgr_reverse() {
1674 let mut model = TerminalModel::new(10, 1);
1675 model.process(b"\x1b[7mR\x1b[0m");
1676 assert!(
1677 model
1678 .cell(0, 0)
1679 .unwrap()
1680 .attrs
1681 .has_flag(StyleFlags::REVERSE)
1682 );
1683 }
1684
1685 #[test]
1686 fn sgr_remove_bold() {
1687 let mut model = TerminalModel::new(10, 1);
1688 model.process(b"\x1b[1mB\x1b[22mX");
1689 assert!(model.cell(0, 0).unwrap().attrs.has_flag(StyleFlags::BOLD));
1690 assert!(!model.cell(1, 0).unwrap().attrs.has_flag(StyleFlags::BOLD));
1691 }
1692
1693 #[test]
1694 fn sgr_remove_italic() {
1695 let mut model = TerminalModel::new(10, 1);
1696 model.process(b"\x1b[3mI\x1b[23mX");
1697 assert!(!model.cell(1, 0).unwrap().attrs.has_flag(StyleFlags::ITALIC));
1698 }
1699
1700 #[test]
1703 fn sgr_basic_background() {
1704 let mut model = TerminalModel::new(10, 1);
1705 model.process(b"\x1b[42mG"); assert_eq!(model.cell(0, 0).unwrap().bg, PackedRgba::rgb(0, 128, 0));
1707 }
1708
1709 #[test]
1710 fn sgr_default_fg_39() {
1711 let mut model = TerminalModel::new(10, 1);
1712 model.process(b"\x1b[31m\x1b[39mX");
1713 assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::WHITE);
1714 }
1715
1716 #[test]
1717 fn sgr_default_bg_49() {
1718 let mut model = TerminalModel::new(10, 1);
1719 model.process(b"\x1b[41m\x1b[49mX");
1720 assert_eq!(model.cell(0, 0).unwrap().bg, PackedRgba::TRANSPARENT);
1721 }
1722
1723 #[test]
1724 fn sgr_bright_fg() {
1725 let mut model = TerminalModel::new(10, 1);
1726 model.process(b"\x1b[91mX"); assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(255, 0, 0));
1728 }
1729
1730 #[test]
1731 fn sgr_bright_bg() {
1732 let mut model = TerminalModel::new(10, 1);
1733 model.process(b"\x1b[104mX"); assert_eq!(model.cell(0, 0).unwrap().bg, PackedRgba::rgb(0, 0, 255));
1735 }
1736
1737 #[test]
1738 fn sgr_256_grayscale() {
1739 let mut model = TerminalModel::new(10, 1);
1740 model.process(b"\x1b[38;5;232mX"); assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(8, 8, 8));
1742 }
1743
1744 #[test]
1745 fn sgr_256_basic_range() {
1746 let mut model = TerminalModel::new(10, 1);
1747 model.process(b"\x1b[38;5;1mX"); assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(128, 0, 0));
1749 }
1750
1751 #[test]
1752 fn sgr_256_bright_range() {
1753 let mut model = TerminalModel::new(10, 1);
1754 model.process(b"\x1b[38;5;9mX"); assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(255, 0, 0));
1756 }
1757
1758 #[test]
1759 fn sgr_empty_params_resets() {
1760 let mut model = TerminalModel::new(10, 1);
1761 model.process(b"\x1b[1m\x1b[mX"); assert!(!model.cell(0, 0).unwrap().attrs.has_flag(StyleFlags::BOLD));
1763 }
1764
1765 #[test]
1768 fn sync_output_extra_end_saturates() {
1769 let mut model = TerminalModel::new(10, 1);
1770 model.process(b"\x1b[?2026l"); assert_eq!(model.modes().sync_output_level, 0);
1772 assert!(model.sync_output_balanced());
1773 }
1774
1775 #[test]
1776 fn sync_output_nested() {
1777 let mut model = TerminalModel::new(10, 1);
1778 model.process(b"\x1b[?2026h");
1779 model.process(b"\x1b[?2026h");
1780 assert_eq!(model.modes().sync_output_level, 2);
1781 model.process(b"\x1b[?2026l");
1782 assert_eq!(model.modes().sync_output_level, 1);
1783 assert!(!model.sync_output_balanced());
1784 }
1785
1786 #[test]
1789 fn diff_grid_identical_returns_none() {
1790 let model = TerminalModel::new(3, 2);
1791 let expected = vec![ModelCell::default(); 6];
1792 assert!(model.diff_grid(&expected).is_none());
1793 }
1794
1795 #[test]
1796 fn diff_grid_different_returns_some() {
1797 let mut model = TerminalModel::new(3, 1);
1798 model.process(b"ABC");
1799 let expected = vec![ModelCell::default(); 3];
1800 let diff = model.diff_grid(&expected);
1801 assert!(diff.is_some());
1802 let diff_str = diff.unwrap();
1803 assert!(diff_str.contains("Grid differences"));
1804 }
1805
1806 #[test]
1807 fn diff_grid_size_mismatch() {
1808 let model = TerminalModel::new(3, 2);
1809 let expected = vec![ModelCell::default(); 5]; let diff = model.diff_grid(&expected);
1811 assert!(diff.is_some());
1812 assert!(diff.unwrap().contains("Grid size mismatch"));
1813 }
1814
1815 #[test]
1818 fn dump_sequences_osc() {
1819 let bytes = b"\x1b]8;;https://example.com\x07text\x1b]8;;\x07";
1820 let dump = TerminalModel::dump_sequences(bytes);
1821 assert!(dump.contains("\\e]8;;https://example.com\\a"));
1822 }
1823
1824 #[test]
1825 fn dump_sequences_osc_st() {
1826 let bytes = b"\x1b]0;title\x1b\\";
1827 let dump = TerminalModel::dump_sequences(bytes);
1828 assert!(dump.contains("\\e]"));
1829 assert!(dump.contains("\\e\\\\"));
1830 }
1831
1832 #[test]
1833 fn dump_sequences_c0_controls() {
1834 let bytes = b"\x08\x09\x0A";
1835 let dump = TerminalModel::dump_sequences(bytes);
1836 assert!(dump.contains("\\x08"));
1837 assert!(dump.contains("\\x09"));
1838 assert!(dump.contains("\\x0a"));
1839 }
1840
1841 #[test]
1842 fn dump_sequences_trailing_esc() {
1843 let bytes = b"text\x1b";
1844 let dump = TerminalModel::dump_sequences(bytes);
1845 assert!(dump.contains("text"));
1846 assert!(dump.contains("\\e"));
1847 }
1848
1849 #[test]
1850 fn dump_sequences_unknown_escape() {
1851 let bytes = b"\x1bQ";
1852 let dump = TerminalModel::dump_sequences(bytes);
1853 assert!(dump.contains("\\eQ"));
1854 }
1855
1856 #[test]
1859 fn erase_line_uses_current_bg() {
1860 let mut model = TerminalModel::new(5, 1);
1861 model.process(b"Hello");
1862 model.process(b"\x1b[1;1H"); model.process(b"\x1b[41m"); model.process(b"\x1b[K"); let cell = model.cell(0, 0).unwrap();
1866 assert_eq!(cell.text, " ");
1867 assert_eq!(cell.bg, PackedRgba::rgb(128, 0, 0));
1868 }
1869
1870 #[test]
1873 fn multiple_hyperlinks_get_different_ids() {
1874 let mut model = TerminalModel::new(30, 1);
1875 model.process(b"\x1b]8;;https://a.com\x07A\x1b]8;;\x07");
1876 model.process(b"\x1b]8;;https://b.com\x07B\x1b]8;;\x07");
1877 let id_a = model.cell(0, 0).unwrap().link_id;
1878 let id_b = model.cell(1, 0).unwrap().link_id;
1879 assert_ne!(id_a, id_b);
1880 assert_eq!(model.link_url(id_a), Some("https://a.com"));
1881 assert_eq!(model.link_url(id_b), Some("https://b.com"));
1882 }
1883
1884 #[test]
1887 fn osc8_with_st_terminator() {
1888 let mut model = TerminalModel::new(20, 1);
1889 model.process(b"\x1b]8;;https://st.com\x1b\\Link\x1b]8;;\x1b\\");
1890 let cell = model.cell(0, 0).unwrap();
1891 assert!(cell.link_id > 0);
1892 assert_eq!(model.link_url(cell.link_id), Some("https://st.com"));
1893 assert!(!model.has_dangling_link());
1894 }
1895
1896 #[test]
1899 fn terminal_model_debug() {
1900 let model = TerminalModel::new(5, 3);
1901 let dbg = format!("{model:?}");
1902 assert!(dbg.contains("TerminalModel"));
1903 }
1904
1905 #[test]
1908 fn wide_char_occupies_two_cells() {
1909 let mut model = TerminalModel::new(10, 1);
1910 model.process("中".as_bytes());
1912 assert_eq!(model.cell(0, 0).unwrap().text, "中");
1913 assert_eq!(model.cell(1, 0).unwrap().text, "");
1915 assert_eq!(model.cursor(), (2, 0));
1916 }
1917
1918 #[test]
1921 fn cup_with_f_final_byte() {
1922 let mut model = TerminalModel::new(80, 24);
1923 model.process(b"\x1b[3;7f"); assert_eq!(model.cursor(), (6, 2));
1925 }
1926
1927 #[test]
1930 fn csi_unknown_final_byte_ignored() {
1931 let mut model = TerminalModel::new(10, 1);
1932 model.process(b"A");
1933 model.process(b"\x1b[99X"); model.process(b"B");
1935 assert_eq!(model.row_text(0).as_deref(), Some("AB"));
1936 }
1937
1938 #[test]
1941 fn csi_save_restore_cursor_no_panic() {
1942 let mut model = TerminalModel::new(10, 5);
1943 model.process(b"\x1b[5;5H");
1944 model.process(b"\x1b[s"); model.process(b"\x1b[1;1H");
1946 model.process(b"\x1b[u"); let (x, y) = model.cursor();
1949 assert!(x < model.width());
1950 assert!(y < model.height());
1951 }
1952
1953 #[test]
1956 fn bel_in_ground_is_ignored() {
1957 let mut model = TerminalModel::new(10, 1);
1958 model.process(b"\x07Hi");
1959 assert_eq!(model.row_text(0).as_deref(), Some("Hi"));
1960 }
1961
1962 #[test]
1965 fn cup_clamps_large_row_col() {
1966 let mut model = TerminalModel::new(10, 5);
1967 model.process(b"\x1b[999;999H");
1968 assert_eq!(model.cursor(), (9, 4));
1969 }
1970
1971 #[test]
1974 fn cuu_at_top_stays() {
1975 let mut model = TerminalModel::new(10, 5);
1976 model.process(b"\x1b[1;1H");
1977 model.process(b"\x1b[50A"); assert_eq!(model.cursor(), (0, 0));
1979 }
1980
1981 #[test]
1982 fn cud_at_bottom_stays() {
1983 let mut model = TerminalModel::new(10, 5);
1984 model.process(b"\x1b[5;1H");
1985 model.process(b"\x1b[50B"); assert_eq!(model.cursor(), (0, 4));
1987 }
1988
1989 #[test]
1990 fn cuf_at_right_stays() {
1991 let mut model = TerminalModel::new(10, 1);
1992 model.process(b"\x1b[1;10H");
1993 model.process(b"\x1b[50C"); assert_eq!(model.cursor().0, 9);
1995 }
1996
1997 #[test]
1998 fn cub_at_left_stays() {
1999 let mut model = TerminalModel::new(10, 1);
2000 model.process(b"\x1b[50D"); assert_eq!(model.cursor().0, 0);
2002 }
2003
2004 #[test]
2007 fn csi_with_intermediate_no_crash() {
2008 let mut model = TerminalModel::new(10, 1);
2009 model.process(b"\x1b[ q");
2012 model.process(b"OK");
2013 assert_eq!(model.row_text(0).as_deref(), Some("qOK"));
2015 }
2016
2017 #[test]
2020 fn reset_preserves_dimensions() {
2021 let mut model = TerminalModel::new(40, 20);
2022 model.process(b"SomeText");
2023 model.reset();
2024 assert_eq!(model.width(), 40);
2025 assert_eq!(model.height(), 20);
2026 assert_eq!(model.cursor(), (0, 0));
2027 }
2028
2029 #[test]
2032 fn lf_at_bottom_row_stays() {
2033 let mut model = TerminalModel::new(10, 3);
2034 model.process(b"\x1b[3;1H"); model.process(b"\n"); assert_eq!(model.cursor().1, 2); }
2038}
2039
2040#[cfg(test)]
2042mod proptests {
2043 use super::*;
2044 use proptest::prelude::*;
2045
2046 fn cup_sequence(row: u8, col: u8) -> Vec<u8> {
2048 format!("\x1b[{};{}H", row.max(1), col.max(1)).into_bytes()
2049 }
2050
2051 fn sgr_sequence(codes: &[u8]) -> Vec<u8> {
2053 let codes_str: Vec<String> = codes.iter().map(|c| c.to_string()).collect();
2054 format!("\x1b[{}m", codes_str.join(";")).into_bytes()
2055 }
2056
2057 proptest! {
2058 #[test]
2060 fn printable_ascii_no_crash(s in "[A-Za-z0-9 ]{0,100}") {
2061 let mut model = TerminalModel::new(80, 24);
2062 model.process(s.as_bytes());
2063 let (x, y) = model.cursor();
2065 prop_assert!(x < model.width());
2066 prop_assert!(y < model.height());
2067 }
2068
2069 #[test]
2071 fn cup_cursor_in_bounds(row in 0u8..100, col in 0u8..200) {
2072 let mut model = TerminalModel::new(80, 24);
2073 let seq = cup_sequence(row, col);
2074 model.process(&seq);
2075
2076 let (x, y) = model.cursor();
2077 prop_assert!(x < model.width(), "cursor_x {} >= width {}", x, model.width());
2078 prop_assert!(y < model.height(), "cursor_y {} >= height {}", y, model.height());
2079 }
2080
2081 #[test]
2083 fn relative_moves_in_bounds(
2084 start_row in 1u8..24,
2085 start_col in 1u8..80,
2086 up in 0u8..50,
2087 down in 0u8..50,
2088 left in 0u8..100,
2089 right in 0u8..100,
2090 ) {
2091 let mut model = TerminalModel::new(80, 24);
2092
2093 model.process(&cup_sequence(start_row, start_col));
2095
2096 model.process(format!("\x1b[{}A", up).as_bytes());
2098 model.process(format!("\x1b[{}B", down).as_bytes());
2099 model.process(format!("\x1b[{}D", left).as_bytes());
2100 model.process(format!("\x1b[{}C", right).as_bytes());
2101
2102 let (x, y) = model.cursor();
2103 prop_assert!(x < model.width());
2104 prop_assert!(y < model.height());
2105 }
2106
2107 #[test]
2109 fn sgr_reset_clears_flags(attrs in proptest::collection::vec(1u8..9, 0..5)) {
2110 let mut model = TerminalModel::new(80, 24);
2111
2112 if !attrs.is_empty() {
2114 model.process(&sgr_sequence(&attrs));
2115 }
2116
2117 model.process(b"\x1b[0m");
2119
2120 prop_assert!(model.sgr_state().flags.is_empty());
2121 }
2122
2123 #[test]
2125 fn hyperlinks_balance(text in "[a-z]{1,20}") {
2126 let mut model = TerminalModel::new(80, 24);
2127
2128 model.process(b"\x1b]8;;https://example.com\x07");
2130 prop_assert!(model.has_dangling_link());
2131
2132 model.process(text.as_bytes());
2134
2135 model.process(b"\x1b]8;;\x07");
2137 prop_assert!(!model.has_dangling_link());
2138 }
2139
2140 #[test]
2142 fn sync_output_balances(nesting in 1usize..5) {
2143 let mut model = TerminalModel::new(80, 24);
2144
2145 for _ in 0..nesting {
2147 model.process(b"\x1b[?2026h");
2148 }
2149 prop_assert_eq!(model.modes().sync_output_level, nesting as u32);
2150
2151 for _ in 0..nesting {
2153 model.process(b"\x1b[?2026l");
2154 }
2155 prop_assert!(model.sync_output_balanced());
2156 }
2157
2158 #[test]
2160 fn erase_operations_safe(
2161 row in 1u8..24,
2162 col in 1u8..80,
2163 ed_mode in 0u8..4,
2164 el_mode in 0u8..3,
2165 ) {
2166 let mut model = TerminalModel::new(80, 24);
2167
2168 model.process(&cup_sequence(row, col));
2170
2171 model.process(format!("\x1b[{}J", ed_mode).as_bytes());
2173
2174 model.process(&cup_sequence(row, col));
2176 model.process(format!("\x1b[{}K", el_mode).as_bytes());
2177
2178 let (x, y) = model.cursor();
2179 prop_assert!(x < model.width());
2180 prop_assert!(y < model.height());
2181 }
2182
2183 #[test]
2185 fn random_bytes_no_panic(bytes in proptest::collection::vec(any::<u8>(), 0..200)) {
2186 let mut model = TerminalModel::new(80, 24);
2187 model.process(&bytes);
2188
2189 let (x, y) = model.cursor();
2191 prop_assert!(x < model.width());
2192 prop_assert!(y < model.height());
2193 }
2194 }
2195}