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 pub fn width(&self) -> usize {
195 self.width
196 }
197
198 pub fn height(&self) -> usize {
200 self.height
201 }
202
203 pub fn cursor(&self) -> (usize, usize) {
205 (self.cursor_x, self.cursor_y)
206 }
207
208 pub fn sgr_state(&self) -> &SgrState {
210 &self.sgr
211 }
212
213 pub fn modes(&self) -> &ModeFlags {
215 &self.modes
216 }
217
218 pub fn cell(&self, x: usize, y: usize) -> Option<&ModelCell> {
220 if x < self.width && y < self.height {
221 Some(&self.cells[y * self.width + x])
222 } else {
223 None
224 }
225 }
226
227 fn cell_mut(&mut self, x: usize, y: usize) -> Option<&mut ModelCell> {
229 if x < self.width && y < self.height {
230 Some(&mut self.cells[y * self.width + x])
231 } else {
232 None
233 }
234 }
235
236 pub fn current_cell(&self) -> Option<&ModelCell> {
238 self.cell(self.cursor_x, self.cursor_y)
239 }
240
241 pub fn cells(&self) -> &[ModelCell] {
243 &self.cells
244 }
245
246 pub fn row(&self, y: usize) -> Option<&[ModelCell]> {
248 if y < self.height {
249 let start = y * self.width;
250 Some(&self.cells[start..start + self.width])
251 } else {
252 None
253 }
254 }
255
256 pub fn row_text(&self, y: usize) -> Option<String> {
258 self.row(y).map(|cells| {
259 let s: String = cells.iter().map(|c| c.text.as_str()).collect();
260 s.trim_end().to_string()
261 })
262 }
263
264 pub fn link_url(&self, link_id: u32) -> Option<&str> {
266 self.links.get(link_id as usize).map(|s| s.as_str())
267 }
268
269 pub fn has_dangling_link(&self) -> bool {
271 self.current_link_id != 0
272 }
273
274 pub fn sync_output_balanced(&self) -> bool {
276 self.modes.sync_output_level == 0
277 }
278
279 pub fn reset(&mut self) {
281 self.cells.fill(ModelCell::default());
282 self.cursor_x = 0;
283 self.cursor_y = 0;
284 self.sgr = SgrState::default();
285 self.modes = ModeFlags::new();
286 self.current_link_id = 0;
287 self.parse_state = ParseState::Ground;
288 self.csi_params.clear();
289 self.csi_intermediate.clear();
290 self.osc_buffer.clear();
291 self.utf8_pending.clear();
292 self.utf8_expected = None;
293 }
294
295 pub fn process(&mut self, bytes: &[u8]) {
297 for &b in bytes {
298 self.process_byte(b);
299 self.bytes_processed += 1;
300 }
301 }
302
303 fn process_byte(&mut self, b: u8) {
305 match self.parse_state {
306 ParseState::Ground => self.ground_state(b),
307 ParseState::Escape => self.escape_state(b),
308 ParseState::CsiEntry => self.csi_entry_state(b),
309 ParseState::CsiParam => self.csi_param_state(b),
310 ParseState::OscEntry => self.osc_entry_state(b),
311 ParseState::OscString => self.osc_string_state(b),
312 }
313 }
314
315 fn ground_state(&mut self, b: u8) {
316 match b {
317 0x1B => {
318 self.flush_pending_utf8_invalid();
320 self.parse_state = ParseState::Escape;
321 }
322 0x00..=0x1A | 0x1C..=0x1F => {
323 self.flush_pending_utf8_invalid();
325 self.handle_c0(b);
326 }
327 _ => {
328 self.handle_printable(b);
330 }
331 }
332 }
333
334 fn escape_state(&mut self, b: u8) {
335 match b {
336 b'[' => {
337 self.csi_params.clear();
339 self.csi_intermediate.clear();
340 self.parse_state = ParseState::CsiEntry;
341 }
342 b']' => {
343 self.osc_buffer.clear();
345 self.parse_state = ParseState::OscEntry;
346 }
347 b'7' => {
348 self.parse_state = ParseState::Ground;
350 }
351 b'8' => {
352 self.parse_state = ParseState::Ground;
354 }
355 b'=' | b'>' => {
356 self.parse_state = ParseState::Ground;
358 }
359 0x1B => {
360 }
362 _ => {
363 self.parse_state = ParseState::Ground;
365 }
366 }
367 }
368
369 fn csi_entry_state(&mut self, b: u8) {
370 match b {
371 b'0'..=b'9' => {
372 self.csi_params.push((b - b'0') as u32);
373 self.parse_state = ParseState::CsiParam;
374 }
375 b';' => {
376 self.csi_params.push(0);
377 self.parse_state = ParseState::CsiParam;
378 }
379 b'?' | b'>' | b'!' => {
380 self.csi_intermediate.push(b);
381 self.parse_state = ParseState::CsiParam;
382 }
383 0x40..=0x7E => {
384 self.execute_csi(b);
386 self.parse_state = ParseState::Ground;
387 }
388 _ => {
389 self.parse_state = ParseState::Ground;
390 }
391 }
392 }
393
394 fn csi_param_state(&mut self, b: u8) {
395 match b {
396 b'0'..=b'9' => {
397 if self.csi_params.is_empty() {
398 self.csi_params.push(0);
399 }
400 if let Some(last) = self.csi_params.last_mut() {
401 *last = last.saturating_mul(10).saturating_add((b - b'0') as u32);
402 }
403 }
404 b';' => {
405 self.csi_params.push(0);
406 }
407 b':' => {
408 self.csi_params.push(0);
410 }
411 0x20..=0x2F => {
412 self.csi_intermediate.push(b);
413 }
414 0x40..=0x7E => {
415 self.execute_csi(b);
417 self.parse_state = ParseState::Ground;
418 }
419 _ => {
420 self.parse_state = ParseState::Ground;
421 }
422 }
423 }
424
425 fn osc_entry_state(&mut self, b: u8) {
426 match b {
427 0x07 => {
428 self.execute_osc();
430 self.parse_state = ParseState::Ground;
431 }
432 0x1B => {
433 self.parse_state = ParseState::OscString;
435 }
436 _ => {
437 self.osc_buffer.push(b);
438 }
439 }
440 }
441
442 fn osc_string_state(&mut self, b: u8) {
443 match b {
444 b'\\' => {
445 self.execute_osc();
447 self.parse_state = ParseState::Ground;
448 }
449 _ => {
450 self.osc_buffer.push(0x1B);
452 self.osc_buffer.push(b);
453 self.parse_state = ParseState::OscEntry;
454 }
455 }
456 }
457
458 fn handle_c0(&mut self, b: u8) {
459 match b {
460 0x07 => {} 0x08 => {
462 if self.cursor_x > 0 {
464 self.cursor_x -= 1;
465 }
466 }
467 0x09 => {
468 self.cursor_x = (self.cursor_x / 8 + 1) * 8;
470 if self.cursor_x >= self.width {
471 self.cursor_x = self.width - 1;
472 }
473 }
474 0x0A => {
475 if self.cursor_y + 1 < self.height {
477 self.cursor_y += 1;
478 }
479 }
480 0x0D => {
481 self.cursor_x = 0;
483 }
484 _ => {} }
486 }
487
488 fn handle_printable(&mut self, b: u8) {
489 if self.utf8_expected.is_none() {
490 if b < 0x80 {
491 self.put_char(b as char);
492 return;
493 }
494 if let Some(expected) = Self::utf8_expected_len(b) {
495 self.utf8_pending.clear();
496 self.utf8_pending.push(b);
497 self.utf8_expected = Some(expected);
498 if expected == 1 {
499 self.flush_utf8_sequence();
500 }
501 } else {
502 self.put_char('\u{FFFD}');
503 }
504 return;
505 }
506
507 if !Self::is_utf8_continuation(b) {
508 self.flush_pending_utf8_invalid();
509 self.handle_printable(b);
510 return;
511 }
512
513 self.utf8_pending.push(b);
514 if let Some(expected) = self.utf8_expected {
515 if self.utf8_pending.len() == expected {
516 self.flush_utf8_sequence();
517 } else if self.utf8_pending.len() > expected {
518 self.flush_pending_utf8_invalid();
519 }
520 }
521 }
522
523 fn flush_utf8_sequence(&mut self) {
524 let chars: Vec<char> = std::str::from_utf8(&self.utf8_pending)
527 .map(|text| text.chars().collect())
528 .unwrap_or_else(|_| vec!['\u{FFFD}']);
529 self.utf8_pending.clear();
530 self.utf8_expected = None;
531 for ch in chars {
532 self.put_char(ch);
533 }
534 }
535
536 fn flush_pending_utf8_invalid(&mut self) {
537 if self.utf8_expected.is_some() {
538 self.put_char('\u{FFFD}');
539 self.utf8_pending.clear();
540 self.utf8_expected = None;
541 }
542 }
543
544 fn utf8_expected_len(first: u8) -> Option<usize> {
545 if first < 0x80 {
546 Some(1)
547 } else if (0xC2..=0xDF).contains(&first) {
548 Some(2)
549 } else if (0xE0..=0xEF).contains(&first) {
550 Some(3)
551 } else if (0xF0..=0xF4).contains(&first) {
552 Some(4)
553 } else {
554 None
555 }
556 }
557
558 fn is_utf8_continuation(byte: u8) -> bool {
559 (0x80..=0xBF).contains(&byte)
560 }
561
562 fn put_char(&mut self, ch: char) {
563 let width = char_width(ch);
564
565 if width == 0 {
567 if self.cursor_x > 0 {
568 let idx = self.cursor_y * self.width + self.cursor_x - 1;
570 if let Some(cell) = self.cells.get_mut(idx) {
571 cell.text.push(ch);
572 }
573 } else if self.cursor_x < self.width && self.cursor_y < self.height {
574 let idx = self.cursor_y * self.width + self.cursor_x;
576 let cell = &mut self.cells[idx];
577 if cell.text == " " {
578 cell.text = format!(" {}", ch);
580 } else {
581 cell.text.push(ch);
582 }
583 }
584 return;
585 }
586
587 if self.cursor_x < self.width && self.cursor_y < self.height {
588 let cell = &mut self.cells[self.cursor_y * self.width + self.cursor_x];
589 cell.text = ch.to_string();
590 cell.fg = self.sgr.fg;
591 cell.bg = self.sgr.bg;
592 cell.attrs = CellAttrs::new(self.sgr.flags, self.current_link_id);
593 cell.link_id = self.current_link_id;
594
595 if width == 2 && self.cursor_x + 1 < self.width {
597 let next_cell = &mut self.cells[self.cursor_y * self.width + self.cursor_x + 1];
598 next_cell.text = String::new(); next_cell.fg = self.sgr.fg; next_cell.bg = self.sgr.bg;
601 next_cell.attrs = CellAttrs::NONE; next_cell.link_id = 0; }
604 }
605
606 self.cursor_x += width;
607
608 if self.cursor_x >= self.width {
610 self.cursor_x = 0;
611 if self.cursor_y + 1 < self.height {
612 self.cursor_y += 1;
613 }
614 }
615 }
616
617 fn execute_csi(&mut self, final_byte: u8) {
618 let has_question = self.csi_intermediate.contains(&b'?');
619
620 match final_byte {
621 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' => {
634 }
636 b'u' => {
637 }
639 _ => {} }
641 }
642
643 fn csi_cup(&mut self) {
644 let row = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
646 let col = self.csi_params.get(1).copied().unwrap_or(1).max(1) as usize;
647 self.cursor_y = (row - 1).min(self.height - 1);
648 self.cursor_x = (col - 1).min(self.width - 1);
649 }
650
651 fn csi_cuu(&mut self) {
652 let n = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
653 self.cursor_y = self.cursor_y.saturating_sub(n);
654 }
655
656 fn csi_cud(&mut self) {
657 let n = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
658 self.cursor_y = (self.cursor_y + n).min(self.height - 1);
659 }
660
661 fn csi_cuf(&mut self) {
662 let n = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
663 self.cursor_x = (self.cursor_x + n).min(self.width - 1);
664 }
665
666 fn csi_cub(&mut self) {
667 let n = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
668 self.cursor_x = self.cursor_x.saturating_sub(n);
669 }
670
671 fn csi_cha(&mut self) {
672 let col = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
673 self.cursor_x = (col - 1).min(self.width - 1);
674 }
675
676 fn csi_vpa(&mut self) {
677 let row = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
678 self.cursor_y = (row - 1).min(self.height - 1);
679 }
680
681 fn csi_ed(&mut self) {
682 let mode = self.csi_params.first().copied().unwrap_or(0);
683 match mode {
684 0 => {
685 for x in self.cursor_x..self.width {
687 self.erase_cell(x, self.cursor_y);
688 }
689 for y in (self.cursor_y + 1)..self.height {
690 for x in 0..self.width {
691 self.erase_cell(x, y);
692 }
693 }
694 }
695 1 => {
696 for y in 0..self.cursor_y {
698 for x in 0..self.width {
699 self.erase_cell(x, y);
700 }
701 }
702 for x in 0..=self.cursor_x {
703 self.erase_cell(x, self.cursor_y);
704 }
705 }
706 2 | 3 => {
707 for cell in &mut self.cells {
709 *cell = ModelCell::default();
710 }
711 }
712 _ => {}
713 }
714 }
715
716 fn csi_el(&mut self) {
717 let mode = self.csi_params.first().copied().unwrap_or(0);
718 match mode {
719 0 => {
720 for x in self.cursor_x..self.width {
722 self.erase_cell(x, self.cursor_y);
723 }
724 }
725 1 => {
726 for x in 0..=self.cursor_x {
728 self.erase_cell(x, self.cursor_y);
729 }
730 }
731 2 => {
732 for x in 0..self.width {
734 self.erase_cell(x, self.cursor_y);
735 }
736 }
737 _ => {}
738 }
739 }
740
741 fn erase_cell(&mut self, x: usize, y: usize) {
742 let bg = self.sgr.bg;
744 if let Some(cell) = self.cell_mut(x, y) {
745 cell.text = " ".to_string();
746 cell.fg = PackedRgba::WHITE;
748 cell.bg = bg;
749 cell.attrs = CellAttrs::NONE;
750 cell.link_id = 0;
751 }
752 }
753
754 fn csi_sgr(&mut self) {
755 if self.csi_params.is_empty() {
756 self.sgr.reset();
757 return;
758 }
759
760 let mut i = 0;
761 while i < self.csi_params.len() {
762 let code = self.csi_params[i];
763 match code {
764 0 => self.sgr.reset(),
765 1 => self.sgr.flags.insert(StyleFlags::BOLD),
766 2 => self.sgr.flags.insert(StyleFlags::DIM),
767 3 => self.sgr.flags.insert(StyleFlags::ITALIC),
768 4 => self.sgr.flags.insert(StyleFlags::UNDERLINE),
769 5 => self.sgr.flags.insert(StyleFlags::BLINK),
770 7 => self.sgr.flags.insert(StyleFlags::REVERSE),
771 8 => self.sgr.flags.insert(StyleFlags::HIDDEN),
772 9 => self.sgr.flags.insert(StyleFlags::STRIKETHROUGH),
773 21 | 22 => self.sgr.flags.remove(StyleFlags::BOLD | StyleFlags::DIM),
774 23 => self.sgr.flags.remove(StyleFlags::ITALIC),
775 24 => self.sgr.flags.remove(StyleFlags::UNDERLINE),
776 25 => self.sgr.flags.remove(StyleFlags::BLINK),
777 27 => self.sgr.flags.remove(StyleFlags::REVERSE),
778 28 => self.sgr.flags.remove(StyleFlags::HIDDEN),
779 29 => self.sgr.flags.remove(StyleFlags::STRIKETHROUGH),
780 30..=37 => {
782 self.sgr.fg = Self::basic_color(code - 30);
783 }
784 39 => {
786 self.sgr.fg = PackedRgba::WHITE;
787 }
788 40..=47 => {
790 self.sgr.bg = Self::basic_color(code - 40);
791 }
792 49 => {
794 self.sgr.bg = PackedRgba::TRANSPARENT;
795 }
796 90..=97 => {
798 self.sgr.fg = Self::bright_color(code - 90);
799 }
800 100..=107 => {
802 self.sgr.bg = Self::bright_color(code - 100);
803 }
804 38 => {
806 if let Some(color) = self.parse_extended_color(&mut i) {
807 self.sgr.fg = color;
808 }
809 }
810 48 => {
811 if let Some(color) = self.parse_extended_color(&mut i) {
812 self.sgr.bg = color;
813 }
814 }
815 _ => {} }
817 i += 1;
818 }
819 }
820
821 fn parse_extended_color(&self, i: &mut usize) -> Option<PackedRgba> {
822 let mode = self.csi_params.get(*i + 1)?;
823 match *mode {
824 5 => {
825 let idx = self.csi_params.get(*i + 2)?;
827 *i += 2;
828 Some(Self::color_256(*idx as u8))
829 }
830 2 => {
831 let r = *self.csi_params.get(*i + 2)? as u8;
833 let g = *self.csi_params.get(*i + 3)? as u8;
834 let b = *self.csi_params.get(*i + 4)? as u8;
835 *i += 4;
836 Some(PackedRgba::rgb(r, g, b))
837 }
838 _ => None,
839 }
840 }
841
842 fn basic_color(idx: u32) -> PackedRgba {
843 match idx {
844 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,
853 }
854 }
855
856 fn bright_color(idx: u32) -> PackedRgba {
857 match idx {
858 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,
867 }
868 }
869
870 fn color_256(idx: u8) -> PackedRgba {
871 match idx {
872 0..=7 => Self::basic_color(idx as u32),
873 8..=15 => Self::bright_color((idx - 8) as u32),
874 16..=231 => {
875 let idx = idx - 16;
877 let r = (idx / 36) % 6;
878 let g = (idx / 6) % 6;
879 let b = idx % 6;
880 let to_channel = |v| if v == 0 { 0 } else { 55 + v * 40 };
881 PackedRgba::rgb(to_channel(r), to_channel(g), to_channel(b))
882 }
883 232..=255 => {
884 let gray = 8 + (idx - 232) * 10;
886 PackedRgba::rgb(gray, gray, gray)
887 }
888 }
889 }
890
891 fn csi_decset(&mut self) {
892 for &code in &self.csi_params {
893 match code {
894 25 => self.modes.cursor_visible = true, 1049 => self.modes.alt_screen = true, 2026 => self.modes.sync_output_level += 1, _ => {}
898 }
899 }
900 }
901
902 fn csi_decrst(&mut self) {
903 for &code in &self.csi_params {
904 match code {
905 25 => self.modes.cursor_visible = false, 1049 => self.modes.alt_screen = false, 2026 => {
908 self.modes.sync_output_level = self.modes.sync_output_level.saturating_sub(1);
910 }
911 _ => {}
912 }
913 }
914 }
915
916 fn execute_osc(&mut self) {
917 let data = String::from_utf8_lossy(&self.osc_buffer).to_string();
920 let mut parts = data.splitn(2, ';');
921 let code: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
922
923 if code == 8
925 && let Some(rest) = parts.next()
926 {
927 let rest = rest.to_string();
928 self.handle_osc8(&rest);
929 }
930 }
931
932 fn handle_osc8(&mut self, data: &str) {
933 let mut parts = data.splitn(2, ';');
936 let _params = parts.next().unwrap_or("");
937 let uri = parts.next().unwrap_or("");
938
939 if uri.is_empty() {
940 self.current_link_id = 0;
942 } else {
943 self.links.push(uri.to_string());
945 self.current_link_id = (self.links.len() - 1) as u32;
946 }
947 }
948
949 pub fn diff_grid(&self, expected: &[ModelCell]) -> Option<String> {
951 if self.cells.len() != expected.len() {
952 return Some(format!(
953 "Grid size mismatch: got {} cells, expected {}",
954 self.cells.len(),
955 expected.len()
956 ));
957 }
958
959 let mut diffs = Vec::new();
960 for (i, (actual, exp)) in self.cells.iter().zip(expected.iter()).enumerate() {
961 if actual != exp {
962 let x = i % self.width;
963 let y = i / self.width;
964 diffs.push(format!(
965 " ({}, {}): got {:?}, expected {:?}",
966 x, y, actual.text, exp.text
967 ));
968 }
969 }
970
971 if diffs.is_empty() {
972 None
973 } else {
974 Some(format!("Grid differences:\n{}", diffs.join("\n")))
975 }
976 }
977
978 pub fn dump_sequences(bytes: &[u8]) -> String {
980 let mut output = String::new();
981 let mut i = 0;
982 while i < bytes.len() {
983 if bytes[i] == 0x1B {
984 if i + 1 < bytes.len() {
985 match bytes[i + 1] {
986 b'[' => {
987 output.push_str("\\e[");
989 i += 2;
990 while i < bytes.len() && !(0x40..=0x7E).contains(&bytes[i]) {
991 output.push(bytes[i] as char);
992 i += 1;
993 }
994 if i < bytes.len() {
995 output.push(bytes[i] as char);
996 i += 1;
997 }
998 }
999 b']' => {
1000 output.push_str("\\e]");
1002 i += 2;
1003 while i < bytes.len() && bytes[i] != 0x07 {
1004 if bytes[i] == 0x1B && i + 1 < bytes.len() && bytes[i + 1] == b'\\'
1005 {
1006 output.push_str("\\e\\\\");
1007 i += 2;
1008 break;
1009 }
1010 output.push(bytes[i] as char);
1011 i += 1;
1012 }
1013 if i < bytes.len() && bytes[i] == 0x07 {
1014 output.push_str("\\a");
1015 i += 1;
1016 }
1017 }
1018 _ => {
1019 output.push_str(&format!("\\e{}", bytes[i + 1] as char));
1020 i += 2;
1021 }
1022 }
1023 } else {
1024 output.push_str("\\e");
1025 i += 1;
1026 }
1027 } else if bytes[i] < 0x20 {
1028 output.push_str(&format!("\\x{:02x}", bytes[i]));
1029 i += 1;
1030 } else {
1031 output.push(bytes[i] as char);
1032 i += 1;
1033 }
1034 }
1035 output
1036 }
1037}
1038
1039#[cfg(test)]
1040mod tests {
1041 use super::*;
1042 use crate::ansi;
1043
1044 #[test]
1045 fn new_creates_empty_grid() {
1046 let model = TerminalModel::new(80, 24);
1047 assert_eq!(model.width(), 80);
1048 assert_eq!(model.height(), 24);
1049 assert_eq!(model.cursor(), (0, 0));
1050 assert_eq!(model.cells().len(), 80 * 24);
1051 }
1052
1053 #[test]
1054 fn printable_text_writes_to_grid() {
1055 let mut model = TerminalModel::new(10, 5);
1056 model.process(b"Hello");
1057 assert_eq!(model.cursor(), (5, 0));
1058 assert_eq!(model.row_text(0), Some("Hello".to_string()));
1059 }
1060
1061 #[test]
1062 fn cup_moves_cursor() {
1063 let mut model = TerminalModel::new(80, 24);
1064 model.process(b"\x1b[5;10H"); assert_eq!(model.cursor(), (9, 4)); }
1067
1068 #[test]
1069 fn cup_with_defaults() {
1070 let mut model = TerminalModel::new(80, 24);
1071 model.process(b"\x1b[H"); assert_eq!(model.cursor(), (0, 0));
1073 }
1074
1075 #[test]
1076 fn relative_cursor_moves() {
1077 let mut model = TerminalModel::new(80, 24);
1078 model.process(b"\x1b[10;10H"); model.process(b"\x1b[2A"); assert_eq!(model.cursor(), (9, 7));
1081 model.process(b"\x1b[3B"); assert_eq!(model.cursor(), (9, 10));
1083 model.process(b"\x1b[5C"); assert_eq!(model.cursor(), (14, 10));
1085 model.process(b"\x1b[3D"); assert_eq!(model.cursor(), (11, 10));
1087 }
1088
1089 #[test]
1090 fn sgr_sets_style_flags() {
1091 let mut model = TerminalModel::new(20, 5);
1092 model.process(b"\x1b[1mBold\x1b[0m");
1093 assert!(model.cell(0, 0).unwrap().attrs.has_flag(StyleFlags::BOLD));
1094 assert!(!model.cell(4, 0).unwrap().attrs.has_flag(StyleFlags::BOLD)); }
1096
1097 #[test]
1098 fn sgr_sets_colors() {
1099 let mut model = TerminalModel::new(20, 5);
1100 model.process(b"\x1b[31mRed\x1b[0m");
1101 assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(128, 0, 0));
1102 }
1103
1104 #[test]
1105 fn sgr_256_colors() {
1106 let mut model = TerminalModel::new(20, 5);
1107 model.process(b"\x1b[38;5;196mX"); let cell = model.cell(0, 0).unwrap();
1109 assert_eq!(cell.fg, PackedRgba::rgb(255, 0, 0));
1112 }
1113
1114 #[test]
1115 fn sgr_rgb_colors() {
1116 let mut model = TerminalModel::new(20, 5);
1117 model.process(b"\x1b[38;2;100;150;200mX");
1118 assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(100, 150, 200));
1119 }
1120
1121 #[test]
1122 fn erase_line() {
1123 let mut model = TerminalModel::new(10, 5);
1124 model.process(b"ABCDEFGHIJ");
1125 model.process(b"\x1b[1;5H"); model.process(b"\x1b[K"); assert_eq!(model.row_text(0), Some("ABCD".to_string()));
1130 }
1131
1132 #[test]
1133 fn erase_display() {
1134 let mut model = TerminalModel::new(10, 5);
1135 model.process(b"Line1\n");
1136 model.process(b"Line2\n");
1137 model.process(b"\x1b[2J"); for y in 0..5 {
1139 assert_eq!(model.row_text(y), Some(String::new()));
1140 }
1141 }
1142
1143 #[test]
1144 fn osc8_hyperlinks() {
1145 let mut model = TerminalModel::new(20, 5);
1146 model.process(b"\x1b]8;;https://example.com\x07Link\x1b]8;;\x07");
1147
1148 let cell = model.cell(0, 0).unwrap();
1149 assert!(cell.link_id > 0);
1150 assert_eq!(model.link_url(cell.link_id), Some("https://example.com"));
1151
1152 let cell_after = model.cell(4, 0).unwrap();
1154 assert_eq!(cell_after.link_id, 0);
1155 }
1156
1157 #[test]
1158 fn dangling_link_detection() {
1159 let mut model = TerminalModel::new(20, 5);
1160 model.process(b"\x1b]8;;https://example.com\x07Link");
1161 assert!(model.has_dangling_link());
1162
1163 model.process(b"\x1b]8;;\x07");
1164 assert!(!model.has_dangling_link());
1165 }
1166
1167 #[test]
1168 fn sync_output_tracking() {
1169 let mut model = TerminalModel::new(20, 5);
1170 assert!(model.sync_output_balanced());
1171
1172 model.process(b"\x1b[?2026h"); assert!(!model.sync_output_balanced());
1174 assert_eq!(model.modes().sync_output_level, 1);
1175
1176 model.process(b"\x1b[?2026l"); assert!(model.sync_output_balanced());
1178 }
1179
1180 #[test]
1181 fn utf8_multibyte_stream_is_decoded() {
1182 let mut model = TerminalModel::new(10, 1);
1183 let text = "a\u{00E9}\u{4E2D}\u{1F600}";
1184 model.process(text.as_bytes());
1185
1186 assert_eq!(model.row_text(0).as_deref(), Some(text));
1187 assert_eq!(model.cursor(), (6, 0));
1188 }
1189
1190 #[test]
1191 fn utf8_sequence_can_span_process_calls() {
1192 let mut model = TerminalModel::new(10, 1);
1193 let text = "\u{00E9}";
1194 let bytes = text.as_bytes();
1195
1196 model.process(&bytes[..1]);
1197 assert_eq!(model.row_text(0).as_deref(), Some(""));
1198
1199 model.process(&bytes[1..]);
1200 assert_eq!(model.row_text(0).as_deref(), Some(text));
1201 }
1202
1203 #[test]
1204 fn line_wrap() {
1205 let mut model = TerminalModel::new(5, 3);
1206 model.process(b"ABCDEFGH");
1207 assert_eq!(model.row_text(0), Some("ABCDE".to_string()));
1208 assert_eq!(model.row_text(1), Some("FGH".to_string()));
1209 assert_eq!(model.cursor(), (3, 1));
1210 }
1211
1212 #[test]
1213 fn cr_lf_handling() {
1214 let mut model = TerminalModel::new(20, 5);
1215 model.process(b"Hello\r\n");
1216 assert_eq!(model.cursor(), (0, 1));
1217 model.process(b"World");
1218 assert_eq!(model.row_text(0), Some("Hello".to_string()));
1219 assert_eq!(model.row_text(1), Some("World".to_string()));
1220 }
1221
1222 #[test]
1223 fn cursor_visibility() {
1224 let mut model = TerminalModel::new(20, 5);
1225 assert!(model.modes().cursor_visible);
1226
1227 model.process(b"\x1b[?25l"); assert!(!model.modes().cursor_visible);
1229
1230 model.process(b"\x1b[?25h"); assert!(model.modes().cursor_visible);
1232 }
1233
1234 #[test]
1235 fn alt_screen_toggle_is_tracked() {
1236 let mut model = TerminalModel::new(20, 5);
1237 assert!(!model.modes().alt_screen);
1238
1239 model.process(b"\x1b[?1049h");
1240 assert!(model.modes().alt_screen);
1241
1242 model.process(b"\x1b[?1049l");
1243 assert!(!model.modes().alt_screen);
1244 }
1245
1246 #[test]
1247 fn dump_sequences_readable() {
1248 let bytes = b"\x1b[1;1H\x1b[1mHello\x1b[0m";
1249 let dump = TerminalModel::dump_sequences(bytes);
1250 assert!(dump.contains("\\e[1;1H"));
1251 assert!(dump.contains("\\e[1m"));
1252 assert!(dump.contains("Hello"));
1253 assert!(dump.contains("\\e[0m"));
1254 }
1255
1256 #[test]
1257 fn reset_clears_state() {
1258 let mut model = TerminalModel::new(20, 5);
1259 model.process(b"\x1b[10;10HTest\x1b[1m");
1260 model.reset();
1261
1262 assert_eq!(model.cursor(), (0, 0));
1263 assert!(model.sgr_state().flags.is_empty());
1264 for y in 0..5 {
1265 assert_eq!(model.row_text(y), Some(String::new()));
1266 }
1267 }
1268
1269 #[test]
1270 fn erase_scrollback_mode_clears_screen() {
1271 let mut model = TerminalModel::new(10, 3);
1272 model.process(b"Line1\nLine2\nLine3");
1273 model.process(b"\x1b[3J"); for y in 0..3 {
1276 assert_eq!(model.row_text(y), Some(String::new()));
1277 }
1278 }
1279
1280 #[test]
1281 fn scroll_region_sequences_are_ignored_but_safe() {
1282 let mut model = TerminalModel::new(12, 3);
1283 model.process(b"ABCD");
1284 let cursor_before = model.cursor();
1285
1286 let mut buf = Vec::new();
1287 ansi::set_scroll_region(&mut buf, 1, 2).expect("scroll region sequence");
1288 model.process(&buf);
1289 model.process(ansi::RESET_SCROLL_REGION);
1290
1291 assert_eq!(model.cursor(), cursor_before);
1292 model.process(b"EF");
1293 assert_eq!(model.row_text(0).as_deref(), Some("ABCDEF"));
1294 }
1295
1296 #[test]
1297 fn scroll_region_invalid_params_do_not_corrupt_state() {
1298 let mut model = TerminalModel::new(8, 2);
1299 model.process(b"Hi");
1300 let cursor_before = model.cursor();
1301
1302 model.process(b"\x1b[5;2r"); model.process(b"\x1b[0;0r"); model.process(b"\x1b[999;999r"); assert_eq!(model.cursor(), cursor_before);
1307 model.process(b"!");
1308 assert_eq!(model.row_text(0).as_deref(), Some("Hi!"));
1309 }
1310}
1311
1312#[cfg(test)]
1314mod proptests {
1315 use super::*;
1316 use proptest::prelude::*;
1317
1318 fn cup_sequence(row: u8, col: u8) -> Vec<u8> {
1320 format!("\x1b[{};{}H", row.max(1), col.max(1)).into_bytes()
1321 }
1322
1323 fn sgr_sequence(codes: &[u8]) -> Vec<u8> {
1325 let codes_str: Vec<String> = codes.iter().map(|c| c.to_string()).collect();
1326 format!("\x1b[{}m", codes_str.join(";")).into_bytes()
1327 }
1328
1329 proptest! {
1330 #[test]
1332 fn printable_ascii_no_crash(s in "[A-Za-z0-9 ]{0,100}") {
1333 let mut model = TerminalModel::new(80, 24);
1334 model.process(s.as_bytes());
1335 let (x, y) = model.cursor();
1337 prop_assert!(x < model.width());
1338 prop_assert!(y < model.height());
1339 }
1340
1341 #[test]
1343 fn cup_cursor_in_bounds(row in 0u8..100, col in 0u8..200) {
1344 let mut model = TerminalModel::new(80, 24);
1345 let seq = cup_sequence(row, col);
1346 model.process(&seq);
1347
1348 let (x, y) = model.cursor();
1349 prop_assert!(x < model.width(), "cursor_x {} >= width {}", x, model.width());
1350 prop_assert!(y < model.height(), "cursor_y {} >= height {}", y, model.height());
1351 }
1352
1353 #[test]
1355 fn relative_moves_in_bounds(
1356 start_row in 1u8..24,
1357 start_col in 1u8..80,
1358 up in 0u8..50,
1359 down in 0u8..50,
1360 left in 0u8..100,
1361 right in 0u8..100,
1362 ) {
1363 let mut model = TerminalModel::new(80, 24);
1364
1365 model.process(&cup_sequence(start_row, start_col));
1367
1368 model.process(format!("\x1b[{}A", up).as_bytes());
1370 model.process(format!("\x1b[{}B", down).as_bytes());
1371 model.process(format!("\x1b[{}D", left).as_bytes());
1372 model.process(format!("\x1b[{}C", right).as_bytes());
1373
1374 let (x, y) = model.cursor();
1375 prop_assert!(x < model.width());
1376 prop_assert!(y < model.height());
1377 }
1378
1379 #[test]
1381 fn sgr_reset_clears_flags(attrs in proptest::collection::vec(1u8..9, 0..5)) {
1382 let mut model = TerminalModel::new(80, 24);
1383
1384 if !attrs.is_empty() {
1386 model.process(&sgr_sequence(&attrs));
1387 }
1388
1389 model.process(b"\x1b[0m");
1391
1392 prop_assert!(model.sgr_state().flags.is_empty());
1393 }
1394
1395 #[test]
1397 fn hyperlinks_balance(text in "[a-z]{1,20}") {
1398 let mut model = TerminalModel::new(80, 24);
1399
1400 model.process(b"\x1b]8;;https://example.com\x07");
1402 prop_assert!(model.has_dangling_link());
1403
1404 model.process(text.as_bytes());
1406
1407 model.process(b"\x1b]8;;\x07");
1409 prop_assert!(!model.has_dangling_link());
1410 }
1411
1412 #[test]
1414 fn sync_output_balances(nesting in 1usize..5) {
1415 let mut model = TerminalModel::new(80, 24);
1416
1417 for _ in 0..nesting {
1419 model.process(b"\x1b[?2026h");
1420 }
1421 prop_assert_eq!(model.modes().sync_output_level, nesting as u32);
1422
1423 for _ in 0..nesting {
1425 model.process(b"\x1b[?2026l");
1426 }
1427 prop_assert!(model.sync_output_balanced());
1428 }
1429
1430 #[test]
1432 fn erase_operations_safe(
1433 row in 1u8..24,
1434 col in 1u8..80,
1435 ed_mode in 0u8..4,
1436 el_mode in 0u8..3,
1437 ) {
1438 let mut model = TerminalModel::new(80, 24);
1439
1440 model.process(&cup_sequence(row, col));
1442
1443 model.process(format!("\x1b[{}J", ed_mode).as_bytes());
1445
1446 model.process(&cup_sequence(row, col));
1448 model.process(format!("\x1b[{}K", el_mode).as_bytes());
1449
1450 let (x, y) = model.cursor();
1451 prop_assert!(x < model.width());
1452 prop_assert!(y < model.height());
1453 }
1454
1455 #[test]
1457 fn random_bytes_no_panic(bytes in proptest::collection::vec(any::<u8>(), 0..200)) {
1458 let mut model = TerminalModel::new(80, 24);
1459 model.process(&bytes);
1460
1461 let (x, y) = model.cursor();
1463 prop_assert!(x < model.width());
1464 prop_assert!(y < model.height());
1465 }
1466 }
1467}