1use std::collections::HashMap;
41use std::fmt::Write as _;
42use std::ops::Range;
43use std::sync::Arc;
44
45use base64::Engine as _;
46use base64::engine::general_purpose::STANDARD as BASE64;
47use image::DynamicImage;
48
49use super::screen::TerminalCellSize;
50
51const MAX_TRANSMIT_BYTES: usize = 32 * 1024 * 1024;
53
54const MAX_APC_BYTES: usize = MAX_TRANSMIT_BYTES;
63
64const DEFAULT_IMAGE_BUDGET_BYTES: usize = 96 * 1024 * 1024;
70
71const MAX_PLACEMENTS: usize = 256;
73
74const MAX_IMAGE_DIMENSION: u32 = 16384;
76
77const FIRST_AUTO_ID: u32 = 1 << 24;
82
83#[derive(Clone)]
90pub struct TerminalImage {
91 pixels: Arc<DynamicImage>,
92 source_hash: u64,
93}
94
95impl TerminalImage {
96 pub fn width(&self) -> u32 {
98 self.pixels.width()
99 }
100
101 pub fn height(&self) -> u32 {
103 self.pixels.height()
104 }
105
106 pub fn source_hash(&self) -> u64 {
111 self.source_hash
112 }
113
114 pub(crate) fn pixels(&self) -> &Arc<DynamicImage> {
115 &self.pixels
116 }
117}
118
119impl PartialEq for TerminalImage {
120 fn eq(&self, other: &Self) -> bool {
122 self.source_hash == other.source_hash
123 }
124}
125
126impl std::fmt::Debug for TerminalImage {
127 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 f.debug_struct("TerminalImage")
129 .field("width", &self.width())
130 .field("height", &self.height())
131 .field("source_hash", &self.source_hash)
132 .finish()
133 }
134}
135
136#[derive(Clone, Debug, PartialEq)]
142pub struct TerminalImagePlacement {
143 pub image_id: u32,
151 pub image: TerminalImage,
153 pub row: i32,
155 pub col: i32,
157 pub rows: u16,
159 pub cols: u16,
161 pub z: i32,
163 pub source_crop: Option<TerminalImageCrop>,
165}
166
167#[derive(Clone, Copy, Debug, PartialEq, Eq)]
169pub struct TerminalImageCrop {
170 pub x: u32,
172 pub y: u32,
174 pub width: u32,
176 pub height: u32,
178}
179
180#[derive(Debug)]
184pub(super) enum GraphicsSegment {
185 Text(Range<usize>),
187 HeldEscape,
191 Command(Box<GraphicsCommand>),
193}
194
195#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
196enum ScanState {
197 #[default]
199 Ground,
200 Escape,
202 Apc,
204 ApcEscape,
206}
207
208#[derive(Debug, Default)]
214pub(super) struct GraphicsScanner {
215 state: ScanState,
216 apc: Vec<u8>,
218 overflowed: bool,
220}
221
222impl GraphicsScanner {
223 pub(super) fn is_plain(&self, bytes: &[u8]) -> bool {
231 self.state == ScanState::Ground
232 && bytes.last() != Some(&0x1b)
233 && !bytes.windows(2).any(|pair| pair == b"\x1b_")
234 }
235
236 pub(super) fn scan(&mut self, bytes: &[u8]) -> Vec<GraphicsSegment> {
241 let mut out = Vec::new();
242 let mut text_start = 0usize;
244 let mut idx = 0usize;
245
246 while idx < bytes.len() {
247 let byte = bytes[idx];
248 match self.state {
249 ScanState::Ground => {
250 if byte == 0x1b {
251 if text_start < idx {
254 out.push(GraphicsSegment::Text(text_start..idx));
255 }
256 text_start = idx;
257 self.state = ScanState::Escape;
258 }
259 idx += 1;
260 }
261 ScanState::Escape => {
262 if byte == b'_' {
263 self.state = ScanState::Apc;
264 self.apc.clear();
265 self.overflowed = false;
266 idx += 1;
267 text_start = idx;
268 } else {
269 self.state = ScanState::Ground;
270 if text_start == idx {
271 out.push(GraphicsSegment::HeldEscape);
273 }
274 }
276 }
277 ScanState::Apc => {
278 match byte {
279 0x1b => self.state = ScanState::ApcEscape,
280 0x07 => {
283 self.finish_apc(&mut out);
284 self.state = ScanState::Ground;
285 }
286 0x18 | 0x1a => {
288 self.apc.clear();
289 self.overflowed = false;
290 self.state = ScanState::Ground;
291 }
292 _ => self.push_apc(byte),
293 }
294 idx += 1;
295 text_start = idx;
296 }
297 ScanState::ApcEscape => {
298 if byte == b'\\' {
299 self.finish_apc(&mut out);
300 self.state = ScanState::Ground;
301 idx += 1;
302 text_start = idx;
303 } else {
304 self.apc.clear();
307 self.overflowed = false;
308 self.state = ScanState::Ground;
309 text_start = idx;
310 }
311 }
312 }
313 }
314
315 if self.state == ScanState::Ground && text_start < bytes.len() {
316 out.push(GraphicsSegment::Text(text_start..bytes.len()));
317 }
318
319 out
320 }
321
322 fn push_apc(&mut self, byte: u8) {
323 if self.overflowed {
324 return;
325 }
326 if self.apc.len() >= MAX_APC_BYTES {
327 self.apc.clear();
328 self.overflowed = true;
329 return;
330 }
331 self.apc.push(byte);
332 }
333
334 fn finish_apc(&mut self, out: &mut Vec<GraphicsSegment>) {
335 let body = std::mem::take(&mut self.apc);
336 let overflowed = std::mem::take(&mut self.overflowed);
337 if overflowed {
338 return;
339 }
340 let Some(rest) = body.strip_prefix(b"G") else {
342 return;
343 };
344 if let Some(command) = GraphicsCommand::parse(rest) {
345 out.push(GraphicsSegment::Command(Box::new(command)));
346 }
347 }
348
349 pub(super) fn reset(&mut self) {
351 self.state = ScanState::Ground;
352 self.apc.clear();
353 self.overflowed = false;
354 }
355}
356
357#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
361enum GraphicsAction {
362 #[default]
364 Transmit,
365 TransmitAndDisplay,
367 Display,
369 Delete,
371 Query,
373 Animate,
375}
376
377#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
379enum GraphicsMedium {
380 #[default]
382 Direct,
383 OutOfBand,
385}
386
387#[derive(Clone, Debug)]
389pub(super) struct GraphicsCommand {
390 action: GraphicsAction,
391 medium: GraphicsMedium,
392 format: u32,
394 width: u32,
396 height: u32,
397 id: u32,
399 number: u32,
401 placement: u32,
403 more: bool,
405 compressed: bool,
407 src_x: u32,
409 src_y: u32,
410 src_w: u32,
411 src_h: u32,
412 cols: u32,
414 rows: u32,
415 z: i32,
417 no_cursor_move: bool,
419 virtual_placement: bool,
421 delete: u8,
423 quiet: u32,
425 payload: Vec<u8>,
427}
428
429impl Default for GraphicsCommand {
430 fn default() -> Self {
431 Self {
432 action: GraphicsAction::default(),
433 medium: GraphicsMedium::default(),
434 format: 32,
435 width: 0,
436 height: 0,
437 id: 0,
438 number: 0,
439 placement: 0,
440 more: false,
441 compressed: false,
442 src_x: 0,
443 src_y: 0,
444 src_w: 0,
445 src_h: 0,
446 cols: 0,
447 rows: 0,
448 z: 0,
449 no_cursor_move: false,
450 virtual_placement: false,
451 delete: b'a',
452 quiet: 0,
453 payload: Vec::new(),
454 }
455 }
456}
457
458impl GraphicsCommand {
459 fn parse(body: &[u8]) -> Option<Self> {
460 let (control, payload) = match body.iter().position(|byte| *byte == b';') {
461 Some(at) => (&body[..at], &body[at + 1..]),
462 None => (body, &body[body.len()..]),
463 };
464
465 let mut command = Self::default();
466 for pair in control.split(|byte| *byte == b',') {
467 let mut halves = pair.splitn(2, |byte| *byte == b'=');
468 let ([key], Some(value)) = (halves.next()?, halves.next()) else {
469 continue;
470 };
471 command.apply_key(*key, value);
472 }
473
474 command.payload = BASE64.decode(payload).ok()?;
477 Some(command)
478 }
479
480 fn apply_key(&mut self, key: u8, value: &[u8]) {
481 let text = std::str::from_utf8(value).unwrap_or("");
482 let first = value.first().copied().unwrap_or(0);
483 match key {
484 b'a' => {
485 self.action = match first {
486 b'T' => GraphicsAction::TransmitAndDisplay,
487 b'p' => GraphicsAction::Display,
488 b'd' => GraphicsAction::Delete,
489 b'q' => GraphicsAction::Query,
490 b'a' | b'f' | b'c' => GraphicsAction::Animate,
491 _ => GraphicsAction::Transmit,
492 }
493 }
494 b't' => {
495 self.medium = match first {
496 b'f' | b't' | b's' => GraphicsMedium::OutOfBand,
497 _ => GraphicsMedium::Direct,
498 }
499 }
500 b'f' => self.format = text.parse().unwrap_or(32),
501 b's' => self.width = text.parse().unwrap_or(0),
502 b'v' => self.height = text.parse().unwrap_or(0),
503 b'i' => self.id = text.parse().unwrap_or(0),
504 b'I' => self.number = text.parse().unwrap_or(0),
505 b'p' => self.placement = text.parse().unwrap_or(0),
506 b'm' => self.more = text.parse().unwrap_or(0) == 1,
507 b'o' => self.compressed = first == b'z',
508 b'x' => self.src_x = text.parse().unwrap_or(0),
509 b'y' => self.src_y = text.parse().unwrap_or(0),
510 b'w' => self.src_w = text.parse().unwrap_or(0),
511 b'h' => self.src_h = text.parse().unwrap_or(0),
512 b'c' => self.cols = text.parse().unwrap_or(0),
513 b'r' => self.rows = text.parse().unwrap_or(0),
514 b'z' => self.z = text.parse().unwrap_or(0),
515 b'C' => self.no_cursor_move = text.parse().unwrap_or(0) == 1,
516 b'U' => self.virtual_placement = text.parse().unwrap_or(0) == 1,
517 b'd' => self.delete = first,
518 b'q' => self.quiet = text.parse().unwrap_or(0),
519 _ => {}
520 }
521 }
522
523 fn reports(&self, ok: bool) -> bool {
525 match self.quiet {
526 0 => true,
527 1 => !ok,
528 _ => false,
529 }
530 }
531}
532
533pub(super) const PLACEHOLDER: char = '\u{10EEEE}';
543
544static ROWCOLUMN_DIACRITICS: [char; 297] = [
551 '\u{305}',
552 '\u{30d}',
553 '\u{30e}',
554 '\u{310}',
555 '\u{312}',
556 '\u{33d}',
557 '\u{33e}',
558 '\u{33f}',
559 '\u{346}',
560 '\u{34a}',
561 '\u{34b}',
562 '\u{34c}',
563 '\u{350}',
564 '\u{351}',
565 '\u{352}',
566 '\u{357}',
567 '\u{35b}',
568 '\u{363}',
569 '\u{364}',
570 '\u{365}',
571 '\u{366}',
572 '\u{367}',
573 '\u{368}',
574 '\u{369}',
575 '\u{36a}',
576 '\u{36b}',
577 '\u{36c}',
578 '\u{36d}',
579 '\u{36e}',
580 '\u{36f}',
581 '\u{483}',
582 '\u{484}',
583 '\u{485}',
584 '\u{486}',
585 '\u{487}',
586 '\u{592}',
587 '\u{593}',
588 '\u{594}',
589 '\u{595}',
590 '\u{597}',
591 '\u{598}',
592 '\u{599}',
593 '\u{59c}',
594 '\u{59d}',
595 '\u{59e}',
596 '\u{59f}',
597 '\u{5a0}',
598 '\u{5a1}',
599 '\u{5a8}',
600 '\u{5a9}',
601 '\u{5ab}',
602 '\u{5ac}',
603 '\u{5af}',
604 '\u{5c4}',
605 '\u{610}',
606 '\u{611}',
607 '\u{612}',
608 '\u{613}',
609 '\u{614}',
610 '\u{615}',
611 '\u{616}',
612 '\u{617}',
613 '\u{657}',
614 '\u{658}',
615 '\u{659}',
616 '\u{65a}',
617 '\u{65b}',
618 '\u{65d}',
619 '\u{65e}',
620 '\u{6d6}',
621 '\u{6d7}',
622 '\u{6d8}',
623 '\u{6d9}',
624 '\u{6da}',
625 '\u{6db}',
626 '\u{6dc}',
627 '\u{6df}',
628 '\u{6e0}',
629 '\u{6e1}',
630 '\u{6e2}',
631 '\u{6e4}',
632 '\u{6e7}',
633 '\u{6e8}',
634 '\u{6eb}',
635 '\u{6ec}',
636 '\u{730}',
637 '\u{732}',
638 '\u{733}',
639 '\u{735}',
640 '\u{736}',
641 '\u{73a}',
642 '\u{73d}',
643 '\u{73f}',
644 '\u{740}',
645 '\u{741}',
646 '\u{743}',
647 '\u{745}',
648 '\u{747}',
649 '\u{749}',
650 '\u{74a}',
651 '\u{7eb}',
652 '\u{7ec}',
653 '\u{7ed}',
654 '\u{7ee}',
655 '\u{7ef}',
656 '\u{7f0}',
657 '\u{7f1}',
658 '\u{7f3}',
659 '\u{816}',
660 '\u{817}',
661 '\u{818}',
662 '\u{819}',
663 '\u{81b}',
664 '\u{81c}',
665 '\u{81d}',
666 '\u{81e}',
667 '\u{81f}',
668 '\u{820}',
669 '\u{821}',
670 '\u{822}',
671 '\u{823}',
672 '\u{825}',
673 '\u{826}',
674 '\u{827}',
675 '\u{829}',
676 '\u{82a}',
677 '\u{82b}',
678 '\u{82c}',
679 '\u{82d}',
680 '\u{951}',
681 '\u{953}',
682 '\u{954}',
683 '\u{f82}',
684 '\u{f83}',
685 '\u{f86}',
686 '\u{f87}',
687 '\u{135d}',
688 '\u{135e}',
689 '\u{135f}',
690 '\u{17dd}',
691 '\u{193a}',
692 '\u{1a17}',
693 '\u{1a75}',
694 '\u{1a76}',
695 '\u{1a77}',
696 '\u{1a78}',
697 '\u{1a79}',
698 '\u{1a7a}',
699 '\u{1a7b}',
700 '\u{1a7c}',
701 '\u{1b6b}',
702 '\u{1b6d}',
703 '\u{1b6e}',
704 '\u{1b6f}',
705 '\u{1b70}',
706 '\u{1b71}',
707 '\u{1b72}',
708 '\u{1b73}',
709 '\u{1cd0}',
710 '\u{1cd1}',
711 '\u{1cd2}',
712 '\u{1cda}',
713 '\u{1cdb}',
714 '\u{1ce0}',
715 '\u{1dc0}',
716 '\u{1dc1}',
717 '\u{1dc3}',
718 '\u{1dc4}',
719 '\u{1dc5}',
720 '\u{1dc6}',
721 '\u{1dc7}',
722 '\u{1dc8}',
723 '\u{1dc9}',
724 '\u{1dcb}',
725 '\u{1dcc}',
726 '\u{1dd1}',
727 '\u{1dd2}',
728 '\u{1dd3}',
729 '\u{1dd4}',
730 '\u{1dd5}',
731 '\u{1dd6}',
732 '\u{1dd7}',
733 '\u{1dd8}',
734 '\u{1dd9}',
735 '\u{1dda}',
736 '\u{1ddb}',
737 '\u{1ddc}',
738 '\u{1ddd}',
739 '\u{1dde}',
740 '\u{1ddf}',
741 '\u{1de0}',
742 '\u{1de1}',
743 '\u{1de2}',
744 '\u{1de3}',
745 '\u{1de4}',
746 '\u{1de5}',
747 '\u{1de6}',
748 '\u{1dfe}',
749 '\u{20d0}',
750 '\u{20d1}',
751 '\u{20d4}',
752 '\u{20d5}',
753 '\u{20d6}',
754 '\u{20d7}',
755 '\u{20db}',
756 '\u{20dc}',
757 '\u{20e1}',
758 '\u{20e7}',
759 '\u{20e9}',
760 '\u{20f0}',
761 '\u{2cef}',
762 '\u{2cf0}',
763 '\u{2cf1}',
764 '\u{2de0}',
765 '\u{2de1}',
766 '\u{2de2}',
767 '\u{2de3}',
768 '\u{2de4}',
769 '\u{2de5}',
770 '\u{2de6}',
771 '\u{2de7}',
772 '\u{2de8}',
773 '\u{2de9}',
774 '\u{2dea}',
775 '\u{2deb}',
776 '\u{2dec}',
777 '\u{2ded}',
778 '\u{2dee}',
779 '\u{2def}',
780 '\u{2df0}',
781 '\u{2df1}',
782 '\u{2df2}',
783 '\u{2df3}',
784 '\u{2df4}',
785 '\u{2df5}',
786 '\u{2df6}',
787 '\u{2df7}',
788 '\u{2df8}',
789 '\u{2df9}',
790 '\u{2dfa}',
791 '\u{2dfb}',
792 '\u{2dfc}',
793 '\u{2dfd}',
794 '\u{2dfe}',
795 '\u{2dff}',
796 '\u{a66f}',
797 '\u{a67c}',
798 '\u{a67d}',
799 '\u{a6f0}',
800 '\u{a6f1}',
801 '\u{a8e0}',
802 '\u{a8e1}',
803 '\u{a8e2}',
804 '\u{a8e3}',
805 '\u{a8e4}',
806 '\u{a8e5}',
807 '\u{a8e6}',
808 '\u{a8e7}',
809 '\u{a8e8}',
810 '\u{a8e9}',
811 '\u{a8ea}',
812 '\u{a8eb}',
813 '\u{a8ec}',
814 '\u{a8ed}',
815 '\u{a8ee}',
816 '\u{a8ef}',
817 '\u{a8f0}',
818 '\u{a8f1}',
819 '\u{aab0}',
820 '\u{aab2}',
821 '\u{aab3}',
822 '\u{aab7}',
823 '\u{aab8}',
824 '\u{aabe}',
825 '\u{aabf}',
826 '\u{aac1}',
827 '\u{fe20}',
828 '\u{fe21}',
829 '\u{fe22}',
830 '\u{fe23}',
831 '\u{fe24}',
832 '\u{fe25}',
833 '\u{fe26}',
834 '\u{10a0f}',
835 '\u{10a38}',
836 '\u{1d185}',
837 '\u{1d186}',
838 '\u{1d187}',
839 '\u{1d188}',
840 '\u{1d189}',
841 '\u{1d1aa}',
842 '\u{1d1ab}',
843 '\u{1d1ac}',
844 '\u{1d1ad}',
845 '\u{1d242}',
846 '\u{1d243}',
847 '\u{1d244}',
848];
849
850#[cfg(test)]
855pub(super) fn diacritic(index: u16) -> char {
856 ROWCOLUMN_DIACRITICS[usize::from(index).min(ROWCOLUMN_DIACRITICS.len() - 1)]
857}
858
859fn diacritic_value(mark: char) -> Option<u16> {
861 ROWCOLUMN_DIACRITICS
862 .binary_search(&mark)
863 .ok()
864 .map(|index| index as u16)
865}
866
867#[derive(Clone, Copy, Debug)]
869pub(super) struct PlaceholderCell {
870 pub(super) row: u16,
872 pub(super) col: u16,
874 pub(super) id_low: u32,
876 pub(super) image_row: Option<u16>,
878 pub(super) image_col: Option<u16>,
880 pub(super) id_high: Option<u16>,
882}
883
884impl PlaceholderCell {
885 pub(super) fn new(row: u16, col: u16, id_low: u32, marks: &[char]) -> Self {
891 let mut values = marks.iter().filter_map(|mark| diacritic_value(*mark));
892 Self {
893 row,
894 col,
895 id_low,
896 image_row: values.next(),
897 image_col: values.next(),
898 id_high: values.next(),
899 }
900 }
901}
902
903#[derive(Clone, Copy, Debug)]
905struct PlaceholderRun {
906 image_id: u32,
907 id_high: u16,
909 row: u16,
910 col: u16,
911 width: u16,
912 image_row: u16,
913 image_col: u16,
914}
915
916fn placeholder_runs(cells: &[PlaceholderCell]) -> Vec<PlaceholderRun> {
925 let mut runs: Vec<PlaceholderRun> = Vec::new();
926 let mut open: Option<PlaceholderRun> = None;
927
928 for cell in cells {
929 let adjacent =
931 open.is_some_and(|run| run.row == cell.row && run.col + run.width == cell.col);
932 let inherited_high = match (adjacent, open) {
933 (true, Some(run)) => run.id_high,
934 _ => 0,
935 };
936 let id_high = cell.id_high.unwrap_or(inherited_high);
937 let image_id = (u32::from(id_high) << 24) | (cell.id_low & 0x00ff_ffff);
938
939 let continues = adjacent
940 && open.is_some_and(|run| {
941 run.image_id == image_id
942 && cell.image_row.is_none_or(|value| value == run.image_row)
943 && cell
944 .image_col
945 .is_none_or(|value| value == run.image_col + run.width)
946 });
947
948 if continues {
949 if let Some(run) = open.as_mut() {
950 run.width += 1;
951 }
952 continue;
953 }
954
955 if let Some(run) = open.take() {
956 runs.push(run);
957 }
958 open = Some(PlaceholderRun {
959 image_id,
960 id_high,
961 row: cell.row,
962 col: cell.col,
963 width: 1,
964 image_row: cell.image_row.unwrap_or(0),
965 image_col: cell.image_col.unwrap_or(0),
966 });
967 }
968
969 runs.extend(open);
970 runs
971}
972
973#[derive(Clone, Copy, Debug, PartialEq, Eq)]
975struct PlaceholderRect {
976 image_id: u32,
977 row: u16,
978 col: u16,
979 width: u16,
980 height: u16,
981 image_row: u16,
982 image_col: u16,
983}
984
985fn merge_placeholder_runs(runs: &[PlaceholderRun]) -> Vec<PlaceholderRect> {
991 let mut rects: Vec<PlaceholderRect> = Vec::new();
992
993 for run in runs {
994 let stackable = rects.iter_mut().find(|rect| {
995 rect.image_id == run.image_id
996 && rect.col == run.col
997 && rect.width == run.width
998 && rect.image_col == run.image_col
999 && rect.row + rect.height == run.row
1000 && rect.image_row + rect.height == run.image_row
1001 });
1002 if let Some(rect) = stackable {
1003 rect.height += 1;
1004 continue;
1005 }
1006 rects.push(PlaceholderRect {
1007 image_id: run.image_id,
1008 row: run.row,
1009 col: run.col,
1010 width: run.width,
1011 height: 1,
1012 image_row: run.image_row,
1013 image_col: run.image_col,
1014 });
1015 }
1016
1017 rects
1018}
1019
1020#[derive(Clone, Copy, Debug)]
1024pub(super) struct GraphicsContext {
1025 pub(super) cursor_line: usize,
1028 pub(super) cursor_col: u16,
1030 pub(super) viewport_top_line: usize,
1032 pub(super) alt_screen: bool,
1034 pub(super) cell: TerminalCellSize,
1036 pub(super) cols: u16,
1038}
1039
1040#[derive(Debug, Default)]
1042pub(super) struct GraphicsOutcome {
1043 pub(super) response: Option<Vec<u8>>,
1045 pub(super) advance: Option<(u16, u16)>,
1047}
1048
1049struct StoredImage {
1051 image: TerminalImage,
1052 bytes: usize,
1053 used: u64,
1055}
1056
1057#[derive(Clone, Debug)]
1059struct Placement {
1060 image_id: u32,
1061 placement_id: u32,
1062 line: usize,
1064 col: u16,
1065 rows: u16,
1066 cols: u16,
1067 z: i32,
1068 crop: Option<TerminalImageCrop>,
1069 alt_screen: bool,
1071}
1072
1073impl Placement {
1074 fn covers_cell(&self, line: usize, col: u16) -> bool {
1075 self.covers_line(line) && self.covers_column(col)
1076 }
1077
1078 fn covers_line(&self, line: usize) -> bool {
1079 line >= self.line && line < self.line.saturating_add(usize::from(self.rows))
1080 }
1081
1082 fn covers_column(&self, col: u16) -> bool {
1083 col >= self.col && col < self.col.saturating_add(self.cols)
1084 }
1085}
1086
1087struct PendingTransmit {
1089 id: u32,
1090 header: GraphicsCommand,
1092 data: Vec<u8>,
1093}
1094
1095pub(super) struct TerminalGraphics {
1097 images: HashMap<u32, StoredImage>,
1098 numbers: HashMap<u32, u32>,
1100 placements: Vec<Placement>,
1101 pending: Option<PendingTransmit>,
1102 next_auto_id: u32,
1103 budget: usize,
1104 used_bytes: usize,
1105 clock: u64,
1106}
1107
1108impl Default for TerminalGraphics {
1109 fn default() -> Self {
1110 Self {
1111 images: HashMap::new(),
1112 numbers: HashMap::new(),
1113 placements: Vec::new(),
1114 pending: None,
1115 next_auto_id: FIRST_AUTO_ID,
1116 budget: DEFAULT_IMAGE_BUDGET_BYTES,
1117 used_bytes: 0,
1118 clock: 0,
1119 }
1120 }
1121}
1122
1123impl TerminalGraphics {
1124 pub(super) fn has_images(&self) -> bool {
1127 !self.images.is_empty()
1128 }
1129
1130 pub(super) fn set_budget(&mut self, bytes: usize) {
1132 self.budget = bytes;
1133 self.enforce_budget();
1134 }
1135
1136 pub(super) fn reset(&mut self) {
1138 self.images.clear();
1139 self.numbers.clear();
1140 self.placements.clear();
1141 self.pending = None;
1142 self.used_bytes = 0;
1143 }
1144
1145 pub(super) fn clear_placements(&mut self) {
1150 self.placements.clear();
1151 }
1152
1153 pub(super) fn clear_alt_screen(&mut self) -> bool {
1155 let before = self.placements.len();
1156 self.placements.retain(|placement| !placement.alt_screen);
1157 before != self.placements.len()
1158 }
1159
1160 pub(super) fn drop_evicted(&mut self, evicted: usize) -> bool {
1165 if evicted == 0 || self.placements.is_empty() {
1166 return false;
1167 }
1168 self.placements
1171 .retain(|placement| placement.line + usize::from(placement.rows) > evicted);
1172 for placement in &mut self.placements {
1173 placement.line = placement.line.saturating_sub(evicted);
1174 }
1175 true
1176 }
1177
1178 pub(super) fn visible(
1185 &self,
1186 history_lines: usize,
1187 display_offset: usize,
1188 rows: u16,
1189 alt_screen: bool,
1190 ) -> Vec<TerminalImagePlacement> {
1191 let mut visible: Vec<_> = self
1192 .placements
1193 .iter()
1194 .filter(|placement| placement.alt_screen == alt_screen)
1195 .filter_map(|placement| {
1196 let row = placement.line as i64 - history_lines as i64 + display_offset as i64;
1197 if row + i64::from(placement.rows) <= 0 || row >= i64::from(rows) {
1198 return None;
1199 }
1200 Some(TerminalImagePlacement {
1201 image_id: placement.image_id,
1202 image: self.images.get(&placement.image_id)?.image.clone(),
1203 row: row.clamp(i32::MIN as i64, i32::MAX as i64) as i32,
1204 col: i32::from(placement.col),
1205 rows: placement.rows,
1206 cols: placement.cols,
1207 z: placement.z,
1208 source_crop: placement.crop,
1209 })
1210 })
1211 .collect();
1212 visible.sort_by_key(|placement| placement.z);
1213 visible
1214 }
1215
1216 pub(super) fn placeholder_placements(
1222 &self,
1223 cells: &[PlaceholderCell],
1224 cell: TerminalCellSize,
1225 ) -> Vec<TerminalImagePlacement> {
1226 merge_placeholder_runs(&placeholder_runs(cells))
1227 .into_iter()
1228 .filter_map(|rect| {
1229 let stored = self.images.get(&rect.image_id)?;
1230 let (width, height) = (stored.image.width(), stored.image.height());
1231 let x = u32::from(rect.image_col) * u32::from(cell.width);
1235 let y = u32::from(rect.image_row) * u32::from(cell.height);
1236 if x >= width || y >= height {
1237 return None;
1238 }
1239 let crop = TerminalImageCrop {
1240 x,
1241 y,
1242 width: (u32::from(rect.width) * u32::from(cell.width)).min(width - x),
1243 height: (u32::from(rect.height) * u32::from(cell.height)).min(height - y),
1244 };
1245 Some(TerminalImagePlacement {
1246 image_id: rect.image_id,
1247 image: stored.image.clone(),
1248 row: i32::from(rect.row),
1249 col: i32::from(rect.col),
1250 rows: rect.height,
1251 cols: rect.width,
1252 z: 0,
1253 source_crop: Some(crop),
1254 })
1255 })
1256 .collect()
1257 }
1258
1259 pub(super) fn apply(
1261 &mut self,
1262 command: GraphicsCommand,
1263 ctx: GraphicsContext,
1264 ) -> GraphicsOutcome {
1265 self.clock = self.clock.wrapping_add(1);
1266 match command.action {
1267 GraphicsAction::Query => self.query(&command),
1268 GraphicsAction::Delete => {
1269 self.delete(&command, ctx);
1270 GraphicsOutcome::default()
1271 }
1272 GraphicsAction::Display => self.display_stored(&command, ctx),
1273 GraphicsAction::Transmit | GraphicsAction::TransmitAndDisplay => {
1274 self.transmit(command, ctx)
1275 }
1276 GraphicsAction::Animate => GraphicsOutcome {
1277 response: report(&command, command.id, Err("ENOTSUPP:animation")),
1278 advance: None,
1279 },
1280 }
1281 }
1282
1283 fn query(&mut self, command: &GraphicsCommand) -> GraphicsOutcome {
1288 let result = match command.medium {
1289 GraphicsMedium::OutOfBand => Err("ENOTSUPP:file transmission"),
1290 GraphicsMedium::Direct => decode_payload(command, &command.payload).map(|_| ()),
1291 };
1292 GraphicsOutcome {
1293 response: report(command, command.id, result),
1294 advance: None,
1295 }
1296 }
1297
1298 fn transmit(&mut self, command: GraphicsCommand, ctx: GraphicsContext) -> GraphicsOutcome {
1299 if command.medium == GraphicsMedium::OutOfBand {
1300 self.pending = None;
1301 return GraphicsOutcome {
1302 response: report(&command, command.id, Err("ENOTSUPP:file transmission")),
1303 advance: None,
1304 };
1305 }
1306 if command.more || self.pending.is_some() {
1307 return self.transmit_chunked(command, ctx);
1308 }
1309 let id = self.resolve_id(command.id, command.number);
1310 let payload = command.payload.clone();
1311 self.finish_transmit(id, &command, payload, ctx)
1312 }
1313
1314 fn transmit_chunked(
1319 &mut self,
1320 command: GraphicsCommand,
1321 ctx: GraphicsContext,
1322 ) -> GraphicsOutcome {
1323 let mut pending = self.pending.take().unwrap_or_else(|| PendingTransmit {
1324 id: 0,
1325 header: command.clone(),
1326 data: Vec::new(),
1327 });
1328 if pending.id == 0 {
1329 pending.id = self.resolve_id(pending.header.id, pending.header.number);
1330 }
1331
1332 if pending.data.len().saturating_add(command.payload.len()) > MAX_TRANSMIT_BYTES {
1333 return GraphicsOutcome {
1334 response: report(&command, pending.id, Err("EFBIG:payload too large")),
1335 advance: None,
1336 };
1337 }
1338 pending.data.extend_from_slice(&command.payload);
1339
1340 if command.more {
1341 self.pending = Some(pending);
1342 return GraphicsOutcome::default();
1343 }
1344 self.finish_transmit(pending.id, &pending.header, pending.data, ctx)
1345 }
1346
1347 fn finish_transmit(
1348 &mut self,
1349 id: u32,
1350 command: &GraphicsCommand,
1351 payload: Vec<u8>,
1352 ctx: GraphicsContext,
1353 ) -> GraphicsOutcome {
1354 let decoded = match decode_payload(command, &payload) {
1355 Ok(image) => image,
1356 Err(error) => {
1357 return GraphicsOutcome {
1358 response: report(command, id, Err(error)),
1359 advance: None,
1360 };
1361 }
1362 };
1363
1364 let bytes = decoded_bytes(&decoded);
1365 let image = TerminalImage {
1366 pixels: Arc::new(decoded),
1367 source_hash: hash_payload(command.format, &payload),
1368 };
1369 self.insert_image(id, image, bytes);
1370 if command.number != 0 {
1371 self.numbers.insert(command.number, id);
1372 }
1373
1374 GraphicsOutcome {
1375 response: report(command, id, Ok(())),
1376 advance: (command.action == GraphicsAction::TransmitAndDisplay)
1377 .then(|| self.place(id, command, ctx))
1378 .flatten(),
1379 }
1380 }
1381
1382 fn display_stored(
1383 &mut self,
1384 command: &GraphicsCommand,
1385 ctx: GraphicsContext,
1386 ) -> GraphicsOutcome {
1387 let id = match self.lookup(command.id, command.number) {
1388 Some(id) => id,
1389 None => {
1390 return GraphicsOutcome {
1391 response: report(command, command.id, Err("ENOENT:no such image")),
1392 advance: None,
1393 };
1394 }
1395 };
1396 let advance = self.place(id, command, ctx);
1397 GraphicsOutcome {
1398 response: report(command, id, Ok(())),
1399 advance,
1400 }
1401 }
1402
1403 fn place(
1405 &mut self,
1406 id: u32,
1407 command: &GraphicsCommand,
1408 ctx: GraphicsContext,
1409 ) -> Option<(u16, u16)> {
1410 let clock = self.clock;
1411 let (image_w, image_h) = {
1412 let stored = self.images.get_mut(&id)?;
1413 stored.used = clock;
1414 (stored.image.width(), stored.image.height())
1415 };
1416 if command.virtual_placement {
1419 return None;
1420 }
1421 if image_w == 0 || image_h == 0 {
1422 return None;
1423 }
1424
1425 let crop = source_crop(command, image_w, image_h);
1426 let (src_w, src_h) = crop
1427 .map(|crop| (crop.width, crop.height))
1428 .unwrap_or((image_w, image_h));
1429
1430 let cols = match command.cols {
1432 0 => src_w.div_ceil(u32::from(ctx.cell.width)),
1433 cols => cols,
1434 };
1435 let rows = match command.rows {
1436 0 => src_h.div_ceil(u32::from(ctx.cell.height)),
1437 rows => rows,
1438 };
1439 let cols = cols.clamp(1, u32::from(ctx.cols.max(1))) as u16;
1440 let rows = rows.clamp(1, u32::from(u16::MAX)) as u16;
1441
1442 self.placements.retain(|placement| {
1444 placement.image_id != id || placement.placement_id != command.placement
1445 });
1446 self.placements.push(Placement {
1447 image_id: id,
1448 placement_id: command.placement,
1449 line: ctx.cursor_line,
1450 col: ctx.cursor_col,
1451 rows,
1452 cols,
1453 z: command.z,
1454 crop,
1455 alt_screen: ctx.alt_screen,
1456 });
1457 while self.placements.len() > MAX_PLACEMENTS {
1458 self.placements.remove(0);
1459 }
1460
1461 (!command.no_cursor_move).then_some((rows, cols))
1462 }
1463
1464 fn delete(&mut self, command: &GraphicsCommand, ctx: GraphicsContext) {
1465 let free_data = command.delete.is_ascii_uppercase();
1467 let selector = command.delete.to_ascii_lowercase();
1468 let target_col = command.src_x.saturating_sub(1).min(u32::from(u16::MAX)) as u16;
1470 let target_line = ctx
1471 .viewport_top_line
1472 .saturating_add(command.src_y.saturating_sub(1) as usize);
1473
1474 let hit: Box<dyn Fn(&Placement) -> bool> = match selector {
1475 b'a' => Box::new(|_| true),
1476 b'i' => {
1477 let (id, placement) = (command.id, command.placement);
1478 Box::new(move |item| {
1479 item.image_id == id && (placement == 0 || item.placement_id == placement)
1480 })
1481 }
1482 b'n' => {
1483 let id = self.numbers.get(&command.number).copied().unwrap_or(0);
1484 Box::new(move |item| id != 0 && item.image_id == id)
1485 }
1486 b'c' => {
1487 let (line, col) = (ctx.cursor_line, ctx.cursor_col);
1488 Box::new(move |item| item.covers_cell(line, col))
1489 }
1490 b'z' => {
1491 let z = command.z;
1492 Box::new(move |item| item.z == z)
1493 }
1494 b'p' => Box::new(move |item| item.covers_cell(target_line, target_col)),
1495 b'x' => Box::new(move |item| item.covers_column(target_col)),
1496 b'y' => Box::new(move |item| item.covers_line(target_line)),
1497 _ => return,
1498 };
1499
1500 let mut freed: Vec<u32> = Vec::new();
1501 self.placements.retain(|item| {
1502 if !hit(item) {
1503 return true;
1504 }
1505 if free_data {
1506 freed.push(item.image_id);
1507 }
1508 false
1509 });
1510
1511 if free_data {
1512 match selector {
1513 b'a' => {
1515 let ids: Vec<u32> = self.images.keys().copied().collect();
1516 for id in ids {
1517 self.remove_image(id);
1518 }
1519 }
1520 b'i' if command.placement == 0 => self.remove_image(command.id),
1521 b'n' => {
1522 if let Some(id) = self.numbers.get(&command.number).copied() {
1523 self.remove_image(id);
1524 }
1525 }
1526 _ => {
1527 for id in freed {
1528 self.remove_image(id);
1529 }
1530 }
1531 }
1532 }
1533 }
1534
1535 fn insert_image(&mut self, id: u32, image: TerminalImage, bytes: usize) {
1536 self.remove_image(id);
1537 let clock = self.clock;
1538 self.images.insert(
1539 id,
1540 StoredImage {
1541 image,
1542 bytes,
1543 used: clock,
1544 },
1545 );
1546 self.used_bytes = self.used_bytes.saturating_add(bytes);
1547 self.enforce_budget();
1548 }
1549
1550 fn remove_image(&mut self, id: u32) {
1551 if let Some(stored) = self.images.remove(&id) {
1552 self.used_bytes = self.used_bytes.saturating_sub(stored.bytes);
1553 }
1554 self.numbers.retain(|_, mapped| *mapped != id);
1555 self.placements.retain(|placement| placement.image_id != id);
1556 }
1557
1558 fn enforce_budget(&mut self) {
1567 while self.used_bytes > self.budget && self.images.len() > 1 {
1568 let victim = self
1569 .images
1570 .iter()
1571 .min_by_key(|(_, stored)| (stored.used, stored.bytes))
1572 .map(|(id, _)| *id);
1573 let Some(victim) = victim else { break };
1574 self.remove_image(victim);
1575 }
1576 }
1577
1578 fn lookup(&self, id: u32, number: u32) -> Option<u32> {
1580 if id != 0 {
1581 return self.images.contains_key(&id).then_some(id);
1582 }
1583 let mapped = *self.numbers.get(&number)?;
1584 self.images.contains_key(&mapped).then_some(mapped)
1585 }
1586
1587 fn resolve_id(&mut self, id: u32, number: u32) -> u32 {
1589 if id != 0 {
1590 return id;
1591 }
1592 if number != 0
1593 && let Some(existing) = self.numbers.get(&number).copied()
1594 {
1595 return existing;
1596 }
1597 let assigned = self.next_auto_id;
1598 self.next_auto_id = self.next_auto_id.checked_add(1).unwrap_or(FIRST_AUTO_ID);
1599 assigned
1600 }
1601}
1602
1603fn source_crop(command: &GraphicsCommand, width: u32, height: u32) -> Option<TerminalImageCrop> {
1604 if command.src_x == 0 && command.src_y == 0 && command.src_w == 0 && command.src_h == 0 {
1605 return None;
1606 }
1607 let x = command.src_x.min(width.saturating_sub(1));
1608 let y = command.src_y.min(height.saturating_sub(1));
1609 let w = match command.src_w {
1610 0 => width - x,
1611 requested => requested.min(width - x),
1612 };
1613 let h = match command.src_h {
1614 0 => height - y,
1615 requested => requested.min(height - y),
1616 };
1617 (w > 0 && h > 0).then_some(TerminalImageCrop {
1618 x,
1619 y,
1620 width: w,
1621 height: h,
1622 })
1623}
1624
1625fn decode_payload(command: &GraphicsCommand, payload: &[u8]) -> Result<DynamicImage, &'static str> {
1629 let mut data = if command.compressed {
1630 decompress(payload).ok_or("EINVAL:bad zlib payload")?
1631 } else {
1632 payload.to_vec()
1633 };
1634
1635 match command.format {
1636 100 => decode_png(&data),
1637 format @ (24 | 32) => {
1638 let channels = if format == 24 { 3usize } else { 4usize };
1639 let (width, height) = (command.width, command.height);
1640 if width == 0 || height == 0 {
1641 return Err("EINVAL:missing s/v for raw pixels");
1642 }
1643 if width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION {
1644 return Err("EFBIG:image too large");
1645 }
1646 let expected = (width as usize)
1647 .checked_mul(height as usize)
1648 .and_then(|pixels| pixels.checked_mul(channels))
1649 .ok_or("EFBIG:image too large")?;
1650 if data.len() < expected {
1651 return Err("EINVAL:truncated pixel payload");
1652 }
1653 data.truncate(expected);
1654 if channels == 3 {
1655 image::RgbImage::from_raw(width, height, data).map(DynamicImage::ImageRgb8)
1656 } else {
1657 image::RgbaImage::from_raw(width, height, data).map(DynamicImage::ImageRgba8)
1658 }
1659 .ok_or("EINVAL:bad pixel payload")
1660 }
1661 _ => Err("ENOTSUPP:unsupported format"),
1662 }
1663}
1664
1665fn decode_png(data: &[u8]) -> Result<DynamicImage, &'static str> {
1666 let mut reader =
1667 image::ImageReader::with_format(std::io::Cursor::new(data), image::ImageFormat::Png);
1668 let mut limits = image::Limits::default();
1669 limits.max_image_width = Some(MAX_IMAGE_DIMENSION);
1670 limits.max_image_height = Some(MAX_IMAGE_DIMENSION);
1671 limits.max_alloc = Some(MAX_TRANSMIT_BYTES as u64);
1672 reader.limits(limits);
1673 reader.decode().map_err(|_| "EINVAL:bad PNG payload")
1674}
1675
1676fn decompress(payload: &[u8]) -> Option<Vec<u8>> {
1677 use std::io::Read as _;
1678
1679 let mut out = Vec::new();
1680 flate2::read::ZlibDecoder::new(payload)
1681 .take(MAX_TRANSMIT_BYTES as u64)
1682 .read_to_end(&mut out)
1683 .ok()?;
1684 Some(out)
1685}
1686
1687fn decoded_bytes(image: &DynamicImage) -> usize {
1688 (image.width() as usize)
1689 .saturating_mul(image.height() as usize)
1690 .saturating_mul(4)
1691}
1692
1693fn hash_payload(format: u32, payload: &[u8]) -> u64 {
1694 use std::hash::{Hash as _, Hasher as _};
1695
1696 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1697 format.hash(&mut hasher);
1698 payload.hash(&mut hasher);
1699 hasher.finish()
1700}
1701
1702fn report(command: &GraphicsCommand, id: u32, result: Result<(), &str>) -> Option<Vec<u8>> {
1704 if !command.reports(result.is_ok()) {
1705 return None;
1706 }
1707 let mut response = format!("\x1b_Gi={id}");
1708 if command.number != 0 {
1709 let _ = write!(response, ",I={}", command.number);
1710 }
1711 if command.placement != 0 {
1712 let _ = write!(response, ",p={}", command.placement);
1713 }
1714 let body = result.err().unwrap_or("OK");
1715 let _ = write!(response, ";{body}\x1b\\");
1716 Some(response.into_bytes())
1717}
1718
1719#[cfg(test)]
1720mod tests {
1721 use super::*;
1722
1723 fn rgb_command(keys: &str, width: u32, height: u32) -> Vec<u8> {
1725 let pixels = vec![0xa0u8; (width * height * 3) as usize];
1726 let payload = BASE64.encode(pixels);
1727 format!("\x1b_Gf=24,s={width},v={height},t=d,{keys};{payload}\x1b\\").into_bytes()
1728 }
1729
1730 fn context() -> GraphicsContext {
1731 GraphicsContext {
1732 cursor_line: 0,
1733 cursor_col: 0,
1734 viewport_top_line: 0,
1735 alt_screen: false,
1736 cell: TerminalCellSize::new(10, 20),
1737 cols: 80,
1738 }
1739 }
1740
1741 fn scan_all(scanner: &mut GraphicsScanner, bytes: &[u8]) -> (Vec<u8>, Vec<GraphicsCommand>) {
1742 let mut text = Vec::new();
1743 let mut commands = Vec::new();
1744 for segment in scanner.scan(bytes) {
1745 match segment {
1746 GraphicsSegment::Text(range) => text.extend_from_slice(&bytes[range]),
1747 GraphicsSegment::HeldEscape => text.push(0x1b),
1748 GraphicsSegment::Command(command) => commands.push(*command),
1749 }
1750 }
1751 (text, commands)
1752 }
1753
1754 #[test]
1755 fn scanner_lifts_commands_out_of_surrounding_text() {
1756 let mut scanner = GraphicsScanner::default();
1757 let mut stream = b"before".to_vec();
1758 stream.extend_from_slice(&rgb_command("a=T", 2, 2));
1759 stream.extend_from_slice(b"after");
1760
1761 let (text, commands) = scan_all(&mut scanner, &stream);
1762 assert_eq!(text, b"beforeafter");
1763 assert_eq!(commands.len(), 1);
1764 assert_eq!(commands[0].action, GraphicsAction::TransmitAndDisplay);
1765 assert_eq!(commands[0].payload.len(), 2 * 2 * 3);
1766 }
1767
1768 #[test]
1769 fn scanner_survives_a_command_split_across_chunks() {
1770 let command = rgb_command("a=T", 2, 2);
1771 for split in 1..command.len() {
1773 let mut scanner = GraphicsScanner::default();
1774 let (head_text, head) = scan_all(&mut scanner, &command[..split]);
1775 let (tail_text, tail) = scan_all(&mut scanner, &command[split..]);
1776 assert!(
1777 head_text.is_empty() && tail_text.is_empty(),
1778 "split at {split} leaked graphics bytes into the grid stream"
1779 );
1780 assert_eq!(
1781 head.len() + tail.len(),
1782 1,
1783 "split at {split} lost or duplicated the command"
1784 );
1785 }
1786 }
1787
1788 #[test]
1789 fn escape_that_is_not_a_command_reaches_the_grid() {
1790 let mut scanner = GraphicsScanner::default();
1791 assert!(!scanner.is_plain(b"red\x1b"));
1793 let (first, _) = scan_all(&mut scanner, b"red\x1b");
1794 let (second, commands) = scan_all(&mut scanner, b"[0m");
1795 let mut text = first;
1796 text.extend_from_slice(&second);
1797 assert_eq!(text, b"red\x1b[0m");
1798 assert!(commands.is_empty());
1799 }
1800
1801 #[test]
1802 fn non_graphics_apc_is_swallowed_like_the_vt_parser_would() {
1803 let mut scanner = GraphicsScanner::default();
1804 let (text, commands) = scan_all(&mut scanner, b"a\x1b_Xsomething\x1b\\b");
1805 assert_eq!(text, b"ab");
1806 assert!(commands.is_empty());
1807 }
1808
1809 #[test]
1810 fn transmit_and_display_places_the_image_and_moves_the_cursor() {
1811 let mut graphics = TerminalGraphics::default();
1812 let mut scanner = GraphicsScanner::default();
1813 let (_, commands) = scan_all(&mut scanner, &rgb_command("a=T,i=7", 30, 40));
1815
1816 let outcome = graphics.apply(commands[0].clone(), context());
1817 assert_eq!(outcome.advance, Some((2, 3)));
1818
1819 let visible = graphics.visible(0, 0, 24, false);
1820 assert_eq!(visible.len(), 1);
1821 assert_eq!((visible[0].row, visible[0].col), (0, 0));
1822 assert_eq!((visible[0].rows, visible[0].cols), (2, 3));
1823 }
1824
1825 #[test]
1826 fn explicit_cell_size_overrides_the_pixel_size() {
1827 let mut graphics = TerminalGraphics::default();
1828 let mut scanner = GraphicsScanner::default();
1829 let (_, commands) = scan_all(&mut scanner, &rgb_command("a=T,c=8,r=4", 30, 40));
1830
1831 let outcome = graphics.apply(commands[0].clone(), context());
1832 assert_eq!(outcome.advance, Some((4, 8)));
1833 }
1834
1835 #[test]
1836 fn suppressed_cursor_movement_still_places() {
1837 let mut graphics = TerminalGraphics::default();
1838 let mut scanner = GraphicsScanner::default();
1839 let (_, commands) = scan_all(&mut scanner, &rgb_command("a=T,C=1", 30, 40));
1840
1841 let outcome = graphics.apply(commands[0].clone(), context());
1842 assert_eq!(outcome.advance, None);
1843 assert_eq!(graphics.visible(0, 0, 24, false).len(), 1);
1844 }
1845
1846 #[test]
1847 fn a_probe_is_answered_without_storing_anything() {
1848 let mut graphics = TerminalGraphics::default();
1849 let mut scanner = GraphicsScanner::default();
1850 let (_, commands) = scan_all(&mut scanner, &rgb_command("a=q,i=31", 1, 1));
1851
1852 let outcome = graphics.apply(commands[0].clone(), context());
1853 assert_eq!(
1854 outcome.response.as_deref(),
1855 Some(b"\x1b_Gi=31;OK\x1b\\".as_ref())
1856 );
1857 assert!(graphics.visible(0, 0, 24, false).is_empty());
1858 }
1859
1860 #[test]
1861 fn out_of_band_transmission_is_refused_in_the_protocol_s_own_terms() {
1862 let mut graphics = TerminalGraphics::default();
1863 let mut scanner = GraphicsScanner::default();
1864 let (_, commands) = scan_all(&mut scanner, b"\x1b_Ga=T,t=f,i=3;L3RtcC9pbWcucG5n\x1b\\");
1865
1866 let outcome = graphics.apply(commands[0].clone(), context());
1867 let response = String::from_utf8(outcome.response.expect("a refusal is reported")).unwrap();
1868 assert!(
1869 response.contains("ENOTSUPP"),
1870 "unexpected report: {response}"
1871 );
1872 }
1873
1874 #[test]
1875 fn quiet_two_suppresses_even_failures() {
1876 let mut graphics = TerminalGraphics::default();
1877 let mut scanner = GraphicsScanner::default();
1878 let (_, commands) = scan_all(&mut scanner, b"\x1b_Ga=T,t=f,q=2;Lw==\x1b\\");
1879
1880 assert!(
1881 graphics
1882 .apply(commands[0].clone(), context())
1883 .response
1884 .is_none()
1885 );
1886 }
1887
1888 #[test]
1889 fn chunked_transmission_reassembles_before_decoding() {
1890 let mut graphics = TerminalGraphics::default();
1891 let pixels = vec![0x40u8; 30 * 40 * 3];
1892 let encoded = BASE64.encode(&pixels);
1893 let (head, tail) = encoded.split_at(encoded.len() / 2);
1894
1895 let mut scanner = GraphicsScanner::default();
1896 let mut stream = format!("\x1b_Ga=T,f=24,s=30,v=40,t=d,i=9,m=1;{head}\x1b\\").into_bytes();
1897 stream.extend_from_slice(format!("\x1b_Gm=0;{tail}\x1b\\").as_bytes());
1898 let (_, commands) = scan_all(&mut scanner, &stream);
1899 assert_eq!(commands.len(), 2);
1900
1901 assert!(
1902 graphics
1903 .apply(commands[0].clone(), context())
1904 .advance
1905 .is_none()
1906 );
1907 let outcome = graphics.apply(commands[1].clone(), context());
1908 assert_eq!(outcome.advance, Some((2, 3)));
1909 assert_eq!(graphics.visible(0, 0, 24, false).len(), 1);
1910 }
1911
1912 #[test]
1913 fn deleting_by_id_drops_the_placement() {
1914 let mut graphics = TerminalGraphics::default();
1915 let mut scanner = GraphicsScanner::default();
1916 let (_, commands) = scan_all(&mut scanner, &rgb_command("a=T,i=4", 30, 40));
1917 graphics.apply(commands[0].clone(), context());
1918
1919 let (_, deletes) = scan_all(&mut scanner, b"\x1b_Ga=d,d=i,i=4;\x1b\\");
1920 graphics.apply(deletes[0].clone(), context());
1921 assert!(graphics.visible(0, 0, 24, false).is_empty());
1922 }
1923
1924 #[test]
1925 fn evicted_scrollback_pulls_placements_up_and_then_off() {
1926 let mut graphics = TerminalGraphics::default();
1927 let mut scanner = GraphicsScanner::default();
1928 let (_, commands) = scan_all(&mut scanner, &rgb_command("a=T", 30, 40));
1929 let mut ctx = context();
1930 ctx.cursor_line = 5;
1931 graphics.apply(commands[0].clone(), ctx);
1932
1933 graphics.drop_evicted(3);
1934 assert_eq!(graphics.visible(0, 0, 24, false)[0].row, 2);
1935
1936 graphics.drop_evicted(3);
1938 assert_eq!(graphics.visible(0, 0, 24, false)[0].row, 0);
1939
1940 graphics.drop_evicted(4);
1942 assert!(graphics.visible(0, 0, 24, false).is_empty());
1943 }
1944
1945 #[test]
1946 fn alt_screen_placements_are_kept_apart_from_the_primary_ones() {
1947 let mut graphics = TerminalGraphics::default();
1948 let mut scanner = GraphicsScanner::default();
1949 let (_, commands) = scan_all(&mut scanner, &rgb_command("a=T,i=1", 30, 40));
1950 graphics.apply(commands[0].clone(), context());
1951
1952 let (_, alt) = scan_all(&mut scanner, &rgb_command("a=T,i=2", 30, 40));
1953 let mut alt_ctx = context();
1954 alt_ctx.alt_screen = true;
1955 graphics.apply(alt[0].clone(), alt_ctx);
1956
1957 assert_eq!(graphics.visible(0, 0, 24, true).len(), 1);
1958 assert_eq!(graphics.visible(0, 0, 24, false).len(), 1);
1959
1960 graphics.clear_alt_screen();
1961 assert!(graphics.visible(0, 0, 24, true).is_empty());
1962 assert_eq!(graphics.visible(0, 0, 24, false).len(), 1);
1963 }
1964
1965 #[test]
1966 fn the_budget_evicts_least_recently_used_images() {
1967 let mut graphics = TerminalGraphics::default();
1968 graphics.set_budget(30 * 40 * 4);
1970
1971 let mut scanner = GraphicsScanner::default();
1972 let (_, first) = scan_all(&mut scanner, &rgb_command("a=T,i=1", 30, 40));
1973 graphics.apply(first[0].clone(), context());
1974 let (_, second) = scan_all(&mut scanner, &rgb_command("a=T,i=2", 31, 40));
1975 graphics.apply(second[0].clone(), context());
1976
1977 let visible = graphics.visible(0, 0, 24, false);
1978 assert_eq!(visible.len(), 1, "the older image must have been evicted");
1979 assert_eq!(visible[0].image.width(), 31);
1980 }
1981
1982 #[test]
1983 fn a_source_rectangle_is_carried_to_the_renderer() {
1984 let mut graphics = TerminalGraphics::default();
1985 let mut scanner = GraphicsScanner::default();
1986 let (_, commands) = scan_all(&mut scanner, &rgb_command("a=T,x=5,y=6,w=10,h=12", 30, 40));
1987 graphics.apply(commands[0].clone(), context());
1988
1989 let visible = graphics.visible(0, 0, 24, false);
1990 assert_eq!(
1991 visible[0].source_crop,
1992 Some(TerminalImageCrop {
1993 x: 5,
1994 y: 6,
1995 width: 10,
1996 height: 12,
1997 })
1998 );
1999 assert_eq!((visible[0].rows, visible[0].cols), (1, 1));
2001 }
2002
2003 #[test]
2004 fn a_large_unchunked_transmission_is_not_dropped() {
2005 let mut graphics = TerminalGraphics::default();
2010 let mut scanner = GraphicsScanner::default();
2011 let (text, commands) = scan_all(&mut scanner, &rgb_command("a=T,i=1", 280, 160));
2012
2013 assert!(
2014 text.is_empty(),
2015 "the escape must not leak into the grid stream"
2016 );
2017 assert_eq!(
2018 commands.len(),
2019 1,
2020 "a large single-escape transmit must survive scanning"
2021 );
2022 assert_eq!(
2023 graphics.apply(commands[0].clone(), context()).advance,
2024 Some((8, 28))
2025 );
2026 }
2027
2028 #[test]
2029 fn a_truncated_raw_payload_is_reported_rather_than_drawn() {
2030 let mut graphics = TerminalGraphics::default();
2031 let mut scanner = GraphicsScanner::default();
2032 let payload = BASE64.encode([1u8, 2, 3]);
2034 let (_, commands) = scan_all(
2035 &mut scanner,
2036 format!("\x1b_Ga=T,f=24,s=30,v=40,t=d;{payload}\x1b\\").as_bytes(),
2037 );
2038
2039 let outcome = graphics.apply(commands[0].clone(), context());
2040 let response = String::from_utf8(outcome.response.expect("a refusal is reported")).unwrap();
2041 assert!(response.contains("EINVAL"), "unexpected report: {response}");
2042 assert!(graphics.visible(0, 0, 24, false).is_empty());
2043 }
2044}