1use std::{
2 collections::BTreeMap,
3 fmt::Write as _,
4 io::{self, Write},
5 ops::Range,
6};
7
8use omp_core::CowBytes;
9use smallvec::SmallVec;
10
11use crate::{
12 Graphics, TerminalCaps,
13 escape::esc,
14 frame::{Cell, CellContent, Color, Frame, LinkId, Size, Style, with_link_url},
15 iterm2::{Iterm2Image, Iterm2Viewport, iterm2_output},
16 kitty::{
17 DirectPlacement, append_delete_image, append_direct_placement, append_placement,
18 append_tmux_passthrough, append_transmission, placeholder_cell,
19 },
20 overlay::Layer,
21 sixel::SixelImage,
22 terminal::terminal_write_all,
23};
24
25const RESET_STYLE: &str = esc!(style_reset);
26const CLEAR_VIEWPORT: &str = esc!(erase_display, cursor_home);
27const SCREEN_TO_SCROLLBACK: &str = esc!(screen_to_scrollback);
28const REBUILD_HISTORY: &str = esc!(cursor_home, erase_scrollback);
29const SYNC_OUTPUT_BEGIN: &str = esc!(sync_output);
30const SYNC_OUTPUT_END: &str = esc!(!sync_output);
31const HIDE_CURSOR: &str = esc!(!cursor_visible);
32const SHOW_CURSOR: &str = esc!(cursor_visible);
33const VIEWPORT_BOTTOM: &str = esc!(viewport_bottom);
36const DEFAULT_CELL_PIXEL_WIDTH: u16 = 9;
37const DEFAULT_CELL_PIXEL_HEIGHT: u16 = 18;
38#[cfg(any(windows, target_os = "linux", test))]
39const MAX_CONPTY_WRITE_CHUNK_BYTES: usize = 16 * 1024;
40const MAX_OUTPUT_BACKLOG_BYTES: usize = 64 * 1024 * 1024;
41
42#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub enum OutputState {
45 Connected,
47 Disconnected,
49}
50
51#[derive(Default)]
52struct OutputBacklogGuard {
53 bytes: usize,
54}
55
56impl OutputBacklogGuard {
57 const fn queue(&mut self, bytes: usize) -> bool {
58 self.bytes = self.bytes.saturating_add(bytes);
59 self.bytes > MAX_OUTPUT_BACKLOG_BYTES
60 }
61
62 const fn flushed(&mut self) {
63 self.bytes = 0;
64 }
65}
66
67#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
69pub struct PaintStats {
70 pub full_repaint: bool,
72 pub changed_cells: usize,
74 pub runs: usize,
76 pub committed_rows: u16,
78 pub clipped_rows: u16,
80 pub bytes: usize,
82}
83
84#[derive(Clone, Copy)]
86pub struct ResolvedLayer<'a> {
87 pub(crate) frame: &'a Frame,
89 pub(crate) x: u16,
91 pub(crate) y: u16,
93 pub(crate) src_top: u16,
95 pub(crate) rows: u16,
97 pub(crate) active: bool,
99}
100
101struct StoredLayer {
102 frame: Frame,
103 x: u16,
104 document_y: u16,
105 src_top: u16,
106 rows: u16,
107 active: bool,
108 source_address: usize,
109 source_id: u64,
110 source_revision: u64,
111}
112
113impl StoredLayer {
114 #[inline(always)]
115 const fn contains(&self, y: u16, x: u16) -> bool {
116 y >= self.document_y
117 && y < self.document_y.saturating_add(self.rows)
118 && x >= self.x
119 && x < self.x.saturating_add(self.frame.size().width)
120 && y - self.document_y + self.src_top < self.frame.size().height
121 }
122
123 #[inline(always)]
124 const fn same_cells_and_placement(&self, other: &Self) -> bool {
125 self.x == other.x
126 && self.document_y == other.document_y
127 && self.src_top == other.src_top
128 && self.rows == other.rows
129 && self.source_address == other.source_address
130 && self.source_id == other.source_id
131 && self.source_revision == other.source_revision
132 }
133}
134
135struct ComposedFrame<'a> {
136 base: &'a Frame,
137 layers: &'a [StoredLayer],
138}
139
140impl ComposedFrame<'_> {
141 #[inline(always)]
142 fn cell_or<'b>(&'b self, y: u16, x: u16, blank: &'b Cell) -> &'b Cell {
143 if self.layers.is_empty() {
144 return self.base.cell_or(y, x, blank);
145 }
146 let layer = self.layer_at(y, x);
147 let cell = match layer {
148 Some(index) => {
149 let layer = &self.layers[index];
150 layer
151 .frame
152 .cell_or(y - layer.document_y + layer.src_top, x - layer.x, blank)
153 },
154 None => self.base.cell_or(y, x, blank),
155 };
156 match &cell.content {
157 CellContent::Grapheme { width, .. } if *width > 1 => {
158 let right = x.saturating_add(*width);
159 if right > self.base.size().width
160 || (x..right).any(|column| self.layer_at(y, column) != layer)
161 {
162 blank
163 } else {
164 cell
165 }
166 },
167 CellContent::Continuation => {
168 let Some((head_x, width)) = self.grapheme_head(layer, y, x) else {
169 return blank;
170 };
171 let right = head_x.saturating_add(width);
172 if right > self.base.size().width
173 || (head_x..right).any(|column| self.layer_at(y, column) != layer)
174 {
175 blank
176 } else {
177 cell
178 }
179 },
180 _ => cell,
181 }
182 }
183
184 #[inline(always)]
185 fn layer_at(&self, y: u16, x: u16) -> Option<usize> {
186 match self.layers {
187 [] => None,
188 [layer] => layer.contains(y, x).then_some(0),
189 layers => layers.iter().rposition(|layer| layer.contains(y, x)),
190 }
191 }
192
193 fn grapheme_head(&self, layer: Option<usize>, y: u16, x: u16) -> Option<(u16, u16)> {
194 let (frame, row, left) = match layer {
195 Some(index) => {
196 let layer = &self.layers[index];
197 (&layer.frame, y - layer.document_y + layer.src_top, layer.x)
198 },
199 None => (self.base, y, 0),
200 };
201 let mut column = x;
202 while column > left {
203 column -= 1;
204 let source_x = column - left;
205 match &frame.cell(source_x, row).content {
206 CellContent::Continuation => {},
207 CellContent::Blank => return None,
208 CellContent::Grapheme { width, .. } => return Some((column, *width)),
209 CellContent::Image { .. } => return None,
210 }
211 }
212 None
213 }
214}
215
216#[derive(Clone, Copy)]
217struct Layout {
218 stable_limit: u16,
219 window_top: u16,
220}
221
222#[derive(Clone, Copy, Eq, PartialEq)]
223struct Window {
224 top: u16,
225 height: u16,
226}
227
228#[derive(Clone, Copy)]
229struct Run {
230 document_y: u16,
231 screen_y: u16,
232 start: u16,
233 end: u16,
234}
235
236#[derive(Clone, Copy, Debug, Eq, PartialEq)]
237struct ScreenCursor {
238 row: u16,
239 col: u16,
240}
241
242struct RegisteredImage {
243 png: CowBytes<'static>,
244 uploaded: bool,
245 placed: SmallVec<(u16, u16), 2>,
247 sixel: Option<SixelImage>,
248 sixel_decoded: bool,
249 direct_visible: bool,
250}
251
252impl RegisteredImage {
253 const fn new(png: CowBytes<'static>) -> Self {
254 Self {
255 png,
256 uploaded: false,
257 placed: SmallVec::new(),
258 sixel: None,
259 sixel_decoded: false,
260 direct_visible: false,
261 }
262 }
263}
264
265pub struct Renderer<W: Write> {
276 writer: W,
277 previous: Option<Frame>,
278 layers: SmallVec<StoredLayer, 4>,
279 layer_scratch: SmallVec<StoredLayer, 4>,
280 preview_previous: Option<Frame>,
282 preview_layers: SmallVec<StoredLayer, 4>,
283 preview_window: Option<Window>,
284 preview_cursor: Option<ScreenCursor>,
285 paint_scratch: String,
287 output_scratch: String,
289 viewport_height: u16,
290 window_top: u16,
291 committed_rows: u16,
292 stable_rows: u16,
293 cursor: Option<ScreenCursor>,
294 poisoned: bool,
295 output_state: OutputState,
296 backlog: OutputBacklogGuard,
297 #[cfg(any(windows, target_os = "linux"))]
298 conpty_hosted: bool,
299 images: BTreeMap<u32, RegisteredImage>,
300 alt_screen: bool,
301 graphics: Graphics,
302 cell_pixel_width: u16,
303 cell_pixel_height: u16,
304 tmux_passthrough: bool,
305 sync_output: bool,
306 screen_to_scrollback: bool,
307 hyperlinks: bool,
308 margin_scrollback: bool,
309}
310
311impl<W: Write> Renderer<W> {
312 pub fn new(writer: W) -> Self {
314 Self {
315 writer,
316 previous: None,
317 viewport_height: 0,
318 layers: SmallVec::new(),
319 layer_scratch: SmallVec::new(),
320 preview_previous: None,
321 preview_layers: SmallVec::new(),
322 preview_window: None,
323 preview_cursor: None,
324 paint_scratch: String::new(),
325 output_scratch: String::new(),
326 window_top: 0,
327 committed_rows: 0,
328 stable_rows: 0,
329 cursor: None,
330 poisoned: false,
331 output_state: OutputState::Connected,
332 backlog: OutputBacklogGuard::default(),
333 #[cfg(any(windows, target_os = "linux"))]
334 conpty_hosted: is_conpty_hosted(),
335 images: BTreeMap::new(),
336 alt_screen: crate::terminal::alt_screen_active(),
337 graphics: Graphics::KittyPlaceholders,
338 cell_pixel_width: DEFAULT_CELL_PIXEL_WIDTH,
339 cell_pixel_height: DEFAULT_CELL_PIXEL_HEIGHT,
340 tmux_passthrough: false,
341 sync_output: true,
342 screen_to_scrollback: false,
343 margin_scrollback: false,
344 hyperlinks: false,
345 }
346 }
347
348 pub fn apply_caps(&mut self, caps: &TerminalCaps) -> io::Result<()> {
354 self.set_graphics(caps.graphics);
355 self.set_sync_output(caps.sync_output);
356 self.set_screen_to_scrollback(caps.screen_to_scrollback);
357 self.set_margin_scrollback(caps.margin_scrollback);
358 self.set_hyperlinks(caps.hyperlinks);
359 self.set_tmux_passthrough(caps.inside_tmux);
360 if let Some((width, height)) = caps.cell_px {
361 self.set_cell_pixel_size(width, height)?;
362 }
363 Ok(())
364 }
365
366 pub fn register_image(
375 &mut self,
376 id: u32,
377 png_bytes: impl Into<CowBytes<'static>>,
378 ) -> io::Result<()> {
379 if id == 0 || id > 0x00ff_ffff {
380 return Err(io::Error::new(
381 io::ErrorKind::InvalidInput,
382 "terminal image ID must fit in 24 bits",
383 ));
384 }
385 self
386 .images
387 .insert(id, RegisteredImage::new(png_bytes.into()));
388 Ok(())
389 }
390
391 pub const fn set_graphics(&mut self, graphics: Graphics) {
398 self.graphics = graphics;
399 }
400
401 pub const fn set_sync_output(&mut self, enabled: bool) {
407 self.sync_output = enabled;
408 }
409
410 pub const fn set_screen_to_scrollback(&mut self, enabled: bool) {
415 self.screen_to_scrollback = enabled;
416 }
417
418 pub const fn set_margin_scrollback(&mut self, enabled: bool) {
433 self.margin_scrollback = enabled;
434 }
435
436 pub const fn set_hyperlinks(&mut self, enabled: bool) {
441 self.hyperlinks = enabled;
442 }
443
444 pub fn set_cell_pixel_size(&mut self, width: u16, height: u16) -> io::Result<()> {
453 if width == 0 || height == 0 {
454 return Err(io::Error::new(
455 io::ErrorKind::InvalidInput,
456 "cell pixel dimensions must be non-zero",
457 ));
458 }
459 self.cell_pixel_width = width;
460 self.cell_pixel_height = height;
461 Ok(())
462 }
463
464 pub const fn set_tmux_passthrough(&mut self, enabled: bool) {
469 self.tmux_passthrough = enabled;
470 }
471
472 pub fn present(
491 &mut self,
492 next: Frame,
493 viewport_height: u16,
494 stable_rows: u16,
495 ) -> io::Result<PaintStats> {
496 self.forget_preview();
497 self.validate_input(&next, viewport_height, stable_rows)?;
498 let stats = if self.previous.is_none() {
499 self.initial_paint(next, viewport_height, stable_rows)?
500 } else {
501 let stats = self.paint_next(&next, viewport_height, stable_rows)?;
502 self.previous = Some(next);
503 stats
504 };
505 self.publish_debug_screen();
506 Ok(stats)
507 }
508
509 pub fn present_ref(
519 &mut self,
520 next: &Frame,
521 viewport_height: u16,
522 stable_rows: u16,
523 ) -> io::Result<PaintStats> {
524 self.forget_preview();
525 self.validate_input(next, viewport_height, stable_rows)?;
526 let stats = if self.previous.is_none() {
527 self.initial_paint(next.clone(), viewport_height, stable_rows)?
528 } else {
529 let stats = self.paint_next(next, viewport_height, stable_rows)?;
530 self
531 .previous
532 .as_mut()
533 .expect("initial-paint branch checked previous above")
534 .clone_from(next);
535 stats
536 };
537 self.publish_debug_screen();
538 Ok(stats)
539 }
540
541 pub fn present_overlaid(
553 &mut self,
554 next: &Frame,
555 damaged: &[(u16, u16)],
556 viewport_height: u16,
557 stable_rows: u16,
558 layers: &[Layer<'_>],
559 ) -> io::Result<PaintStats> {
560 let viewport = Size::new(next.size().width, viewport_height);
561 let resolved = resolve_layers(layers, viewport);
562 self.present_resolved(next, damaged, viewport_height, stable_rows, &resolved)
563 }
564
565 pub(crate) fn present_resolved(
567 &mut self,
568 next: &Frame,
569 damaged: &[(u16, u16)],
570 viewport_height: u16,
571 stable_rows: u16,
572 layers: &[ResolvedLayer<'_>],
573 ) -> io::Result<PaintStats> {
574 self.forget_preview();
575 self.validate_input(next, viewport_height, stable_rows)?;
576 let stats = if self.previous.is_none() {
577 self.initial_paint_overlaid(next.clone(), viewport_height, stable_rows, layers)?
578 } else {
579 self.validate_damaged_stable_prefix(next, damaged)?;
580 let stats =
581 self.paint_validated_next(next, viewport_height, stable_rows, Some(damaged), layers)?;
582 let previous = self
583 .previous
584 .as_mut()
585 .expect("initial-paint branch checked previous above");
586 previous.resize_height(next.size().height, Style::default());
587 for &(start, end) in damaged {
588 for row in start..end.min(next.size().height) {
589 previous.copy_row_from(next, row);
590 }
591 }
592 previous.sync_soft_wraps(next);
593 stats
594 };
595 self.publish_debug_screen();
596 Ok(stats)
597 }
598
599 pub fn present_damaged(
607 &mut self,
608 next: &Frame,
609 damaged: &[(u16, u16)],
610 viewport_height: u16,
611 stable_rows: u16,
612 ) -> io::Result<PaintStats> {
613 self.present_resolved(next, damaged, viewport_height, stable_rows, &[])
614 }
615
616 pub fn clear_layers(&mut self) -> io::Result<()> {
630 if self.poisoned || self.layers.is_empty() || crate::terminal::alt_screen_active() {
631 return Ok(());
632 }
633 if self.previous.is_none() {
634 self.layers.clear();
635 return Ok(());
636 }
637 self.sync_screen_buffer();
638 let layers = std::mem::take(&mut self.layers);
639 let window = Window { top: self.window_top, height: self.viewport_height };
640 let mut stats = PaintStats::default();
641 let (output, next_cursor) = {
642 let previous = self
643 .previous
644 .as_ref()
645 .expect("layer-clearing checked previous above");
646 let previous_view = ComposedFrame { base: previous, layers: &layers };
647 let next_view = ComposedFrame { base: previous, layers: &[] };
648 let mut paint = String::new();
649 emit_window_diff(
650 &mut paint,
651 &previous_view,
652 window,
653 &next_view,
654 window,
655 0,
656 self.viewport_height,
657 self.graphics,
658 self.hyperlinks,
659 &mut stats,
660 );
661 let next_cursor = frame_cursor(previous, window);
662 let mut output = String::with_capacity(paint.len().saturating_add(64));
663 if stats.runs > 0 || next_cursor != self.cursor {
664 if self.sync_output {
665 output.push_str(SYNC_OUTPUT_BEGIN);
666 }
667 output.push_str(HIDE_CURSOR);
668 output.push_str(VIEWPORT_BOTTOM);
669 output.push_str(&paint);
670 place_cursor(&mut output, next_cursor, self.viewport_height);
671 if self.sync_output {
672 output.push_str(SYNC_OUTPUT_END);
673 }
674 }
675 (output, next_cursor)
676 };
677 self.write(&output)?;
678 self.cursor = next_cursor;
679 Ok(())
680 }
681
682 fn paint_next(
683 &mut self,
684 next: &Frame,
685 viewport_height: u16,
686 stable_rows: u16,
687 ) -> io::Result<PaintStats> {
688 self.validate_stable_prefix(next)?;
689 self.paint_validated_next(next, viewport_height, stable_rows, None, &[])
690 }
691
692 fn validate_stable_prefix(&self, next: &Frame) -> io::Result<()> {
693 let previous = self
694 .previous
695 .as_ref()
696 .expect("callers checked previous before painting");
697 if (0..self.stable_rows).any(|row| !previous.row_equals(row, next, row)) {
698 return Err(Self::stable_mutation_error());
699 }
700 Ok(())
701 }
702
703 fn validate_damaged_stable_prefix(
704 &self,
705 next: &Frame,
706 damaged: &[(u16, u16)],
707 ) -> io::Result<()> {
708 let previous = self
709 .previous
710 .as_ref()
711 .expect("callers checked previous before painting");
712 for &(start, end) in damaged {
713 let end = end.min(self.stable_rows);
714 if (start.min(end)..end).any(|row| !previous.row_equals(row, next, row)) {
715 return Err(Self::stable_mutation_error());
716 }
717 }
718 Ok(())
719 }
720
721 fn stable_mutation_error() -> io::Error {
722 contract_error("a previously declared-stable row changed; native history was left untouched")
723 }
724
725 fn paint_validated_next(
726 &mut self,
727 next: &Frame,
728 viewport_height: u16,
729 stable_rows: u16,
730 damaged: Option<&[(u16, u16)]>,
731 layers: &[ResolvedLayer<'_>],
732 ) -> io::Result<PaintStats> {
733 self.sync_screen_buffer();
734 let image_prefix = self.image_prefix(next, layers);
735 let mut output = std::mem::take(&mut self.output_scratch);
736 output.clear();
737 output.push_str(&image_prefix);
738 self.prepare_sixels(next);
739 let previous = self
740 .previous
741 .as_ref()
742 .expect("callers checked previous before painting");
743 let layout = layout(next.size().height, viewport_height, stable_rows, self.committed_rows);
744 let previous_window = Window { top: self.window_top, height: viewport_height };
745 let next_window = Window { top: layout.window_top, height: viewport_height };
746 let mut incoming = std::mem::take(&mut self.layer_scratch);
747 store_layers_into(layers, next_window, next.size().width, &mut incoming);
748 let commit_to =
749 scroll_append_to(previous_window, next_window, self.committed_rows, layout.stable_limit);
750 let newly_committed = commit_to - self.committed_rows;
751 let margin_rows = if self.margin_scrollback {
752 stable_rows
753 .saturating_sub(layout.window_top)
754 .max(newly_committed)
755 .max(2)
756 } else {
757 viewport_height
758 };
759 let mut stats = PaintStats {
760 committed_rows: newly_committed,
761 clipped_rows: layout.window_top.saturating_sub(commit_to),
762 ..PaintStats::default()
763 };
764 let sixels = self.sixel_output(
767 next,
768 next_window,
769 Some((previous, previous_window)),
770 damaged,
771 previous_window.top != next_window.top,
772 );
773 let kitty_direct = kitty_direct_output(
774 self.graphics,
775 &mut self.images,
776 next,
777 next_window,
778 Some((previous, previous_window)),
779 damaged,
780 false,
781 self.cell_pixel_width,
782 self.cell_pixel_height,
783 self.tmux_passthrough,
784 );
785 let iterm2 = iterm2_output(
786 self.graphics,
787 self
788 .images
789 .iter()
790 .map(|(&id, image)| Iterm2Image { id, png: &image.png }),
791 next,
792 Iterm2Viewport { top: next_window.top, height: next_window.height },
793 Some((previous, Iterm2Viewport {
794 top: previous_window.top,
795 height: previous_window.height,
796 })),
797 damaged,
798 false,
799 self.tmux_passthrough,
800 );
801
802 let dirty_rows = damaged.and_then(|damaged| {
803 (previous_window.top == next_window.top && previous_window.height == next_window.height)
804 .then(|| changed_screen_rows(damaged, &self.layers, &incoming, next_window))
805 });
806 let previous_view = ComposedFrame { base: previous, layers: &self.layers };
807 let next_view = ComposedFrame { base: next, layers: &incoming };
808 let capacity = usize::from(next.size().width).saturating_mul(usize::from(viewport_height));
809 let mut paint = std::mem::take(&mut self.paint_scratch);
810 paint.clear();
811 paint.reserve(capacity);
812 if newly_committed > 0 {
813 if margin_rows < viewport_height {
819 emit_margin_scroll_append(
820 &mut paint,
821 &previous_view,
822 previous_window,
823 &next_view,
824 next_window,
825 margin_rows,
826 self.graphics,
827 self.hyperlinks,
828 &mut stats,
829 );
830 } else {
831 emit_scroll_append(
832 &mut paint,
833 &previous_view,
834 previous_window,
835 &next_view,
836 next_window,
837 self.graphics,
838 self.hyperlinks,
839 &mut stats,
840 );
841 }
842 } else {
843 emit_window_diff_rows(
844 &mut paint,
845 &previous_view,
846 previous_window,
847 &next_view,
848 next_window,
849 0,
850 viewport_height,
851 dirty_rows.as_deref(),
852 self.graphics,
853 self.hyperlinks,
854 &mut stats,
855 );
856 }
857 reconcile_wrap_boundaries(
862 &mut paint,
863 &previous_view,
864 previous_window,
865 &next_view,
866 next_window,
867 newly_committed,
868 margin_rows.min(viewport_height),
869 self.graphics,
870 self.hyperlinks,
871 &mut stats,
872 );
873
874 let next_cursor = compose_cursor(next, &incoming, next_window, next.size().width);
875 output.reserve(
876 paint
877 .len()
878 .saturating_add(sixels.len())
879 .saturating_add(kitty_direct.len())
880 .saturating_add(iterm2.len())
881 .saturating_add(64),
882 );
883 if stats.runs > 0
884 || !sixels.is_empty()
885 || !kitty_direct.is_empty()
886 || !iterm2.is_empty()
887 || next_cursor != self.cursor
888 {
889 if self.sync_output {
890 output.push_str(SYNC_OUTPUT_BEGIN);
891 }
892 output.push_str(HIDE_CURSOR);
893 output.push_str(VIEWPORT_BOTTOM);
894 output.push_str(&paint);
895 output.push_str(&sixels);
896 output.push_str(&kitty_direct);
897 output.push_str(&iterm2);
898 place_cursor(&mut output, next_cursor, viewport_height);
899 if self.sync_output {
900 output.push_str(SYNC_OUTPUT_END);
901 }
902 }
903 let bytes = output.len();
904 let write_result = self.write(&output);
905 self.paint_scratch = paint;
906 self.output_scratch = output;
907 write_result?;
908
909 self.window_top = layout.window_top;
910 self.committed_rows = commit_to;
911 self.stable_rows = stable_rows;
912 self.cursor = next_cursor;
913 stats.bytes = bytes;
914 let previous_layers = std::mem::replace(&mut self.layers, incoming);
915 self.layer_scratch = previous_layers;
916 Ok(stats)
917 }
918
919 pub fn preview(
933 &mut self,
934 next: &Frame,
935 viewport_height: u16,
936 leading_sequence: &str,
937 ) -> io::Result<PaintStats> {
938 self.preview_resolved(next, &[], viewport_height, leading_sequence)
939 }
940
941 pub fn preview_overlaid(
953 &mut self,
954 next: &Frame,
955 layers: &[Layer<'_>],
956 viewport_height: u16,
957 leading_sequence: &str,
958 ) -> io::Result<PaintStats> {
959 let viewport = Size::new(next.size().width, viewport_height);
960 let resolved = resolve_layers(layers, viewport);
961 self.preview_resolved(next, &resolved, viewport_height, leading_sequence)
962 }
963
964 pub(crate) fn preview_resolved(
966 &mut self,
967 next: &Frame,
968 layers: &[ResolvedLayer<'_>],
969 viewport_height: u16,
970 leading_sequence: &str,
971 ) -> io::Result<PaintStats> {
972 self.validate_frame(next, viewport_height)?;
973 if !leading_sequence.is_empty() {
974 self.forget_preview();
975 }
976 self.sync_screen_buffer();
977
978 let paint_cells = usize::from(next.size().width).saturating_mul(usize::from(viewport_height));
979 let window = Window {
980 top: next.size().height.saturating_sub(viewport_height),
981 height: viewport_height,
982 };
983 let can_diff = leading_sequence.is_empty()
984 && self.preview_window == Some(window)
985 && self
986 .preview_previous
987 .as_ref()
988 .is_some_and(|previous| previous.size() == next.size());
989 let composited = store_layers(layers, window, next.size().width);
990 let images = self.image_prefix(next, layers);
991 self.prepare_sixels(next);
992 let sixels = self.sixel_output(next, window, None, None, true);
993 let kitty_direct = kitty_direct_output(
994 self.graphics,
995 &mut self.images,
996 next,
997 window,
998 None,
999 None,
1000 true,
1001 self.cell_pixel_width,
1002 self.cell_pixel_height,
1003 self.tmux_passthrough,
1004 );
1005 let iterm2 = iterm2_output(
1006 self.graphics,
1007 self
1008 .images
1009 .iter()
1010 .map(|(&id, image)| Iterm2Image { id, png: &image.png }),
1011 next,
1012 Iterm2Viewport { top: window.top, height: window.height },
1013 None,
1014 None,
1015 true,
1016 self.tmux_passthrough,
1017 );
1018 let raw = ComposedFrame { base: next, layers: &composited };
1019 let cursor = compose_cursor(next, &composited, window, next.size().width);
1020 let mut stats = PaintStats::default();
1021 let mut paint = String::new();
1022 if can_diff {
1023 let previous = ComposedFrame {
1024 base: self
1025 .preview_previous
1026 .as_ref()
1027 .expect("preview geometry checked above"),
1028 layers: &self.preview_layers,
1029 };
1030 emit_window_diff(
1031 &mut paint,
1032 &previous,
1033 window,
1034 &raw,
1035 window,
1036 0,
1037 viewport_height,
1038 self.graphics,
1039 self.hyperlinks,
1040 &mut stats,
1041 );
1042 }
1043
1044 let auxiliary =
1045 !images.is_empty() || !sixels.is_empty() || !kitty_direct.is_empty() || !iterm2.is_empty();
1046 let full_repaint = !can_diff;
1047 let mut output = String::with_capacity(
1048 if full_repaint {
1049 paint_cells.saturating_mul(2)
1050 } else {
1051 paint.len()
1052 }
1053 .saturating_add(images.len())
1054 .saturating_add(sixels.len())
1055 .saturating_add(kitty_direct.len())
1056 .saturating_add(iterm2.len())
1057 .saturating_add(64),
1058 );
1059 if full_repaint {
1060 if self.sync_output {
1061 output.push_str(SYNC_OUTPUT_BEGIN);
1062 }
1063 output.push_str(HIDE_CURSOR);
1064 output.push_str(leading_sequence);
1065 output.push_str(&images);
1068 output.push_str(RESET_STYLE);
1069 output.push_str(esc!(cursor_home));
1070 emit_rows(&mut output, &raw, 0..0, window, self.graphics, self.hyperlinks);
1071 output.push_str(RESET_STYLE);
1072 output.push('\r');
1073 output.push_str(&sixels);
1074 output.push_str(&kitty_direct);
1075 output.push_str(&iterm2);
1076 place_cursor(&mut output, cursor, viewport_height);
1077 if self.sync_output {
1078 output.push_str(SYNC_OUTPUT_END);
1079 }
1080 stats.full_repaint = true;
1081 stats.changed_cells = paint_cells;
1082 stats.runs = usize::from(viewport_height);
1083 } else if stats.runs > 0 || cursor != self.preview_cursor || auxiliary {
1084 if self.sync_output {
1085 output.push_str(SYNC_OUTPUT_BEGIN);
1086 }
1087 output.push_str(HIDE_CURSOR);
1088 output.push_str(&images);
1089 output.push_str(VIEWPORT_BOTTOM);
1090 output.push_str(&paint);
1091 output.push_str(&sixels);
1092 output.push_str(&kitty_direct);
1093 output.push_str(&iterm2);
1094 place_cursor(&mut output, cursor, viewport_height);
1095 if self.sync_output {
1096 output.push_str(SYNC_OUTPUT_END);
1097 }
1098 }
1099
1100 stats.bytes = output.len();
1101 self.write(&output)?;
1102 if crate::debug::publishing() {
1103 crate::debug::publish_screen(crate::debug::ScreenSnapshot {
1107 lines: stored_text(next, &composited, window.top, viewport_height),
1108 cursor: cursor.map(|cursor| (cursor.row, cursor.col)),
1109 window_top: window.top,
1110 cols: next.size().width,
1111 rows: viewport_height,
1112 doc_height: next.size().height,
1113 overlay: !composited.is_empty(),
1114 });
1115 }
1116 match &mut self.preview_previous {
1117 Some(previous) => previous.clone_from(next),
1118 None => self.preview_previous = Some(next.clone()),
1119 }
1120 self.preview_layers = composited;
1121 self.preview_window = Some(window);
1122 self.preview_cursor = cursor;
1123 Ok(stats)
1124 }
1125
1126 pub fn rebuild(
1137 &mut self,
1138 next: Frame,
1139 viewport_height: u16,
1140 stable_rows: u16,
1141 leading_sequence: &str,
1142 ) -> io::Result<PaintStats> {
1143 self.forget_preview();
1144 self.validate_frame(&next, viewport_height)?;
1145 if stable_rows > next.size().height {
1146 return Err(contract_error("stable_rows exceeds the document height"));
1147 }
1148 let stats =
1149 self.full_paint(next, viewport_height, stable_rows, leading_sequence, REBUILD_HISTORY)?;
1150 self.publish_debug_screen();
1151 Ok(stats)
1152 }
1153
1154 fn publish_debug_screen(&self) {
1157 if !crate::debug::publishing() {
1158 return;
1159 }
1160 let Some(previous) = &self.previous else {
1161 return;
1162 };
1163 crate::debug::publish_screen(crate::debug::ScreenSnapshot {
1164 lines: self.screen_text(),
1165 cursor: self.screen_cursor(),
1166 window_top: self.window_top,
1167 cols: previous.size().width,
1168 rows: self.viewport_height,
1169 doc_height: previous.size().height,
1170 overlay: !self.layers.is_empty(),
1171 });
1172 }
1173
1174 pub const fn committed_rows(&self) -> u16 {
1177 self.committed_rows
1178 }
1179
1180 pub const fn window_top(&self) -> u16 {
1182 self.window_top
1183 }
1184
1185 pub fn screen_text(&self) -> Vec<String> {
1192 match &self.previous {
1193 Some(previous) => {
1194 stored_text(previous, &self.layers, self.window_top, self.viewport_height)
1195 },
1196 None => Vec::new(),
1197 }
1198 }
1199
1200 pub const fn screen_cursor(&self) -> Option<(u16, u16)> {
1203 match self.cursor {
1204 Some(cursor) => Some((cursor.row, cursor.col)),
1205 None => None,
1206 }
1207 }
1208
1209 pub const fn output_state(&self) -> OutputState {
1212 self.output_state
1213 }
1214
1215 pub const fn writer_mut(&mut self) -> &mut W {
1217 &mut self.writer
1218 }
1219
1220 pub fn into_inner(self) -> W {
1222 self.writer
1223 }
1224
1225 fn validate_frame(&self, next: &Frame, viewport_height: u16) -> io::Result<()> {
1226 if self.poisoned {
1227 return Err(io::Error::other(
1228 "renderer state is unknown after a partial write; restart the terminal session",
1229 ));
1230 }
1231 if next.size().width == 0 || viewport_height == 0 {
1232 return Err(io::Error::new(
1233 io::ErrorKind::InvalidInput,
1234 "document width and viewport height must be non-zero",
1235 ));
1236 }
1237 Ok(())
1238 }
1239
1240 fn validate_input(
1241 &self,
1242 next: &Frame,
1243 viewport_height: u16,
1244 stable_rows: u16,
1245 ) -> io::Result<()> {
1246 self.validate_frame(next, viewport_height)?;
1247 if stable_rows > next.size().height {
1248 return Err(contract_error("stable_rows exceeds the document height"));
1249 }
1250 if stable_rows < self.stable_rows {
1251 return Err(contract_error("stable_rows cannot retreat"));
1252 }
1253 if next.size().height < self.committed_rows {
1254 return Err(contract_error(
1255 "document is shorter than rows already committed to native history",
1256 ));
1257 }
1258 if next.size().height.saturating_sub(viewport_height) < self.committed_rows {
1259 return Err(contract_error(
1260 "document tail shrank below committed history; document height must stay monotonic \
1261 between rebuilds",
1262 ));
1263 }
1264 if let Some(previous) = &self.previous
1265 && (previous.size().width != next.size().width || self.viewport_height != viewport_height)
1266 {
1267 return Err(io::Error::new(
1268 io::ErrorKind::InvalidInput,
1269 "terminal geometry changed; preserving native history requires a new renderer session",
1270 ));
1271 }
1272 Ok(())
1273 }
1274
1275 fn initial_paint(
1276 &mut self,
1277 next: Frame,
1278 viewport_height: u16,
1279 stable_rows: u16,
1280 ) -> io::Result<PaintStats> {
1281 self.full_paint(next, viewport_height, stable_rows, "", CLEAR_VIEWPORT)
1282 }
1283
1284 fn initial_paint_overlaid(
1285 &mut self,
1286 next: Frame,
1287 viewport_height: u16,
1288 stable_rows: u16,
1289 layers: &[ResolvedLayer<'_>],
1290 ) -> io::Result<PaintStats> {
1291 self.paint_full(next, viewport_height, stable_rows, "", CLEAR_VIEWPORT, layers)
1292 }
1293
1294 fn full_paint(
1295 &mut self,
1296 next: Frame,
1297 viewport_height: u16,
1298 stable_rows: u16,
1299 leading_sequence: &str,
1300 clear_sequence: &str,
1301 ) -> io::Result<PaintStats> {
1302 self.paint_full(next, viewport_height, stable_rows, leading_sequence, clear_sequence, &[])
1303 }
1304
1305 fn paint_full(
1306 &mut self,
1307 next: Frame,
1308 viewport_height: u16,
1309 stable_rows: u16,
1310 leading_sequence: &str,
1311 clear_sequence: &str,
1312 layers: &[ResolvedLayer<'_>],
1313 ) -> io::Result<PaintStats> {
1314 let layout = layout(next.size().height, viewport_height, stable_rows, 0);
1315 let paint_rows = layout.stable_limit.saturating_add(viewport_height);
1316 let paint_cells = usize::from(next.size().width).saturating_mul(usize::from(paint_rows));
1317 let window = Window { top: layout.window_top, height: viewport_height };
1318 let stored_layers = store_layers(layers, window, next.size().width);
1319 let next_cursor = compose_cursor(&next, &stored_layers, window, next.size().width);
1320 self.sync_screen_buffer();
1321 let images = self.image_prefix(&next, layers);
1322 self.prepare_sixels(&next);
1323 let sixels = self.sixel_output(&next, window, None, None, true);
1324 let kitty_direct = kitty_direct_output(
1325 self.graphics,
1326 &mut self.images,
1327 &next,
1328 window,
1329 None,
1330 None,
1331 true,
1332 self.cell_pixel_width,
1333 self.cell_pixel_height,
1334 self.tmux_passthrough,
1335 );
1336 let iterm2 = iterm2_output(
1337 self.graphics,
1338 self
1339 .images
1340 .iter()
1341 .map(|(&id, image)| Iterm2Image { id, png: &image.png }),
1342 &next,
1343 Iterm2Viewport { top: window.top, height: window.height },
1344 None,
1345 None,
1346 true,
1347 self.tmux_passthrough,
1348 );
1349 let mut output = String::with_capacity(
1350 paint_cells
1351 .saturating_mul(2)
1352 .saturating_add(images.len())
1353 .saturating_add(sixels.len())
1354 .saturating_add(kitty_direct.len())
1355 .saturating_add(iterm2.len()),
1356 );
1357 if self.sync_output {
1358 output.push_str(SYNC_OUTPUT_BEGIN);
1359 }
1360 output.push_str(HIDE_CURSOR);
1361 output.push_str(leading_sequence);
1362 output.push_str(RESET_STYLE);
1363 if self.screen_to_scrollback && clear_sequence == CLEAR_VIEWPORT {
1364 output.push_str(SCREEN_TO_SCROLLBACK);
1365 }
1366 output.push_str(clear_sequence);
1367 output.push_str(&images);
1371 let composed = ComposedFrame { base: &next, layers: &stored_layers };
1372 emit_rows(
1373 &mut output,
1374 &composed,
1375 0..layout.stable_limit,
1376 window,
1377 self.graphics,
1378 self.hyperlinks,
1379 );
1380 output.push_str(RESET_STYLE);
1381 output.push('\r');
1382 output.push_str(&sixels);
1383 output.push_str(&kitty_direct);
1384 output.push_str(&iterm2);
1385 place_cursor(&mut output, next_cursor, viewport_height);
1386 if self.sync_output {
1387 output.push_str(SYNC_OUTPUT_END);
1388 }
1389
1390 let bytes = output.len();
1391 self.write(&output)?;
1392 let stats = PaintStats {
1393 full_repaint: true,
1394 changed_cells: paint_cells,
1395 runs: usize::from(paint_rows),
1396 committed_rows: layout.stable_limit,
1397 clipped_rows: layout.window_top.saturating_sub(layout.stable_limit),
1398 bytes,
1399 };
1400 self.previous = Some(next);
1401 self.viewport_height = viewport_height;
1402 self.window_top = layout.window_top;
1403 self.committed_rows = layout.stable_limit;
1404 self.stable_rows = stable_rows;
1405 self.cursor = next_cursor;
1406 self.layers = stored_layers;
1407 Ok(stats)
1408 }
1409
1410 fn forget_preview(&mut self) {
1411 self.preview_previous = None;
1412 self.preview_layers.clear();
1413 self.preview_window = None;
1414 self.preview_cursor = None;
1415 }
1416
1417 fn sync_screen_buffer(&mut self) {
1419 self.set_screen_buffer(crate::terminal::alt_screen_active());
1420 }
1421
1422 fn set_screen_buffer(&mut self, alt_screen: bool) {
1429 if alt_screen == self.alt_screen {
1430 return;
1431 }
1432 self.forget_preview();
1433 self.alt_screen = alt_screen;
1434 for image in self.images.values_mut() {
1435 image.uploaded = false;
1436 image.placed.clear();
1437 image.direct_visible = false;
1438 }
1439 }
1440
1441 fn image_prefix(&mut self, frame: &Frame, layers: &[ResolvedLayer<'_>]) -> String {
1450 if self.graphics != Graphics::KittyPlaceholders
1451 || (!frame.may_have_images() && layers.iter().all(|layer| !layer.frame.may_have_images()))
1452 {
1453 return String::new();
1454 }
1455 let mut needed: SmallVec<(u32, u16, u16), 8> = SmallVec::new();
1456 let mut collect = |frame: &Frame, y0: u16, y1: u16| {
1457 for y in y0..y1.min(frame.size().height) {
1458 for x in 0..frame.size().width {
1459 if let CellContent::Image { id, rows, cols, .. } = frame.cell(x, y).content
1460 && rows > 0 && cols > 0
1461 && !needed.contains(&(id, rows, cols))
1462 {
1463 needed.push((id, rows, cols));
1464 }
1465 }
1466 }
1467 };
1468 collect(frame, 0, frame.size().height);
1469 for layer in layers {
1470 collect(layer.frame, layer.src_top, layer.src_top.saturating_add(layer.rows));
1471 }
1472 let mut output = String::new();
1473 for (id, rows, cols) in needed {
1474 let image = match self.images.entry(id) {
1475 std::collections::btree_map::Entry::Occupied(entry) => entry.into_mut(),
1476 std::collections::btree_map::Entry::Vacant(entry) => {
1477 let Some(png) = crate::imagereg::bytes(id) else {
1478 continue;
1479 };
1480 entry.insert(RegisteredImage::new(png))
1481 },
1482 };
1483 if !image.uploaded {
1484 append_transmission(&mut output, id, &image.png, self.tmux_passthrough);
1485 image.uploaded = true;
1486 }
1487 if !image.placed.contains(&(rows, cols)) {
1488 append_placement(&mut output, id, rows, cols, self.tmux_passthrough);
1489 image.placed.push((rows, cols));
1490 }
1491 }
1492 output
1493 }
1494
1495 fn prepare_sixels(&mut self, frame: &Frame) {
1496 if self.graphics != Graphics::Sixel {
1497 return;
1498 }
1499 for y in 0..frame.size().height {
1500 for x in 0..frame.size().width {
1501 let CellContent::Image { id, .. } = frame.cell(x, y).content else {
1502 continue;
1503 };
1504 let Some(image) = self.images.get_mut(&id) else {
1505 continue;
1506 };
1507 if !image.sixel_decoded {
1508 image.sixel = SixelImage::from_png(&image.png);
1509 image.sixel_decoded = true;
1510 }
1511 }
1512 }
1513 }
1514
1515 fn sixel_output(
1516 &self,
1517 frame: &Frame,
1518 window: Window,
1519 previous: Option<(&Frame, Window)>,
1520 damaged: Option<&[(u16, u16)]>,
1521 force: bool,
1522 ) -> String {
1523 if self.graphics != Graphics::Sixel {
1524 return String::new();
1525 }
1526 let mut output = String::new();
1527 let mut cursor_row = window.height - 1;
1528 for (&id, registered) in &self.images {
1529 let Some(image) = ®istered.sixel else {
1530 continue;
1531 };
1532 let Some((top, left, rows, cols)) = image_placement(frame, id) else {
1533 continue;
1534 };
1535 let visible_top = top.max(window.top);
1536 let visible_bottom = top
1537 .saturating_add(rows)
1538 .min(window.top.saturating_add(window.height))
1539 .min(frame.size().height);
1540 if visible_top >= visible_bottom {
1541 continue;
1542 }
1543 let needs_emit = force
1544 || match damaged {
1545 Some(ranges) => ranges
1546 .iter()
1547 .any(|&(start, end)| start < visible_bottom && end > visible_top),
1548 None => match previous {
1549 None => true,
1550 Some((previous, previous_window)) => {
1551 previous_window.top != window.top
1552 || (visible_top..visible_bottom)
1553 .any(|row| !previous.row_equals(row, frame, row))
1554 },
1555 },
1556 };
1557 if !needs_emit {
1558 continue;
1559 }
1560 let target_width = usize::from(cols).saturating_mul(usize::from(self.cell_pixel_width));
1561 let target_height = usize::from(rows).saturating_mul(usize::from(self.cell_pixel_height));
1562 let y0 = usize::from(visible_top - top).saturating_mul(target_height) / usize::from(rows);
1563 let y1 =
1564 usize::from(visible_bottom - top).saturating_mul(target_height) / usize::from(rows);
1565 let sixel = image.encode_band(target_width, target_height, y0, y1);
1566 if sixel.is_empty() {
1567 continue;
1568 }
1569 move_cursor_row(&mut output, &mut cursor_row, visible_top - window.top);
1570 output.push('\r');
1571 if left > 0 {
1572 let _ = write!(output, esc!(cursor_forward), left);
1573 }
1574 if self.tmux_passthrough {
1575 append_tmux_passthrough(&mut output, &sixel);
1576 } else {
1577 output.push_str(&sixel);
1578 }
1579 }
1580 if !output.is_empty() {
1581 move_cursor_row(&mut output, &mut cursor_row, window.height - 1);
1582 output.push('\r');
1583 }
1584 output
1585 }
1586
1587 fn write(&mut self, output: &str) -> io::Result<()> {
1588 if output.is_empty() {
1589 return Ok(());
1590 }
1591 if self.output_state == OutputState::Disconnected || self.backlog.queue(output.len()) {
1592 self.output_state = OutputState::Disconnected;
1593 self.poisoned = true;
1594 return Err(io::Error::new(
1595 io::ErrorKind::BrokenPipe,
1596 "terminal output backlog exceeded 64 MiB; terminal is disconnected",
1597 ));
1598 }
1599 let result = self
1600 .write_output(output.as_bytes())
1601 .and_then(|()| self.writer.flush());
1602 if let Err(error) = result {
1603 self.poisoned = true;
1604 return Err(error);
1605 }
1606 self.backlog.flushed();
1607 Ok(())
1608 }
1609
1610 fn write_output(&mut self, output: &[u8]) -> io::Result<()> {
1611 #[cfg(any(windows, target_os = "linux"))]
1612 if self.conpty_hosted && output.len() > MAX_CONPTY_WRITE_CHUNK_BYTES {
1613 for chunk in ConptyChunks::new(output, MAX_CONPTY_WRITE_CHUNK_BYTES) {
1614 terminal_write_all(&mut self.writer, chunk)?;
1615 }
1616 return Ok(());
1617 }
1618 terminal_write_all(&mut self.writer, output)
1619 }
1620}
1621
1622#[cfg(any(windows, target_os = "linux", test))]
1623struct ConptyChunks<'a> {
1624 bytes: &'a [u8],
1625 pos: usize,
1626 max: usize,
1627}
1628
1629#[cfg(any(windows, target_os = "linux", test))]
1630impl<'a> ConptyChunks<'a> {
1631 fn new(bytes: &'a [u8], max: usize) -> Self {
1632 debug_assert!(max > 0);
1633 Self { bytes, pos: 0, max }
1634 }
1635}
1636
1637#[cfg(any(windows, target_os = "linux", test))]
1638impl<'a> Iterator for ConptyChunks<'a> {
1639 type Item = &'a [u8];
1640
1641 fn next(&mut self) -> Option<Self::Item> {
1642 if self.pos == self.bytes.len() {
1643 return None;
1644 }
1645 let start = self.pos;
1646 if self.bytes.len() - start <= self.max {
1647 self.pos = self.bytes.len();
1648 return Some(&self.bytes[start..]);
1649 }
1650
1651 let mut window_end = start + self.max;
1652 while self.bytes[window_end] & 0xc0 == 0x80 {
1653 window_end -= 1;
1654 }
1655 let mut search_end = window_end;
1656 let cut = loop {
1657 let newline = self.bytes[start..search_end]
1658 .iter()
1659 .rposition(|byte| *byte == b'\n')
1660 .map(|index| start + index + 1);
1661 let Some(newline) = newline else {
1662 break escape_end_crossing(self.bytes, start, window_end).unwrap_or(window_end);
1663 };
1664 if escape_end_crossing(self.bytes, start, newline).is_none() {
1665 break newline;
1666 }
1667 search_end = newline - 1;
1668 };
1669 self.pos = cut;
1670 Some(&self.bytes[start..cut])
1671 }
1672}
1673
1674#[cfg(any(windows, target_os = "linux", test))]
1675fn escape_end_crossing(bytes: &[u8], start: usize, cut: usize) -> Option<usize> {
1676 let mut index = start;
1677 while index < cut {
1678 if bytes[index] != b'\x1b' {
1679 index += 1;
1680 continue;
1681 }
1682 let end = escape_sequence_end(bytes, index);
1683 if end > cut {
1684 return Some(end);
1685 }
1686 index = end.max(index + 1);
1687 }
1688 None
1689}
1690
1691#[cfg(any(windows, target_os = "linux", test))]
1692fn escape_sequence_end(bytes: &[u8], start: usize) -> usize {
1693 let Some(&kind) = bytes.get(start + 1) else {
1694 return bytes.len();
1695 };
1696 match kind {
1697 b'[' => {
1698 for (offset, byte) in bytes[start + 2..].iter().enumerate() {
1699 if (0x40..=0x7e).contains(byte) {
1700 return start + 3 + offset;
1701 }
1702 }
1703 bytes.len()
1704 },
1705 b']' => string_escape_end(bytes, start + 2, true),
1706 b'P' | b'X' | b'^' | b'_' => string_escape_end(bytes, start + 2, false),
1707 0x20..=0x2f => {
1708 for (offset, byte) in bytes[start + 2..].iter().enumerate() {
1709 if (0x30..=0x7e).contains(byte) {
1710 return start + 3 + offset;
1711 }
1712 }
1713 bytes.len()
1714 },
1715 _ => (start + 2).min(bytes.len()),
1716 }
1717}
1718
1719#[cfg(any(windows, target_os = "linux", test))]
1720fn string_escape_end(bytes: &[u8], start: usize, bell_terminated: bool) -> usize {
1721 let mut index = start;
1722 while index < bytes.len() {
1723 if bell_terminated && bytes[index] == b'\x07' {
1724 return index + 1;
1725 }
1726 if bytes[index] == b'\x1b' && bytes.get(index + 1) == Some(&b'\\') {
1727 return index + 2;
1728 }
1729 index += 1;
1730 }
1731 bytes.len()
1732}
1733
1734#[cfg(windows)]
1735const fn is_conpty_hosted() -> bool {
1736 true
1737}
1738
1739#[cfg(target_os = "linux")]
1740fn is_conpty_hosted() -> bool {
1741 std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some()
1742}
1743
1744#[allow(clippy::too_many_arguments, reason = "rendering inputs are independent frame state")]
1745fn kitty_direct_output(
1746 graphics: Graphics,
1747 images: &mut BTreeMap<u32, RegisteredImage>,
1748 frame: &Frame,
1749 window: Window,
1750 previous: Option<(&Frame, Window)>,
1751 damaged: Option<&[(u16, u16)]>,
1752 force: bool,
1753 cell_pixel_width: u16,
1754 cell_pixel_height: u16,
1755 tmux_passthrough: bool,
1756) -> String {
1757 if graphics != Graphics::KittyDirect {
1758 return String::new();
1759 }
1760 let mut output = String::new();
1761 let mut cursor_row = window.height - 1;
1762 for (&id, image) in images {
1763 let placement = image_placement(frame, id);
1764 let visible = placement.and_then(|(top, left, rows, cols)| {
1765 let visible_top = top.max(window.top);
1766 let visible_bottom = top
1767 .saturating_add(rows)
1768 .min(window.top.saturating_add(window.height))
1769 .min(frame.size().height);
1770 (visible_top < visible_bottom).then_some((
1771 top,
1772 left,
1773 rows,
1774 cols,
1775 visible_top,
1776 visible_bottom,
1777 ))
1778 });
1779 let Some((top, left, rows, cols, visible_top, visible_bottom)) = visible else {
1780 if image.direct_visible {
1781 append_delete_image(&mut output, id, tmux_passthrough);
1782 image.uploaded = false;
1783 image.direct_visible = false;
1784 }
1785 continue;
1786 };
1787
1788 let moved = previous.is_none_or(|(previous_frame, previous_window)| {
1789 image_placement(previous_frame, id) != placement || previous_window.top != window.top
1790 });
1791 let intersects_damage = damaged.is_some_and(|ranges| {
1792 ranges
1793 .iter()
1794 .any(|&(start, end)| start < visible_bottom && end > visible_top)
1795 });
1796 let changed = damaged.is_none()
1797 && previous.is_some_and(|(previous_frame, _)| {
1798 (visible_top..visible_bottom).any(|row| !previous_frame.row_equals(row, frame, row))
1799 });
1800 let needs_emit =
1801 force || !image.uploaded || !image.direct_visible || moved || intersects_damage || changed;
1802 image.direct_visible = true;
1803 if !needs_emit {
1804 continue;
1805 }
1806 if !image.uploaded {
1807 append_transmission(&mut output, id, &image.png, tmux_passthrough);
1808 image.uploaded = true;
1809 }
1810
1811 let fallback_width = u32::from(cols)
1812 .saturating_mul(u32::from(cell_pixel_width))
1813 .max(1);
1814 let fallback_height = u32::from(rows)
1815 .saturating_mul(u32::from(cell_pixel_height))
1816 .max(1);
1817 let (source_width, source_height) =
1818 png_dimensions(&image.png).unwrap_or((fallback_width, fallback_height));
1819 let row_offset = u64::from(visible_top - top);
1820 let row_end = u64::from(visible_bottom - top);
1821 let source_y = (row_offset.saturating_mul(u64::from(source_height)) / u64::from(rows)) as u32;
1822 let source_bottom =
1823 (row_end.saturating_mul(u64::from(source_height)) / u64::from(rows)) as u32;
1824 let source_height = source_bottom.saturating_sub(source_y).max(1);
1825
1826 move_cursor_row(&mut output, &mut cursor_row, visible_top - window.top);
1827 output.push('\r');
1828 if left > 0 {
1829 let _ = write!(output, esc!(cursor_forward), left);
1830 }
1831 append_direct_placement(
1832 &mut output,
1833 id,
1834 DirectPlacement {
1835 source_x: 0,
1836 source_y,
1837 source_width,
1838 source_height,
1839 rows: visible_bottom - visible_top,
1840 cols,
1841 },
1842 tmux_passthrough,
1843 );
1844 }
1845 if !output.is_empty() {
1846 move_cursor_row(&mut output, &mut cursor_row, window.height - 1);
1847 output.push('\r');
1848 }
1849 output
1850}
1851
1852fn png_dimensions(png: &[u8]) -> Option<(u32, u32)> {
1853 const SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
1854 if png.get(..8) != Some(SIGNATURE) || png.get(12..16) != Some(b"IHDR") {
1855 return None;
1856 }
1857 let width = u32::from_be_bytes(png.get(16..20)?.try_into().ok()?);
1858 let height = u32::from_be_bytes(png.get(20..24)?.try_into().ok()?);
1859 (width > 0 && height > 0).then_some((width, height))
1860}
1861
1862pub fn image_placement(frame: &Frame, id: u32) -> Option<(u16, u16, u16, u16)> {
1863 for y in 0..frame.size().height {
1864 for x in 0..frame.size().width {
1865 if let CellContent::Image { id: cell_id, row, col, rows, cols } = frame.cell(x, y).content
1866 && cell_id == id
1867 && rows > 0
1868 && cols > 0
1869 {
1870 return Some((y.saturating_sub(row), x.saturating_sub(col), rows, cols));
1871 }
1872 }
1873 }
1874 None
1875}
1876
1877fn contract_error(message: &'static str) -> io::Error {
1878 io::Error::new(io::ErrorKind::InvalidData, message)
1879}
1880
1881fn layout(
1882 document_height: u16,
1883 viewport_height: u16,
1884 stable_rows: u16,
1885 committed_rows: u16,
1886) -> Layout {
1887 let natural_top = document_height.saturating_sub(viewport_height);
1888 let stable_limit = committed_rows.max(stable_rows.min(natural_top));
1889 let window_top = committed_rows.max(natural_top);
1890 Layout { stable_limit, window_top }
1891}
1892
1893fn resolve_layers<'a>(layers: &'a [Layer<'_>], viewport: Size) -> SmallVec<ResolvedLayer<'a>, 4> {
1895 let mut ordered: SmallVec<(i16, ResolvedLayer<'a>), 4> = layers
1896 .iter()
1897 .filter_map(|layer| {
1898 let band = layer.band(viewport);
1899 (band.rows > 0).then_some((layer.options.z, ResolvedLayer {
1900 frame: layer.frame,
1901 x: band.x,
1902 y: band.y,
1903 src_top: band.src_top,
1904 rows: band.rows,
1905 active: layer.active,
1906 }))
1907 })
1908 .collect();
1909 ordered.sort_by_key(|(z, _)| *z);
1910 ordered.into_iter().map(|(_, layer)| layer).collect()
1911}
1912
1913fn store_layers(
1914 layers: &[ResolvedLayer<'_>],
1915 window: Window,
1916 document_width: u16,
1917) -> SmallVec<StoredLayer, 4> {
1918 let mut stored = SmallVec::new();
1919 store_layers_into(layers, window, document_width, &mut stored);
1920 stored
1921}
1922
1923fn store_layers_into(
1924 layers: &[ResolvedLayer<'_>],
1925 window: Window,
1926 document_width: u16,
1927 stored: &mut SmallVec<StoredLayer, 4>,
1928) {
1929 let mut len = 0;
1930 for layer in layers {
1931 if layer.y >= window.height
1932 || layer.x >= document_width
1933 || layer.src_top >= layer.frame.size().height
1934 || layer.frame.size().width == 0
1935 {
1936 continue;
1937 }
1938 let rows = layer
1939 .rows
1940 .min(window.height - layer.y)
1941 .min(layer.frame.size().height - layer.src_top);
1942 if rows == 0 {
1943 continue;
1944 }
1945 let source_address = std::ptr::from_ref(layer.frame).addr();
1946 let (source_id, source_revision) = layer.frame.source_stamp();
1947 if let Some(slot) = stored.get_mut(len) {
1948 let source_unchanged = slot.source_address == source_address
1949 && slot.source_id == source_id
1950 && slot.source_revision == source_revision;
1951 if !source_unchanged && !slot.frame.same_grid(layer.frame) {
1952 slot.frame.clone_from(layer.frame);
1953 }
1954 slot.x = layer.x;
1955 slot.document_y = window.top.saturating_add(layer.y);
1956 slot.src_top = layer.src_top;
1957 slot.rows = rows;
1958 slot.active = layer.active;
1959 slot.source_address = source_address;
1960 slot.source_id = source_id;
1961 slot.source_revision = source_revision;
1962 } else {
1963 stored.push(StoredLayer {
1964 frame: layer.frame.clone(),
1965 x: layer.x,
1966 document_y: window.top.saturating_add(layer.y),
1967 src_top: layer.src_top,
1968 rows,
1969 active: layer.active,
1970 source_address,
1971 source_id,
1972 source_revision,
1973 });
1974 }
1975 len += 1;
1976 }
1977 stored.truncate(len);
1978}
1979
1980fn changed_screen_rows(
1981 damaged: &[(u16, u16)],
1982 previous_layers: &[StoredLayer],
1983 next_layers: &[StoredLayer],
1984 window: Window,
1985) -> SmallVec<(u16, u16), 12> {
1986 let mut rows = SmallVec::new();
1987 let window_end = window.top.saturating_add(window.height);
1988 let mut push_document_rows = |start: u16, end: u16| {
1989 let start = start.max(window.top);
1990 let end = end.min(window_end);
1991 if start < end {
1992 rows.push((start - window.top, end - window.top));
1993 }
1994 };
1995 for &(start, end) in damaged {
1996 push_document_rows(start, end);
1997 }
1998 for index in 0..previous_layers.len().max(next_layers.len()) {
1999 let previous = previous_layers.get(index);
2000 let next = next_layers.get(index);
2001 if previous
2002 .zip(next)
2003 .is_some_and(|(previous, next)| previous.same_cells_and_placement(next))
2004 {
2005 continue;
2006 }
2007 if let Some(layer) = previous {
2008 push_document_rows(layer.document_y, layer.document_y.saturating_add(layer.rows));
2009 }
2010 if let Some(layer) = next {
2011 push_document_rows(layer.document_y, layer.document_y.saturating_add(layer.rows));
2012 }
2013 }
2014 rows
2015}
2016
2017fn stored_text(base: &Frame, layers: &[StoredLayer], top: u16, height: u16) -> Vec<String> {
2019 let composed = ComposedFrame { base, layers };
2020 let blank = Cell::blank(Style::default());
2021 let width = base.size().width;
2022 let mut rows = Vec::with_capacity(usize::from(height));
2023 for offset in 0..height {
2024 let y = top.saturating_add(offset);
2025 let mut text = String::new();
2026 for x in 0..width {
2027 match &composed.cell_or(y, x, &blank).content {
2028 CellContent::Blank => text.push(' '),
2029 CellContent::Grapheme { text: glyph, .. } => text.push_str(glyph),
2030 CellContent::Image { .. } => text.push(' '),
2031 CellContent::Continuation => {},
2032 }
2033 }
2034 text.truncate(text.trim_end().len());
2035 rows.push(text);
2036 }
2037 rows
2038}
2039
2040fn compose_cursor(
2045 base: &Frame,
2046 layers: &[StoredLayer],
2047 window: Window,
2048 document_width: u16,
2049) -> Option<ScreenCursor> {
2050 match layers.iter().rev().find(|layer| layer.active) {
2051 Some(layer) => layer_cursor(layer, window, document_width),
2052 None => frame_cursor(base, window),
2053 }
2054}
2055
2056fn layer_cursor(layer: &StoredLayer, window: Window, document_width: u16) -> Option<ScreenCursor> {
2058 let (col, row) = layer.frame.cursor()?;
2059 if col >= layer.frame.size().width
2060 || row < layer.src_top
2061 || row >= layer.src_top.saturating_add(layer.rows)
2062 {
2063 return None;
2064 }
2065 let screen_row = layer
2066 .document_y
2067 .saturating_sub(window.top)
2068 .saturating_add(row - layer.src_top);
2069 let screen_col = layer.x.saturating_add(col);
2070 (screen_row < window.height && screen_col < document_width)
2071 .then_some(ScreenCursor { row: screen_row, col: screen_col })
2072}
2073
2074fn frame_cursor(frame: &Frame, window: Window) -> Option<ScreenCursor> {
2075 let (col, document_row) = frame.cursor()?;
2076 if col >= frame.size().width
2077 || document_row < window.top
2078 || document_row >= window.top.saturating_add(window.height)
2079 {
2080 return None;
2081 }
2082 Some(ScreenCursor { row: document_row - window.top, col })
2083}
2084
2085fn place_cursor(output: &mut String, cursor: Option<ScreenCursor>, viewport_height: u16) {
2086 let Some(cursor) = cursor else {
2087 return;
2088 };
2089 let mut row = viewport_height - 1;
2090 move_cursor_row(output, &mut row, cursor.row);
2091 output.push('\r');
2092 if cursor.col > 0 {
2093 let _ = write!(output, esc!(cursor_forward), cursor.col);
2094 }
2095 output.push_str(SHOW_CURSOR);
2096}
2097
2098const fn scroll_append_to(
2099 previous_window: Window,
2100 next_window: Window,
2101 committed_rows: u16,
2102 stable_limit: u16,
2103) -> u16 {
2104 if committed_rows != previous_window.top || next_window.top <= previous_window.top {
2105 return committed_rows;
2106 }
2107 let scroll = next_window.top - previous_window.top;
2108 if scroll >= previous_window.height || next_window.top > stable_limit {
2109 return committed_rows;
2110 }
2111 next_window.top
2112}
2113
2114#[allow(clippy::too_many_arguments, reason = "diff inputs describe two composed viewport slices")]
2125fn reconcile_wrap_boundaries(
2126 output: &mut String,
2127 previous: &ComposedFrame<'_>,
2128 previous_window: Window,
2129 next: &ComposedFrame<'_>,
2130 next_window: Window,
2131 scroll: u16,
2132 region: u16,
2133 graphics: Graphics,
2134 hyperlinks: bool,
2135 stats: &mut PaintStats,
2136) {
2137 let height = next_window.height;
2138 let erase = !next.base.may_have_images();
2139 let mut cursor_row = height - 1;
2140 let mut emitted = false;
2141 for boundary in 0..height.saturating_sub(1) {
2142 let row = next_window.top.saturating_add(boundary);
2143 let wanted = wrap_joinable(next, row);
2144 let painted = if scroll == 0 {
2145 wrap_joinable(previous, previous_window.top.saturating_add(boundary))
2146 } else if boundary.saturating_add(1) < region.saturating_sub(scroll) {
2147 wrap_joinable(previous, next_window.top.saturating_add(boundary))
2149 } else if boundary.saturating_add(1) == region {
2150 false
2152 } else if boundary >= region {
2153 wrap_joinable(previous, previous_window.top.saturating_add(boundary))
2155 } else {
2156 continue;
2158 };
2159 if painted == wanted {
2160 continue;
2161 }
2162 emitted = true;
2163 stats.runs += 1;
2164 stats.changed_cells = stats
2165 .changed_cells
2166 .saturating_add(usize::from(next.base.size().width).saturating_mul(2));
2167 move_cursor_row(output, &mut cursor_row, boundary);
2168 if wanted {
2169 output.push_str(esc!(autowrap));
2170 arm_wrap_boundary(output, next, row, graphics, hyperlinks);
2171 encode_frame_row(output, next, row.saturating_add(1), graphics, hyperlinks);
2174 output.push_str(esc!(!autowrap));
2175 cursor_row = boundary + 1;
2176 } else {
2177 output.push('\r');
2178 if erase {
2179 output.push_str(esc!(erase_line));
2180 }
2181 encode_frame_row(output, next, row, graphics, hyperlinks);
2182 move_cursor_row(output, &mut cursor_row, boundary + 1);
2183 output.push('\r');
2184 if erase {
2185 output.push_str(esc!(erase_line));
2186 }
2187 encode_frame_row(output, next, row.saturating_add(1), graphics, hyperlinks);
2188 }
2189 }
2190 if emitted {
2191 output.push_str(RESET_STYLE);
2192 move_cursor_row(output, &mut cursor_row, height - 1);
2193 output.push('\r');
2194 }
2195}
2196fn emit_scroll_append(
2197 output: &mut String,
2198 previous: &ComposedFrame<'_>,
2199 previous_window: Window,
2200 next: &ComposedFrame<'_>,
2201 next_window: Window,
2202 graphics: Graphics,
2203 hyperlinks: bool,
2204 stats: &mut PaintStats,
2205) {
2206 let scroll = next_window.top - previous_window.top;
2207 emit_window_diff(
2208 output,
2209 previous,
2210 Window { top: previous_window.top, height: scroll },
2211 next,
2212 Window { top: next_window.top - scroll, height: scroll },
2213 0,
2214 next_window.height,
2215 graphics,
2216 hyperlinks,
2217 stats,
2218 );
2219 output.push_str(VIEWPORT_BOTTOM);
2220 let first_new = next_window.height - scroll;
2221 let any_join = (first_new..next_window.height).any(|screen_y| {
2222 let row = next_window.top.saturating_add(screen_y);
2223 row > 0 && wrap_joinable(next, row - 1)
2224 });
2225 if any_join {
2226 output.push_str(esc!(autowrap));
2227 }
2228 for screen_y in first_new..next_window.height {
2229 let row = next_window.top.saturating_add(screen_y);
2230 if row > 0 && wrap_joinable(next, row - 1) {
2231 if screen_y == first_new {
2236 arm_wrap_boundary(output, next, row - 1, graphics, hyperlinks);
2237 }
2238 } else {
2239 output.push_str("\r\n");
2240 }
2241 encode_frame_row(output, next, row, graphics, hyperlinks);
2242 }
2243 if any_join {
2244 output.push_str(esc!(!autowrap));
2245 }
2246 stats.runs += usize::from(scroll);
2247 stats.changed_cells += usize::from(next.base.size().width).saturating_mul(usize::from(scroll));
2248
2249 let retained_rows = next_window.height - scroll;
2250 emit_window_diff(
2251 output,
2252 previous,
2253 Window { top: previous_window.top.saturating_add(scroll), height: retained_rows },
2254 next,
2255 Window { top: next_window.top, height: retained_rows },
2256 0,
2257 next_window.height,
2258 graphics,
2259 hyperlinks,
2260 stats,
2261 );
2262}
2263
2264fn emit_margin_scroll_append(
2276 output: &mut String,
2277 previous: &ComposedFrame<'_>,
2278 previous_window: Window,
2279 next: &ComposedFrame<'_>,
2280 next_window: Window,
2281 region_rows: u16,
2282 graphics: Graphics,
2283 hyperlinks: bool,
2284 stats: &mut PaintStats,
2285) {
2286 let scroll = next_window.top - previous_window.top;
2287 emit_window_diff(
2290 output,
2291 previous,
2292 Window { top: previous_window.top, height: scroll },
2293 next,
2294 Window { top: next_window.top - scroll, height: scroll },
2295 0,
2296 next_window.height,
2297 graphics,
2298 hyperlinks,
2299 stats,
2300 );
2301 let _ = write!(output, esc!(scroll_region, cursor_down), region_rows, region_rows - 1);
2304 let first_new = region_rows - scroll;
2305 let any_join = (first_new..region_rows).any(|screen_y| {
2306 let row = next_window.top.saturating_add(screen_y);
2307 row > 0 && wrap_joinable(next, row - 1)
2308 });
2309 if any_join {
2310 output.push_str(esc!(autowrap));
2311 }
2312 for screen_y in first_new..region_rows {
2313 let row = next_window.top.saturating_add(screen_y);
2314 if row > 0 && wrap_joinable(next, row - 1) {
2315 if screen_y == first_new {
2316 arm_wrap_boundary(output, next, row - 1, graphics, hyperlinks);
2317 }
2318 } else {
2319 output.push_str("\r\n");
2320 }
2321 encode_frame_row(output, next, row, graphics, hyperlinks);
2322 }
2323 if any_join {
2324 output.push_str(esc!(!autowrap));
2325 }
2326 stats.runs += usize::from(scroll);
2327 stats.changed_cells += usize::from(next.base.size().width).saturating_mul(usize::from(scroll));
2328 output.push_str(esc!(margins_reset));
2331 output.push_str(VIEWPORT_BOTTOM);
2332 let shifted = region_rows - scroll;
2333 emit_window_diff(
2334 output,
2335 previous,
2336 Window { top: previous_window.top.saturating_add(scroll), height: shifted },
2337 next,
2338 Window { top: next_window.top, height: shifted },
2339 0,
2340 next_window.height,
2341 graphics,
2342 hyperlinks,
2343 stats,
2344 );
2345 let pinned = next_window.height - region_rows;
2347 emit_window_diff(
2348 output,
2349 previous,
2350 Window { top: previous_window.top.saturating_add(region_rows), height: pinned },
2351 next,
2352 Window { top: next_window.top.saturating_add(region_rows), height: pinned },
2353 region_rows,
2354 next_window.height,
2355 graphics,
2356 hyperlinks,
2357 stats,
2358 );
2359}
2360
2361fn emit_rows(
2366 output: &mut String,
2367 frame: &ComposedFrame<'_>,
2368 prefix: Range<u16>,
2369 window: Window,
2370 graphics: Graphics,
2371 hyperlinks: bool,
2372) {
2373 let mut any_join = false;
2374 let mut previous: Option<u16> = None;
2375 for row in prefix
2376 .clone()
2377 .chain((0..window.height).map(|screen_y| window.top.saturating_add(screen_y)))
2378 {
2379 if previous.is_some_and(|p| row == p.saturating_add(1) && wrap_joinable(frame, p)) {
2380 any_join = true;
2381 break;
2382 }
2383 previous = Some(row);
2384 }
2385 if any_join {
2386 output.push_str(esc!(autowrap));
2387 }
2388 let mut previous: Option<u16> = None;
2389 for row in prefix.chain((0..window.height).map(|screen_y| window.top.saturating_add(screen_y))) {
2390 if let Some(p) = previous
2391 && !(row == p.saturating_add(1) && wrap_joinable(frame, p))
2392 {
2393 output.push_str("\r\n");
2394 }
2395 encode_frame_row(output, frame, row, graphics, hyperlinks);
2396 previous = Some(row);
2397 }
2398 if any_join {
2399 output.push_str(esc!(!autowrap));
2400 }
2401}
2402
2403#[inline(always)]
2404fn cells_equal(previous: &Cell, next: &Cell, hyperlinks: bool) -> bool {
2405 previous.content == next.content
2406 && (previous.style == next.style
2407 || (!hyperlinks && previous.style.without_link() == next.style.without_link()))
2408}
2409
2410#[inline]
2411fn emit_window_diff(
2412 output: &mut String,
2413 previous: &ComposedFrame<'_>,
2414 previous_window: Window,
2415 next: &ComposedFrame<'_>,
2416 next_window: Window,
2417 screen_top: u16,
2418 screen_height: u16,
2419 graphics: Graphics,
2420 hyperlinks: bool,
2421 stats: &mut PaintStats,
2422) {
2423 emit_window_diff_rows(
2424 output,
2425 previous,
2426 previous_window,
2427 next,
2428 next_window,
2429 screen_top,
2430 screen_height,
2431 None,
2432 graphics,
2433 hyperlinks,
2434 stats,
2435 );
2436}
2437
2438#[allow(clippy::too_many_arguments, reason = "diff inputs describe two composed viewport slices")]
2439fn emit_window_diff_rows(
2440 output: &mut String,
2441 previous: &ComposedFrame<'_>,
2442 previous_window: Window,
2443 next: &ComposedFrame<'_>,
2444 next_window: Window,
2445 screen_top: u16,
2446 screen_height: u16,
2447 dirty_rows: Option<&[(u16, u16)]>,
2448 graphics: Graphics,
2449 hyperlinks: bool,
2450 stats: &mut PaintStats,
2451) {
2452 let blank = Cell::blank(Style::default());
2453 let width = next.base.size().width;
2454 let mut active_style = Style::default();
2455 let mut cursor_row = screen_height - 1;
2456
2457 for screen_y in 0..next_window.height {
2458 if let Some(rows) = dirty_rows
2459 && !rows
2460 .iter()
2461 .any(|&(start, end)| start <= screen_y && screen_y < end)
2462 {
2463 continue;
2464 }
2465 let previous_y = previous_window.top.saturating_add(screen_y);
2466 let next_y = next_window.top.saturating_add(screen_y);
2467 let mut x = 0;
2468 while x < width {
2469 if cells_equal(
2470 previous.cell_or(previous_y, x, &blank),
2471 next.cell_or(next_y, x, &blank),
2472 hyperlinks,
2473 ) {
2474 x += 1;
2475 continue;
2476 }
2477
2478 let mut start = x;
2479 while start > 0
2480 && matches!(next.cell_or(next_y, start, &blank).content, CellContent::Continuation)
2481 {
2482 start -= 1;
2483 }
2484
2485 let mut end = x + 1;
2486 stats.changed_cells += 1;
2487 while end < width {
2488 let previous_cell = previous.cell_or(previous_y, end, &blank);
2489 let next_cell = next.cell_or(next_y, end, &blank);
2490 if cells_equal(previous_cell, next_cell, hyperlinks) {
2491 break;
2492 }
2493 end += 1;
2494 stats.changed_cells += 1;
2495 }
2496 while end < width
2497 && matches!(next.cell_or(next_y, end, &blank).content, CellContent::Continuation)
2498 {
2499 end += 1;
2500 }
2501
2502 emit_run(
2503 output,
2504 next,
2505 Run { document_y: next_y, screen_y: screen_top.saturating_add(screen_y), start, end },
2506 &blank,
2507 &mut active_style,
2508 &mut cursor_row,
2509 graphics,
2510 hyperlinks,
2511 );
2512 stats.runs += 1;
2513 x = end;
2514 }
2515 }
2516
2517 if stats.runs > 0 {
2518 output.push_str(RESET_STYLE);
2519 move_cursor_row(output, &mut cursor_row, screen_height - 1);
2520 output.push('\r');
2521 }
2522}
2523
2524pub fn move_cursor_row(output: &mut String, current: &mut u16, target: u16) {
2525 if target < *current {
2526 let _ = write!(output, esc!(cursor_up), *current - target);
2527 } else if target > *current {
2528 let _ = write!(output, esc!(cursor_down), target - *current);
2529 }
2530 *current = target;
2531}
2532
2533fn emit_run(
2534 output: &mut String,
2535 frame: &ComposedFrame<'_>,
2536 run: Run,
2537 blank: &Cell,
2538 active_style: &mut Style,
2539 cursor_row: &mut u16,
2540 graphics: Graphics,
2541 hyperlinks: bool,
2542) {
2543 move_cursor_row(output, cursor_row, run.screen_y);
2544 output.push('\r');
2545 if run.start > 0 {
2546 let _ = write!(output, esc!(cursor_forward), run.start);
2547 }
2548 let mut x = run.start;
2549
2550 while x < run.end {
2551 let cell = frame.cell_or(run.document_y, x, blank);
2552 match &cell.content {
2553 CellContent::Blank => {
2554 emit_cell_style(output, cell.style, active_style, hyperlinks);
2555 output.push(' ');
2556 x += 1;
2557 },
2558 CellContent::Grapheme { text, width } => {
2559 emit_cell_style(output, cell.style, active_style, hyperlinks);
2560 output.push_str(text);
2561 x = x.saturating_add(*width);
2562 },
2563 CellContent::Image { id, row, col, rows, cols } => {
2564 emit_image_cell(
2565 output,
2566 *id,
2567 *row,
2568 *col,
2569 *rows,
2570 *cols,
2571 active_style,
2572 graphics,
2573 hyperlinks,
2574 );
2575 x += 1;
2576 },
2577 CellContent::Continuation => x += 1,
2578 }
2579 }
2580 close_active_link(output, active_style, hyperlinks);
2581}
2582fn wrap_joinable(frame: &ComposedFrame<'_>, row: u16) -> bool {
2593 frame.base.soft_wrap(row) && trailing_glyph_start(frame.base, row).is_some()
2594}
2595
2596fn trailing_glyph_start(frame: &Frame, row: u16) -> Option<u16> {
2599 let width = frame.size().width;
2600 let blank = Cell::blank(Style::default());
2601 let mut x = width.checked_sub(1)?;
2602 loop {
2603 match &frame.cell_or(row, x, &blank).content {
2604 CellContent::Continuation if x > 0 => x -= 1,
2605 CellContent::Grapheme { width: glyph, .. } if x.saturating_add(*glyph) == width => {
2606 return Some(x);
2607 },
2608 _ => return None,
2609 }
2610 }
2611}
2612
2613fn arm_wrap_boundary(
2619 output: &mut String,
2620 frame: &ComposedFrame<'_>,
2621 row: u16,
2622 graphics: Graphics,
2623 hyperlinks: bool,
2624) {
2625 let width = frame.base.size().width;
2626 let Some(last) = width.checked_sub(1) else {
2627 return;
2628 };
2629 let blank = Cell::blank(Style::default());
2630 let mut x = last;
2633 let cell = loop {
2634 let cell = frame.cell_or(row, x, &blank);
2635 match &cell.content {
2636 CellContent::Continuation if x > 0 => x -= 1,
2637 _ => break cell,
2638 }
2639 };
2640 output.push('\r');
2641 if x > 0 {
2642 let _ = write!(output, esc!(cursor_forward), x);
2643 }
2644 output.push_str(RESET_STYLE);
2645 let mut active = Style::default();
2646 match &cell.content {
2647 CellContent::Grapheme { text, width: glyph }
2648 if x.saturating_add(*glyph) == width && *glyph > 0 =>
2649 {
2650 emit_cell_style(output, cell.style, &mut active, hyperlinks);
2651 output.push_str(text);
2652 },
2653 CellContent::Image { id, row: img_row, col, rows, cols } if x == last => {
2654 emit_image_cell(
2655 output,
2656 *id,
2657 *img_row,
2658 *col,
2659 *rows,
2660 *cols,
2661 &mut active,
2662 graphics,
2663 hyperlinks,
2664 );
2665 },
2666 _ => {
2667 emit_cell_style(output, cell.style, &mut active, hyperlinks);
2670 for _ in x..width {
2671 output.push(' ');
2672 }
2673 },
2674 }
2675 close_active_link(output, &mut active, hyperlinks);
2676}
2677fn encode_frame_row(
2678 output: &mut String,
2679 frame: &ComposedFrame<'_>,
2680 row: u16,
2681 graphics: Graphics,
2682 hyperlinks: bool,
2683) {
2684 if row < frame.base.size().height {
2685 encode_row(output, frame, row, graphics, hyperlinks);
2686 } else {
2687 encode_blank_row(output, frame.base.size().width);
2688 }
2689}
2690
2691fn encode_row(
2692 output: &mut String,
2693 frame: &ComposedFrame<'_>,
2694 row: u16,
2695 graphics: Graphics,
2696 hyperlinks: bool,
2697) {
2698 output.push_str(RESET_STYLE);
2699 let blank = Cell::blank(Style::default());
2700 let mut active_style = Style::default();
2701 let mut x = 0;
2702 while x < frame.base.size().width {
2703 let cell = frame.cell_or(row, x, &blank);
2704 match &cell.content {
2705 CellContent::Blank => {
2706 emit_cell_style(output, cell.style, &mut active_style, hyperlinks);
2707 output.push(' ');
2708 x += 1;
2709 },
2710 CellContent::Grapheme { text, width } => {
2711 emit_cell_style(output, cell.style, &mut active_style, hyperlinks);
2712 output.push_str(text);
2713 x = x.saturating_add(*width);
2714 },
2715 CellContent::Image { id, row, col, rows, cols } => {
2716 emit_image_cell(
2717 output,
2718 *id,
2719 *row,
2720 *col,
2721 *rows,
2722 *cols,
2723 &mut active_style,
2724 graphics,
2725 hyperlinks,
2726 );
2727 x += 1;
2728 },
2729 CellContent::Continuation => x += 1,
2730 }
2731 }
2732 close_active_link(output, &mut active_style, hyperlinks);
2733}
2734
2735#[allow(clippy::too_many_arguments, reason = "flat cell emission hot path")]
2736fn emit_image_cell(
2737 output: &mut String,
2738 id: u32,
2739 row: u16,
2740 col: u16,
2741 rows: u16,
2742 cols: u16,
2743 active_style: &mut Style,
2744 graphics: Graphics,
2745 hyperlinks: bool,
2746) {
2747 if graphics != Graphics::KittyPlaceholders {
2748 emit_cell_style(output, Style::default(), active_style, hyperlinks);
2749 output.push(' ');
2750 return;
2751 }
2752 let (placeholder, style) = placeholder_cell(id, row, col, rows, cols);
2753 emit_cell_style(output, style, active_style, hyperlinks);
2754 output.push_str(&placeholder);
2755}
2756
2757fn encode_blank_row(output: &mut String, width: u16) {
2758 output.push_str(RESET_STYLE);
2759 for _ in 0..width {
2760 output.push(' ');
2761 }
2762}
2763
2764fn emit_cell_style(output: &mut String, style: Style, active_style: &mut Style, hyperlinks: bool) {
2765 let link_changed = hyperlinks && active_style.link != style.link;
2766 if link_changed && active_style.link.is_some() {
2767 output.push_str(esc!(osc, "8;;", st));
2768 }
2769 let visual = style.without_link();
2770 if active_style.without_link() != visual {
2771 emit_style(output, visual);
2772 }
2773 if link_changed && let Some(id) = style.link {
2774 emit_link_open(output, id);
2775 }
2776 *active_style = style;
2777}
2778
2779fn close_active_link(output: &mut String, active_style: &mut Style, hyperlinks: bool) {
2780 if hyperlinks && active_style.link.is_some() {
2781 output.push_str(esc!(osc, "8;;", st));
2782 }
2783 active_style.link = None;
2784}
2785
2786fn emit_link_open(output: &mut String, id: LinkId) {
2787 let _ = with_link_url(id, |url| {
2788 let _ = write!(output, esc!(osc, "8;id={};"), id.get());
2789 for ch in url.chars().filter(|ch| !matches!(ch, '\x1b' | '\x07')) {
2790 output.push(ch);
2791 }
2792 output.push_str(esc!(st));
2793 });
2794}
2795
2796fn emit_style(output: &mut String, style: Style) {
2797 let style = style.without_link();
2798 output.push_str(RESET_STYLE);
2799 if style == Style::default() {
2800 return;
2801 }
2802
2803 output.push_str(esc!(csi));
2804 let mut first = true;
2805 push_style_parameters(output, style, &mut first);
2806 output.push('m');
2807}
2808
2809pub fn push_style_parameters(output: &mut String, style: Style, first: &mut bool) {
2811 if style.bold {
2812 push_parameter(output, first, "1");
2813 }
2814 if style.dim {
2815 push_parameter(output, first, "2");
2816 }
2817 if style.italic {
2818 push_parameter(output, first, "3");
2819 }
2820 if style.underline {
2821 push_parameter(output, first, "4");
2822 }
2823 match style.underline_color {
2824 Color::Default => {},
2825 color => {
2826 if !*first {
2827 output.push(';');
2828 }
2829 *first = false;
2830 match color {
2832 Color::Indexed(index) => {
2833 let _ = write!(output, "58:5:{index}");
2834 },
2835 Color::Rgb(red, green, blue) => {
2836 let _ = write!(output, "58:2::{red}:{green}:{blue}");
2837 },
2838 Color::Default => unreachable!("matched above"),
2839 }
2840 },
2841 }
2842 if style.reverse {
2843 push_parameter(output, first, "7");
2844 }
2845 if style.strikethrough {
2846 push_parameter(output, first, "9");
2847 }
2848 push_color_code(output, first, style.foreground, false);
2849 push_color_code(output, first, style.background, true);
2850}
2851
2852fn push_parameter(output: &mut String, first: &mut bool, parameter: &str) {
2853 if !*first {
2854 output.push(';');
2855 }
2856 output.push_str(parameter);
2857 *first = false;
2858}
2859
2860fn push_color_code(output: &mut String, first: &mut bool, color: Color, background: bool) {
2861 if color == Color::Default {
2862 return;
2863 }
2864 if !*first {
2865 output.push(';');
2866 }
2867 *first = false;
2868
2869 let prefix = if background { 48 } else { 38 };
2870 match color {
2871 Color::Default => unreachable!("default colors returned before emission"),
2872 Color::Indexed(index) => {
2873 let _ = write!(output, "{prefix};5;{index}");
2874 },
2875 Color::Rgb(red, green, blue) => {
2876 let _ = write!(output, "{prefix};2;{red};{green};{blue}");
2877 },
2878 }
2879}
2880
2881#[cfg(test)]
2882mod tests {
2883 use std::io::ErrorKind;
2884
2885 use super::{
2886 ConptyChunks, MAX_CONPTY_WRITE_CHUNK_BYTES, MAX_OUTPUT_BACKLOG_BYTES, OutputBacklogGuard,
2887 REBUILD_HISTORY, ResolvedLayer, SYNC_OUTPUT_BEGIN, SYNC_OUTPUT_END, VIEWPORT_BOTTOM,
2888 };
2889 use crate::{
2890 Color, Frame, Graphics, Renderer, Size, Style,
2891 overlay::{Layer, OverlayAnchor, OverlayOptions},
2892 test_support::TerminalModel,
2893 };
2894
2895 fn document(lines: &[&str]) -> Frame {
2896 let mut frame = Frame::new(Size::new(8, u16::try_from(lines.len()).expect("small fixture")));
2897 for (row, line) in lines.iter().enumerate() {
2898 frame.put(0, u16::try_from(row).expect("small fixture"), line, Style::default());
2899 }
2900 frame
2901 }
2902 fn soft_document(lines: &[&str], soft_after: &[u16]) -> Frame {
2904 let mut frame = document(lines);
2905 for &row in soft_after {
2906 frame.set_soft_wrap(row);
2907 }
2908 frame
2909 }
2910
2911 fn apply_paint(renderer: &mut Renderer<Vec<u8>>, terminal: &mut TerminalModel) {
2912 let output = String::from_utf8(std::mem::take(renderer.writer_mut())).expect("ANSI is UTF-8");
2913 terminal.apply(&output);
2914 }
2915
2916 fn without_sync_markers(output: &str) -> String {
2917 output
2918 .replace(SYNC_OUTPUT_BEGIN, "")
2919 .replace(SYNC_OUTPUT_END, "")
2920 }
2921
2922 #[test]
2923 fn layer_appears_once_at_viewport_coordinates() {
2924 let base = document(&["base000", "base111"]);
2925 let mut overlay = Frame::new(Size::new(2, 1));
2926 overlay.put(0, 0, "OV", Style::default());
2927 let layer = ResolvedLayer {
2928 frame: &overlay,
2929 x: 2,
2930 y: 1,
2931 src_top: 0,
2932 rows: 1,
2933 active: false,
2934 };
2935 let mut renderer = Renderer::new(Vec::new());
2936 let mut terminal = TerminalModel::new(8, 2);
2937
2938 let first = renderer
2939 .present_resolved(&base, &[], 2, 0, &[layer])
2940 .expect("overlay paint succeeds");
2941 apply_paint(&mut renderer, &mut terminal);
2942 assert!(first.runs > 0);
2943 assert_eq!(terminal.visible_rows(), ["base000", "baOV111"]);
2944
2945 let second = renderer
2946 .present_resolved(&base, &[], 2, 0, &[ResolvedLayer {
2947 frame: &overlay,
2948 x: 2,
2949 y: 1,
2950 src_top: 0,
2951 rows: 1,
2952 active: false,
2953 }])
2954 .expect("identical overlay paint succeeds");
2955 assert_eq!(second.runs, 0);
2956 assert_eq!(second.bytes, 0);
2957 }
2958
2959 #[test]
2960 fn declarative_layer_resolves_options_to_a_band() {
2961 let base = document(&["base000", "base111"]);
2962 let mut overlay = Frame::new(Size::new(2, 1));
2963 overlay.put(0, 0, "OV", Style::default());
2964 let options = OverlayOptions::default()
2965 .anchor(OverlayAnchor::TopLeft)
2966 .offset_x(2)
2967 .offset_y(1);
2968 let layer = Layer { frame: &overlay, options: &options, active: false };
2969 let mut renderer = Renderer::new(Vec::new());
2970 let mut terminal = TerminalModel::new(8, 2);
2971
2972 renderer
2973 .present_overlaid(&base, &[], 2, 0, &[layer])
2974 .expect("declarative layer paint succeeds");
2975 apply_paint(&mut renderer, &mut terminal);
2976 assert_eq!(terminal.visible_rows(), ["base000", "baOV111"]);
2977 }
2978
2979 #[test]
2980 fn clearing_overlay_repaints_document_cells() {
2981 let base = document(&["base000", "base111"]);
2982 let mut overlay = Frame::new(Size::new(2, 1));
2983 overlay.put(0, 0, "OV", Style::default());
2984 let mut renderer = Renderer::new(Vec::new());
2985 let mut terminal = TerminalModel::new(8, 2);
2986 renderer
2987 .present(base.clone(), 2, 0)
2988 .expect("base paint succeeds");
2989 apply_paint(&mut renderer, &mut terminal);
2990 renderer
2991 .present_resolved(&base, &[], 2, 0, &[ResolvedLayer {
2992 frame: &overlay,
2993 x: 2,
2994 y: 1,
2995 src_top: 0,
2996 rows: 1,
2997 active: false,
2998 }])
2999 .expect("overlay paint succeeds");
3000 apply_paint(&mut renderer, &mut terminal);
3001
3002 let stats = renderer
3003 .present_ref(&base, 2, 0)
3004 .expect("clearing paint succeeds");
3005 apply_paint(&mut renderer, &mut terminal);
3006 assert!(stats.runs > 0);
3007 assert_eq!(terminal.visible_rows(), ["base000", "base111"]);
3008 }
3009
3010 #[test]
3011 fn document_growth_scrolls_raw_rows_to_history_under_open_overlay() {
3012 fn resolved_layer(overlay: &Frame) -> ResolvedLayer<'_> {
3013 ResolvedLayer {
3014 frame: overlay,
3015 x: 0,
3016 y: 0,
3017 src_top: 0,
3018 rows: 1,
3019 active: false,
3020 }
3021 }
3022 let mut overlay = Frame::new(Size::new(2, 1));
3023 overlay.put(0, 0, "OV", Style::default());
3024 let mut renderer = Renderer::new(Vec::new());
3025 let mut terminal = TerminalModel::new(8, 2);
3026 renderer
3027 .present(document(&["row00", "row01"]), 2, 2)
3028 .expect("initial paint succeeds");
3029 apply_paint(&mut renderer, &mut terminal);
3030
3031 let first = renderer
3032 .present_resolved(&document(&["row00", "row01", "row02"]), &[(2, 3)], 2, 3, &[
3033 resolved_layer(&overlay),
3034 ])
3035 .expect("first growth under the layer succeeds");
3036 apply_paint(&mut renderer, &mut terminal);
3037 assert_eq!(first.committed_rows, 1, "commits keep flowing under an open layer");
3038 assert_eq!(terminal.history, ["row00"]);
3039 assert_eq!(
3040 terminal.visible_rows(),
3041 ["OVw01", "row02"],
3042 "the layer stays viewport-anchored after the scroll"
3043 );
3044
3045 let second = renderer
3046 .present_resolved(&document(&["row00", "row01", "row02", "row03"]), &[(3, 4)], 2, 4, &[
3047 resolved_layer(&overlay),
3048 ])
3049 .expect("second growth under the layer succeeds");
3050 apply_paint(&mut renderer, &mut terminal);
3051 assert_eq!(second.committed_rows, 1);
3052 assert_eq!(
3053 terminal.history,
3054 ["row00", "row01"],
3055 "the row physically under the layer is restored before it scrolls out"
3056 );
3057 assert!(terminal.history.iter().all(|row| !row.contains("OV")));
3058 assert_eq!(terminal.visible_rows(), ["OVw02", "row03"]);
3059
3060 renderer
3061 .present_damaged(&document(&["row00", "row01", "row02", "row03"]), &[], 2, 4)
3062 .expect("clearing the layer succeeds");
3063 apply_paint(&mut renderer, &mut terminal);
3064 assert_eq!(terminal.visible_rows(), ["row02", "row03"]);
3065 assert_eq!(terminal.history, ["row00", "row01"], "clearing commits nothing extra");
3066 }
3067
3068 #[test]
3069 fn clear_layers_restores_raw_cells_without_committing() {
3070 let mut overlay = Frame::new(Size::new(2, 1));
3071 overlay.put(0, 0, "OV", Style::default());
3072 let mut renderer = Renderer::new(Vec::new());
3073 let mut terminal = TerminalModel::new(8, 2);
3074 renderer
3075 .present(document(&["row00", "row01", "row02"]), 2, 3)
3076 .expect("initial paint succeeds");
3077 apply_paint(&mut renderer, &mut terminal);
3078 renderer
3079 .present_resolved(&document(&["row00", "row01", "row02"]), &[], 2, 3, &[ResolvedLayer {
3080 frame: &overlay,
3081 x: 0,
3082 y: 0,
3083 src_top: 0,
3084 rows: 1,
3085 active: false,
3086 }])
3087 .expect("layered paint succeeds");
3088 apply_paint(&mut renderer, &mut terminal);
3089 assert_eq!(terminal.visible_rows(), ["OVw01", "row02"]);
3090
3091 renderer.clear_layers().expect("teardown scrub succeeds");
3092 apply_paint(&mut renderer, &mut terminal);
3093 assert_eq!(
3094 terminal.visible_rows(),
3095 ["row01", "row02"],
3096 "bands repaint from the raw document"
3097 );
3098 assert_eq!(terminal.history, ["row00"], "the scrub commits nothing");
3099
3100 renderer.clear_layers().expect("layer-free scrub succeeds");
3101 assert!(renderer.writer_mut().is_empty(), "a layer-free scrub writes nothing");
3102 }
3103
3104 #[test]
3105 fn cursor_follows_the_active_layer_and_base_shows_through_passive_ones() {
3106 let mut base = document(&["base000", "base111", "base222"]);
3107 base.set_cursor(0, 0);
3108 let mut overlay = Frame::new(Size::new(2, 2));
3109 overlay.put(0, 0, "aa", Style::default());
3110 overlay.put(0, 1, "bb", Style::default());
3111 overlay.set_cursor(1, 1);
3112 let layer =
3113 |active| ResolvedLayer { frame: &overlay, x: 3, y: 0, src_top: 0, rows: 2, active };
3114 let mut renderer = Renderer::new(Vec::new());
3115 renderer
3116 .present(base.clone(), 3, 0)
3117 .expect("base paint succeeds");
3118 renderer.writer_mut().clear();
3119
3120 renderer
3122 .present_resolved(&base, &[], 3, 0, &[layer(true)])
3123 .expect("active layer paint succeeds");
3124 let output = String::from_utf8(std::mem::take(renderer.writer_mut())).expect("UTF-8");
3125 assert!(output.contains("\x1b[1A\r\x1b[4C\x1b[?25h"), "{output:?}");
3126
3127 renderer
3129 .present_resolved(&base, &[], 3, 0, &[layer(false)])
3130 .expect("passive layer paint succeeds");
3131 let output = String::from_utf8(std::mem::take(renderer.writer_mut())).expect("UTF-8");
3132 assert!(output.contains("\x1b[2A\r\x1b[?25h"), "base caret at (0,0): {output:?}");
3133
3134 let mut blank = Frame::new(Size::new(2, 2));
3136 blank.put(0, 0, "cc", Style::default());
3137 renderer
3138 .present_resolved(&base, &[], 3, 0, &[ResolvedLayer {
3139 frame: &blank,
3140 x: 3,
3141 y: 0,
3142 src_top: 0,
3143 rows: 2,
3144 active: true,
3145 }])
3146 .expect("cursorless active paint succeeds");
3147 let output = String::from_utf8(std::mem::take(renderer.writer_mut())).expect("UTF-8");
3148 assert!(!output.contains("\x1b[?25h"), "no caret may show: {output:?}");
3149 }
3150
3151 #[test]
3152 fn wide_document_grapheme_cut_by_overlay_is_blank_not_torn() {
3153 let mut base = Frame::new(Size::new(4, 1));
3154 base.put(0, 0, "界x", Style::default());
3155 let mut overlay = Frame::new(Size::new(1, 1));
3156 overlay.put(0, 0, "O", Style::default());
3157 let mut renderer = Renderer::new(Vec::new());
3158 let mut terminal = TerminalModel::new(4, 1);
3159 renderer
3160 .present(base.clone(), 1, 0)
3161 .expect("base paint succeeds");
3162 apply_paint(&mut renderer, &mut terminal);
3163
3164 renderer
3165 .present_resolved(&base, &[], 1, 0, &[ResolvedLayer {
3166 frame: &overlay,
3167 x: 1,
3168 y: 0,
3169 src_top: 0,
3170 rows: 1,
3171 active: false,
3172 }])
3173 .expect("wide overlay paint succeeds");
3174 apply_paint(&mut renderer, &mut terminal);
3175 assert_eq!(terminal.visible_rows(), [" Ox"]);
3176 }
3177
3178 #[test]
3179 fn damaged_present_diffs_away_stored_overlay() {
3180 let base = document(&["base000", "base111"]);
3181 let mut overlay = Frame::new(Size::new(2, 1));
3182 overlay.put(0, 0, "OV", Style::default());
3183 let mut renderer = Renderer::new(Vec::new());
3184 let mut terminal = TerminalModel::new(8, 2);
3185 renderer
3186 .present(base.clone(), 2, 0)
3187 .expect("base paint succeeds");
3188 apply_paint(&mut renderer, &mut terminal);
3189 renderer
3190 .present_resolved(&base, &[], 2, 0, &[ResolvedLayer {
3191 frame: &overlay,
3192 x: 2,
3193 y: 1,
3194 src_top: 0,
3195 rows: 1,
3196 active: false,
3197 }])
3198 .expect("overlay paint succeeds");
3199 apply_paint(&mut renderer, &mut terminal);
3200
3201 let stats = renderer
3202 .present_damaged(&base, &[], 2, 0)
3203 .expect("damaged clearing paint succeeds");
3204 apply_paint(&mut renderer, &mut terminal);
3205 assert!(stats.runs > 0);
3206 assert_eq!(terminal.visible_rows(), ["base000", "base111"]);
3207 }
3208
3209 #[test]
3210 fn layer_only_kitty_image_still_transmits_and_places() {
3211 let base = document(&["base000", "base111"]);
3212 let mut overlay = Frame::new(Size::new(3, 1));
3213 for col in 0..2 {
3214 overlay.put_image_cell(col, 0, 7, 0, col, 1, 2);
3215 }
3216 let mut renderer = Renderer::new(Vec::new());
3217 renderer.set_graphics(Graphics::KittyPlaceholders);
3218 renderer
3219 .register_image(7, b"\x89PNG\r\n\x1a\nsmall".to_vec())
3220 .expect("image registration succeeds");
3221 renderer
3222 .present(base.clone(), 2, 0)
3223 .expect("base paint succeeds");
3224 renderer.writer_mut().clear();
3225
3226 renderer
3227 .present_resolved(&base, &[], 2, 0, &[ResolvedLayer {
3228 frame: &overlay,
3229 x: 0,
3230 y: 0,
3231 src_top: 0,
3232 rows: 1,
3233 active: false,
3234 }])
3235 .expect("overlay image paint succeeds");
3236 let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3237 assert!(
3238 output.contains("\x1b_G"),
3239 "an image referenced only by a layer still uploads: {output:?}"
3240 );
3241
3242 renderer.writer_mut().clear();
3243 renderer
3244 .present_resolved(&base, &[], 2, 0, &[ResolvedLayer {
3245 frame: &overlay,
3246 x: 0,
3247 y: 0,
3248 src_top: 0,
3249 rows: 1,
3250 active: false,
3251 }])
3252 .expect("steady overlay paint succeeds");
3253 let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3254 assert!(!output.contains("\x1b_G"), "uploads happen once: {output:?}");
3255 }
3256
3257 #[test]
3258 fn conpty_chunker_prefers_newlines_within_sixteen_kibibytes() {
3259 let line = "x".repeat(8 * 1024 - 1) + "\n";
3260 let payload = line.repeat(5);
3261 assert_eq!(payload.len(), 40 * 1024);
3262 let chunks =
3263 ConptyChunks::new(payload.as_bytes(), MAX_CONPTY_WRITE_CHUNK_BYTES).collect::<Vec<_>>();
3264 assert!(chunks.len() > 1);
3265 assert!(
3266 chunks
3267 .iter()
3268 .all(|chunk| chunk.len() <= MAX_CONPTY_WRITE_CHUNK_BYTES)
3269 );
3270 assert!(
3271 chunks[..chunks.len() - 1]
3272 .iter()
3273 .all(|chunk| chunk.ends_with(b"\n"))
3274 );
3275 assert_eq!(chunks.concat(), payload.as_bytes());
3276 }
3277
3278 #[test]
3279 fn conpty_chunker_extends_past_an_escape_sequence_without_newlines() {
3280 let mut payload = vec![b'x'; MAX_CONPTY_WRITE_CHUNK_BYTES - 2];
3281 payload.extend_from_slice(b"\x1b]8;;https://example.test/a-very-long-link\x1b\\");
3282 payload.extend(std::iter::repeat_n(b'y', MAX_CONPTY_WRITE_CHUNK_BYTES));
3283 let chunks = ConptyChunks::new(&payload, MAX_CONPTY_WRITE_CHUNK_BYTES).collect::<Vec<_>>();
3284 assert!(chunks[0].len() > MAX_CONPTY_WRITE_CHUNK_BYTES);
3285 assert!(chunks[0].ends_with(b"\x1b\\"));
3286 assert_eq!(chunks.concat(), payload);
3287 }
3288
3289 #[test]
3290 fn backlog_disconnects_only_after_sixty_four_mibibytes() {
3291 let mut guard = OutputBacklogGuard::default();
3292 assert!(!guard.queue(MAX_OUTPUT_BACKLOG_BYTES - 1));
3293 assert!(!guard.queue(1));
3294 assert!(guard.queue(1));
3295 guard.flushed();
3296 assert!(!guard.queue(1));
3297 }
3298 #[test]
3299 fn hyperlink_capability_materializes_only_the_link_label() {
3300 let target = "https://example.test/docs";
3301 let link_style = Style::new().underline().link(target);
3302 let id = link_style.link.expect("non-empty URL is interned").get();
3303 let mut frame = Frame::new(Size::new(12, 1));
3304 frame.put(0, 0, "go ", Style::new());
3305 frame.put(3, 0, "label", link_style);
3306 frame.put(8, 0, " end", Style::new());
3307
3308 let mut renderer = Renderer::new(Vec::new());
3309 renderer.set_hyperlinks(true);
3310 renderer
3311 .present(frame, 1, 0)
3312 .expect("hyperlinked frame paints");
3313 let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3314 let linked = format!("\x1b]8;id={id};{target}\x1b\\label\x1b]8;;\x1b\\");
3315 assert!(output.contains(&linked), "{output:?}");
3316 assert_eq!(output.matches("\x1b]8;id=").count(), 1);
3317 assert_eq!(output.matches("\x1b]8;;\x1b\\").count(), 1);
3318 }
3319
3320 #[test]
3321 fn disabled_hyperlinks_are_byte_identical_to_plain_styled_cells() {
3322 let mut linked = Frame::new(Size::new(8, 1));
3323 linked.put(0, 0, "label", Style::new().underline().link("https://example.test"));
3324 let mut plain = Frame::new(Size::new(8, 1));
3325 plain.put(0, 0, "label", Style::new().underline());
3326
3327 let mut linked_renderer = Renderer::new(Vec::new());
3328 let mut plain_renderer = Renderer::new(Vec::new());
3329 linked_renderer
3330 .present(linked, 1, 0)
3331 .expect("disabled hyperlink frame paints");
3332 plain_renderer
3333 .present(plain, 1, 0)
3334 .expect("plain frame paints");
3335 assert_eq!(linked_renderer.writer_mut(), plain_renderer.writer_mut());
3336 }
3337
3338 #[test]
3339 fn iterm2_graphics_dispatches_registered_png_post_pass() {
3340 let mut frame = Frame::new(Size::new(2, 1));
3341 for col in 0..2 {
3342 frame.put_image_cell(col, 0, 7, 0, col, 1, 2);
3343 }
3344 let mut renderer = Renderer::new(Vec::new());
3345 renderer.set_graphics(Graphics::Iterm2);
3346 renderer
3347 .register_image(7, b"\x89PNG\r\n\x1a\nsmall".to_vec())
3348 .expect("image registration succeeds");
3349 renderer.present(frame, 1, 0).expect("iTerm2 frame paints");
3350 let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3351 assert!(output.contains("\x1b]1337;File=inline=1;"));
3352 }
3353
3354 #[test]
3355 fn disabled_synchronized_output_only_removes_wrappers_from_every_paint_path() {
3356 let initial = document(&["one", "two", "three"]);
3357 let changed = document(&["one", "TWO", "three"]);
3358 let mut synchronized = Renderer::new(Vec::new());
3359 let mut plain = Renderer::new(Vec::new());
3360 plain.set_sync_output(false);
3361
3362 synchronized
3363 .present(initial.clone(), 2, 1)
3364 .expect("synchronized full paint succeeds");
3365 plain
3366 .present(initial, 2, 1)
3367 .expect("plain full paint succeeds");
3368 let synchronized_output =
3369 String::from_utf8(std::mem::take(synchronized.writer_mut())).expect("ANSI is UTF-8");
3370 let plain_output =
3371 String::from_utf8(std::mem::take(plain.writer_mut())).expect("ANSI is UTF-8");
3372 assert_eq!(without_sync_markers(&synchronized_output), plain_output);
3373 assert!(!plain_output.contains(SYNC_OUTPUT_BEGIN));
3374 assert!(!plain_output.contains(SYNC_OUTPUT_END));
3375
3376 synchronized.writer_mut().clear();
3377 plain.writer_mut().clear();
3378 synchronized
3379 .present(changed.clone(), 2, 1)
3380 .expect("synchronized incremental paint succeeds");
3381 plain
3382 .present(changed.clone(), 2, 1)
3383 .expect("plain incremental paint succeeds");
3384 let synchronized_output =
3385 String::from_utf8(synchronized.writer_mut().clone()).expect("ANSI is UTF-8");
3386 let plain_output = String::from_utf8(plain.writer_mut().clone()).expect("ANSI is UTF-8");
3387 assert_eq!(without_sync_markers(&synchronized_output), plain_output);
3388 assert!(!plain_output.contains(SYNC_OUTPUT_BEGIN));
3389 assert!(!plain_output.contains(SYNC_OUTPUT_END));
3390
3391 synchronized.writer_mut().clear();
3392 plain.writer_mut().clear();
3393 synchronized
3394 .preview(&changed, 2, "\x1b[?1049h")
3395 .expect("synchronized preview succeeds");
3396 plain
3397 .preview(&changed, 2, "\x1b[?1049h")
3398 .expect("plain preview succeeds");
3399 let synchronized_output =
3400 String::from_utf8(synchronized.writer_mut().clone()).expect("ANSI is UTF-8");
3401 let plain_output = String::from_utf8(plain.writer_mut().clone()).expect("ANSI is UTF-8");
3402 assert_eq!(without_sync_markers(&synchronized_output), plain_output);
3403 assert!(!plain_output.contains(SYNC_OUTPUT_BEGIN));
3404 assert!(!plain_output.contains(SYNC_OUTPUT_END));
3405 }
3406
3407 #[test]
3408 fn screen_to_scrollback_precedes_viewport_clear_only_when_enabled() {
3409 let mut ordinary = Renderer::new(Vec::new());
3410 ordinary
3411 .present(document(&["one", "two"]), 2, 0)
3412 .expect("ordinary paint succeeds");
3413 let ordinary_output =
3414 String::from_utf8(ordinary.writer_mut().clone()).expect("ANSI is UTF-8");
3415 assert!(!ordinary_output.contains("\x1b[22J"));
3416
3417 let mut preserving = Renderer::new(Vec::new());
3418 preserving.set_screen_to_scrollback(true);
3419 preserving
3420 .present(document(&["one", "two"]), 2, 0)
3421 .expect("scrollback-preserving paint succeeds");
3422 let preserving_output =
3423 String::from_utf8(preserving.writer_mut().clone()).expect("ANSI is UTF-8");
3424 assert!(preserving_output.contains("\x1b[22J\x1b[2J\x1b[H"));
3425 }
3426
3427 #[test]
3428 fn soft_wrapped_rows_join_on_screen_and_in_history() {
3429 let mut renderer = Renderer::new(Vec::new());
3430 let mut terminal = TerminalModel::new(8, 3);
3431
3432 renderer
3434 .present(soft_document(&["abcdefgh", "ij", "tail"], &[0]), 3, 0)
3435 .expect("initial paint succeeds");
3436 apply_paint(&mut renderer, &mut terminal);
3437 assert!(terminal.row_wrapped(1), "the continuation row carries the wrap attribute");
3438 assert_eq!(terminal.visible_rows(), ["abcdefgh", "ij", "tail"]);
3439
3440 renderer
3443 .present(soft_document(&["abcdefgh", "ij", "tail", "x", "y"], &[0]), 3, 4)
3444 .expect("growth paint succeeds");
3445 apply_paint(&mut renderer, &mut terminal);
3446 assert_eq!(terminal.history, ["abcdefghij"]);
3447 assert_eq!(terminal.visible_rows(), ["tail", "x", "y"]);
3448 }
3449
3450 #[test]
3451 fn scroll_append_arms_joins_for_committed_pairs() {
3452 let mut renderer = Renderer::new(Vec::new());
3453 let mut terminal = TerminalModel::new(8, 3);
3454 renderer
3455 .present(document(&["one", "two", "three"]), 3, 3)
3456 .expect("initial paint succeeds");
3457 apply_paint(&mut renderer, &mut terminal);
3458
3459 renderer
3460 .present(soft_document(&["one", "two", "three", "abcdefgh", "ij"], &[3]), 3, 5)
3461 .expect("scroll paint succeeds");
3462 let output = String::from_utf8(std::mem::take(renderer.writer_mut())).expect("UTF-8");
3463 terminal.apply(&output);
3464 assert!(output.contains("\x1b[?7h"), "committing a joined pair enables autowrap");
3465 assert!(output.contains("\x1b[?7l"), "autowrap is restored after the commit");
3466 assert_eq!(terminal.history, ["one", "two"]);
3467 assert_eq!(terminal.visible_rows(), ["three", "abcdefgh", "ij"]);
3468 assert!(terminal.row_wrapped(2), "the committed continuation soft-wraps on screen");
3469
3470 renderer
3471 .present(soft_document(&["one", "two", "three", "abcdefgh", "ij", "z", "w"], &[3]), 3, 7)
3472 .expect("second growth succeeds");
3473 apply_paint(&mut renderer, &mut terminal);
3474 assert_eq!(terminal.history, ["one", "two", "three", "abcdefgh"]);
3475
3476 renderer
3477 .present(
3478 soft_document(&["one", "two", "three", "abcdefgh", "ij", "z", "w", "v", "u"], &[3]),
3479 3,
3480 9,
3481 )
3482 .expect("third growth succeeds");
3483 apply_paint(&mut renderer, &mut terminal);
3484 assert_eq!(
3485 terminal.history,
3486 ["one", "two", "three", "abcdefghij", "z"],
3487 "the soft pair merges as it scrolls into history"
3488 );
3489 assert_eq!(terminal.visible_rows(), ["w", "v", "u"]);
3490 }
3491
3492 #[test]
3493 fn wrap_boundary_flips_reconcile_in_place() {
3494 let mut renderer = Renderer::new(Vec::new());
3495 let mut terminal = TerminalModel::new(8, 2);
3496 renderer
3497 .present(soft_document(&["abcdefgh", "ij"], &[0]), 2, 0)
3498 .expect("initial paint succeeds");
3499 apply_paint(&mut renderer, &mut terminal);
3500 assert!(terminal.row_wrapped(1));
3501
3502 renderer
3506 .present(document(&["abcdefgh", "ij"]), 2, 0)
3507 .expect("hardening paint succeeds");
3508 let output = String::from_utf8(std::mem::take(renderer.writer_mut())).expect("UTF-8");
3509 terminal.apply(&output);
3510 assert!(!output.contains("\x1b[H"), "reconciliation never clears the viewport");
3511 assert!(output.contains("\x1b[2K"), "the stale soft rows are erased in place");
3512 assert!(!terminal.row_wrapped(1), "the boundary reads hard again");
3513 assert_eq!(terminal.visible_rows(), ["abcdefgh", "ij"]);
3514
3515 renderer
3518 .present(soft_document(&["abcdefgh", "ij"], &[0]), 2, 0)
3519 .expect("softening paint succeeds");
3520 apply_paint(&mut renderer, &mut terminal);
3521 assert!(terminal.row_wrapped(1), "the boundary soft-wraps again");
3522 assert_eq!(terminal.visible_rows(), ["abcdefgh", "ij"]);
3523 }
3524
3525 #[test]
3526 fn padded_rows_never_join() {
3527 let mut renderer = Renderer::new(Vec::new());
3528 let mut terminal = TerminalModel::new(8, 2);
3529 let stats = renderer
3532 .present(soft_document(&["one", "two"], &[0]), 2, 0)
3533 .expect("paint succeeds");
3534 let output = String::from_utf8(std::mem::take(renderer.writer_mut())).expect("UTF-8");
3535 terminal.apply(&output);
3536 assert!(!output.contains("\x1b[?7h"));
3537 assert!(!terminal.row_wrapped(1));
3538 assert!(!stats.full_repaint || stats.committed_rows == 0);
3539
3540 let mut second = Renderer::new(Vec::new());
3542 second
3543 .present(soft_document(&["one", "two"], &[0]), 2, 0)
3544 .expect("paint succeeds");
3545 second.writer_mut().clear();
3546 let stats = second
3547 .present(document(&["one", "two"]), 2, 0)
3548 .expect("unflagged repaint succeeds");
3549 assert_eq!(stats.runs, 0, "an unjoinable flag change emits nothing");
3550 }
3551 #[test]
3552 fn repeated_seam_advances_preserve_one_ordered_history_copy() {
3553 let mut renderer = Renderer::new(Vec::new());
3554 let mut terminal = TerminalModel::new(8, 3);
3555
3556 renderer
3557 .present(document(&["row00", "row01", "work02", "work03", "footer"]), 3, 2)
3558 .expect("initial paint succeeds");
3559 apply_paint(&mut renderer, &mut terminal);
3560
3561 renderer
3562 .present(document(&["row00", "row01", "row02", "work03", "work04", "footer"]), 3, 3)
3563 .expect("first seam advance succeeds");
3564 apply_paint(&mut renderer, &mut terminal);
3565
3566 renderer
3567 .present(
3568 document(&["row00", "row01", "row02", "row03", "work04", "work05", "footer"]),
3569 3,
3570 4,
3571 )
3572 .expect("second seam advance succeeds");
3573 apply_paint(&mut renderer, &mut terminal);
3574
3575 renderer
3576 .present(
3577 document(&["row00", "row01", "row02", "row03", "row04", "work05", "work06", "footer"]),
3578 3,
3579 5,
3580 )
3581 .expect("third seam advance succeeds");
3582 apply_paint(&mut renderer, &mut terminal);
3583
3584 assert_eq!(terminal.history, ["row00", "row01", "row02", "row03", "row04"]);
3585 assert_eq!(terminal.visible_rows(), ["work05", "work06", "footer"]);
3586 }
3587
3588 #[test]
3589 fn initial_paint_commits_only_stable_overflow() {
3590 let mut renderer = Renderer::new(Vec::new());
3591
3592 let stats = renderer
3593 .present(document(&["one", "two", "three", "four"]), 2, 1)
3594 .expect("paint succeeds");
3595
3596 assert!(stats.full_repaint);
3597 assert_eq!(stats.committed_rows, 1);
3598 assert_eq!(stats.clipped_rows, 1);
3599 assert_eq!(renderer.committed_rows(), 1);
3600 }
3601
3602 #[test]
3603 fn clipped_stable_growth_is_deferred_without_replay() {
3604 let mut renderer = Renderer::new(Vec::new());
3605 let mut terminal = TerminalModel::new(8, 2);
3606 renderer
3607 .present(document(&["one", "two", "three", "four", "five"]), 2, 1)
3608 .expect("initial paint succeeds");
3609 apply_paint(&mut renderer, &mut terminal);
3610
3611 let stats = renderer
3612 .present(document(&["one", "two", "three", "FOUR", "five", "six", "seven"]), 2, 2)
3613 .expect("clipped stable growth succeeds");
3614 let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3615 terminal.apply(&output);
3616
3617 assert_eq!(stats.committed_rows, 0);
3618 assert_eq!(stats.clipped_rows, 4);
3619 assert_eq!(renderer.committed_rows(), 1);
3620 assert_eq!(output.matches("\r\n").count(), 0);
3621 assert_eq!(terminal.history, ["one"]);
3622 assert_eq!(terminal.visible_rows(), ["six", "seven"]);
3623
3624 renderer.writer_mut().clear();
3625 let error = renderer
3626 .present(document(&["one", "TWO", "three", "FOUR", "five", "six", "seven"]), 2, 2)
3627 .expect_err("deferred stable rows remain immutable");
3628 assert_eq!(error.kind(), ErrorKind::InvalidData);
3629 assert_eq!(renderer.writer_mut().as_slice(), &[] as &[u8]);
3630 }
3631
3632 #[test]
3633 fn visible_mutation_uses_relative_cursor_without_scrolling() {
3634 let mut renderer = Renderer::new(Vec::new());
3635 renderer
3636 .present(document(&["one", "two", "three", "four"]), 2, 2)
3637 .expect("first paint succeeds");
3638 renderer.writer_mut().clear();
3639
3640 let stats = renderer
3641 .present(document(&["one", "two", "THREE", "four"]), 2, 2)
3642 .expect("diff succeeds");
3643 let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3644
3645 assert_eq!(stats.committed_rows, 0);
3646 assert!(stats.changed_cells > 0);
3647 assert!(output.contains("\x1b[1A\r"));
3648 assert!(output.contains("\x1b[1B\r"));
3649 assert!(!output.contains("\r\n"));
3650 assert!(!output.contains("\x1b[1;"));
3651 assert!(!output.contains("\x1b[2;"));
3652 assert!(!output.contains("\x1b[2J"));
3653 assert!(!output.contains("\x1b[3J"));
3654 }
3655
3656 #[test]
3657 fn committed_mutation_is_rejected_without_output() {
3658 let mut renderer = Renderer::new(Vec::new());
3659 renderer
3660 .present(document(&["one", "two", "three", "four"]), 2, 2)
3661 .expect("first paint succeeds");
3662 renderer.writer_mut().clear();
3663
3664 let error = renderer
3665 .present(document(&["ONE", "two", "three", "four"]), 2, 2)
3666 .expect_err("stable mutation must fail");
3667
3668 assert_eq!(error.kind(), ErrorKind::InvalidData);
3669 assert_eq!(renderer.writer_mut().as_slice(), &[] as &[u8]);
3670 assert_eq!(renderer.committed_rows(), 2);
3671 }
3672
3673 #[test]
3674 fn damaged_stable_mutation_is_rejected_without_output() {
3675 let mut renderer = Renderer::new(Vec::new());
3676 let initial = document(&["one", "two", "three", "four"]);
3677 renderer
3678 .present_damaged(&initial, &[(0, 4)], 2, 2)
3679 .expect("first paint succeeds");
3680 renderer.writer_mut().clear();
3681
3682 let changed = document(&["ONE", "two", "three", "four"]);
3683 let error = renderer
3684 .present_damaged(&changed, &[(0, 1)], 2, 2)
3685 .expect_err("reported stable mutation must fail");
3686
3687 assert_eq!(error.kind(), ErrorKind::InvalidData);
3688 assert_eq!(renderer.writer_mut().as_slice(), &[] as &[u8]);
3689 assert_eq!(renderer.committed_rows(), 2);
3690 }
3691
3692 #[test]
3693 fn seam_advance_corrects_final_row_then_scrolls_once() {
3694 let mut renderer = Renderer::new(Vec::new());
3695 let mut terminal = TerminalModel::new(8, 2);
3696 renderer
3697 .present(document(&["one", "two", "three", "four"]), 2, 2)
3698 .expect("first paint succeeds");
3699 apply_paint(&mut renderer, &mut terminal);
3700 let stats = renderer
3701 .present(document(&["one", "two", "THREE", "four", "five"]), 2, 3)
3702 .expect("seam commit succeeds");
3703 let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3704
3705 assert_eq!(stats.committed_rows, 1);
3706 assert_eq!(renderer.committed_rows(), 3);
3707 assert_eq!(output.matches("\r\n").count(), 1);
3708 assert!(output.contains(VIEWPORT_BOTTOM));
3709 assert!(output.contains("THREE"));
3710 assert!(output.contains("five"));
3711 terminal.apply(&output);
3712 assert_eq!(terminal.history, ["one", "two", "THREE"]);
3713 assert_eq!(terminal.visible_rows(), ["four", "five"]);
3714 assert!(!output.contains("\x1b[2J"));
3715 assert!(!output.contains("\x1b[3J"));
3716 }
3717
3718 #[test]
3719 fn scroll_append_keeps_adjacent_box_edges_separate() {
3720 let mut renderer = Renderer::new(Vec::new());
3721 let mut terminal = TerminalModel::new(8, 5);
3722 renderer
3723 .present(
3724 document(&["old0", "old1", "╰ live─╯", "", "╭──────╮", "│ body │", "footer"]),
3725 5,
3726 2,
3727 )
3728 .expect("initial paint succeeds");
3729 apply_paint(&mut renderer, &mut terminal);
3730
3731 renderer
3732 .present(
3733 document(&["old0", "old1", "╰──────╯", "", "╭──────╮", "│ body │", "new", "footer"]),
3734 5,
3735 3,
3736 )
3737 .expect("box boundary commit succeeds");
3738 let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3739 terminal.apply(&output);
3740
3741 assert_eq!(output.matches("\r\n").count(), 1);
3742 assert!(output.contains(VIEWPORT_BOTTOM));
3743 assert_eq!(terminal.history, ["old0", "old1", "╰──────╯"]);
3744 assert_eq!(terminal.visible_rows(), ["", "╭──────╮", "│ body │", "new", "footer"]);
3745 }
3746
3747 #[test]
3748 fn margin_commit_scrolls_history_without_touching_pinned_rows() {
3749 let mut renderer = Renderer::new(Vec::new());
3750 renderer.set_margin_scrollback(true);
3751 let mut terminal = TerminalModel::new(8, 4);
3752 renderer
3753 .present(document(&["old0", "old1", "work2", "editor", "footer"]), 4, 2)
3754 .expect("initial paint succeeds");
3755 apply_paint(&mut renderer, &mut terminal);
3756
3757 let stats = renderer
3758 .present(document(&["old0", "old1", "row2", "row3", "editor", "footer"]), 4, 4)
3759 .expect("margin commit succeeds");
3760 let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3761 terminal.apply(&output);
3762
3763 assert_eq!(stats.committed_rows, 1);
3764 assert!(output.contains("\x1b[1;2r"));
3765 assert!(output.contains("\x1b[r"));
3766 assert_eq!(output.matches("\r\n").count(), 1);
3767 assert!(
3768 !output.contains("editor") && !output.contains("footer"),
3769 "pinned rows must never be re-emitted"
3770 );
3771 assert_eq!(terminal.history, ["old0", "old1"]);
3772 assert_eq!(terminal.visible_rows(), ["row2", "row3", "editor", "footer"]);
3773 }
3774
3775 #[test]
3776 fn margin_commit_matches_whole_screen_scroll_end_state() {
3777 let mut margin = Renderer::new(Vec::new());
3778 margin.set_margin_scrollback(true);
3779 let mut plain = Renderer::new(Vec::new());
3780 let mut margin_terminal = TerminalModel::new(8, 4);
3781 let mut plain_terminal = TerminalModel::new(8, 4);
3782
3783 for (renderer, terminal) in
3784 [(&mut margin, &mut margin_terminal), (&mut plain, &mut plain_terminal)]
3785 {
3786 renderer
3787 .present(document(&["old0", "old1", "work2", "editor", "footer"]), 4, 2)
3788 .expect("initial paint succeeds");
3789 apply_paint(renderer, terminal);
3790 renderer
3791 .present(document(&["old0", "old1", "row2", "row3", "editor", "footer"]), 4, 4)
3792 .expect("commit succeeds");
3793 apply_paint(renderer, terminal);
3794 }
3795
3796 assert_eq!(margin_terminal.history, plain_terminal.history);
3797 assert_eq!(margin_terminal.visible_rows(), plain_terminal.visible_rows());
3798 }
3799
3800 #[test]
3801 fn margin_commit_repaints_changed_live_rows_in_place() {
3802 let mut renderer = Renderer::new(Vec::new());
3803 renderer.set_margin_scrollback(true);
3804 let mut terminal = TerminalModel::new(8, 4);
3805 renderer
3806 .present(document(&["old0", "old1", "work2", "spin0", "footer"]), 4, 2)
3807 .expect("initial paint succeeds");
3808 apply_paint(&mut renderer, &mut terminal);
3809
3810 renderer
3811 .present(document(&["old0", "old1", "row2", "row3", "pulse", "footer"]), 4, 4)
3812 .expect("margin commit succeeds");
3813 let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3814 terminal.apply(&output);
3815
3816 assert!(output.contains("\x1b[1;2r"), "animated live rows must not shrink the pin");
3817 assert!(output.contains("pulse"), "changed live cells repaint in place");
3818 assert!(!output.contains("footer"), "unchanged pinned rows stay untouched");
3819 assert_eq!(terminal.history, ["old0", "old1"]);
3820 assert_eq!(terminal.visible_rows(), ["row2", "row3", "pulse", "footer"]);
3821 }
3822
3823 #[test]
3824 fn margin_commit_falls_back_when_stable_seam_reaches_screen_bottom() {
3825 let mut renderer = Renderer::new(Vec::new());
3826 renderer.set_margin_scrollback(true);
3827 let mut terminal = TerminalModel::new(8, 4);
3828 renderer
3829 .present(document(&["old0", "old1", "work2", "editor", "footer"]), 4, 2)
3830 .expect("initial paint succeeds");
3831 apply_paint(&mut renderer, &mut terminal);
3832
3833 renderer
3834 .present(document(&["old0", "old1", "row2", "row3", "editor", "footer"]), 4, 6)
3835 .expect("fully stable commit succeeds");
3836 let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3837 terminal.apply(&output);
3838
3839 assert!(
3840 !output.contains("\x1b[1;"),
3841 "a seam at the screen bottom must use the whole-screen scroll"
3842 );
3843 assert_eq!(terminal.history, ["old0", "old1"]);
3844 assert_eq!(terminal.visible_rows(), ["row2", "row3", "editor", "footer"]);
3845 }
3846
3847 #[test]
3848 fn growing_mutable_suffix_is_clipped_without_committing_snapshots() {
3849 let mut renderer = Renderer::new(Vec::new());
3850 renderer
3851 .present(document(&["one", "two", "three", "four"]), 2, 2)
3852 .expect("first paint succeeds");
3853 renderer.writer_mut().clear();
3854
3855 let stats = renderer
3856 .present(document(&["one", "two", "three", "four", "five"]), 2, 2)
3857 .expect("virtual shift succeeds");
3858 let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3859
3860 assert_eq!(stats.committed_rows, 0);
3861 assert_eq!(stats.clipped_rows, 1);
3862 assert_eq!(renderer.committed_rows(), 2);
3863 assert!(stats.changed_cells > 0);
3864 assert!(output.contains("\x1b[1A\r"));
3865 assert!(output.contains("\x1b[1C"));
3866 assert!(!output.contains("\x1b[1;"));
3867 assert!(!output.contains("\x1b[2;"));
3868 assert!(!output.contains("\r\n"));
3869 }
3870
3871 #[test]
3872 fn live_collapse_below_history_is_rejected_until_rebuild() {
3873 let mut renderer = Renderer::new(Vec::new());
3874 renderer
3875 .present(document(&["one", "two", "three", "four"]), 2, 2)
3876 .expect("first paint succeeds");
3877 renderer.writer_mut().clear();
3878
3879 let error = renderer
3880 .present(document(&["one", "two", "three"]), 2, 2)
3881 .expect_err("a document tail shorter than committed history must be rejected");
3882 assert_eq!(error.kind(), ErrorKind::InvalidData);
3883 assert_eq!(renderer.writer_mut().as_slice(), &[] as &[u8]);
3884
3885 renderer
3886 .rebuild(document(&["one", "two", "three"]), 2, 2, "")
3887 .expect("rebuild accepts the shorter document");
3888 let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3889 assert!(output.contains("\x1b[3J"));
3890 assert_eq!(renderer.committed_rows(), 1);
3891 }
3892
3893 #[test]
3894 fn stable_boundary_cannot_retreat() {
3895 let mut renderer = Renderer::new(Vec::new());
3896 renderer
3897 .present(document(&["one", "two", "three"]), 2, 2)
3898 .expect("first paint succeeds");
3899 renderer.writer_mut().clear();
3900
3901 let error = renderer
3902 .present(document(&["one", "two", "three"]), 2, 1)
3903 .expect_err("retreat must fail");
3904
3905 assert_eq!(error.kind(), ErrorKind::InvalidData);
3906 assert_eq!(renderer.writer_mut().as_slice(), &[] as &[u8]);
3907 }
3908
3909 #[test]
3910 fn resize_preview_leaves_normal_renderer_state_untouched() {
3911 let mut renderer = Renderer::new(Vec::new());
3912 renderer
3913 .present(document(&["row00", "row01", "work02", "footer"]), 2, 2)
3914 .expect("initial paint succeeds");
3915 renderer.writer_mut().clear();
3916
3917 let preview = document(&["new00", "new01", "live02", "live03", "footer"]);
3918 let stats = renderer
3919 .preview(&preview, 3, "\x1b[?1049h")
3920 .expect("alternate viewport preview succeeds");
3921 let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3922
3923 assert!(stats.full_repaint);
3924 assert_eq!(renderer.committed_rows(), 2);
3925 assert!(output.contains("\x1b[?1049h"));
3926 assert!(output.contains("live02"));
3927 assert!(output.contains("footer"));
3928 assert!(!output.contains("new00"));
3929 assert!(!output.contains("\x1b[3J"));
3930
3931 renderer.writer_mut().clear();
3932 let stats = renderer
3933 .present(document(&["row00", "row01", "work02", "footer"]), 2, 2)
3934 .expect("normal state still matches its pre-preview frame");
3935 assert_eq!(stats.bytes, 0);
3936 }
3937
3938 #[test]
3939 fn settled_resize_clears_and_rebuilds_history_once() {
3940 let mut renderer = Renderer::new(Vec::new());
3941 let mut terminal = TerminalModel::new(8, 3);
3942 renderer
3943 .present(document(&["old00", "old01", "old02", "old03", "old04"]), 3, 4)
3944 .expect("initial paint succeeds");
3945 apply_paint(&mut renderer, &mut terminal);
3946 assert_eq!(terminal.history, ["old00", "old01"]);
3947
3948 terminal.resize(8, 2);
3949 let stats = renderer
3950 .rebuild(document(&["new00", "new01", "new02", "live03", "footer"]), 2, 3, "\x1b[?1049l")
3951 .expect("settled resize rebuild succeeds");
3952 let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3953
3954 let sync = output.find(SYNC_OUTPUT_BEGIN).expect("synchronized paint");
3955 let alt_exit = output.find("\x1b[?1049l").expect("alternate-buffer exit");
3956 let clear = output.find(REBUILD_HISTORY).expect("history clear");
3957 assert!(sync < alt_exit && alt_exit < clear);
3958 assert_eq!(output.matches("\x1b[3J").count(), 1);
3959 assert!(!output.contains("\x1b[2J"));
3960 assert!(stats.full_repaint);
3961 assert_eq!(stats.committed_rows, 3);
3962
3963 apply_paint(&mut renderer, &mut terminal);
3964 assert_eq!(terminal.history, ["new00", "new01", "new02"]);
3965 assert_eq!(terminal.visible_rows(), ["live03", "footer"]);
3966
3967 let stats = renderer
3968 .present(document(&["new00", "new01", "new02", "live03", "footer"]), 2, 3)
3969 .expect("incremental rendering resumes from rebuilt state");
3970 assert_eq!(stats.bytes, 0);
3971
3972 let stats = renderer
3973 .present(document(&["new00", "new01", "new02", "new03", "live04", "footer"]), 2, 4)
3974 .expect("immutable seam advances after the rebuild");
3975 let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3976 assert_eq!(stats.committed_rows, 1);
3977 assert!(!output.contains("\x1b[3J"));
3978
3979 apply_paint(&mut renderer, &mut terminal);
3980 assert_eq!(terminal.history, ["new00", "new01", "new02", "new03"]);
3981 assert_eq!(terminal.visible_rows(), ["live04", "footer"]);
3982 }
3983
3984 #[test]
3985 fn hardware_cursor_moves_without_repainting_cells() {
3986 let mut renderer = Renderer::new(Vec::new());
3987 let mut first = document(&["one", "two"]);
3988 first.set_cursor(1, 1);
3989 renderer.present(first, 2, 2).expect("first paint succeeds");
3990 renderer.writer_mut().clear();
3991
3992 let mut second = document(&["one", "two"]);
3993 second.set_cursor(4, 0);
3994 let stats = renderer
3995 .present(second, 2, 2)
3996 .expect("cursor move succeeds");
3997 let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3998
3999 assert_eq!(stats.changed_cells, 0);
4000 assert_eq!(stats.runs, 0);
4001 assert!(stats.bytes > 0);
4002 assert!(output.contains("\x1b[?25l"));
4003 assert!(output.contains("\x1b[1A\r\x1b[4C\x1b[?25h"));
4004 assert!(!output.contains("\r\n"));
4005 assert!(!output.contains("\x1b[H"));
4006 }
4007
4008 #[test]
4009 fn identical_document_writes_nothing() {
4010 let mut renderer = Renderer::new(Vec::new());
4011 renderer
4012 .present(document(&["one", "two"]), 2, 2)
4013 .expect("first paint succeeds");
4014 renderer.writer_mut().clear();
4015
4016 let stats = renderer
4017 .present(document(&["one", "two"]), 2, 2)
4018 .expect("second paint succeeds");
4019
4020 assert_eq!(stats.bytes, 0);
4021 assert_eq!(renderer.writer_mut().as_slice(), &[] as &[u8]);
4022 }
4023
4024 #[test]
4025 fn kitty_images_upload_place_and_materialize_typed_cells() {
4026 let mut frame = Frame::new(Size::new(3, 2));
4027 frame.put_image_cell(0, 0, 0x12_34_56, 0, 0, 2, 3);
4028 frame.put_image_cell(2, 1, 0x12_34_56, 1, 2, 2, 3);
4029 let mut renderer = Renderer::new(Vec::new());
4030 renderer
4031 .register_image(0x12_34_56, vec![0x5a; 3073])
4032 .unwrap();
4033 renderer.present(frame, 2, 0).unwrap();
4034 let output = String::from_utf8(renderer.into_inner()).unwrap();
4035
4036 assert!(output.contains("\x1b_Gf=100,t=d,a=t,i=1193046,q=2,m=1;"));
4039 assert!(output.contains("\x1b_Gm=0;"));
4040 assert!(output.contains("\x1b_Ga=p,U=1,i=1193046,p=1027,r=2,c=3,q=2\x1b\\"));
4041 assert!(output.contains("\u{10eeee}\u{0305}\u{0305}"));
4042 assert!(output.contains("\u{10eeee}\u{030d}\u{030e}"));
4043 assert!(output.contains("38;2;18;52;86m"));
4046 assert!(output.contains("58:2::0:4:3"));
4047
4048 let packets = output
4049 .split("\x1b\\")
4050 .filter_map(|piece| piece.find("\x1b_G").map(|start| &piece[start..]))
4051 .filter(|packet| packet.contains(';'))
4052 .collect::<Vec<_>>();
4053 assert_eq!(packets.len(), 2);
4054 assert!(
4055 packets
4056 .iter()
4057 .all(|packet| packet.split_once(';').unwrap().1.len() <= 4096)
4058 );
4059 }
4060
4061 #[test]
4062 fn clipped_kitty_image_uses_full_placement_without_scroll_rescaling() {
4063 fn clipped_image(first_row: u16) -> Frame {
4064 let mut frame = Frame::new(Size::new(8, 4));
4065 for row in first_row..4 {
4066 for col in 0..8 {
4067 frame.put_image_cell(col, row, 7, row, col, 4, 8);
4068 }
4069 }
4070 frame
4071 }
4072
4073 let mut renderer = Renderer::new(Vec::new());
4074 renderer.register_image(7, vec![1, 2, 3]).unwrap();
4075 renderer.present(clipped_image(3), 4, 0).unwrap();
4076 let initial = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4077 assert!(initial.contains("\x1b_Ga=p,U=1,i=7,p=2056,r=4,c=8,q=2\x1b\\"));
4078
4079 for first_row in (0..3).rev() {
4080 renderer.present(clipped_image(first_row), 4, 0).unwrap();
4081 let output = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4082 assert!(
4083 !output.contains("\x1b_Ga=p"),
4084 "declared 4x8 dimensions must not be re-placed when row {first_row} enters"
4085 );
4086 }
4087 }
4088
4089 #[test]
4090 fn distinct_cell_boxes_of_one_image_place_once_each_with_stable_ids() {
4091 let mut frame = Frame::new(Size::new(8, 3));
4093 for col in 0..2 {
4094 frame.put_image_cell(col, 0, 7, 0, col, 1, 2);
4095 }
4096 for row in 0..2 {
4097 for col in 0..4 {
4098 frame.put_image_cell(col, 1 + row, 7, row, col, 2, 4);
4099 }
4100 }
4101 let mut renderer = Renderer::new(Vec::new());
4102 renderer
4103 .register_image(7, b"\x89PNG\r\n\x1a\nsmall".to_vec())
4104 .unwrap();
4105 renderer.present(frame.clone(), 3, 0).unwrap();
4106 let output = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4107 assert_eq!(output.matches("a=t").count(), 1, "one upload serves every box");
4108 assert!(output.contains("\x1b_Ga=p,U=1,i=7,p=514,r=1,c=2,q=2\x1b\\"));
4109 assert!(output.contains("\x1b_Ga=p,U=1,i=7,p=1028,r=2,c=4,q=2\x1b\\"));
4110 assert!(output.contains("58:2::0:2:2"));
4113 assert!(output.contains("58:2::0:4:4"));
4114
4115 renderer.present(frame, 3, 0).unwrap();
4117 let output = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4118 assert!(!output.contains("\x1b_G"), "no image traffic on a settled frame: {output:?}");
4119 }
4120
4121 #[test]
4122 fn screen_buffer_switch_retransmits_and_replaces_images() {
4123 let mut frame = Frame::new(Size::new(8, 3));
4128 for col in 0..2 {
4129 frame.put_image_cell(col, 0, 7, 0, col, 1, 2);
4130 }
4131 let mut renderer = Renderer::new(Vec::new());
4132 renderer
4133 .register_image(7, b"\x89PNG\r\n\x1a\nsmall".to_vec())
4134 .unwrap();
4135 renderer.present(frame.clone(), 3, 0).unwrap();
4136 let output = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4137 assert_eq!(output.matches("a=t").count(), 1, "first paint uploads: {output:?}");
4138
4139 renderer.preview(&frame, 3, "").unwrap();
4140 let output = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4141 assert!(!output.contains("\x1b_G"), "no retransmit within one buffer: {output:?}");
4142
4143 renderer.set_screen_buffer(true);
4144 renderer.preview(&frame, 3, "").unwrap();
4145 let output = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4146 assert_eq!(
4147 output.matches("a=t").count(),
4148 1,
4149 "a buffer switch re-uploads to the new screen's store: {output:?}"
4150 );
4151 assert!(
4152 output.contains("\x1b_Ga=p,U=1,i=7,p=514,r=1,c=2,q=2\x1b\\"),
4153 "virtual placements are re-created after a switch: {output:?}"
4154 );
4155
4156 renderer.preview(&frame, 3, "").unwrap();
4157 let output = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4158 assert!(!output.contains("\x1b_G"), "caches hold once settled: {output:?}");
4159 }
4160
4161 #[test]
4162 fn alt_entry_preview_uploads_overlay_only_images_after_the_switch() {
4163 let base = document(&["base000", "base111"]);
4169 let mut overlay = Frame::new(Size::new(3, 1));
4170 for col in 0..2 {
4171 overlay.put_image_cell(col, 0, 7, 0, col, 1, 2);
4172 }
4173 let layer = ResolvedLayer {
4174 frame: &overlay,
4175 x: 0,
4176 y: 0,
4177 src_top: 0,
4178 rows: 1,
4179 active: false,
4180 };
4181 let mut renderer = Renderer::new(Vec::new());
4182 renderer
4183 .register_image(7, b"\x89PNG\r\n\x1a\nsmall".to_vec())
4184 .unwrap();
4185
4186 renderer
4187 .preview_resolved(&base, &[layer], 2, "\x1b[?1049h")
4188 .unwrap();
4189 let output = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4190 let switch = output
4191 .find("\x1b[?1049h")
4192 .expect("staged alt entry is emitted");
4193 let upload = output
4194 .find("\x1b_Gf=100,t=d,a=t")
4195 .expect("overlay-only image uploads");
4196 assert!(switch < upload, "upload must follow the buffer switch: {output:?}");
4197 assert!(
4198 output.contains("\x1b_Ga=p,U=1,i=7,p=514,r=1,c=2,q=2\x1b\\"),
4199 "overlay-only image is placed: {output:?}"
4200 );
4201
4202 renderer.preview_resolved(&base, &[layer], 2, "").unwrap();
4203 let output = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4204 assert!(!output.contains("\x1b_G"), "steady overlay preview stays quiet: {output:?}");
4205
4206 renderer.set_screen_buffer(true);
4209 renderer
4210 .preview_resolved(&base, &[layer], 2, "\x1b[?1049l")
4211 .unwrap();
4212 let output = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4213 let switch = output
4214 .find("\x1b[?1049l")
4215 .expect("staged alt exit is emitted");
4216 let upload = output
4217 .find("\x1b_Gf=100,t=d,a=t")
4218 .expect("the other buffer needs its own upload");
4219 assert!(switch < upload, "re-upload must follow the buffer switch: {output:?}");
4220 }
4221
4222 #[test]
4223 fn staged_buffer_switch_precedes_image_uploads() {
4224 let mut frame = Frame::new(Size::new(8, 3));
4228 for col in 0..2 {
4229 frame.put_image_cell(col, 0, 7, 0, col, 1, 2);
4230 }
4231 let mut renderer = Renderer::new(Vec::new());
4232 renderer
4233 .register_image(7, b"\x89PNG\r\n\x1a\nsmall".to_vec())
4234 .unwrap();
4235 renderer.present(frame.clone(), 3, 0).unwrap();
4236 renderer.writer_mut().clear();
4237
4238 renderer.set_screen_buffer(true);
4239 renderer.preview(&frame, 3, "\x1b[?1049h").unwrap();
4240 let output = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4241 let switch = output.find("\x1b[?1049h").expect("staged entry is emitted");
4242 let upload = output.find("a=t").expect("the new screen needs an upload");
4243 assert!(switch < upload, "upload lands on the freshly entered screen: {output:?}");
4244
4245 renderer.set_screen_buffer(true);
4248 renderer.rebuild(frame, 3, 0, "\x1b[?1049l").unwrap();
4249 let output = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4250 let switch = output.find("\x1b[?1049l").expect("staged exit is emitted");
4251 let upload = output
4252 .find("a=t")
4253 .expect("the restored screen needs an upload");
4254 assert!(switch < upload, "upload lands on the restored screen: {output:?}");
4255 let clear = output
4256 .find(REBUILD_HISTORY)
4257 .expect("history clear is emitted");
4258 assert!(clear < upload, "upload must follow the history clear: {output:?}");
4259 }
4260 #[test]
4261 fn kitty_direct_crops_replaces_without_retransmit_and_deletes_offscreen() {
4262 fn png_fixture() -> Vec<u8> {
4263 let mut bytes = Vec::new();
4264 {
4265 let mut encoder = png::Encoder::new(&mut bytes, 2, 4);
4266 encoder.set_color(png::ColorType::Rgb);
4267 encoder.set_depth(png::BitDepth::Eight);
4268 let mut writer = encoder.write_header().unwrap();
4269 writer.write_image_data(&[0x7f; 24]).unwrap();
4270 }
4271 bytes
4272 }
4273
4274 fn image_frame(top: u16) -> Frame {
4275 let mut frame = Frame::new(Size::new(4, 6));
4276 for row in 0..4 {
4277 let y = top + row;
4278 if y >= frame.size().height {
4279 break;
4280 }
4281 frame.put(0, y, "L", Style::default());
4282 for col in 0..2 {
4283 frame.put_image_cell(1 + col, y, 7, row, col, 4, 2);
4284 }
4285 frame.put(3, y, "R", Style::default());
4286 }
4287 frame
4288 }
4289
4290 let mut renderer = Renderer::new(Vec::new());
4291 renderer.set_graphics(Graphics::KittyDirect);
4292 renderer.set_cell_pixel_size(1, 1).unwrap();
4293 renderer.register_image(7, png_fixture()).unwrap();
4294
4295 renderer.present(image_frame(0), 4, 0).unwrap();
4296 let clipped = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4297 assert_eq!(clipped.matches("a=t").count(), 1);
4298 assert!(
4299 clipped
4300 .contains("\x1b[3A\r\x1b[1C\x1b_Ga=p,q=2,C=1,i=7,p=7,x=0,y=2,w=2,h=2,c=2,r=2\x1b\\")
4301 );
4302 assert!(clipped.contains("L R"));
4303 assert!(!clipped.contains("\u{10eeee}"));
4304
4305 renderer.present(image_frame(2), 4, 0).unwrap();
4306 let fully_visible = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4307 assert!(!fully_visible.contains("a=t"));
4308 assert!(
4309 fully_visible
4310 .contains("\x1b[3A\r\x1b[1C\x1b_Ga=p,q=2,C=1,i=7,p=7,x=0,y=0,w=2,h=4,c=2,r=4\x1b\\")
4311 );
4312
4313 renderer.present(Frame::new(Size::new(4, 6)), 4, 0).unwrap();
4314 let offscreen = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4315 assert!(offscreen.contains("\x1b_Ga=d,d=I,i=7,q=2\x1b\\"));
4316 }
4317
4318 #[test]
4319 fn tmux_passthrough_wraps_kitty_packets_but_not_text_sgr() {
4320 let mut frame = Frame::new(Size::new(2, 1));
4321 frame.put_image_cell(0, 0, 7, 0, 0, 1, 1);
4322 frame.put(1, 0, "X", Style::new().fg(Color::Rgb(1, 2, 3)));
4323 let mut renderer = Renderer::new(Vec::new());
4324 renderer.set_graphics(Graphics::KittyDirect);
4325 renderer.set_tmux_passthrough(true);
4326 renderer.set_cell_pixel_size(1, 1).unwrap();
4327 renderer.register_image(7, [1, 2, 3]).unwrap();
4328
4329 renderer.present(frame, 1, 0).unwrap();
4330 let output = String::from_utf8(renderer.into_inner()).unwrap();
4331 assert!(
4332 output.contains("\x1bPtmux;\x1b\x1b_Gf=100,t=d,a=t,i=7,q=2,m=0;AQID\x1b\x1b\\\x1b\\")
4333 );
4334 assert!(output.contains(
4335 "\x1bPtmux;\x1b\x1b_Ga=p,q=2,C=1,i=7,p=7,x=0,y=0,w=1,h=1,c=1,r=1\x1b\x1b\\\x1b\\"
4336 ));
4337 assert_eq!(output.matches("\x1bPtmux;").count(), output.matches("_G").count());
4338 assert!(output.contains("\x1b[38;2;1;2;3mX"));
4339 assert!(!output.contains("\x1bPtmux;\x1b\x1b[38;2;1;2;3m"));
4340 }
4341
4342 #[test]
4343 fn sixel_images_crop_reemit_and_leave_text_cells_intact() {
4344 fn png_fixture() -> Vec<u8> {
4345 let mut bytes = Vec::new();
4346 {
4347 let mut encoder = png::Encoder::new(&mut bytes, 4, 2);
4348 encoder.set_color(png::ColorType::Rgb);
4349 encoder.set_depth(png::BitDepth::Eight);
4350 let mut writer = encoder.write_header().unwrap();
4351 writer
4352 .write_image_data(&[
4353 255, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 255, 255, 0, 0, 255, 0, 0, 0, 0, 255, 0,
4354 0, 255,
4355 ])
4356 .unwrap();
4357 }
4358 bytes
4359 }
4360
4361 fn image_frame(top: u16, height: u16) -> Frame {
4362 let mut frame = Frame::new(Size::new(10, height));
4363 for row in 0..4 {
4364 let y = top + row;
4365 if y >= frame.size().height {
4366 break;
4367 }
4368 frame.put(0, y, "L", Style::default());
4369 for col in 0..8 {
4370 frame.put_image_cell(1 + col, y, 7, row, col, 4, 8);
4371 }
4372 frame.put(9, y, "R", Style::default());
4373 }
4374 frame
4375 }
4376
4377 let mut renderer = Renderer::new(Vec::new());
4378 renderer.set_graphics(Graphics::Sixel);
4379 renderer.set_cell_pixel_size(1, 1).unwrap();
4380 renderer.register_image(7, png_fixture()).unwrap();
4381
4382 renderer.present(image_frame(0, 6), 4, 0).unwrap();
4383 let clipped = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4384 assert!(clipped.contains("\x1b[3A\r\x1b[1C\x1bP0;1;0q"));
4385 assert!(clipped.contains("\"1;1;8;2"));
4386 assert!(!clipped.contains("\x1b_G"));
4387 assert!(clipped.contains("L R"));
4388
4389 let fully_visible = image_frame(4, 8);
4390 renderer.present(fully_visible.clone(), 4, 0).unwrap();
4391 let moved = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4392 assert!(moved.contains("\x1b[3A\r\x1b[1C\x1bP0;1;0q"));
4393 assert!(moved.contains("\"1;1;8;4"));
4394 assert!(!moved.contains("\x1b_G"));
4395 assert!(moved.contains("L R"));
4396
4397 let stats = renderer.present(fully_visible, 4, 0).unwrap();
4398 assert_eq!(stats.bytes, 0);
4399 assert_eq!(renderer.writer_mut().as_slice(), &[] as &[u8]);
4400
4401 let mut tmux = Renderer::new(Vec::new());
4402 tmux.set_graphics(Graphics::Sixel);
4403 tmux.set_tmux_passthrough(true);
4404 tmux.set_cell_pixel_size(1, 1).unwrap();
4405 tmux.register_image(7, png_fixture()).unwrap();
4406 tmux.present(image_frame(0, 4), 4, 0).unwrap();
4407 let wrapped = String::from_utf8(tmux.into_inner()).unwrap();
4408 assert!(wrapped.contains("\x1bPtmux;\x1b\x1bP0;1;0q"));
4409 assert!(wrapped.contains("\x1b\x1b\\\x1b\\"));
4410 assert!(!wrapped.contains("\x1b_G"));
4411 }
4412}