1use std::cell::{Cell, RefCell};
2use std::collections::{BTreeMap, VecDeque};
3use std::hash::{Hash, Hasher};
4use std::ops::ControlFlow;
5use std::rc::Rc;
6use std::sync::Arc;
7
8use alacritty_terminal::event::{Event as TermEvent, EventListener, WindowSize};
9use alacritty_terminal::grid::{Dimensions, GridCell, Scroll};
10use alacritty_terminal::index::{Column, Line};
11use alacritty_terminal::term::cell::{Cell as TermCell, Flags as CellFlags};
12use alacritty_terminal::term::{self, Config as TermConfig, Term, TermMode};
13use alacritty_terminal::vte::Parser as SemanticVteParser;
14use alacritty_terminal::vte::ansi::Processor as VteProcessor;
15use alacritty_terminal::vte::ansi::{
16 Color as TermColor, CursorShape as TermCursorShape, CursorStyle as TermCursorStyle, NamedColor,
17 Rgb as TermRgb,
18};
19
20use super::events::{
21 KittyKeyboardFlags, MouseEncoding, MouseMode, MouseModeState, TerminalKeyModes,
22 terminal_selection_text_with,
23};
24#[cfg(feature = "terminal-images")]
25use super::graphics::{
26 GraphicsCommand, GraphicsContext, GraphicsScanner, GraphicsSegment, PLACEHOLDER,
27 PlaceholderCell, TerminalGraphics, TerminalImagePlacement,
28};
29use super::osc::{
30 SemanticObserver, TerminalCommandPhase, TerminalSemanticEvent, TerminalSemanticState,
31};
32use super::scrollback_ledger::{LedgerTerm, ledger_capacity, settle_history};
33use super::selection::{ScrollbackLineage, TerminalSelection};
34use crate::style::{CaretShape, Color as UiColor, HostTerminalColors, Span, Style, Theme};
35use crate::utils::{GridPos, GridSelection, SelectionEnd};
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum SemanticMarkKind {
40 Prompt,
42 OutputStart,
44 OutputEnd,
46}
47
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub struct SemanticMark {
52 pub kind: SemanticMarkKind,
54 pub absolute_line: usize,
56 pub exit_status: Option<i32>,
58}
59
60const MAX_SEMANTIC_MARKS: usize = 256;
61
62const DEFAULT_CURSOR_STYLE: TermCursorStyle = TermCursorStyle {
67 shape: TermCursorShape::Block,
68 blinking: true,
69};
70
71fn caret_shape_from_term(shape: TermCursorShape) -> CaretShape {
76 match shape {
77 TermCursorShape::Underline => CaretShape::Underline,
78 TermCursorShape::Beam => CaretShape::Bar,
79 TermCursorShape::Block | TermCursorShape::HollowBlock | TermCursorShape::Hidden => {
80 CaretShape::Block
81 }
82 }
83}
84
85#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87pub struct TerminalViewport {
88 pub cols: u16,
90 pub rows: u16,
92}
93
94struct TermDimensions {
95 rows: usize,
96 cols: usize,
97}
98
99impl Dimensions for TermDimensions {
100 fn total_lines(&self) -> usize {
101 self.rows
102 }
103
104 fn screen_lines(&self) -> usize {
105 self.rows
106 }
107
108 fn columns(&self) -> usize {
109 self.cols
110 }
111}
112
113#[derive(Clone, Copy, Debug, PartialEq, Eq)]
123pub struct TerminalCellSize {
124 pub width: u16,
126 pub height: u16,
128}
129
130impl Default for TerminalCellSize {
131 fn default() -> Self {
136 Self::new(10, 20)
137 }
138}
139
140impl TerminalCellSize {
141 pub fn new(width: u16, height: u16) -> Self {
143 Self {
144 width: width.max(1),
145 height: height.max(1),
146 }
147 }
148}
149
150#[derive(Clone, Default)]
155struct ResponseCapture {
156 responses: Rc<RefCell<Vec<Vec<u8>>>>,
157 bell_count: Rc<Cell<u64>>,
159 title: Rc<RefCell<Option<String>>>,
161 palette: Rc<RefCell<TerminalColorPalette>>,
165 viewport: Rc<Cell<ViewportGeometry>>,
169}
170
171#[derive(Clone, Copy, Debug, Default)]
173struct ViewportGeometry {
174 rows: u16,
175 cols: u16,
176 cell: TerminalCellSize,
177}
178
179impl ResponseCapture {
180 fn resolve_query_color(&self, index: usize) -> TermRgb {
185 let palette = self.palette.borrow();
186 let standard = |i: usize| UiColor::Indexed(i as u8).to_rgb().unwrap_or((0, 0, 0));
187 let (r, g, b) = match index {
188 0..=15 => palette.ansi[index]
189 .to_rgb()
190 .unwrap_or_else(|| standard(index)),
191 16..=255 => standard(index),
192 257 | 268 => palette
193 .background
194 .and_then(UiColor::to_rgb)
195 .unwrap_or((0, 0, 0)),
196 _ => palette
197 .foreground
198 .and_then(UiColor::to_rgb)
199 .unwrap_or((255, 255, 255)),
200 };
201 TermRgb { r, g, b }
202 }
203}
204
205impl EventListener for ResponseCapture {
206 fn send_event(&self, event: TermEvent) {
207 match event {
208 TermEvent::PtyWrite(text) => self.responses.borrow_mut().push(text.into_bytes()),
209 TermEvent::Bell => self.bell_count.set(self.bell_count.get().saturating_add(1)),
210 TermEvent::Title(title) => *self.title.borrow_mut() = Some(title),
211 TermEvent::ResetTitle => *self.title.borrow_mut() = None,
212 TermEvent::ColorRequest(index, formatter) => {
216 let response = formatter(self.resolve_query_color(index));
217 self.responses.borrow_mut().push(response.into_bytes());
218 }
219 TermEvent::TextAreaSizeRequest(formatter) => {
223 let viewport = self.viewport.get();
224 let response = formatter(WindowSize {
225 num_lines: viewport.rows,
226 num_cols: viewport.cols,
227 cell_width: viewport.cell.width,
228 cell_height: viewport.cell.height,
229 });
230 self.responses.borrow_mut().push(response.into_bytes());
231 }
232 _ => {}
234 }
235 }
236}
237
238pub struct TerminalScreen {
240 processor: VteProcessor,
241 term: Term<ResponseCapture>,
242 listener: ResponseCapture,
243 semantic_parser: SemanticVteParser,
249 semantic: SemanticObserver,
250 rows: u16,
252 cols: u16,
254 scrollback_len: usize,
255 ledger_capacity: usize,
257 mouse_mode: MouseModeState,
258 scrollback_offset: usize,
259 cache: TerminalRenderSnapshot,
260 palette: TerminalColorPalette,
261 dirty: bool,
262 sequence: u64,
263 semantic_marks: VecDeque<SemanticMark>,
265 semantic_events_seen: usize,
267 #[cfg(feature = "terminal-images")]
269 graphics_scanner: GraphicsScanner,
270 #[cfg(feature = "terminal-images")]
272 graphics: TerminalGraphics,
273 #[cfg(feature = "terminal-images")]
276 graphics_alt_screen: bool,
277 evicted_lines: u64,
279 history_epoch: u64,
281 alt_screen: bool,
283 cell_size: TerminalCellSize,
285}
286
287#[derive(Clone)]
298pub struct TerminalScreenHandle(Rc<RefCell<TerminalScreen>>);
299
300impl TerminalScreenHandle {
301 pub fn new(screen: Rc<RefCell<TerminalScreen>>) -> Self {
303 Self(screen)
304 }
305
306 pub fn snapshot(&self) -> TerminalRenderSnapshot {
309 self.0.borrow_mut().render_snapshot()
310 }
311
312 pub fn selection_display_text(
314 &self,
315 sel: &TerminalSelection,
316 endpoint: SelectionEnd,
317 trim_row_end: bool,
318 ) -> String {
319 self.0
320 .borrow()
321 .selection_display_text(sel, endpoint, trim_row_end)
322 }
323}
324
325impl PartialEq for TerminalScreenHandle {
329 fn eq(&self, other: &Self) -> bool {
330 Rc::ptr_eq(&self.0, &other.0)
331 }
332}
333
334impl std::fmt::Debug for TerminalScreenHandle {
335 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336 f.debug_struct("TerminalScreenHandle")
337 .finish_non_exhaustive()
338 }
339}
340
341impl From<Rc<RefCell<TerminalScreen>>> for TerminalScreenHandle {
342 fn from(screen: Rc<RefCell<TerminalScreen>>) -> Self {
343 Self::new(screen)
344 }
345}
346
347#[derive(Clone, Debug)]
349pub struct TerminalRenderSnapshot {
350 pub text: Arc<str>,
352 pub color_lines: Arc<[Vec<Span>]>,
354 pub cursor_row: u16,
356 pub cursor_col: u16,
358 pub cursor_visible: bool,
360 pub cursor_shape: CaretShape,
362 pub cursor_blinking: bool,
364 pub sequence: u64,
366 pub scrollback_offset: usize,
368 pub total_scrollback_rows: usize,
370 pub evicted_lines: u64,
372 pub history_epoch: u64,
374 pub mouse_mode: MouseModeState,
376 pub key_modes: TerminalKeyModes,
378 #[cfg(feature = "terminal-images")]
383 pub images: Arc<[TerminalImagePlacement]>,
384}
385
386#[derive(Clone, Debug, PartialEq, Eq, Hash)]
393pub struct TerminalDecoration {
394 row: usize,
395 start_col: usize,
396 end_col: usize,
397 style: Style,
398 insert: Option<Span>,
399}
400
401impl TerminalDecoration {
402 pub fn highlight(row: usize, cols: std::ops::Range<usize>, style: Style) -> Self {
404 Self {
405 row,
406 start_col: cols.start,
407 end_col: cols.end,
408 style,
409 insert: None,
410 }
411 }
412
413 pub fn label(row: usize, col: usize, span: Span) -> Self {
415 Self {
416 row,
417 start_col: col,
418 end_col: col,
419 style: Style::default(),
420 insert: Some(span),
421 }
422 }
423}
424
425impl Default for TerminalRenderSnapshot {
426 fn default() -> Self {
427 Self {
428 text: Arc::from(""),
429 color_lines: Arc::new([vec![Span::new("")]]),
430 cursor_row: 0,
431 cursor_col: 0,
432 cursor_visible: true,
433 cursor_shape: CaretShape::Block,
434 cursor_blinking: true,
435 sequence: 0,
436 scrollback_offset: 0,
437 total_scrollback_rows: 0,
438 evicted_lines: 0,
439 history_epoch: 0,
440 mouse_mode: MouseModeState::default(),
441 key_modes: TerminalKeyModes::default(),
442 #[cfg(feature = "terminal-images")]
443 images: Arc::from([]),
444 }
445 }
446}
447
448impl TerminalRenderSnapshot {
449 #[allow(clippy::too_many_arguments)]
455 pub fn from_parts(
456 text: impl Into<Arc<str>>,
457 color_lines: Vec<Vec<Span>>,
458 cursor_row: u16,
459 cursor_col: u16,
460 cursor_visible: bool,
461 cursor_shape: CaretShape,
462 cursor_blinking: bool,
463 sequence: u64,
464 scrollback_offset: usize,
465 total_scrollback_rows: usize,
466 mouse_mode: MouseModeState,
467 key_modes: TerminalKeyModes,
468 ) -> Self {
469 Self::from_parts_inner(
470 text,
471 color_lines,
472 cursor_row,
473 cursor_col,
474 cursor_visible,
475 cursor_shape,
476 cursor_blinking,
477 sequence,
478 scrollback_offset,
479 total_scrollback_rows,
480 0,
481 0,
482 mouse_mode,
483 key_modes,
484 )
485 }
486
487 pub fn with_scrollback_lineage(mut self, evicted_lines: u64, history_epoch: u64) -> Self {
489 self.evicted_lines = evicted_lines;
490 self.history_epoch = history_epoch;
491 self
492 }
493
494 #[allow(clippy::too_many_arguments)]
495 fn from_parts_inner(
496 text: impl Into<Arc<str>>,
497 color_lines: Vec<Vec<Span>>,
498 cursor_row: u16,
499 cursor_col: u16,
500 cursor_visible: bool,
501 cursor_shape: CaretShape,
502 cursor_blinking: bool,
503 sequence: u64,
504 scrollback_offset: usize,
505 total_scrollback_rows: usize,
506 evicted_lines: u64,
507 history_epoch: u64,
508 mouse_mode: MouseModeState,
509 key_modes: TerminalKeyModes,
510 ) -> Self {
511 Self {
512 text: text.into(),
513 color_lines: Arc::from(color_lines.into_boxed_slice()),
514 cursor_row,
515 cursor_col,
516 cursor_visible,
517 cursor_shape,
518 cursor_blinking,
519 sequence,
520 scrollback_offset,
521 total_scrollback_rows,
522 evicted_lines,
523 history_epoch,
524 mouse_mode,
525 key_modes,
526 #[cfg(feature = "terminal-images")]
527 images: Arc::from([]),
528 }
529 }
530
531 pub fn selection_text(
537 &self,
538 selection: &GridSelection,
539 endpoint: SelectionEnd,
540 trim_row_end: bool,
541 ) -> String {
542 terminal_selection_text_with(&self.color_lines, selection, endpoint, trim_row_end)
543 }
544
545 pub fn decorated(&self, decorations: &[TerminalDecoration]) -> Self {
553 if decorations.is_empty() {
554 return self.clone();
555 }
556
557 let mut hasher = std::collections::hash_map::DefaultHasher::new();
558 self.sequence.hash(&mut hasher);
559 decorations.hash(&mut hasher);
560 let sequence = hasher.finish();
561
562 let mut rows: BTreeMap<usize, Vec<(&TerminalDecoration, usize)>> = BTreeMap::new();
563 for (index, decoration) in decorations.iter().enumerate() {
564 rows.entry(decoration.row)
565 .or_default()
566 .push((decoration, index));
567 }
568
569 let mut color_lines: Vec<Vec<Span>> = self.color_lines.iter().cloned().collect();
570 for (row, decorations) in rows {
571 let Some(line) = color_lines.get_mut(row) else {
572 continue;
573 };
574 let mut sorted = decorations;
575 sorted.sort_by_key(|(decoration, index)| (decoration.start_col, *index));
576
577 let ranges: Vec<_> = sorted
578 .iter()
579 .filter(|(decoration, _)| decoration.insert.is_none())
580 .map(|(decoration, _)| (decoration.start_col..decoration.end_col, decoration.style))
581 .collect();
582 if !ranges.is_empty() {
583 *line = crate::utils::spans::restyle_columns(line, &ranges);
584 }
585
586 for (decoration, _) in sorted
587 .iter()
588 .filter(|(decoration, _)| decoration.insert.is_some())
589 .rev()
590 {
591 if let Some(insert) = decoration.insert.clone() {
592 *line =
593 crate::utils::spans::insert_at_column(line, decoration.start_col, insert);
594 }
595 }
596 }
597
598 Self {
599 color_lines: color_lines.into(),
600 sequence,
601 ..self.clone()
602 }
603 }
604}
605
606#[derive(Clone, Copy, Debug, PartialEq, Eq)]
612pub struct TerminalColorPalette {
613 pub foreground: Option<UiColor>,
615 pub background: Option<UiColor>,
617 pub ansi: [UiColor; 16],
620}
621
622impl Default for TerminalColorPalette {
623 fn default() -> Self {
624 Self {
625 foreground: None,
626 background: None,
627 ansi: default_ansi_palette(),
628 }
629 }
630}
631
632impl TerminalColorPalette {
633 pub fn new(foreground: UiColor, background: UiColor, ansi: [UiColor; 16]) -> Self {
635 Self {
636 foreground: Some(foreground),
637 background: Some(background),
638 ansi,
639 }
640 }
641
642 pub fn from_host_colors(colors: HostTerminalColors, background: UiColor) -> Self {
649 Self::new(colors.fg, background, colors.ansi)
650 }
651
652 pub fn from_theme(theme: &Theme, background: UiColor) -> Self {
659 let resolve_style_fg = |style: Style, fallback: UiColor| {
660 style
661 .resolved_fg()
662 .map(|color| color.resolve(UiColor::Reset))
663 .filter(|color| !color.is_sentinel())
664 .unwrap_or(fallback)
665 };
666 let foreground = resolve_style_fg(theme.primary, UiColor::White);
667 let background = background.resolve(UiColor::Black);
668 if let Some(host_colors) = theme.extension::<HostTerminalColors>() {
669 return Self::from_host_colors(*host_colors, background);
670 }
671
672 let muted = resolve_style_fg(theme.muted, theme.surface.menu.resolve(background));
673 let accent = resolve_style_fg(theme.accent, theme.border_active.resolve(foreground));
674 let error = theme.status.error.resolve(UiColor::Red);
675 let success = theme.status.success.resolve(UiColor::Green);
676 let warning = theme.status.warning.resolve(UiColor::Yellow);
677 let info = theme.status.info.resolve(accent);
678 let purple = theme.file_icons.purple.resolve(UiColor::Magenta);
679 let cyan = theme.file_icons.cyan.resolve(UiColor::Cyan);
680
681 Self::new(
682 foreground,
683 background,
684 [
685 background,
686 error,
687 success,
688 warning,
689 info,
690 purple,
691 cyan,
692 foreground,
693 muted,
694 error.lighten_by(0.18),
695 success.lighten_by(0.18),
696 warning.lighten_by(0.18),
697 accent.lighten_by(0.12),
698 purple.lighten_by(0.18),
699 cyan.lighten_by(0.18),
700 foreground.lighten_by(0.12),
701 ],
702 )
703 }
704
705 pub fn foreground(mut self, color: Option<UiColor>) -> Self {
707 self.foreground = color;
708 self
709 }
710
711 pub fn background(mut self, color: Option<UiColor>) -> Self {
713 self.background = color;
714 self
715 }
716
717 pub fn ansi(mut self, ansi: [UiColor; 16]) -> Self {
719 self.ansi = ansi;
720 self
721 }
722}
723
724impl TerminalScreen {
725 pub fn new(rows: u16, cols: u16, scrollback: usize) -> Self {
727 let rows = rows.max(1);
728 let cols = cols.max(1);
729 let dimensions = TermDimensions {
730 rows: rows as usize,
731 cols: cols as usize,
732 };
733 let capacity = ledger_capacity(scrollback, rows);
737 let config = TermConfig {
738 scrolling_history: capacity,
739 default_cursor_style: DEFAULT_CURSOR_STYLE,
740 kitty_keyboard: true,
743 ..TermConfig::default()
744 };
745 let listener = ResponseCapture::default();
746 let term = Term::new(config, &dimensions, listener.clone());
747 let screen = Self {
748 processor: VteProcessor::new(),
749 term,
750 listener,
751 semantic_parser: SemanticVteParser::new(),
752 semantic: SemanticObserver::default(),
753 rows,
754 cols,
755 scrollback_len: scrollback,
756 ledger_capacity: capacity,
757 mouse_mode: MouseModeState::default(),
758 scrollback_offset: 0,
759 cache: TerminalRenderSnapshot::default(),
760 palette: TerminalColorPalette::default(),
761 dirty: true,
762 sequence: 0,
763 semantic_marks: VecDeque::new(),
764 semantic_events_seen: 0,
765 #[cfg(feature = "terminal-images")]
766 graphics_scanner: GraphicsScanner::default(),
767 #[cfg(feature = "terminal-images")]
768 graphics: TerminalGraphics::default(),
769 #[cfg(feature = "terminal-images")]
770 graphics_alt_screen: false,
771 evicted_lines: 0,
772 history_epoch: 0,
773 alt_screen: false,
774 cell_size: TerminalCellSize::default(),
775 };
776 screen.sync_viewport();
777 screen
778 }
779
780 pub fn process_bytes(&mut self, bytes: &[u8]) {
783 let evicted = self.feed_grid(bytes);
784 if evicted > 0 {
785 self.evicted_lines = self.evicted_lines.saturating_add(evicted as u64);
786 }
787 self.semantic_parser.advance(&mut self.semantic, bytes);
788 self.drop_evicted_semantic_marks(evicted);
789 self.settle_graphics(evicted);
790 let alt_screen = self.term.mode().contains(TermMode::ALT_SCREEN);
791 if self.alt_screen != alt_screen {
792 self.history_epoch = self.history_epoch.saturating_add(1);
793 self.alt_screen = alt_screen;
794 }
795 if self.term.mode().contains(TermMode::ALT_SCREEN) {
796 self.discard_pending_semantic_marks();
801 } else {
802 self.record_semantic_marks_from_pending();
803 }
804 self.scrollback_offset = self.term.grid().display_offset();
805 self.mouse_mode = mouse_mode_from_term(*self.term.mode());
806 self.dirty = true;
807 }
808
809 #[cfg(feature = "terminal-images")]
817 fn feed_grid(&mut self, bytes: &[u8]) -> usize {
818 if self.graphics_scanner.is_plain(bytes) {
819 return self.advance_vte(bytes);
820 }
821
822 let mut evicted = 0;
823 for segment in self.graphics_scanner.scan(bytes) {
824 evicted += match segment {
825 GraphicsSegment::Text(range) => self.advance_vte(&bytes[range]),
826 GraphicsSegment::HeldEscape => self.advance_vte(&[0x1b]),
827 GraphicsSegment::Command(command) => self.apply_graphics(*command),
828 };
829 }
830 evicted
831 }
832
833 #[cfg(not(feature = "terminal-images"))]
834 fn feed_grid(&mut self, bytes: &[u8]) -> usize {
835 self.advance_vte(bytes)
836 }
837
838 fn advance_vte(&mut self, bytes: &[u8]) -> usize {
839 if bytes.is_empty() {
840 return 0;
841 }
842 let mut ledger = LedgerTerm::new(&mut self.term, self.scrollback_len, self.ledger_capacity);
843 self.processor.advance(&mut ledger, bytes);
844 ledger.evicted()
845 }
846
847 #[cfg(feature = "terminal-images")]
849 fn apply_graphics(&mut self, command: GraphicsCommand) -> usize {
850 let ctx = GraphicsContext {
851 cursor_line: self.cursor_absolute_line(),
852 cursor_col: u16::try_from(self.term.grid().cursor.point.column.0).unwrap_or(u16::MAX),
853 viewport_top_line: self.term.history_size(),
854 alt_screen: self.term.mode().contains(TermMode::ALT_SCREEN),
855 cell: self.cell_size,
856 cols: self.cols,
857 };
858 let outcome = self.graphics.apply(command, ctx);
859 if let Some(response) = outcome.response {
860 self.listener.responses.borrow_mut().push(response);
861 }
862 let Some((rows, cols)) = outcome.advance else {
863 return 0;
864 };
865
866 let mut movement = vec![b'\n'; usize::from(rows.saturating_sub(1))];
871 movement.extend_from_slice(format!("\x1b[{cols}C").as_bytes());
872 self.advance_vte(&movement)
873 }
874
875 #[cfg(feature = "terminal-images")]
882 fn visible_images(
883 &self,
884 display_offset: usize,
885 alt_screen: bool,
886 ) -> Vec<TerminalImagePlacement> {
887 let mut images = self.graphics.visible(
888 self.term.history_size(),
889 display_offset,
890 self.rows,
891 alt_screen,
892 );
893 images.extend(
894 self.graphics
895 .placeholder_placements(&self.placeholder_cells(display_offset), self.cell_size),
896 );
897 images
898 }
899
900 #[cfg(feature = "terminal-images")]
906 fn placeholder_cells(&self, display_offset: usize) -> Vec<PlaceholderCell> {
907 let mut cells = Vec::new();
908 if !self.graphics.has_images() {
911 return cells;
912 }
913
914 let grid = self.term.grid();
915 for row in 0..self.rows {
916 let line = Line(i32::from(row) - display_offset as i32);
917 if line < grid.topmost_line() || line > grid.bottommost_line() {
918 continue;
919 }
920 for col in 0..self.cols {
921 let cell = &grid[line][Column(col as usize)];
922 if cell.c != PLACEHOLDER {
923 continue;
924 }
925 let Some(id_low) = placeholder_id(cell) else {
926 continue;
927 };
928 cells.push(PlaceholderCell::new(
929 row,
930 col,
931 id_low,
932 cell.zerowidth().unwrap_or(&[]),
933 ));
934 }
935 }
936 cells
937 }
938
939 #[cfg(feature = "terminal-images")]
941 fn settle_graphics(&mut self, evicted: usize) {
942 self.graphics.drop_evicted(evicted);
943 let alt_screen = self.term.mode().contains(TermMode::ALT_SCREEN);
944 if self.graphics_alt_screen && !alt_screen {
945 self.graphics.clear_alt_screen();
947 }
948 self.graphics_alt_screen = alt_screen;
949 }
950
951 #[cfg(not(feature = "terminal-images"))]
952 fn settle_graphics(&mut self, _evicted: usize) {}
953
954 pub fn set_cell_size(&mut self, cell: TerminalCellSize) {
962 if self.cell_size != cell {
963 self.cell_size = cell;
964 self.sync_viewport();
965 self.dirty = true;
966 }
967 }
968
969 pub fn cell_size(&self) -> TerminalCellSize {
971 self.cell_size
972 }
973
974 #[cfg(feature = "terminal-images")]
982 fn width_change_rewraps(&self, new_cols: u16) -> bool {
983 let grid = self.term.grid();
984 let last_column = grid.last_column();
985 for line in grid.topmost_line().0..=grid.bottommost_line().0 {
986 let line = Line(line);
987 if grid[line][last_column].flags.contains(CellFlags::WRAPLINE) {
988 return true;
989 }
990 for column in usize::from(new_cols)..=last_column.0 {
992 let cell = &grid[line][Column(column)];
993 if cell.c != ' ' || cell.bg != TermColor::Named(NamedColor::Background) {
994 return true;
995 }
996 }
997 }
998 false
999 }
1000
1001 fn sync_viewport(&self) {
1003 self.listener.viewport.set(ViewportGeometry {
1004 rows: self.rows,
1005 cols: self.cols,
1006 cell: self.cell_size,
1007 });
1008 }
1009
1010 #[cfg(feature = "terminal-images")]
1015 pub fn set_image_budget(&mut self, bytes: usize) {
1016 self.graphics.set_budget(bytes);
1017 self.dirty = true;
1018 }
1019
1020 pub fn semantic_state(&self) -> TerminalSemanticState {
1026 self.semantic.state()
1027 }
1028
1029 pub fn drain_semantic_events(&mut self) -> Vec<TerminalSemanticEvent> {
1036 self.semantic_events_seen = 0;
1037 self.semantic.drain_events()
1038 }
1039
1040 pub fn scrollback_lineage(&self) -> ScrollbackLineage {
1042 ScrollbackLineage {
1043 evicted_lines: self.evicted_lines,
1044 history_epoch: self.history_epoch,
1045 }
1046 }
1047
1048 pub fn selection_display_text(
1050 &self,
1051 sel: &TerminalSelection,
1052 endpoint: SelectionEnd,
1053 trim_row_end: bool,
1054 ) -> String {
1055 if sel.is_empty() && matches!(endpoint, SelectionEnd::Exclusive) {
1056 return String::new();
1057 }
1058
1059 let (start, end) = sel.normalized();
1060 let total = self.total_text_lines();
1061 let row_start = start.line.min(total);
1062 let row_end = end.line.min(total.saturating_sub(1));
1063 if row_start > row_end {
1064 return String::new();
1065 }
1066
1067 let grid = self.term.grid();
1068 let top = grid.topmost_line().0;
1069 let mut result = String::new();
1070 for absolute in row_start..=row_end {
1071 let line = Line(top + absolute as i32);
1072 let col_start = if absolute == start.line { start.col } else { 0 };
1073 let col_end = if absolute == end.line {
1074 end.col
1075 .saturating_add(matches!(endpoint, SelectionEnd::Inclusive) as usize)
1076 } else {
1077 display_line_width(grid, line)
1078 };
1079 let mut text = display_columns_text(grid, line, col_start, col_end);
1080 if trim_row_end {
1081 text.truncate(text.trim_end().len());
1082 }
1083 result.push_str(&text);
1084 if absolute < row_end {
1085 result.push('\n');
1086 }
1087 }
1088 result
1089 }
1090
1091 pub fn restore_semantic_state(&mut self, state: TerminalSemanticState) {
1098 self.semantic.restore_state(state);
1099 }
1100
1101 pub fn drain_responses(&mut self) -> Vec<Vec<u8>> {
1107 std::mem::take(&mut *self.listener.responses.borrow_mut())
1108 }
1109
1110 pub fn bell_count(&self) -> u64 {
1112 self.listener.bell_count.get()
1113 }
1114
1115 pub fn export_replay_bytes(&mut self) -> Vec<u8> {
1128 let dirty = self.dirty;
1129 let cache = self.cache.clone();
1130 let sequence = self.sequence;
1131 let scrollback_offset = self.scrollback_offset;
1132 let mouse_mode = self.mouse_mode;
1133 let responses = self.drain_responses();
1134
1135 let was_alt = self.term.mode().contains(TermMode::ALT_SCREEN);
1136 let bytes = if was_alt {
1137 let saved_alt_cursor = self.term.grid().cursor.clone();
1138 let saved_alt_saved_cursor = self.term.grid().saved_cursor.clone();
1139 let alt_repaint = self.export_active_grid_repaint(false);
1140 self.term.swap_alt();
1141 let mut bytes = self.export_primary_replay();
1142
1143 self.term.swap_alt();
1146 let mut repair_processor: VteProcessor = VteProcessor::new();
1147 repair_processor.advance(&mut self.term, &alt_repaint);
1148 self.term.grid_mut().cursor = saved_alt_cursor;
1149 self.term.grid_mut().saved_cursor = saved_alt_saved_cursor;
1150
1151 bytes.extend_from_slice(b"\x1b[?1049h");
1152 bytes.extend_from_slice(&alt_repaint);
1153 self.push_cursor_position(&mut bytes);
1154 self.push_modes(&mut bytes);
1155 bytes
1156 } else {
1157 self.export_primary_replay()
1158 };
1159
1160 *self.listener.responses.borrow_mut() = responses;
1161 self.dirty = dirty;
1162 self.cache = cache;
1163 self.sequence = sequence;
1164 self.scrollback_offset = scrollback_offset;
1165 self.mouse_mode = mouse_mode;
1166 bytes
1167 }
1168
1169 pub fn resize(&mut self, rows: u16, cols: u16) {
1172 let reflowed = cols.max(1) != self.cols;
1173 self.rows = rows.max(1);
1174 self.cols = cols.max(1);
1175 let dimensions = TermDimensions {
1176 rows: self.rows as usize,
1177 cols: self.cols as usize,
1178 };
1179 #[cfg(feature = "terminal-images")]
1180 let rewraps = self.width_change_rewraps(cols.max(1));
1181 self.term.resize(dimensions);
1182 self.sync_viewport();
1183 self.ledger_capacity = ledger_capacity(self.scrollback_len, self.rows);
1186 let evicted = settle_history(&mut self.term, self.scrollback_len, self.ledger_capacity);
1187 if evicted > 0 {
1188 self.evicted_lines = self.evicted_lines.saturating_add(evicted as u64);
1189 }
1190 if reflowed {
1191 self.history_epoch = self.history_epoch.saturating_add(1);
1192 self.semantic_marks.clear();
1195 #[cfg(feature = "terminal-images")]
1200 if rewraps {
1201 self.graphics.clear_placements();
1202 }
1203 } else {
1204 self.drop_evicted_semantic_marks(evicted);
1205 self.settle_graphics(evicted);
1206 }
1207 let alt_screen = self.term.mode().contains(TermMode::ALT_SCREEN);
1208 if self.alt_screen != alt_screen {
1209 self.history_epoch = self.history_epoch.saturating_add(1);
1210 self.alt_screen = alt_screen;
1211 }
1212 self.scrollback_offset = self.term.grid().display_offset();
1213 self.mouse_mode = mouse_mode_from_term(*self.term.mode());
1214 self.dirty = true;
1215 }
1216
1217 pub fn snapshot(&mut self) -> Arc<str> {
1219 self.render_snapshot().text
1220 }
1221
1222 pub fn render_snapshot(&mut self) -> TerminalRenderSnapshot {
1224 if self.dirty {
1225 self.sequence = self.sequence.saturating_add(1);
1226 let content = self.term.renderable_content();
1227 let display_offset = content.display_offset;
1228 let mode = content.mode;
1229 let cursor = content.cursor;
1230 let display_iter = content.display_iter;
1231 self.scrollback_offset = display_offset;
1232 self.mouse_mode = mouse_mode_from_term(mode);
1233
1234 let cursor_view = term::point_to_viewport(display_offset, cursor.point);
1235 let cursor_row = cursor_view.as_ref().map(|p| p.line as u16).unwrap_or(0);
1236 let cursor_col = cursor_view.as_ref().map(|p| p.column.0 as u16).unwrap_or(0);
1237 let cursor_visible =
1238 mode.contains(TermMode::SHOW_CURSOR) && self.scrollback_offset == 0;
1239 let cursor_style = self.term.cursor_style();
1240 let cursor_shape = caret_shape_from_term(cursor_style.shape);
1241 let cursor_blinking = cursor_style.blinking;
1242
1243 let mut visible = renderable_content_lines(
1244 display_iter,
1245 display_offset,
1246 self.rows,
1247 self.cols,
1248 self.palette,
1249 );
1250 if visible.is_empty() {
1251 visible.push(vec![Span::new("")]);
1252 }
1253
1254 #[cfg(feature = "terminal-images")]
1255 let images = self.visible_images(display_offset, mode.contains(TermMode::ALT_SCREEN));
1256
1257 let mut text = String::new();
1258 for (idx, line) in visible.iter().enumerate() {
1259 if idx > 0 {
1260 text.push('\n');
1261 }
1262 for span in line {
1263 text.push_str(span.content.as_ref());
1264 }
1265 }
1266
1267 self.cache = TerminalRenderSnapshot {
1268 text: Arc::from(text),
1269 color_lines: visible.into(),
1270 cursor_row,
1271 cursor_col,
1272 cursor_visible,
1273 cursor_shape,
1274 cursor_blinking,
1275 sequence: self.sequence,
1276 scrollback_offset: self.scrollback_offset,
1277 total_scrollback_rows: self.term.history_size(),
1278 evicted_lines: self.evicted_lines,
1279 history_epoch: self.history_epoch,
1280 mouse_mode: self.mouse_mode,
1281 key_modes: key_modes_from_term(mode),
1282 #[cfg(feature = "terminal-images")]
1283 images: images.into(),
1284 };
1285 self.dirty = false;
1286 }
1287 self.cache.clone()
1288 }
1289
1290 pub fn palette(&self) -> TerminalColorPalette {
1292 self.palette
1293 }
1294
1295 pub fn set_palette(&mut self, palette: TerminalColorPalette) {
1297 if self.palette != palette {
1298 self.palette = palette;
1299 *self.listener.palette.borrow_mut() = palette;
1302 self.dirty = true;
1303 }
1304 }
1305
1306 pub fn scrollback_offset(&self) -> usize {
1308 self.scrollback_offset
1309 }
1310
1311 pub fn set_scrollback(&mut self, offset: usize) {
1316 let max_offset = self.term.history_size();
1317 let target = offset.min(max_offset);
1318 let current = self.term.grid().display_offset();
1319 let delta = target as i32 - current as i32;
1320 if delta != 0 {
1321 self.term.scroll_display(Scroll::Delta(delta));
1322 }
1323 self.scrollback_offset = self.term.grid().display_offset();
1324 self.dirty = true;
1325 }
1326
1327 pub fn total_scrollback_rows(&mut self) -> usize {
1329 self.term.history_size()
1330 }
1331
1332 pub fn total_text_lines(&self) -> usize {
1338 let grid = self.term.grid();
1339 let top = grid.topmost_line().0;
1340 let bottom = grid.bottommost_line().0;
1341 usize::try_from(bottom.saturating_sub(top).saturating_add(1)).unwrap_or(0)
1342 }
1343
1344 pub fn try_for_each_text_line(
1351 &self,
1352 start: usize,
1353 end: usize,
1354 mut visitor: impl FnMut(usize, &str) -> ControlFlow<()>,
1355 ) -> ControlFlow<()> {
1356 let total = self.total_text_lines();
1357 let start = start.min(total);
1358 let end = end.min(total).max(start);
1359 let grid = self.term.grid();
1360 let top = grid.topmost_line().0;
1361 let mut scratch = String::with_capacity(grid.columns());
1362
1363 for absolute in start..end {
1364 scratch.clear();
1365 push_plain_line_text(grid, Line(top + absolute as i32), &mut scratch);
1366 visitor(absolute, &scratch)?;
1367 }
1368 ControlFlow::Continue(())
1369 }
1370
1371 pub fn text_lines(&self, start: usize, end: usize) -> Vec<String> {
1376 let total = self.total_text_lines();
1377 let start = start.min(total);
1378 let end = end.min(total).max(start);
1379 let mut lines = Vec::with_capacity(end - start);
1380 let _ = self.try_for_each_text_line(start, end, |_, line| {
1381 lines.push(line.to_owned());
1382 ControlFlow::Continue(())
1383 });
1384 lines
1385 }
1386
1387 pub fn export_text(&self, start: usize, end: usize) -> String {
1389 let mut text = String::new();
1390 let mut first = true;
1391 let _ = self.try_for_each_text_line(start, end, |_, line| {
1392 if !first {
1393 text.push('\n');
1394 }
1395 first = false;
1396 text.push_str(line);
1397 ControlFlow::Continue(())
1398 });
1399 text
1400 }
1401
1402 pub fn export_selection_text(
1410 &self,
1411 start: GridPos,
1412 end: GridPos,
1413 endpoint: SelectionEnd,
1414 ) -> String {
1415 let row_start = start.row.min(end.row);
1416 let row_end = start.row.max(end.row);
1417 let selection = GridSelection {
1418 anchor: GridPos {
1419 row: start.row - row_start,
1420 col: start.col,
1421 },
1422 cursor: GridPos {
1423 row: end.row - row_start,
1424 col: end.col,
1425 },
1426 };
1427 let lines = self.text_lines(row_start, row_end.saturating_add(1));
1428 selection.extract_text_with(&lines, endpoint, false)
1429 }
1430
1431 pub fn absolute_line_to_viewport(&self, absolute: usize) -> Option<(usize, usize)> {
1437 let total = self.total_text_lines();
1438 if absolute >= total {
1439 return None;
1440 }
1441 let grid = self.term.grid();
1442 let top = grid.topmost_line().0;
1443 let grid_line = top + absolute as i32;
1444 if grid_line < 0 {
1445 let offset = usize::try_from(-grid_line).ok()?;
1446 Some((offset, 0))
1447 } else {
1448 Some((0, grid_line as usize))
1449 }
1450 }
1451
1452 pub fn absolute_line_to_offset(&self, absolute: usize) -> Option<usize> {
1454 self.absolute_line_to_viewport(absolute)
1455 .map(|(offset, _)| offset)
1456 }
1457
1458 pub fn semantic_marks(&self) -> Vec<SemanticMark> {
1460 self.semantic_marks.iter().copied().collect()
1461 }
1462
1463 pub fn last_command_output_range(&self) -> Option<(usize, usize)> {
1468 let start_idx = self
1469 .semantic_marks
1470 .iter()
1471 .rposition(|mark| mark.kind == SemanticMarkKind::OutputStart)?;
1472 let start = self.semantic_marks[start_idx].absolute_line;
1473 let end = self
1474 .semantic_marks
1475 .iter()
1476 .skip(start_idx + 1)
1477 .find(|mark| mark.kind == SemanticMarkKind::OutputEnd)
1478 .map(|mark| mark.absolute_line)
1479 .unwrap_or_else(|| self.total_text_lines());
1480 Some((start, end.max(start)))
1481 }
1482
1483 pub fn export_last_command_output(&self) -> Option<String> {
1485 let (start, end) = self.last_command_output_range()?;
1486 Some(self.export_text(start, end))
1487 }
1488
1489 fn cursor_absolute_line(&self) -> usize {
1490 let grid = self.term.grid();
1491 let top = grid.topmost_line().0;
1492 let cursor = grid.cursor.point.line.0;
1493 usize::try_from((cursor - top).max(0)).unwrap_or(0)
1494 }
1495
1496 fn drop_evicted_semantic_marks(&mut self, evicted: usize) {
1505 if evicted == 0 {
1506 return;
1507 }
1508 self.semantic_marks
1509 .retain(|mark| mark.absolute_line >= evicted);
1510 for mark in &mut self.semantic_marks {
1511 mark.absolute_line -= evicted;
1512 }
1513 }
1514
1515 fn discard_pending_semantic_marks(&mut self) {
1517 self.semantic_events_seen = self.semantic.event_count();
1518 }
1519
1520 fn record_semantic_marks_from_pending(&mut self) {
1521 let absolute_line = self.cursor_absolute_line();
1522 let events = self.semantic.peek_events();
1523 let from = self.semantic_events_seen.min(events.len());
1524 let pending: Vec<_> = events[from..].to_vec();
1525 self.semantic_events_seen = self.semantic.event_count();
1526 for event in pending {
1527 let TerminalSemanticEvent::CommandPhaseChanged(phase) = event else {
1528 continue;
1529 };
1530 let mark = match phase {
1531 TerminalCommandPhase::Prompt => SemanticMark {
1532 kind: SemanticMarkKind::Prompt,
1533 absolute_line,
1534 exit_status: None,
1535 },
1536 TerminalCommandPhase::Executing => SemanticMark {
1537 kind: SemanticMarkKind::OutputStart,
1538 absolute_line,
1539 exit_status: None,
1540 },
1541 TerminalCommandPhase::Completed { exit_status } => SemanticMark {
1542 kind: SemanticMarkKind::OutputEnd,
1543 absolute_line,
1544 exit_status,
1545 },
1546 TerminalCommandPhase::Unknown | TerminalCommandPhase::Input => continue,
1547 };
1548 self.semantic_marks.push_back(mark);
1549 while self.semantic_marks.len() > MAX_SEMANTIC_MARKS {
1550 self.semantic_marks.pop_front();
1551 }
1552 }
1553 }
1554
1555 pub fn reset(&mut self) {
1557 let dimensions = TermDimensions {
1558 rows: self.rows as usize,
1559 cols: self.cols as usize,
1560 };
1561 let config = TermConfig {
1562 scrolling_history: self.scrollback_len,
1563 default_cursor_style: DEFAULT_CURSOR_STYLE,
1564 kitty_keyboard: true,
1565 ..TermConfig::default()
1566 };
1567 self.listener = ResponseCapture::default();
1568 self.term = Term::new(config, &dimensions, self.listener.clone());
1569 self.sync_viewport();
1570 self.processor = VteProcessor::new();
1571 self.semantic_parser = SemanticVteParser::new();
1575 self.mouse_mode = MouseModeState::default();
1576 self.scrollback_offset = 0;
1577 self.cache = TerminalRenderSnapshot::default();
1578 self.semantic_marks.clear();
1579 self.semantic_events_seen = 0;
1580 self.evicted_lines = 0;
1581 self.history_epoch = self.history_epoch.saturating_add(1);
1582 self.alt_screen = false;
1583 #[cfg(feature = "terminal-images")]
1586 {
1587 self.graphics_scanner.reset();
1588 self.graphics.reset();
1589 self.graphics_alt_screen = false;
1590 }
1591 self.dirty = true;
1592 }
1593
1594 pub fn mouse_mode(&self) -> MouseModeState {
1596 self.mouse_mode
1597 }
1598
1599 pub fn key_modes(&self) -> TerminalKeyModes {
1604 key_modes_from_term(*self.term.mode())
1605 }
1606
1607 pub fn title(&self) -> Option<String> {
1611 self.listener.title.borrow().clone()
1612 }
1613
1614 fn export_primary_replay(&self) -> Vec<u8> {
1615 let mut bytes = Vec::new();
1616 bytes.extend_from_slice(b"\x1bc");
1617 bytes.extend_from_slice(&self.export_active_grid_repaint(true));
1618 self.push_cursor_position(&mut bytes);
1619 self.push_title(&mut bytes);
1620 self.push_modes(&mut bytes);
1621 bytes
1622 }
1623
1624 fn export_active_grid_repaint(&self, include_scrollback: bool) -> Vec<u8> {
1625 let grid = self.term.grid();
1626 let top = if include_scrollback {
1627 grid.topmost_line()
1628 } else {
1629 Line(0)
1630 };
1631 let bottom = grid.bottommost_line();
1632 let mut bytes = Vec::new();
1633 bytes.extend_from_slice(b"\x1b[0m\x1b[H");
1637 let mut style = ReplayStyle::default();
1638
1639 for line in top.0..=bottom.0 {
1640 let line = Line(line);
1641 let wrapline = grid[line][grid.last_column()]
1642 .flags
1643 .contains(CellFlags::WRAPLINE);
1644 let end_col = if wrapline {
1645 grid.columns()
1646 } else {
1647 (0..grid.columns())
1648 .rfind(|col| !grid[line][Column(*col)].is_empty())
1649 .map_or(0, |col| col + 1)
1650 };
1651 for col in 0..end_col {
1652 let cell = &grid[line][Column(col)];
1653 if cell
1654 .flags
1655 .intersects(CellFlags::WIDE_CHAR_SPACER | CellFlags::LEADING_WIDE_CHAR_SPACER)
1656 {
1657 continue;
1658 }
1659 let next_style = ReplayStyle::from(cell);
1660 if next_style != style {
1661 next_style.push_sgr(&mut bytes);
1662 style = next_style;
1663 }
1664 push_cell_text(&mut bytes, cell);
1665 }
1666 if line != bottom && !wrapline {
1667 if style != ReplayStyle::default() {
1668 ReplayStyle::default().push_sgr(&mut bytes);
1669 style = ReplayStyle::default();
1670 }
1671 bytes.extend_from_slice(b"\r\n");
1672 }
1673 }
1674 bytes.extend_from_slice(b"\x1b[0m");
1675 bytes
1676 }
1677
1678 fn push_cursor_position(&self, bytes: &mut Vec<u8>) {
1679 let cursor = &self.term.grid().cursor;
1680 let row = (cursor.point.line.0.max(0) as usize + 1).min(self.rows as usize);
1681 let col = (cursor.point.column.0 + 1).min(self.cols as usize);
1682 bytes.extend_from_slice(format!("\x1b[{row};{col}H").as_bytes());
1683 ReplayStyle::from(&cursor.template).push_sgr(bytes);
1684 }
1685
1686 fn push_title(&self, bytes: &mut Vec<u8>) {
1687 if let Some(title) = self.title().filter(|title| !title.is_empty()) {
1688 bytes.extend_from_slice(b"\x1b]2;");
1689 bytes.extend_from_slice(title.as_bytes());
1690 bytes.extend_from_slice(b"\x1b\\");
1691 }
1692 }
1693
1694 fn push_modes(&self, bytes: &mut Vec<u8>) {
1695 let mode = *self.term.mode();
1696 push_dec_mode(bytes, 1, mode.contains(TermMode::APP_CURSOR));
1697 push_dec_mode(bytes, 7, mode.contains(TermMode::LINE_WRAP));
1698 push_dec_mode(bytes, 25, mode.contains(TermMode::SHOW_CURSOR));
1699 push_dec_mode(bytes, 1000, mode.contains(TermMode::MOUSE_REPORT_CLICK));
1700 push_dec_mode(bytes, 1002, mode.contains(TermMode::MOUSE_DRAG));
1701 push_dec_mode(bytes, 1003, mode.contains(TermMode::MOUSE_MOTION));
1702 push_dec_mode(bytes, 1004, mode.contains(TermMode::FOCUS_IN_OUT));
1703 push_dec_mode(bytes, 1005, mode.contains(TermMode::UTF8_MOUSE));
1704 push_dec_mode(bytes, 1006, mode.contains(TermMode::SGR_MOUSE));
1705 push_dec_mode(bytes, 2004, mode.contains(TermMode::BRACKETED_PASTE));
1706 let kitty_flags = u8::from(mode.contains(TermMode::DISAMBIGUATE_ESC_CODES))
1707 | (u8::from(mode.contains(TermMode::REPORT_EVENT_TYPES)) << 1)
1708 | (u8::from(mode.contains(TermMode::REPORT_ALTERNATE_KEYS)) << 2)
1709 | (u8::from(mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC)) << 3)
1710 | (u8::from(mode.contains(TermMode::REPORT_ASSOCIATED_TEXT)) << 4);
1711 if kitty_flags != 0 {
1712 bytes.extend_from_slice(format!("\x1b[>{kitty_flags}u").as_bytes());
1713 }
1714 bytes.extend_from_slice(if mode.contains(TermMode::APP_KEYPAD) {
1715 b"\x1b="
1716 } else {
1717 b"\x1b>"
1718 });
1719 }
1720}
1721
1722#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1723struct ReplayStyle {
1724 fg: TermColor,
1725 bg: TermColor,
1726 flags: CellFlags,
1727 underline_color: Option<TermColor>,
1728}
1729
1730impl Default for ReplayStyle {
1731 fn default() -> Self {
1732 Self {
1733 fg: TermColor::Named(NamedColor::Foreground),
1734 bg: TermColor::Named(NamedColor::Background),
1735 flags: CellFlags::empty(),
1736 underline_color: None,
1737 }
1738 }
1739}
1740
1741impl From<&TermCell> for ReplayStyle {
1742 fn from(cell: &TermCell) -> Self {
1743 Self {
1744 fg: cell.fg,
1745 bg: cell.bg,
1746 flags: cell.flags
1747 & (CellFlags::BOLD
1748 | CellFlags::DIM
1749 | CellFlags::ITALIC
1750 | CellFlags::ALL_UNDERLINES
1751 | CellFlags::INVERSE
1752 | CellFlags::HIDDEN
1753 | CellFlags::STRIKEOUT),
1754 underline_color: cell.underline_color(),
1755 }
1756 }
1757}
1758
1759impl ReplayStyle {
1760 fn push_sgr(self, bytes: &mut Vec<u8>) {
1761 let mut params = vec!["0".to_string()];
1762 let flags = self.flags;
1763 if flags.contains(CellFlags::BOLD) {
1764 params.push("1".to_string());
1765 }
1766 if flags.contains(CellFlags::DIM) {
1767 params.push("2".to_string());
1768 }
1769 if flags.contains(CellFlags::ITALIC) {
1770 params.push("3".to_string());
1771 }
1772 if flags.contains(CellFlags::DOUBLE_UNDERLINE) {
1773 params.push("4:2".to_string());
1774 } else if flags.contains(CellFlags::UNDERCURL) {
1775 params.push("4:3".to_string());
1776 } else if flags.contains(CellFlags::DOTTED_UNDERLINE) {
1777 params.push("4:4".to_string());
1778 } else if flags.contains(CellFlags::DASHED_UNDERLINE) {
1779 params.push("4:5".to_string());
1780 } else if flags.contains(CellFlags::UNDERLINE) {
1781 params.push("4".to_string());
1782 }
1783 if flags.contains(CellFlags::INVERSE) {
1784 params.push("7".to_string());
1785 }
1786 if flags.contains(CellFlags::HIDDEN) {
1787 params.push("8".to_string());
1788 }
1789 if flags.contains(CellFlags::STRIKEOUT) {
1790 params.push("9".to_string());
1791 }
1792 push_color_sgr(&mut params, self.fg, true);
1793 push_color_sgr(&mut params, self.bg, false);
1794 if let Some(color) = self.underline_color {
1795 push_underline_color_sgr(&mut params, color);
1796 }
1797 bytes.extend_from_slice(format!("\x1b[{}m", params.join(";")).as_bytes());
1798 }
1799}
1800
1801fn push_dec_mode(bytes: &mut Vec<u8>, mode: u16, enabled: bool) {
1802 let suffix = if enabled { 'h' } else { 'l' };
1803 bytes.extend_from_slice(format!("\x1b[?{mode}{suffix}").as_bytes());
1804}
1805
1806fn push_cell_text(bytes: &mut Vec<u8>, cell: &TermCell) {
1807 let mut buf = [0; 4];
1808 bytes.extend_from_slice(cell.c.encode_utf8(&mut buf).as_bytes());
1809 if let Some(zerowidth) = cell.zerowidth() {
1810 for ch in zerowidth {
1811 bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
1812 }
1813 }
1814}
1815
1816fn push_color_sgr(params: &mut Vec<String>, color: TermColor, foreground: bool) {
1817 match color {
1818 TermColor::Named(named) => {
1819 let value = named_color_sgr(named, foreground);
1820 params.push(value.to_string());
1821 }
1822 TermColor::Indexed(index) => {
1823 params.push(if foreground { "38" } else { "48" }.to_string());
1824 params.push("5".to_string());
1825 params.push(index.to_string());
1826 }
1827 TermColor::Spec(TermRgb { r, g, b }) => {
1828 params.push(if foreground { "38" } else { "48" }.to_string());
1829 params.push("2".to_string());
1830 params.push(r.to_string());
1831 params.push(g.to_string());
1832 params.push(b.to_string());
1833 }
1834 }
1835}
1836
1837fn push_underline_color_sgr(params: &mut Vec<String>, color: TermColor) {
1838 match color {
1839 TermColor::Named(named) => {
1840 if let Some(index) = named_color_index(named) {
1841 params.push("58".to_string());
1842 params.push("5".to_string());
1843 params.push(index.to_string());
1844 }
1845 }
1846 TermColor::Indexed(index) => {
1847 params.push("58".to_string());
1848 params.push("5".to_string());
1849 params.push(index.to_string());
1850 }
1851 TermColor::Spec(TermRgb { r, g, b }) => {
1852 params.push("58".to_string());
1853 params.push("2".to_string());
1854 params.push(r.to_string());
1855 params.push(g.to_string());
1856 params.push(b.to_string());
1857 }
1858 }
1859}
1860
1861fn named_color_sgr(color: NamedColor, foreground: bool) -> u16 {
1862 match color {
1863 NamedColor::Foreground | NamedColor::BrightForeground | NamedColor::DimForeground => 39,
1864 NamedColor::Background => 49,
1865 NamedColor::Black | NamedColor::DimBlack => {
1866 if foreground {
1867 30
1868 } else {
1869 40
1870 }
1871 }
1872 NamedColor::Red | NamedColor::DimRed => {
1873 if foreground {
1874 31
1875 } else {
1876 41
1877 }
1878 }
1879 NamedColor::Green | NamedColor::DimGreen => {
1880 if foreground {
1881 32
1882 } else {
1883 42
1884 }
1885 }
1886 NamedColor::Yellow | NamedColor::DimYellow => {
1887 if foreground {
1888 33
1889 } else {
1890 43
1891 }
1892 }
1893 NamedColor::Blue | NamedColor::DimBlue => {
1894 if foreground {
1895 34
1896 } else {
1897 44
1898 }
1899 }
1900 NamedColor::Magenta | NamedColor::DimMagenta => {
1901 if foreground {
1902 35
1903 } else {
1904 45
1905 }
1906 }
1907 NamedColor::Cyan | NamedColor::DimCyan => {
1908 if foreground {
1909 36
1910 } else {
1911 46
1912 }
1913 }
1914 NamedColor::White | NamedColor::DimWhite => {
1915 if foreground {
1916 37
1917 } else {
1918 47
1919 }
1920 }
1921 NamedColor::BrightBlack => {
1922 if foreground {
1923 90
1924 } else {
1925 100
1926 }
1927 }
1928 NamedColor::BrightRed => {
1929 if foreground {
1930 91
1931 } else {
1932 101
1933 }
1934 }
1935 NamedColor::BrightGreen => {
1936 if foreground {
1937 92
1938 } else {
1939 102
1940 }
1941 }
1942 NamedColor::BrightYellow => {
1943 if foreground {
1944 93
1945 } else {
1946 103
1947 }
1948 }
1949 NamedColor::BrightBlue => {
1950 if foreground {
1951 94
1952 } else {
1953 104
1954 }
1955 }
1956 NamedColor::BrightMagenta => {
1957 if foreground {
1958 95
1959 } else {
1960 105
1961 }
1962 }
1963 NamedColor::BrightCyan => {
1964 if foreground {
1965 96
1966 } else {
1967 106
1968 }
1969 }
1970 NamedColor::BrightWhite => {
1971 if foreground {
1972 97
1973 } else {
1974 107
1975 }
1976 }
1977 NamedColor::Cursor => {
1978 if foreground {
1979 39
1980 } else {
1981 49
1982 }
1983 }
1984 }
1985}
1986
1987fn named_color_index(color: NamedColor) -> Option<u8> {
1988 match color {
1989 NamedColor::Black | NamedColor::DimBlack => Some(0),
1990 NamedColor::Red | NamedColor::DimRed => Some(1),
1991 NamedColor::Green | NamedColor::DimGreen => Some(2),
1992 NamedColor::Yellow | NamedColor::DimYellow => Some(3),
1993 NamedColor::Blue | NamedColor::DimBlue => Some(4),
1994 NamedColor::Magenta | NamedColor::DimMagenta => Some(5),
1995 NamedColor::Cyan | NamedColor::DimCyan => Some(6),
1996 NamedColor::White | NamedColor::DimWhite => Some(7),
1997 NamedColor::BrightBlack => Some(8),
1998 NamedColor::BrightRed => Some(9),
1999 NamedColor::BrightGreen => Some(10),
2000 NamedColor::BrightYellow => Some(11),
2001 NamedColor::BrightBlue => Some(12),
2002 NamedColor::BrightMagenta => Some(13),
2003 NamedColor::BrightCyan => Some(14),
2004 NamedColor::BrightWhite => Some(15),
2005 NamedColor::Foreground
2006 | NamedColor::Background
2007 | NamedColor::Cursor
2008 | NamedColor::BrightForeground
2009 | NamedColor::DimForeground => None,
2010 }
2011}
2012
2013fn renderable_content_lines(
2014 display_iter: alacritty_terminal::grid::GridIterator<'_, TermCell>,
2015 display_offset: usize,
2016 rows: u16,
2017 cols: u16,
2018 palette: TerminalColorPalette,
2019) -> Vec<Vec<Span>> {
2020 let mut lines: Vec<Vec<Span>> = vec![Vec::new(); rows as usize];
2021 let mut current_row: Option<usize> = None;
2022 let mut run_style: Option<Style> = None;
2023 let mut run_text = String::new();
2024
2025 let flush_run = |row: usize,
2026 run_style: &mut Option<Style>,
2027 run_text: &mut String,
2028 lines: &mut Vec<Vec<Span>>| {
2029 if run_text.is_empty() {
2030 *run_style = None;
2031 return;
2032 }
2033 if let Some(style) = run_style.take() {
2034 lines[row].push(Span::new(std::mem::take(run_text)).style(style));
2035 } else {
2036 lines[row].push(Span::new(std::mem::take(run_text)));
2037 }
2038 };
2039
2040 for indexed in display_iter {
2041 let Some(point) = term::point_to_viewport(display_offset, indexed.point) else {
2042 continue;
2043 };
2044 if point.line >= rows as usize || point.column.0 >= cols as usize {
2045 continue;
2046 }
2047
2048 let row = point.line;
2049 if current_row != Some(row) {
2050 if let Some(prev_row) = current_row {
2051 flush_run(prev_row, &mut run_style, &mut run_text, &mut lines);
2052 }
2053 current_row = Some(row);
2054 }
2055
2056 let cell = indexed.cell;
2057 if cell
2058 .flags
2059 .intersects(CellFlags::WIDE_CHAR_SPACER | CellFlags::LEADING_WIDE_CHAR_SPACER)
2060 {
2061 continue;
2062 }
2063
2064 let style = style_from_term_cell(cell, &palette);
2065 if run_style != Some(style) {
2066 if let Some(prev_row) = current_row {
2067 flush_run(prev_row, &mut run_style, &mut run_text, &mut lines);
2068 }
2069 run_style = Some(style);
2070 }
2071 push_cell_text_str(&mut run_text, cell);
2072 }
2073
2074 if let Some(row) = current_row {
2075 flush_run(row, &mut run_style, &mut run_text, &mut lines);
2076 }
2077
2078 for line in &mut lines {
2079 if line.is_empty() {
2080 line.push(Span::new(""));
2081 }
2082 }
2083
2084 lines
2085}
2086
2087fn push_cell_text_str(out: &mut String, cell: &TermCell) {
2088 #[cfg(feature = "terminal-images")]
2092 if cell.c == PLACEHOLDER {
2093 out.push(' ');
2094 return;
2095 }
2096 let ch = if cell.flags.contains(CellFlags::HIDDEN) {
2097 ' '
2098 } else {
2099 cell.c
2100 };
2101 out.push(ch);
2102 if let Some(zerowidth) = cell.zerowidth() {
2103 for ch in zerowidth {
2104 out.push(*ch);
2105 }
2106 }
2107}
2108
2109fn display_line_width(grid: &alacritty_terminal::grid::Grid<TermCell>, line: Line) -> usize {
2110 let wrapline = grid[line][grid.last_column()]
2111 .flags
2112 .contains(CellFlags::WRAPLINE);
2113 if wrapline {
2114 return grid.columns();
2115 }
2116 (0..grid.columns())
2117 .rfind(|col| {
2118 let cell = &grid[line][Column(*col)];
2119 !cell
2120 .flags
2121 .intersects(CellFlags::WIDE_CHAR_SPACER | CellFlags::LEADING_WIDE_CHAR_SPACER)
2122 && !cell.is_empty()
2123 })
2124 .map_or(0, |col| col + 1)
2125}
2126
2127fn display_columns_text(
2128 grid: &alacritty_terminal::grid::Grid<TermCell>,
2129 line: Line,
2130 start_col: usize,
2131 end_col: usize,
2132) -> String {
2133 let width = display_line_width(grid, line);
2134 let col_start = start_col.min(width);
2135 let col_end = end_col.min(width);
2136 if col_start >= col_end {
2137 return String::new();
2138 }
2139
2140 let mut result = String::new();
2141 let mut display_col = 0usize;
2142 for col in 0..grid.columns() {
2143 let cell = &grid[line][Column(col)];
2144 if cell
2145 .flags
2146 .intersects(CellFlags::WIDE_CHAR_SPACER | CellFlags::LEADING_WIDE_CHAR_SPACER)
2147 {
2148 continue;
2149 }
2150 let cell_width = if cell.flags.contains(CellFlags::WIDE_CHAR) {
2151 2
2152 } else {
2153 1
2154 };
2155 let cell_end = display_col.saturating_add(cell_width);
2156 if cell_end > col_start && display_col < col_end {
2157 push_cell_text_str(&mut result, cell);
2158 }
2159 display_col = cell_end;
2160 if display_col >= col_end {
2161 break;
2162 }
2163 }
2164 result
2165}
2166
2167#[cfg(feature = "terminal-images")]
2172fn placeholder_id(cell: &TermCell) -> Option<u32> {
2173 match cell.fg {
2174 TermColor::Spec(rgb) => {
2175 Some((u32::from(rgb.r) << 16) | (u32::from(rgb.g) << 8) | u32::from(rgb.b))
2176 }
2177 TermColor::Indexed(index) => Some(u32::from(index)),
2178 TermColor::Named(_) => None,
2179 }
2180}
2181
2182fn push_plain_line_text(
2183 grid: &alacritty_terminal::grid::Grid<TermCell>,
2184 line: Line,
2185 out: &mut String,
2186) {
2187 let wrapline = grid[line][grid.last_column()]
2188 .flags
2189 .contains(CellFlags::WRAPLINE);
2190 let end_col = if wrapline {
2191 grid.columns()
2192 } else {
2193 (0..grid.columns())
2194 .rfind(|col| !grid[line][Column(*col)].is_empty())
2195 .map_or(0, |col| col + 1)
2196 };
2197 for col in 0..end_col {
2198 let cell = &grid[line][Column(col)];
2199 if cell
2200 .flags
2201 .intersects(CellFlags::WIDE_CHAR_SPACER | CellFlags::LEADING_WIDE_CHAR_SPACER)
2202 {
2203 continue;
2204 }
2205 push_cell_text_str(out, cell);
2206 }
2207}
2208
2209fn key_modes_from_term(mode: TermMode) -> TerminalKeyModes {
2210 TerminalKeyModes {
2211 app_cursor: mode.contains(TermMode::APP_CURSOR),
2212 bracketed_paste: mode.contains(TermMode::BRACKETED_PASTE),
2213 kitty_keyboard: KittyKeyboardFlags {
2214 disambiguate_escape_codes: mode.contains(TermMode::DISAMBIGUATE_ESC_CODES),
2215 report_event_types: mode.contains(TermMode::REPORT_EVENT_TYPES),
2216 report_alternate_keys: mode.contains(TermMode::REPORT_ALTERNATE_KEYS),
2217 report_all_keys_as_escape_codes: mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC),
2218 report_associated_text: mode.contains(TermMode::REPORT_ASSOCIATED_TEXT),
2219 },
2220 }
2221}
2222
2223fn mouse_mode_from_term(mode: TermMode) -> MouseModeState {
2224 let encoding = if mode.contains(TermMode::SGR_MOUSE) {
2225 MouseEncoding::Sgr
2226 } else if mode.contains(TermMode::UTF8_MOUSE) {
2227 MouseEncoding::Utf8
2228 } else {
2229 MouseEncoding::X10
2230 };
2231
2232 let mouse_mode = if mode.contains(TermMode::MOUSE_MOTION) {
2233 MouseMode::AnyEvent
2234 } else if mode.contains(TermMode::MOUSE_DRAG) || mode.contains(TermMode::MOUSE_REPORT_CLICK) {
2235 MouseMode::Normal
2236 } else {
2237 MouseMode::None
2238 };
2239
2240 let focus_events_enabled = mode.contains(TermMode::FOCUS_IN_OUT);
2241
2242 MouseModeState {
2243 mode: mouse_mode,
2244 encoding,
2245 focus_events_enabled,
2246 }
2247}
2248
2249fn style_from_term_cell(cell: &TermCell, palette: &TerminalColorPalette) -> Style {
2250 let fg = map_term_color(cell.fg, palette).map(Into::into);
2251 let bg = map_term_color(cell.bg, palette).map(Into::into);
2252 let flags = cell.flags;
2253
2254 Style {
2255 fg,
2256 bg,
2257 fg_transform: None,
2258 bg_transform: None,
2259 contrast_policy: None,
2260 bold: Some(flags.contains(CellFlags::BOLD)),
2261 dim: Some(flags.contains(CellFlags::DIM)),
2262 italic: Some(flags.contains(CellFlags::ITALIC)),
2263 underline: Some(flags.intersects(CellFlags::ALL_UNDERLINES)),
2264 reverse: Some(flags.contains(CellFlags::INVERSE)),
2265 dim_amount: None,
2266 strikethrough: Some(flags.contains(CellFlags::STRIKEOUT)),
2267 underline_color: None,
2268 tint: None,
2269 }
2270}
2271
2272fn map_term_color(color: TermColor, palette: &TerminalColorPalette) -> Option<UiColor> {
2273 match color {
2274 TermColor::Named(named) => map_named_color(named, palette),
2275 TermColor::Spec(TermRgb { r, g, b }) => Some(UiColor::Rgb(r, g, b)),
2276 TermColor::Indexed(index) if usize::from(index) < palette.ansi.len() => {
2277 Some(palette.ansi[usize::from(index)])
2278 }
2279 TermColor::Indexed(index) => Some(UiColor::Indexed(index)),
2280 }
2281}
2282
2283fn map_named_color(color: NamedColor, palette: &TerminalColorPalette) -> Option<UiColor> {
2284 match color {
2285 NamedColor::Black => Some(palette.ansi[0]),
2286 NamedColor::Red => Some(palette.ansi[1]),
2287 NamedColor::Green => Some(palette.ansi[2]),
2288 NamedColor::Yellow => Some(palette.ansi[3]),
2289 NamedColor::Blue => Some(palette.ansi[4]),
2290 NamedColor::Magenta => Some(palette.ansi[5]),
2291 NamedColor::Cyan => Some(palette.ansi[6]),
2292 NamedColor::White => Some(palette.ansi[7]),
2293 NamedColor::BrightBlack => Some(palette.ansi[8]),
2294 NamedColor::BrightRed => Some(palette.ansi[9]),
2295 NamedColor::BrightGreen => Some(palette.ansi[10]),
2296 NamedColor::BrightYellow => Some(palette.ansi[11]),
2297 NamedColor::BrightBlue => Some(palette.ansi[12]),
2298 NamedColor::BrightMagenta => Some(palette.ansi[13]),
2299 NamedColor::BrightCyan => Some(palette.ansi[14]),
2300 NamedColor::BrightWhite => Some(palette.ansi[15]),
2301 NamedColor::Foreground | NamedColor::BrightForeground | NamedColor::DimForeground => {
2302 palette.foreground
2303 }
2304 NamedColor::Background => palette.background,
2305 NamedColor::Cursor
2306 | NamedColor::DimBlack
2307 | NamedColor::DimRed
2308 | NamedColor::DimGreen
2309 | NamedColor::DimYellow
2310 | NamedColor::DimBlue
2311 | NamedColor::DimMagenta
2312 | NamedColor::DimCyan
2313 | NamedColor::DimWhite => None,
2314 }
2315}
2316
2317fn default_ansi_palette() -> [UiColor; 16] {
2318 [
2319 UiColor::Black,
2320 UiColor::Red,
2321 UiColor::Green,
2322 UiColor::Yellow,
2323 UiColor::Blue,
2324 UiColor::Magenta,
2325 UiColor::Cyan,
2326 UiColor::Gray,
2327 UiColor::DarkGray,
2328 UiColor::LightRed,
2329 UiColor::LightGreen,
2330 UiColor::LightYellow,
2331 UiColor::LightBlue,
2332 UiColor::LightMagenta,
2333 UiColor::LightCyan,
2334 UiColor::White,
2335 ]
2336}
2337
2338#[cfg(test)]
2339mod tests {
2340 use super::*;
2341
2342 fn assert_replay_round_trips(source: &mut TerminalScreen) -> TerminalScreen {
2343 let replay = source.export_replay_bytes();
2344 let mut target = TerminalScreen::new(source.rows, source.cols, source.scrollback_len);
2345 target.set_palette(source.palette());
2346 target.process_bytes(&replay);
2347 assert!(target.drain_responses().is_empty());
2348
2349 let source_snapshot = source.render_snapshot();
2350 let target_snapshot = target.render_snapshot();
2351 assert_eq!(target_snapshot.text, source_snapshot.text);
2352 assert_eq!(target_snapshot.color_lines, source_snapshot.color_lines);
2353 assert_eq!(target_snapshot.cursor_row, source_snapshot.cursor_row);
2354 assert_eq!(target_snapshot.cursor_col, source_snapshot.cursor_col);
2355 assert_eq!(
2356 target_snapshot.cursor_visible,
2357 source_snapshot.cursor_visible
2358 );
2359 assert_eq!(target_snapshot.mouse_mode, source_snapshot.mouse_mode);
2360 assert_eq!(target_snapshot.key_modes, source_snapshot.key_modes);
2361 assert_eq!(target.title(), source.title());
2362 target
2363 }
2364
2365 fn assert_scrollback_views_round_trip(
2366 source: &mut TerminalScreen,
2367 target: &mut TerminalScreen,
2368 ) {
2369 let total_scrollback_rows = source.total_scrollback_rows();
2370 assert_eq!(target.total_scrollback_rows(), total_scrollback_rows);
2371
2372 for offset in 0..=total_scrollback_rows {
2373 source.set_scrollback(offset);
2374 target.set_scrollback(offset);
2375 let source_snapshot = source.render_snapshot();
2376 let target_snapshot = target.render_snapshot();
2377 assert_eq!(
2378 target_snapshot.text, source_snapshot.text,
2379 "offset {offset}"
2380 );
2381 assert_eq!(
2382 target_snapshot.color_lines, source_snapshot.color_lines,
2383 "offset {offset}"
2384 );
2385 }
2386
2387 source.set_scrollback(0);
2388 target.set_scrollback(0);
2389 }
2390
2391 fn span_fg(snapshot: &TerminalRenderSnapshot, span_index: usize) -> Option<UiColor> {
2392 snapshot.color_lines[0][span_index]
2393 .style
2394 .fg
2395 .map(|paint| paint.color())
2396 }
2397
2398 fn span_bg(snapshot: &TerminalRenderSnapshot, span_index: usize) -> Option<UiColor> {
2399 snapshot.color_lines[0][span_index]
2400 .style
2401 .bg
2402 .map(|paint| paint.color())
2403 }
2404
2405 #[test]
2406 fn snapshot_selection_text_uses_display_columns_and_inclusive_endpoints() {
2407 let snapshot = TerminalRenderSnapshot::from_parts(
2408 "a界🙂b",
2409 vec![vec![Span::new("a界🙂b")]],
2410 0,
2411 0,
2412 true,
2413 CaretShape::Block,
2414 true,
2415 7,
2416 0,
2417 0,
2418 MouseModeState::default(),
2419 TerminalKeyModes::default(),
2420 );
2421
2422 assert_eq!(
2423 snapshot.selection_text(
2424 &GridSelection {
2425 anchor: GridPos { row: 0, col: 1 },
2426 cursor: GridPos { row: 0, col: 2 },
2427 },
2428 SelectionEnd::Inclusive,
2429 true,
2430 ),
2431 "界"
2432 );
2433 assert_eq!(
2434 snapshot.selection_text(
2435 &GridSelection {
2436 anchor: GridPos { row: 0, col: 3 },
2437 cursor: GridPos { row: 0, col: 4 },
2438 },
2439 SelectionEnd::Inclusive,
2440 true,
2441 ),
2442 "🙂"
2443 );
2444
2445 let trailing = TerminalRenderSnapshot::from_parts(
2446 "a ",
2447 vec![vec![Span::new("a ")]],
2448 0,
2449 0,
2450 true,
2451 CaretShape::Block,
2452 true,
2453 7,
2454 0,
2455 0,
2456 MouseModeState::default(),
2457 TerminalKeyModes::default(),
2458 );
2459 let trailing_selection = GridSelection {
2460 anchor: GridPos { row: 0, col: 0 },
2461 cursor: GridPos { row: 0, col: 2 },
2462 };
2463 assert_eq!(
2464 trailing.selection_text(&trailing_selection, SelectionEnd::Inclusive, false),
2465 "a "
2466 );
2467 assert_eq!(
2468 trailing.selection_text(&trailing_selection, SelectionEnd::Inclusive, true),
2469 "a"
2470 );
2471 }
2472
2473 #[test]
2474 fn decorated_snapshot_keeps_plain_text_and_hashes_decorations() {
2475 let snapshot = TerminalRenderSnapshot::from_parts(
2476 "abc",
2477 vec![vec![Span::new("abc")]],
2478 0,
2479 0,
2480 true,
2481 CaretShape::Block,
2482 true,
2483 7,
2484 0,
2485 0,
2486 MouseModeState::default(),
2487 TerminalKeyModes::default(),
2488 );
2489 let decoration = TerminalDecoration::highlight(0, 1..2, Style::new().bold());
2490 let decorated = snapshot.decorated(std::slice::from_ref(&decoration));
2491 let repeated = snapshot.decorated(std::slice::from_ref(&decoration));
2492
2493 assert_eq!(decorated.text, snapshot.text);
2494 assert_ne!(decorated.sequence, snapshot.sequence);
2495 assert_eq!(decorated.sequence, repeated.sequence);
2496 assert_eq!(decorated.color_lines[0][0].content.as_ref(), "a");
2497 assert_eq!(decorated.color_lines[0][1].content.as_ref(), "b");
2498 assert_eq!(decorated.color_lines[0][1].style.bold, Some(true));
2499 }
2500
2501 #[test]
2502 fn decorated_snapshot_uses_sorted_overlap_precedence_and_right_to_left_labels() {
2503 let snapshot = TerminalRenderSnapshot::from_parts(
2504 "abcd",
2505 vec![vec![Span::new("abcd")]],
2506 0,
2507 0,
2508 true,
2509 CaretShape::Block,
2510 true,
2511 7,
2512 0,
2513 0,
2514 MouseModeState::default(),
2515 TerminalKeyModes::default(),
2516 );
2517 let red = TerminalDecoration::highlight(0, 0..3, Style::new().fg(UiColor::Red));
2518 let blue = TerminalDecoration::highlight(0, 1..2, Style::new().fg(UiColor::Blue));
2519 let decorated = snapshot.decorated(&[red, blue]);
2520 assert_eq!(
2521 decorated.color_lines[0][0].style.fg,
2522 Some(UiColor::Red.into())
2523 );
2524 assert_eq!(
2525 decorated.color_lines[0][1].style.fg,
2526 Some(UiColor::Blue.into())
2527 );
2528 assert_eq!(
2529 decorated.color_lines[0][2].style.fg,
2530 Some(UiColor::Red.into())
2531 );
2532
2533 let labels = snapshot.decorated(&[
2534 TerminalDecoration::label(0, 1, Span::new("X")),
2535 TerminalDecoration::label(0, 3, Span::new("Y")),
2536 ]);
2537 let text: String = labels.color_lines[0]
2538 .iter()
2539 .map(|span| span.content.as_ref())
2540 .collect();
2541 assert_eq!(text, "aXbcYd");
2542 }
2543
2544 #[test]
2545 fn repeated_decorations_have_distinct_sequence_from_single_decoration() {
2546 let snapshot = TerminalRenderSnapshot::from_parts(
2547 "abc",
2548 vec![vec![Span::new("abc")]],
2549 0,
2550 0,
2551 true,
2552 CaretShape::Block,
2553 true,
2554 7,
2555 0,
2556 0,
2557 MouseModeState::default(),
2558 TerminalKeyModes::default(),
2559 );
2560 let decoration = TerminalDecoration::highlight(0, 1..2, Style::new().bold());
2561 let once = snapshot.decorated(std::slice::from_ref(&decoration));
2562 let twice = snapshot.decorated(&[decoration.clone(), decoration]);
2563 let mut different_source = snapshot.clone();
2564 different_source.sequence = 8;
2565 let same_decoration = TerminalDecoration::highlight(0, 1..2, Style::new().bold());
2566 let different = different_source.decorated(std::slice::from_ref(&same_decoration));
2567
2568 assert_ne!(once.sequence, twice.sequence);
2569 assert_ne!(twice.sequence, snapshot.sequence);
2570 assert_ne!(once.sequence, different.sequence);
2571 }
2572
2573 #[test]
2574 fn export_selection_text_is_char_indexed_and_uses_trimmed_lines() {
2575 let mut screen = TerminalScreen::new(1, 20, 10);
2576 screen.process_bytes("a界🙂b ".as_bytes());
2577
2578 assert_eq!(
2579 screen.export_selection_text(
2580 GridPos { row: 0, col: 1 },
2581 GridPos { row: 0, col: 2 },
2582 SelectionEnd::Inclusive,
2583 ),
2584 "界🙂"
2585 );
2586 assert_eq!(
2587 screen.export_selection_text(
2588 GridPos { row: 0, col: 2 },
2589 GridPos { row: 0, col: 3 },
2590 SelectionEnd::Inclusive,
2591 ),
2592 "🙂b"
2593 );
2594 }
2595
2596 #[test]
2597 fn bell_count_starts_at_zero() {
2598 let screen = TerminalScreen::new(2, 8, 10);
2599
2600 assert_eq!(screen.bell_count(), 0);
2601 }
2602
2603 #[test]
2604 fn bell_count_tracks_each_bel() {
2605 let mut screen = TerminalScreen::new(2, 8, 10);
2606
2607 screen.process_bytes(b"\x07text\x07\x07");
2608
2609 assert_eq!(screen.bell_count(), 3);
2610 }
2611
2612 #[test]
2613 fn bell_count_ignores_non_bel_input() {
2614 let mut screen = TerminalScreen::new(2, 8, 10);
2615
2616 screen.process_bytes(b"text\r\n\x1b[31mred\x1b[0m");
2617
2618 assert_eq!(screen.bell_count(), 0);
2619 }
2620
2621 #[test]
2622 fn palette_resolves_named_and_indexed_ansi_slots() {
2623 let mut screen = TerminalScreen::new(2, 8, 10);
2624 let mut ansi = default_ansi_palette();
2625 ansi[1] = UiColor::Rgb(1, 2, 3);
2626 ansi[2] = UiColor::Rgb(4, 5, 6);
2627 screen.set_palette(TerminalColorPalette::default().ansi(ansi));
2628
2629 screen.process_bytes(b"\x1b[31mR\x1b[38;5;2mG");
2630 let snapshot = screen.render_snapshot();
2631
2632 assert_eq!(span_fg(&snapshot, 0), Some(UiColor::Rgb(1, 2, 3)));
2633 assert_eq!(span_fg(&snapshot, 1), Some(UiColor::Rgb(4, 5, 6)));
2634 }
2635
2636 #[test]
2637 fn palette_resolves_default_foreground_and_background() {
2638 let mut screen = TerminalScreen::new(2, 8, 10);
2639 screen.set_palette(TerminalColorPalette::new(
2640 UiColor::Rgb(10, 20, 30),
2641 UiColor::Rgb(40, 50, 60),
2642 default_ansi_palette(),
2643 ));
2644
2645 screen.process_bytes(b"X");
2646 let snapshot = screen.render_snapshot();
2647
2648 assert_eq!(span_fg(&snapshot, 0), Some(UiColor::Rgb(10, 20, 30)));
2649 assert_eq!(span_bg(&snapshot, 0), Some(UiColor::Rgb(40, 50, 60)));
2650 }
2651
2652 #[test]
2653 fn palette_from_host_colors_preserves_host_foreground_and_ansi_slots() {
2654 let ansi = std::array::from_fn(|i| UiColor::Rgb(i as u8, 10 + i as u8, 20 + i as u8));
2655 let colors = HostTerminalColors {
2656 ansi,
2657 fg: UiColor::Rgb(230, 231, 232),
2658 bg: UiColor::Rgb(10, 11, 12),
2659 };
2660 let pane_background = UiColor::Rgb(1, 2, 3);
2661
2662 let palette = TerminalColorPalette::from_host_colors(colors, pane_background);
2663
2664 assert_eq!(palette.foreground, Some(colors.fg));
2665 assert_eq!(palette.background, Some(pane_background));
2666 assert_eq!(palette.ansi, colors.ansi);
2667 }
2668
2669 #[test]
2670 fn terminal_palette_from_theme_preserves_host_extension() {
2671 let ansi = std::array::from_fn(|i| UiColor::Rgb(i as u8, 10, 20));
2672 let colors = HostTerminalColors {
2673 ansi,
2674 fg: UiColor::Rgb(230, 231, 232),
2675 bg: UiColor::Rgb(10, 11, 12),
2676 };
2677 let theme = Theme::from_host_colors(colors);
2678 let palette = TerminalColorPalette::from_theme(&theme, UiColor::Rgb(1, 2, 3));
2679
2680 assert_eq!(palette.foreground, Some(colors.fg));
2681 assert_eq!(palette.background, Some(UiColor::Rgb(1, 2, 3)));
2682 assert_eq!(palette.ansi, colors.ansi);
2683 }
2684
2685 #[test]
2686 fn terminal_palette_from_theme_derives_ansi_slots() {
2687 let foreground = UiColor::Rgb(230, 231, 232);
2688 let background = UiColor::Rgb(10, 11, 12);
2689 let accent = UiColor::Rgb(30, 80, 210);
2690 let theme = Theme::custom(foreground, background, accent);
2691 let palette = TerminalColorPalette::from_theme(&theme, background);
2692
2693 assert_eq!(palette.foreground, Some(foreground));
2694 assert_eq!(palette.background, Some(background));
2695 assert_eq!(palette.ansi[0], background);
2696 assert_eq!(palette.ansi[1], theme.status.error);
2697 assert_eq!(palette.ansi[4], theme.status.info);
2698 assert_eq!(palette.ansi[12], accent.lighten_by(0.12));
2699 }
2700
2701 #[test]
2702 fn terminal_palette_from_theme_resolves_sentinel_derivations() {
2703 let mut theme = Theme::custom(UiColor::Backdrop, UiColor::Transparent, UiColor::Reset)
2704 .primary(Style::new().fg(UiColor::Backdrop))
2705 .accent(Style::new().fg(UiColor::Transparent))
2706 .muted(Style::new().fg(UiColor::Reset));
2707 theme.border_active = UiColor::Backdrop;
2708 theme.surface.menu = UiColor::Transparent;
2709 theme.status.error = UiColor::Backdrop;
2710 theme.status.success = UiColor::Transparent;
2711 theme.status.warning = UiColor::Reset;
2712 theme.status.info = UiColor::Backdrop;
2713 theme.file_icons.purple = UiColor::Transparent;
2714 theme.file_icons.cyan = UiColor::Reset;
2715
2716 let palette = TerminalColorPalette::from_theme(&theme, UiColor::Backdrop);
2717 let colors = palette
2718 .foreground
2719 .into_iter()
2720 .chain(palette.background)
2721 .chain(palette.ansi)
2722 .collect::<Vec<_>>();
2723
2724 assert!(colors.iter().all(|color| !color.is_sentinel()));
2725 assert_eq!(palette.foreground, Some(UiColor::White));
2726 assert_eq!(palette.background, Some(UiColor::Black));
2727 assert_eq!(palette.ansi[1], UiColor::Red);
2728 assert_eq!(palette.ansi[2], UiColor::Green);
2729 assert_eq!(palette.ansi[3], UiColor::Yellow);
2730 assert_eq!(palette.ansi[5], UiColor::Magenta);
2731 assert_eq!(palette.ansi[6], UiColor::Cyan);
2732 }
2733
2734 #[test]
2735 fn answers_osc_color_queries_from_palette() {
2736 let mut screen = TerminalScreen::new(2, 8, 10);
2737 let mut ansi = default_ansi_palette();
2738 ansi[1] = UiColor::Rgb(0xab, 0xcd, 0xef);
2739 screen.set_palette(TerminalColorPalette::new(
2740 UiColor::Rgb(0x11, 0x22, 0x33),
2741 UiColor::Rgb(0x44, 0x55, 0x66),
2742 ansi,
2743 ));
2744
2745 screen.process_bytes(b"\x1b]4;1;?\x1b\\\x1b]10;?\x1b\\\x1b]11;?\x1b\\");
2747 let responses: Vec<String> = screen
2748 .drain_responses()
2749 .into_iter()
2750 .map(|r| String::from_utf8_lossy(&r).into_owned())
2751 .collect();
2752
2753 let joined = responses.join("");
2754 assert!(joined.contains("]4;1;rgb:abab/cdcd/efef"), "{joined:?}");
2756 assert!(joined.contains("]10;rgb:1111/2222/3333"), "{joined:?}");
2758 assert!(joined.contains("]11;rgb:4444/5555/6666"), "{joined:?}");
2759 }
2760
2761 #[test]
2762 fn replay_round_trips_styled_scrollback() {
2763 let mut screen = TerminalScreen::new(3, 10, 20);
2764 screen.process_bytes(b"\x1b]2;demo\x1b\\");
2765 screen.process_bytes(b"\x1b[31mred\x1b[0m\r\n");
2766 screen.process_bytes(b"\x1b[38;5;45mindexed\x1b[0m\r\n");
2767 screen.process_bytes(b"\x1b[38;2;1;2;3mtrue\x1b[48;2;4;5;6mcolor\x1b[0m\r\n");
2768 screen.process_bytes(b"tail");
2769
2770 let mut target = assert_replay_round_trips(&mut screen);
2771 assert_scrollback_views_round_trip(&mut screen, &mut target);
2772 }
2773
2774 #[test]
2775 fn replay_soft_wrap_reflows_identically_after_resize() {
2776 let mut source = TerminalScreen::new(2, 8, 20);
2777 source.process_bytes(b"abcdefghijklmnopqrst");
2778
2779 let mut target = assert_replay_round_trips(&mut source);
2780 assert_scrollback_views_round_trip(&mut source, &mut target);
2781
2782 source.resize(2, 24);
2783 target.resize(2, 24);
2784 let source_snapshot = source.render_snapshot();
2785 let target_snapshot = target.render_snapshot();
2786
2787 assert_eq!(target_snapshot.text, source_snapshot.text);
2788 assert_eq!(target_snapshot.color_lines, source_snapshot.color_lines);
2789 assert!(source_snapshot.text.starts_with("abcdefghijklmnopqrst"));
2790 }
2791
2792 #[test]
2793 fn replay_round_trips_underline_variants_and_hidden_cells() {
2794 let mut source = TerminalScreen::new(2, 8, 10);
2795 source.process_bytes(b"\x1b[4:2mD\x1b[4:3;58;2;1;2;3mC\x1b[4:4mO\x1b[4:5mA\x1b[8mH");
2796
2797 let target = assert_replay_round_trips(&mut source);
2798 for (col, flags) in [
2799 (0, CellFlags::DOUBLE_UNDERLINE),
2800 (1, CellFlags::UNDERCURL),
2801 (2, CellFlags::DOTTED_UNDERLINE),
2802 (3, CellFlags::DASHED_UNDERLINE),
2803 (4, CellFlags::HIDDEN),
2804 ] {
2805 let source_cell = &source.term.grid()[Line(0)][Column(col)];
2806 let target_cell = &target.term.grid()[Line(0)][Column(col)];
2807 assert!(source_cell.flags.contains(flags), "source col {col}");
2808 assert!(target_cell.flags.contains(flags), "target col {col}");
2809 assert_eq!(target_cell.flags & flags, source_cell.flags & flags);
2810 assert_eq!(target_cell.underline_color(), source_cell.underline_color());
2811 }
2812 }
2813
2814 #[test]
2815 fn replay_round_trips_wide_combining_and_modes() {
2816 let mut screen = TerminalScreen::new(3, 12, 10);
2817 screen.process_bytes("wide 漢e\u{301}".as_bytes());
2818 screen.process_bytes(b"\x1b[?25l\x1b[?1003h\x1b[?1006h\x1b[?1004h\x1b[?2004h\x1b[>3u");
2819
2820 assert_replay_round_trips(&mut screen);
2821 }
2822
2823 #[test]
2824 fn replay_export_is_idempotent() {
2825 let mut screen = TerminalScreen::new(3, 8, 10);
2826 screen.process_bytes(b"one\r\ntwo\r\nthree");
2827
2828 let first = screen.export_replay_bytes();
2829 let second = screen.export_replay_bytes();
2830
2831 assert_eq!(first, second);
2832 }
2833
2834 #[test]
2835 fn replay_alt_screen_preserves_source() {
2836 let mut screen = TerminalScreen::new(3, 10, 10);
2837 screen.process_bytes(b"primary\r\nline");
2838 screen.process_bytes(b"\x1b[?1049halt\x1b[32mscreen\x1b[2;3H");
2839 let before = screen.render_snapshot();
2840 let before_title = screen.title();
2841
2842 assert_replay_round_trips(&mut screen);
2843 let after = screen.render_snapshot();
2844
2845 assert_eq!(after.text, before.text);
2846 assert_eq!(after.color_lines, before.color_lines);
2847 assert_eq!(after.cursor_row, before.cursor_row);
2848 assert_eq!(after.cursor_col, before.cursor_col);
2849 assert_eq!(after.cursor_visible, before.cursor_visible);
2850 assert_eq!(screen.title(), before_title);
2851
2852 screen.process_bytes(b"Z");
2853 let after_input = screen.render_snapshot();
2854 assert!(
2855 after_input
2856 .text
2857 .lines()
2858 .nth(1)
2859 .is_some_and(|line| line.starts_with(" Z"))
2860 );
2861 }
2862
2863 #[test]
2864 fn cursor_defaults_to_blinking_block() {
2865 let mut screen = TerminalScreen::new(3, 10, 10);
2866 let snapshot = screen.render_snapshot();
2867 assert_eq!(snapshot.cursor_shape, CaretShape::Block);
2868 assert!(snapshot.cursor_blinking);
2869 }
2870
2871 #[test]
2872 fn key_modes_track_decckm_and_bracketed_paste() {
2873 let mut screen = TerminalScreen::new(3, 10, 10);
2874 assert_eq!(screen.key_modes(), TerminalKeyModes::default());
2875
2876 screen.process_bytes(b"\x1b[?1h\x1b[?2004h");
2879 let modes = screen.render_snapshot().key_modes;
2880 assert!(modes.app_cursor);
2881 assert!(modes.bracketed_paste);
2882 assert_eq!(screen.key_modes(), modes);
2883
2884 screen.process_bytes(b"\x1b[?1l\x1b[?2004l");
2886 let modes = screen.render_snapshot().key_modes;
2887 assert!(!modes.app_cursor);
2888 assert!(!modes.bracketed_paste);
2889 }
2890
2891 #[test]
2892 fn key_modes_track_pushed_kitty_keyboard_flags() {
2893 let mut screen = TerminalScreen::new(3, 10, 10);
2894 assert!(!screen.key_modes().kitty_keyboard.any());
2895
2896 screen.process_bytes(b"\x1b[>3u");
2899 let flags = screen.render_snapshot().key_modes.kitty_keyboard;
2900 assert!(flags.disambiguate_escape_codes);
2901 assert!(flags.report_event_types);
2902 assert!(!flags.report_alternate_keys);
2903 assert!(flags.any());
2904
2905 screen.process_bytes(b"\x1b[<1u");
2907 assert!(!screen.key_modes().kitty_keyboard.any());
2908 }
2909
2910 #[test]
2911 fn decscusr_sets_cursor_shape_and_blink() {
2912 let mut screen = TerminalScreen::new(3, 10, 10);
2913
2914 screen.process_bytes(b"\x1b[6 q");
2916 let snapshot = screen.render_snapshot();
2917 assert_eq!(snapshot.cursor_shape, CaretShape::Bar);
2918 assert!(!snapshot.cursor_blinking);
2919
2920 screen.process_bytes(b"\x1b[3 q");
2922 let snapshot = screen.render_snapshot();
2923 assert_eq!(snapshot.cursor_shape, CaretShape::Underline);
2924 assert!(snapshot.cursor_blinking);
2925
2926 screen.process_bytes(b"\x1b[2 q");
2928 let snapshot = screen.render_snapshot();
2929 assert_eq!(snapshot.cursor_shape, CaretShape::Block);
2930 assert!(!snapshot.cursor_blinking);
2931
2932 screen.process_bytes(b"\x1b[0 q");
2934 let snapshot = screen.render_snapshot();
2935 assert_eq!(snapshot.cursor_shape, CaretShape::Block);
2936 assert!(snapshot.cursor_blinking);
2937 }
2938
2939 #[test]
2940 fn export_text_reads_absolute_lines_without_mutating_offset() {
2941 let mut screen = TerminalScreen::new(3, 10, 20);
2942 screen.process_bytes(b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
2943 screen.set_scrollback(2);
2944 let offset_before = screen.scrollback_offset();
2945 let total = screen.total_text_lines();
2946 assert!(total >= 5);
2947
2948 let lines = screen.text_lines(0, total);
2949 assert!(lines.iter().any(|line| line.contains("one")));
2950 assert!(lines.iter().any(|line| line.contains("five")));
2951 assert_eq!(screen.scrollback_offset(), offset_before);
2952
2953 let last_two = screen.export_text(total.saturating_sub(2), total);
2954 assert!(last_two.contains("four"));
2955 assert!(last_two.contains("five"));
2956 assert_eq!(screen.scrollback_offset(), offset_before);
2957 }
2958
2959 #[test]
2960 fn absolute_line_to_viewport_maps_history_and_live_rows() {
2961 let mut screen = TerminalScreen::new(3, 10, 20);
2962 screen.process_bytes(b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
2963 let total = screen.total_text_lines();
2964
2965 let (oldest_offset, oldest_row) = screen.absolute_line_to_viewport(0).unwrap();
2966 assert_eq!(oldest_row, 0);
2967 assert!(oldest_offset > 0);
2968
2969 let (live_offset, live_row) = screen
2970 .absolute_line_to_viewport(total.saturating_sub(1))
2971 .unwrap();
2972 assert_eq!(live_offset, 0);
2973 assert!(live_row < 3);
2974
2975 assert_eq!(screen.absolute_line_to_viewport(total), None);
2976 }
2977
2978 #[test]
2979 fn export_text_clamps_evicted_and_empty_ranges() {
2980 let mut screen = TerminalScreen::new(2, 8, 3);
2981 for i in 0..20 {
2982 screen.process_bytes(format!("line{i}\r\n").as_bytes());
2983 }
2984 let total = screen.total_text_lines();
2985 assert!(total <= 2 + 3);
2986 assert!(screen.text_lines(total, total + 10).is_empty());
2987 assert_eq!(screen.export_text(0, 0), "");
2988 assert_eq!(screen.text_lines(0, total).len(), total);
2989 }
2990
2991 #[test]
2992 fn text_line_visitor_clamps_ranges_and_stops_immediately() {
2993 let mut screen = TerminalScreen::new(3, 8, 10);
2994 screen.process_bytes(b"one\r\ntwo\r\nthree");
2995 let total = screen.total_text_lines();
2996 let mut visited = Vec::new();
2997
2998 let flow = screen.try_for_each_text_line(1, usize::MAX, |absolute, line| {
2999 visited.push((absolute, line.to_owned()));
3000 ControlFlow::Break(())
3001 });
3002 assert_eq!(flow, ControlFlow::Break(()));
3003 assert_eq!(visited, [(1, "two".to_string())]);
3004
3005 let flow = screen.try_for_each_text_line(total + 10, usize::MAX, |_, _| {
3006 panic!("a fully clamped range must not invoke the visitor")
3007 });
3008 assert_eq!(flow, ControlFlow::Continue(()));
3009
3010 let flow = screen.try_for_each_text_line(2, 1, |_, _| {
3011 panic!("a reversed half-open range must be empty")
3012 });
3013 assert_eq!(flow, ControlFlow::Continue(()));
3014 }
3015
3016 #[test]
3017 fn streaming_text_lines_match_owned_exports_for_special_cells() {
3018 let mut screen = TerminalScreen::new(5, 20, 0);
3019 screen.process_bytes("\r\nwide 漢e\u{301}\r\n\r\n".as_bytes());
3020 screen.process_bytes(b"\x1b[8mH\x1b[0mX");
3021 let total = screen.total_text_lines();
3022 let expected = vec![
3023 String::new(),
3024 "wide 漢e\u{301}".to_string(),
3025 String::new(),
3026 " X".to_string(),
3027 String::new(),
3028 ];
3029 let mut streamed = Vec::new();
3030 let mut streamed_indices = Vec::new();
3031
3032 let flow = screen.try_for_each_text_line(0, total, |absolute, line| {
3033 streamed_indices.push(absolute);
3034 streamed.push(line.to_owned());
3035 ControlFlow::Continue(())
3036 });
3037
3038 assert_eq!(flow, ControlFlow::Continue(()));
3039 assert_eq!(streamed_indices, (0..total).collect::<Vec<_>>());
3040 assert_eq!(streamed, expected);
3041 assert_eq!(screen.text_lines(0, total), expected);
3042 assert_eq!(screen.export_text(0, total), "\nwide 漢e\u{301}\n\n X\n");
3043 }
3044
3045 #[cfg(feature = "terminal-images")]
3046 #[test]
3047 fn streaming_text_lines_match_owned_exports_for_image_placeholders() {
3048 let mut screen = TerminalScreen::new(1, 8, 0);
3049 screen.process_bytes(format!("{PLACEHOLDER}X").as_bytes());
3050 let mut streamed = Vec::new();
3051 let mut streamed_indices = Vec::new();
3052
3053 let flow = screen.try_for_each_text_line(0, usize::MAX, |absolute, line| {
3054 streamed_indices.push(absolute);
3055 streamed.push(line.to_owned());
3056 ControlFlow::Continue(())
3057 });
3058
3059 assert_eq!(flow, ControlFlow::Continue(()));
3060 assert_eq!(streamed_indices, [0]);
3061 assert_eq!(streamed, [" X"]);
3062 assert_eq!(screen.text_lines(0, usize::MAX), streamed);
3063 assert_eq!(screen.export_text(0, usize::MAX), " X");
3064 }
3065
3066 #[test]
3067 fn semantic_marks_track_prompt_and_output_ranges() {
3068 let mut screen = TerminalScreen::new(5, 20, 50);
3069 screen.process_bytes(b"\x1b]133;A\x1b\\");
3071 screen.process_bytes(b"\x1b]133;C\x1b\\");
3072 screen.process_bytes(b"hello\r\nworld\r\n");
3073 screen.process_bytes(b"\x1b]133;D;0\x1b\\");
3074 screen.process_bytes(b"\x1b]133;A\x1b\\");
3075
3076 let marks = screen.semantic_marks();
3077 assert!(
3078 marks
3079 .iter()
3080 .any(|m| m.kind == SemanticMarkKind::OutputStart)
3081 );
3082 assert!(
3083 marks
3084 .iter()
3085 .any(|m| { m.kind == SemanticMarkKind::OutputEnd && m.exit_status == Some(0) })
3086 );
3087 assert!(marks.iter().any(|m| m.kind == SemanticMarkKind::Prompt));
3088
3089 let (start, end) = screen.last_command_output_range().expect("range");
3090 let text = screen.export_text(start, end);
3091 assert!(text.contains("hello"));
3092 assert!(text.contains("world"));
3093
3094 screen.reset();
3095 assert!(screen.semantic_marks().is_empty());
3096 assert_eq!(screen.last_command_output_range(), None);
3097 }
3098
3099 #[test]
3100 fn running_command_output_range_extends_to_live_bottom() {
3101 let mut screen = TerminalScreen::new(4, 20, 20);
3102 screen.process_bytes(b"\x1b]133;A\x1b\\");
3103 screen.process_bytes(b"\x1b]133;C\x1b\\");
3104 screen.process_bytes(b"partial\r\n");
3105 let (start, end) = screen.last_command_output_range().expect("open range");
3106 assert!(end > start);
3107 assert_eq!(end, screen.total_text_lines());
3108 }
3109
3110 #[test]
3117 fn marks_survive_eviction_once_scrollback_saturates() {
3118 let mut screen = TerminalScreen::new(2, 20, 3);
3119 screen.process_bytes(b"\x1b]133;C\x1b\\");
3120 screen.process_bytes(b"MARKED\r\n");
3121 for i in 0..2 {
3122 screen.process_bytes(format!("filler{i}\r\n").as_bytes());
3123 }
3124
3125 let (start, _) = screen.last_command_output_range().expect("range");
3127 assert_eq!(
3128 screen.text_lines(start, start + 1),
3129 vec!["MARKED".to_string()]
3130 );
3131
3132 for i in 0..10 {
3135 screen.process_bytes(format!("more{i}\r\n").as_bytes());
3136 }
3137 assert!(
3138 !screen
3139 .text_lines(0, screen.total_text_lines())
3140 .iter()
3141 .any(|line| line.contains("MARKED")),
3142 "precondition: the marked line should have been evicted"
3143 );
3144 assert_eq!(
3145 screen.last_command_output_range(),
3146 None,
3147 "an evicted mark must be dropped, not left pointing at recycled lines"
3148 );
3149 }
3150
3151 #[test]
3152 fn alt_screen_marks_do_not_leak_onto_main_screen() {
3153 let mut screen = TerminalScreen::new(5, 20, 50);
3154 screen.process_bytes(b"\x1b[?1049h");
3155 screen.process_bytes(b"\x1b]133;A\x1b\\\x1b]133;C\x1b\\");
3156 screen.process_bytes(b"altstuff\r\n");
3157 screen.process_bytes(b"\x1b[?1049l");
3158 screen.process_bytes(b"back-on-main\r\n");
3159
3160 assert!(
3161 screen.semantic_marks().is_empty(),
3162 "alt-screen OSC 133 must not be replayed against main-screen lines"
3163 );
3164 assert_eq!(screen.last_command_output_range(), None);
3165 }
3166
3167 #[test]
3168 fn resize_keeps_scrollback_within_the_requested_limit() {
3169 let mut screen = TerminalScreen::new(6, 20, 3);
3170 for i in 0..20 {
3171 screen.process_bytes(format!("line{i}\r\n").as_bytes());
3172 }
3173 screen.resize(2, 20);
3175 assert!(
3176 screen.total_scrollback_rows() <= 3,
3177 "resize must not leave history above the exposed scrollback limit"
3178 );
3179 }
3180
3181 #[test]
3182 fn reflowing_resize_drops_semantic_marks() {
3183 let mut screen = TerminalScreen::new(4, 20, 20);
3184 screen.process_bytes(b"\x1b]133;C\x1b\\");
3185 screen.process_bytes(b"output\r\n");
3186 assert!(screen.last_command_output_range().is_some());
3187
3188 screen.resize(4, 10);
3190 assert!(
3191 screen.semantic_marks().is_empty(),
3192 "reflow invalidates line anchoring; marks must not survive it"
3193 );
3194 }
3195
3196 #[cfg(feature = "terminal-images")]
3197 mod images {
3198 use super::*;
3199 use crate::widgets::terminal::graphics::TerminalImageCrop;
3200 use base64::Engine as _;
3201 use base64::engine::general_purpose::STANDARD as BASE64;
3202
3203 fn place(width: u32, height: u32, keys: &str) -> Vec<u8> {
3205 let payload = BASE64.encode(vec![0x80u8; (width * height * 3) as usize]);
3206 format!("\x1b_Ga=T,f=24,s={width},v={height},t=d,{keys};{payload}\x1b\\").into_bytes()
3207 }
3208
3209 fn screen(rows: u16, cols: u16, scrollback: usize) -> TerminalScreen {
3210 let mut screen = TerminalScreen::new(rows, cols, scrollback);
3211 screen.set_cell_size(TerminalCellSize::new(10, 20));
3212 screen
3213 }
3214
3215 #[test]
3216 fn graphics_commands_never_reach_the_grid() {
3217 let mut screen = screen(6, 20, 10);
3218 let mut stream = b"before".to_vec();
3219 stream.extend_from_slice(&place(10, 20, "i=1,C=1"));
3220 stream.extend_from_slice(b"after");
3221 screen.process_bytes(&stream);
3222
3223 assert!(screen.snapshot().starts_with("beforeafter"));
3225 }
3226
3227 #[test]
3228 fn a_placement_lands_at_the_cursor_and_pushes_it_past_the_image() {
3229 let mut screen = screen(10, 20, 10);
3230 screen.process_bytes(b"x");
3231 screen.process_bytes(&place(30, 60, "i=1"));
3233
3234 let snapshot = screen.render_snapshot();
3235 assert_eq!(snapshot.images.len(), 1);
3236 let placement = &snapshot.images[0];
3237 assert_eq!((placement.row, placement.col), (0, 1));
3238 assert_eq!((placement.rows, placement.cols), (3, 3));
3239
3240 assert_eq!((snapshot.cursor_row, snapshot.cursor_col), (2, 4));
3242 }
3243
3244 #[test]
3245 fn an_image_scrolls_with_the_text_it_was_drawn_against() {
3246 let mut screen = screen(6, 20, 50);
3247 screen.process_bytes(&place(20, 100, "i=1,C=1"));
3249 assert_eq!(screen.render_snapshot().images[0].row, 0);
3250
3251 screen.process_bytes(b"\r\n".repeat(3).as_slice());
3253 assert_eq!(screen.render_snapshot().images[0].row, 0);
3254
3255 screen.process_bytes(b"\r\n".repeat(3).as_slice());
3258 assert_eq!(screen.render_snapshot().images[0].row, -1);
3259 }
3260
3261 #[test]
3262 fn scrolling_back_brings_an_image_back_into_view() {
3263 let mut screen = screen(4, 20, 50);
3264 screen.process_bytes(&place(20, 40, "i=1,C=1"));
3265 screen.process_bytes(b"\r\n".repeat(10).as_slice());
3266 assert!(screen.render_snapshot().images.is_empty());
3267
3268 screen.set_scrollback(10);
3269 let snapshot = screen.render_snapshot();
3270 assert_eq!(snapshot.images.len(), 1);
3271 assert_eq!(snapshot.images[0].row, 0);
3272 }
3273
3274 #[test]
3275 fn images_do_not_survive_the_alternate_screen_they_were_drawn_on() {
3276 let mut screen = screen(6, 20, 10);
3277 screen.process_bytes(b"\x1b[?1049h");
3278 screen.process_bytes(&place(20, 40, "i=1,C=1"));
3279 assert_eq!(screen.render_snapshot().images.len(), 1);
3280
3281 screen.process_bytes(b"\x1b[?1049l");
3282 assert!(screen.render_snapshot().images.is_empty());
3283 }
3284
3285 #[test]
3286 fn a_probe_is_answered_on_the_response_channel() {
3287 let mut screen = screen(6, 20, 10);
3288 let payload = BASE64.encode([1u8, 2, 3]);
3289 screen.process_bytes(
3290 format!("\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;{payload}\x1b\\").as_bytes(),
3291 );
3292
3293 let responses = screen.drain_responses();
3294 assert_eq!(responses.len(), 1);
3295 assert_eq!(responses[0], b"\x1b_Gi=31;OK\x1b\\");
3296 }
3297
3298 #[test]
3299 fn the_text_area_pixel_size_is_reported_from_the_installed_cell() {
3300 let mut screen = screen(24, 80, 10);
3301 screen.process_bytes(b"\x1b[14t");
3302
3303 let responses = screen.drain_responses();
3304 assert_eq!(responses, vec![b"\x1b[4;480;800t".to_vec()]);
3306 }
3307
3308 fn placeholders(id: u32, cols: u16, rows: u16, cell: TerminalCellSize) -> Vec<u8> {
3311 use crate::widgets::terminal::graphics::diacritic;
3312
3313 let (width, height) = (
3314 u32::from(cols) * u32::from(cell.width),
3315 u32::from(rows) * u32::from(cell.height),
3316 );
3317 let payload = BASE64.encode(vec![0x60u8; (width * height * 4) as usize]);
3318 let mut out = format!(
3319 "\x1b_Gq=2,i={id},a=T,U=1,f=32,t=d,s={width},v={height},m=0;{payload}\x1b\\"
3320 )
3321 .into_bytes();
3322
3323 let [id_extra, r, g, b] = id.to_be_bytes();
3324 for row in 0..rows {
3325 out.extend_from_slice(
3327 format!("\x1b[{};1H\x1b[38;2;{r};{g};{b}m", row + 1).as_bytes(),
3328 );
3329 let mut first = String::from(PLACEHOLDER);
3330 first.push(diacritic(row));
3331 first.push(diacritic(0));
3332 first.push(diacritic(u16::from(id_extra)));
3333 out.extend_from_slice(first.as_bytes());
3334 for _ in 1..cols {
3336 out.extend_from_slice(PLACEHOLDER.to_string().as_bytes());
3337 }
3338 }
3339 out
3340 }
3341
3342 #[test]
3343 fn placeholder_cells_place_a_virtual_image() {
3344 let cell = TerminalCellSize::new(10, 20);
3345 let mut screen = screen(10, 40, 50);
3346 screen.process_bytes(&placeholders(1, 6, 3, cell));
3347
3348 let snapshot = screen.render_snapshot();
3349 assert_eq!(snapshot.images.len(), 1);
3351 let placement = &snapshot.images[0];
3352 assert_eq!((placement.row, placement.col), (0, 0));
3353 assert_eq!((placement.rows, placement.cols), (3, 6));
3354 assert_eq!(
3355 placement.source_crop,
3356 Some(TerminalImageCrop {
3357 x: 0,
3358 y: 0,
3359 width: 60,
3360 height: 60,
3361 })
3362 );
3363 }
3364
3365 #[test]
3366 fn a_virtual_placement_draws_nothing_where_it_was_transmitted() {
3367 let mut screen = screen(10, 40, 50);
3368 let (width, height) = (60u32, 60u32);
3370 let payload = BASE64.encode(vec![0x60u8; (width * height * 4) as usize]);
3371 screen.process_bytes(
3372 format!("\x1b_Gq=2,i=1,a=T,U=1,f=32,t=d,s={width},v={height},m=0;{payload}\x1b\\")
3373 .as_bytes(),
3374 );
3375
3376 let snapshot = screen.render_snapshot();
3377 assert!(
3378 snapshot.images.is_empty(),
3379 "a virtual placement must not draw at the cursor"
3380 );
3381 assert_eq!((snapshot.cursor_row, snapshot.cursor_col), (0, 0));
3383 }
3384
3385 #[test]
3386 fn placeholder_cells_are_not_text() {
3387 let cell = TerminalCellSize::new(10, 20);
3388 let mut screen = screen(10, 40, 50);
3389 screen.process_bytes(&placeholders(1, 6, 2, cell));
3390
3391 let text = screen.snapshot();
3392 assert!(
3393 !text.contains(PLACEHOLDER),
3394 "the placeholder character must not reach text: {text:?}"
3395 );
3396 }
3397
3398 #[test]
3399 fn scrolling_clips_a_placeholder_image_to_the_cells_still_on_screen() {
3400 let cell = TerminalCellSize::new(10, 20);
3401 let mut screen = screen(6, 40, 50);
3402 screen.process_bytes(&placeholders(1, 4, 2, cell));
3403 assert_eq!(screen.render_snapshot().images[0].rows, 2);
3404
3405 screen.process_bytes(b"\x1b[6;1H\r\n");
3409 let snapshot = screen.render_snapshot();
3410 assert_eq!(snapshot.images.len(), 1);
3411 let placement = &snapshot.images[0];
3412 assert_eq!((placement.row, placement.rows), (0, 1));
3413 assert_eq!(placement.source_crop.unwrap().y, u32::from(cell.height));
3414
3415 screen.set_scrollback(1);
3417 assert_eq!(screen.render_snapshot().images[0].rows, 2);
3418 }
3419
3420 #[test]
3421 fn two_images_side_by_side_stay_separate() {
3422 let cell = TerminalCellSize::new(10, 20);
3423 let mut screen = screen(10, 40, 50);
3424 screen.process_bytes(&placeholders(1, 4, 2, cell));
3425 let mut second = placeholders(2, 4, 2, cell);
3427 let shifted = String::from_utf8(second.clone())
3428 .unwrap()
3429 .replace("\x1b[1;1H", "\x1b[1;20H")
3430 .replace("\x1b[2;1H", "\x1b[2;20H");
3431 second = shifted.into_bytes();
3432 screen.process_bytes(&second);
3433
3434 let snapshot = screen.render_snapshot();
3435 assert_eq!(snapshot.images.len(), 2);
3436 let cols: Vec<i32> = snapshot.images.iter().map(|image| image.col).collect();
3437 assert!(cols.contains(&0) && cols.contains(&19), "got {cols:?}");
3438 }
3439
3440 #[test]
3447 fn a_continuation_cell_inherits_the_high_byte_of_the_image_id() {
3448 use crate::widgets::terminal::graphics::diacritic;
3449
3450 let id: u32 = 0x02c5_fd02;
3452 let [id_extra, r, g, b] = id.to_be_bytes();
3453 assert_ne!(id_extra, 0, "the test is pointless with a small id");
3454
3455 let mut screen = screen(6, 40, 50);
3456 let payload = BASE64.encode(vec![0x40u8; (80 * 20 * 4) as usize]);
3457 screen.process_bytes(
3458 format!("\x1b_Gq=2,i={id},a=T,U=1,f=32,t=d,s=80,v=20,m=0;{payload}\x1b\\")
3459 .as_bytes(),
3460 );
3461
3462 let mut row = format!("\x1b[1;1H\x1b[38;2;{r};{g};{b}m");
3463 row.push(PLACEHOLDER);
3464 row.push(diacritic(0));
3465 row.push(diacritic(0));
3466 row.push(diacritic(u16::from(id_extra)));
3467 for _ in 1..8 {
3468 row.push(PLACEHOLDER);
3469 }
3470 screen.process_bytes(row.as_bytes());
3471
3472 let snapshot = screen.render_snapshot();
3473 assert_eq!(snapshot.images.len(), 1);
3474 assert_eq!(
3475 snapshot.images[0].cols, 8,
3476 "the row collapsed to its first cell"
3477 );
3478 }
3479
3480 #[test]
3481 fn a_hard_reset_clears_stored_images() {
3482 let mut screen = screen(6, 20, 10);
3483 screen.process_bytes(&place(20, 40, "i=1,C=1"));
3484 assert_eq!(screen.render_snapshot().images.len(), 1);
3485
3486 screen.reset();
3487 assert!(screen.render_snapshot().images.is_empty());
3488 }
3489
3490 #[test]
3491 fn a_width_change_that_rewraps_drops_placements() {
3492 let mut screen = screen(8, 20, 50);
3493 screen.process_bytes("x".repeat(35).as_bytes());
3496 screen.process_bytes(b"\r\n");
3497 screen.process_bytes(&place(20, 40, "i=1,C=1"));
3498 assert_eq!(screen.render_snapshot().images.len(), 1);
3499
3500 screen.resize(8, 40);
3501 assert!(screen.render_snapshot().images.is_empty());
3502 }
3503
3504 #[test]
3508 fn a_width_change_that_rewraps_nothing_keeps_placements() {
3509 let mut screen = screen(8, 40, 50);
3510 screen.process_bytes(b"short\r\n");
3511 screen.process_bytes(&place(20, 40, "i=1,C=1"));
3512 assert_eq!(screen.render_snapshot().images.len(), 1);
3513
3514 screen.resize(8, 60);
3516 assert_eq!(
3517 screen.render_snapshot().images.len(),
3518 1,
3519 "no text moved, so nothing anchored to it should have"
3520 );
3521 }
3522
3523 #[test]
3527 fn identical_images_keep_separate_ids() {
3528 let mut screen = screen(20, 40, 50);
3529 screen.process_bytes(&place(20, 40, "i=1,C=1"));
3530 screen.process_bytes(b"\r\n\r\n\r\n");
3531 screen.process_bytes(&place(20, 40, "i=2,C=1"));
3532
3533 let snapshot = screen.render_snapshot();
3534 assert_eq!(snapshot.images.len(), 2);
3535 assert_eq!(
3536 snapshot.images[0].image.source_hash(),
3537 snapshot.images[1].image.source_hash(),
3538 "the pixels really are identical, which is what makes this worth pinning"
3539 );
3540 assert_ne!(
3541 snapshot.images[0].image_id, snapshot.images[1].image_id,
3542 "but the placements are not, and the renderer keys on that"
3543 );
3544 }
3545 }
3546
3547 #[test]
3548 fn scrollback_depth_matches_requested_limit() {
3549 let mut screen = TerminalScreen::new(2, 20, 3);
3550 for i in 0..50 {
3551 screen.process_bytes(format!("line{i}\r\n").as_bytes());
3552 }
3553 assert_eq!(screen.total_scrollback_rows(), 3);
3555 assert_eq!(screen.total_text_lines(), 5);
3556 }
3557}