Skip to main content

tui_lipan/widgets/terminal/
screen.rs

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/// Kind of semantic mark anchored to an absolute text line.
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum SemanticMarkKind {
40    /// `OSC 133;A` — shell drawing a prompt.
41    Prompt,
42    /// `OSC 133;C` — command output started.
43    OutputStart,
44    /// `OSC 133;D` — command output ended.
45    OutputEnd,
46}
47
48/// A semantic mark recorded against the absolute text-line space used by
49/// [`TerminalScreen::total_text_lines`] / [`TerminalScreen::export_text`].
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub struct SemanticMark {
52    /// Prompt / output-start / output-end.
53    pub kind: SemanticMarkKind,
54    /// Absolute line index (`0` = oldest retained history line).
55    pub absolute_line: usize,
56    /// Exit status from `OSC 133;D`, when present.
57    pub exit_status: Option<i32>,
58}
59
60const MAX_SEMANTIC_MARKS: usize = 256;
61
62/// Cursor style applied when the child program never issues `DECSCUSR`.
63///
64/// A blinking block matches the historical default and the common terminal
65/// baseline; explicit `CSI Ps SP q` sequences from the child override it.
66const DEFAULT_CURSOR_STYLE: TermCursorStyle = TermCursorStyle {
67    shape: TermCursorShape::Block,
68    blinking: true,
69};
70
71/// Map an `alacritty_terminal` cursor shape to the framework [`CaretShape`].
72///
73/// `HollowBlock`/`Hidden` collapse to `Block`; visibility is tracked separately
74/// via `cursor_visible`.
75fn 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/// Terminal viewport dimensions in character cells.
86#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87pub struct TerminalViewport {
88    /// Visible columns in the terminal viewport.
89    pub cols: u16,
90    /// Visible rows in the terminal viewport.
91    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/// Event listener that captures PtyWrite events for forwarding to the PTY.
114///
115/// Cell size in pixels, as the host draws it.
116///
117/// A terminal's own contents are cells, but programs that draw pictures need pixels: they read the
118/// PTY's `TIOCGWINSZ` pixel fields or ask with `CSI 14 t`, then size their output against the
119/// answer. Both are reported from the value installed with
120/// [`TerminalScreen::set_cell_size`](TerminalScreen::set_cell_size), so what a child computes and
121/// what this screen lays out agree.
122#[derive(Clone, Copy, Debug, PartialEq, Eq)]
123pub struct TerminalCellSize {
124    /// Cell width in pixels.
125    pub width: u16,
126    /// Cell height in pixels.
127    pub height: u16,
128}
129
130impl Default for TerminalCellSize {
131    /// 10x20, the same guess the image encoder falls back to when the host answers no size query.
132    ///
133    /// Apps that can measure the host should install
134    /// [`host_cell_size`](crate::host_cell_size) instead of relying on this.
135    fn default() -> Self {
136        Self::new(10, 20)
137    }
138}
139
140impl TerminalCellSize {
141    /// Clamp both axes to at least one pixel.
142    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/// When the terminal parser encounters escape sequences that require a response
151/// (e.g., device attributes queries, cursor position reports), alacritty_terminal
152/// generates `Event::PtyWrite` events. This listener captures those responses
153/// so they can be written back to the PTY.
154#[derive(Clone, Default)]
155struct ResponseCapture {
156    responses: Rc<RefCell<Vec<Vec<u8>>>>,
157    /// Number of BEL events emitted by the terminal parser.
158    bell_count: Rc<Cell<u64>>,
159    /// Latest window title set by the program via OSC 0/2; `None` once reset.
160    title: Rc<RefCell<Option<String>>>,
161    /// The active palette, shared with [`TerminalScreen`], used to answer
162    /// `OSC 4/10/11 ; ?` color queries so guest programs don't block waiting
163    /// for a reply (see [`Self::resolve_query_color`]).
164    palette: Rc<RefCell<TerminalColorPalette>>,
165    /// Viewport geometry, shared with [`TerminalScreen`], used to answer `CSI 14 t` (text-area
166    /// size in pixels). Programs that draw images ask this when the PTY reports no pixel
167    /// dimensions, and block on the reply.
168    viewport: Rc<Cell<ViewportGeometry>>,
169}
170
171/// The viewport in both units, which is what a pixel-size report needs.
172#[derive(Clone, Copy, Debug, Default)]
173struct ViewportGeometry {
174    rows: u16,
175    cols: u16,
176    cell: TerminalCellSize,
177}
178
179impl ResponseCapture {
180    /// Resolve the RGB a color query (`OSC 4/10/11 ; ?`) should report for the
181    /// alacritty color slot `index`, using the active palette. Slots are:
182    /// `0..16` themed ANSI, `16..256` the standard 256-color cube/grayscale
183    /// ramp, `256` foreground, `257`/`268` background, others foreground-ish.
184    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            // Answer color queries from the active palette. Without this the
213            // guest blocks until its own timeout (e.g. tui-lipan's host-color
214            // refresh), since alacritty delegates the reply to the listener.
215            TermEvent::ColorRequest(index, formatter) => {
216                let response = formatter(self.resolve_query_color(index));
217                self.responses.borrow_mut().push(response.into_bytes());
218            }
219            // Report the text area in pixels. Without this a child that measures itself
220            // before drawing an image waits out its own timeout, since alacritty delegates the
221            // reply to the listener.
222            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            // Ignore other events (Clipboard, etc.) for now
233            _ => {}
234        }
235    }
236}
237
238/// Alacritty terminal screen parser for PTY output.
239pub struct TerminalScreen {
240    processor: VteProcessor,
241    term: Term<ResponseCapture>,
242    listener: ResponseCapture,
243    /// Parallel OSC 7/9;9/133 observer, driven by the same raw bytes as `processor`.
244    ///
245    /// Kept entirely separate from the Alacritty grid parser above: it never sees a callback
246    /// besides `osc_dispatch`, so it cannot affect rendering, and its state is deliberately not
247    /// part of `TerminalRenderSnapshot`.
248    semantic_parser: SemanticVteParser,
249    semantic: SemanticObserver,
250    /// Logical viewport rows (matches the PTY size).
251    rows: u16,
252    /// Logical viewport cols (matches the PTY size).
253    cols: u16,
254    scrollback_len: usize,
255    /// Grid capacity backing `scrollback_len`, including ledger headroom.
256    ledger_capacity: usize,
257    mouse_mode: MouseModeState,
258    scrollback_offset: usize,
259    cache: TerminalRenderSnapshot,
260    palette: TerminalColorPalette,
261    dirty: bool,
262    sequence: u64,
263    /// Bounded history of OSC 133 marks anchored to absolute text lines.
264    semantic_marks: VecDeque<SemanticMark>,
265    /// How many semantic events have already been turned into marks (reset on drain).
266    semantic_events_seen: usize,
267    /// Splits `APC _G` graphics commands out of the byte stream before the grid parser sees it.
268    #[cfg(feature = "terminal-images")]
269    graphics_scanner: GraphicsScanner,
270    /// Decoded images and their placements, anchored to the same absolute lines as the marks.
271    #[cfg(feature = "terminal-images")]
272    graphics: TerminalGraphics,
273    /// Whether the alternate screen was active after the last chunk, so leaving it can drop the
274    /// placements that belonged to it.
275    #[cfg(feature = "terminal-images")]
276    graphics_alt_screen: bool,
277    /// Cumulative scrollback lines evicted since creation.
278    evicted_lines: u64,
279    /// Bumped when absolute line indices are invalidated.
280    history_epoch: u64,
281    /// Whether the alternate screen was active after the last update.
282    alt_screen: bool,
283    /// Host cell size, reported to the child and used to size image placements.
284    cell_size: TerminalCellSize,
285}
286
287/// A [`TerminalScreen`] an app owns and lets the widget read for itself.
288///
289/// Handing the widget a handle instead of a [`TerminalRenderSnapshot`] takes the screen's contents
290/// out of the element tree, which is what lets new output be a repaint rather than a rebuild: the
291/// element a `view()` produces no longer changes when the child program draws, so an app can answer
292/// output with [`Update::paint`] and the runtime pulls the current snapshot on its way to the
293/// screen. Without this, every chunk of terminal output forces `view()` + layout for the whole
294/// window — which for a multiplexer means the cost of one pane streaming is paid by all of them.
295///
296/// [`Update::paint`]: crate::Update::paint
297#[derive(Clone)]
298pub struct TerminalScreenHandle(Rc<RefCell<TerminalScreen>>);
299
300impl TerminalScreenHandle {
301    /// Share `screen` with the widget.
302    pub fn new(screen: Rc<RefCell<TerminalScreen>>) -> Self {
303        Self(screen)
304    }
305
306    /// The screen's current snapshot, rebuilding it only if the screen took output since the last
307    /// call (see [`TerminalScreen::render_snapshot`]).
308    pub fn snapshot(&self) -> TerminalRenderSnapshot {
309        self.0.borrow_mut().render_snapshot()
310    }
311
312    /// Extract selected text across retained scrollback using display columns.
313    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
325/// Identity, not contents: two handles are the same handle when they share one screen. Comparing
326/// contents would defeat the purpose, since the point is an element that holds still while the
327/// screen behind it moves.
328impl 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/// Renderable terminal snapshot from `TerminalScreen`.
348#[derive(Clone, Debug)]
349pub struct TerminalRenderSnapshot {
350    /// Plain visible contents.
351    pub text: Arc<str>,
352    /// Styled lines matching `text` logical lines.
353    pub color_lines: Arc<[Vec<Span>]>,
354    /// Cursor row in the visible viewport.
355    pub cursor_row: u16,
356    /// Cursor column in the visible viewport.
357    pub cursor_col: u16,
358    /// Whether cursor should be displayed.
359    pub cursor_visible: bool,
360    /// Shape the child program requested for the cursor (via `DECSCUSR`).
361    pub cursor_shape: CaretShape,
362    /// Whether the child program requested a blinking cursor (via `DECSCUSR`).
363    pub cursor_blinking: bool,
364    /// Stable sequence key for cache invalidation.
365    pub sequence: u64,
366    /// Current scrollback offset (0 = live view, >0 = scrolled into history).
367    pub scrollback_offset: usize,
368    /// Total number of scrollback rows available.
369    pub total_scrollback_rows: usize,
370    /// Cumulative scrollback lines evicted since creation.
371    pub evicted_lines: u64,
372    /// Bumped when absolute line indices are invalidated.
373    pub history_epoch: u64,
374    /// Current mouse mode state.
375    pub mouse_mode: MouseModeState,
376    /// Input-affecting DEC private modes the child has enabled (DECCKM, bracketed paste).
377    pub key_modes: TerminalKeyModes,
378    /// Images overlapping the visible rows, back to front.
379    ///
380    /// Positions are viewport-relative and may start above or left of it, so a partly scrolled
381    /// image reports the rect it would occupy in full and the renderer crops the pixels.
382    #[cfg(feature = "terminal-images")]
383    pub images: Arc<[TerminalImagePlacement]>,
384}
385
386/// A display-column decoration applied to a terminal render snapshot.
387///
388/// Decorations affect only [`TerminalRenderSnapshot::color_lines`]. The snapshot's plain `text`
389/// remains unchanged so callers that scan plain snapshot text continue to see the terminal's
390/// original contents. Use [`Self::highlight`] for a restyled range and [`Self::label`] for an
391/// inserted span.
392#[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    /// Highlight the half-open display-column range `cols` on `row`.
403    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    /// Insert a styled label at a display column on `row`.
414    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    /// Build a render snapshot from owned parts.
450    ///
451    /// This constructor is intended for external render-snapshot transports that
452    /// keep their own versioned wire format. It does not make
453    /// `TerminalRenderSnapshot` itself a stable wire protocol.
454    #[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    /// Attach scrollback lineage counters to an externally built snapshot.
488    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    /// Extract a selection from the styled visible grid using display columns.
532    ///
533    /// This deliberately reads [`Self::color_lines`] rather than [`Self::text`], because the
534    /// latter has no display-column mapping once wide characters are present. Call it on the
535    /// undecorated snapshot when labels or other render-only overlays must not be copied.
536    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    /// Return a copy of this snapshot with display-column decorations applied to its styled lines.
546    ///
547    /// Decorations are grouped and restyled once per row, then inserted labels are applied from
548    /// right to left so earlier display columns remain stable. The plain [`Self::text`] is left
549    /// unchanged by design, keeping render-only overlays out of plain-text scanners. Copy from
550    /// the undecorated snapshot when labels should not be included. The sequence combines the
551    /// source sequence with an order-sensitive decoration hash.
552    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/// Color palette used to resolve terminal ANSI/default colors into concrete UI colors.
607///
608/// This affects render snapshots produced by [`TerminalScreen`]. Truecolor escape
609/// sequences are preserved as-is; 16-color ANSI slots and default foreground/background
610/// colors are resolved through this palette.
611#[derive(Clone, Copy, Debug, PartialEq, Eq)]
612pub struct TerminalColorPalette {
613    /// Terminal default foreground (`SGR 39`, [`NamedColor::Foreground`]).
614    pub foreground: Option<UiColor>,
615    /// Terminal default background (`SGR 49`, [`NamedColor::Background`]).
616    pub background: Option<UiColor>,
617    /// ANSI slots 0..15: black, red, green, yellow, blue, magenta, cyan, white,
618    /// then bright black through bright white.
619    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    /// Create a palette from default foreground/background colors and 16 ANSI slots.
634    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    /// Create a terminal palette from a probed host terminal palette.
643    ///
644    /// The host default foreground and ANSI 0..15 slots are preserved exactly, while
645    /// `background` becomes the emulated terminal's default background. This lets
646    /// apps keep ANSI colors faithful to the real terminal while still choosing an
647    /// app-owned surface color for embedded terminal panes.
648    pub fn from_host_colors(colors: HostTerminalColors, background: UiColor) -> Self {
649        Self::new(colors.fg, background, colors.ansi)
650    }
651
652    /// Create a terminal palette from an application theme.
653    ///
654    /// A [`HostTerminalColors`] theme extension takes precedence so a probed ANSI palette is
655    /// preserved exactly. Otherwise the palette is derived from the theme's semantic status,
656    /// icon, accent, and muted colors. `background` is resolved to black when it is a sentinel,
657    /// matching terminal protocol defaults.
658    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    /// Set the terminal default foreground color.
706    pub fn foreground(mut self, color: Option<UiColor>) -> Self {
707        self.foreground = color;
708        self
709    }
710
711    /// Set the terminal default background color.
712    pub fn background(mut self, color: Option<UiColor>) -> Self {
713        self.background = color;
714        self
715    }
716
717    /// Set all 16 ANSI color slots.
718    pub fn ansi(mut self, ansi: [UiColor; 16]) -> Self {
719        self.ansi = ansi;
720        self
721    }
722}
723
724impl TerminalScreen {
725    /// Create an Alacritty terminal-backed screen with bounded scrollback.
726    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        // Extra headroom above the exposed scrollback so the grid can never saturate
734        // inside a single handler call; `LedgerTerm` trims back down to `scrollback`
735        // and counts what it dropped. See `scrollback_ledger`.
736        let capacity = ledger_capacity(scrollback, rows);
737        let config = TermConfig {
738            scrolling_history: capacity,
739            default_cursor_style: DEFAULT_CURSOR_STYLE,
740            // Track Kitty keyboard protocol pushes so `key_modes()` can report what the child
741            // negotiated; without this alacritty silently drops every `CSI > <flags> u`.
742            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    /// Feed terminal bytes.
781    ///
782    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            // Alt-screen programs still emit OSC 133, but those marks belong to a grid
797            // with no scrollback and no absolute-line space of its own. Discard them
798            // rather than leaving them pending, or they get replayed against
799            // main-screen coordinates the moment the alt screen is torn down.
800            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    /// Drive the grid parser, returning how many scrollback lines the chunk evicted.
810    ///
811    /// With image support compiled in, graphics commands are lifted out of the stream first and
812    /// the grid sees only what is left, plus whatever cursor movement each placement implies. The
813    /// VT parser discards `APC` bodies anyway, so removing them changes nothing it would have
814    /// done - what it buys is the cursor position *at* each command, which a parser running
815    /// alongside this one could not observe.
816    #[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    /// Run one graphics command against the store, then apply what it implies to the grid.
848    #[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        // The protocol leaves the cursor just past the image: down by its height minus one, right
867        // by its width. Synthesizing that as real output rather than moving the cursor directly is
868        // what makes the grid scroll to make room, exactly as it would for the same many lines of
869        // text - which in turn is what keeps the placement's absolute-line anchor meaningful.
870        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    /// Every image overlapping the viewport: those placed at a cursor, and those the grid names
876    /// with placeholder cells.
877    ///
878    /// Both kinds are wanted at once - a session can have `icat` output scrolled up the pane while
879    /// a TUI below it draws with placeholders - so the two lists are simply concatenated and left
880    /// in back-to-front order.
881    #[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    /// Read the viewport's placeholder cells, left to right and top to bottom.
901    ///
902    /// Walks the grid directly rather than the render iterator: the marks that carry a cell's
903    /// position inside its image are zero-width characters, which the styled-span path folds into
904    /// text and cannot be recovered from.
905    #[cfg(feature = "terminal-images")]
906    fn placeholder_cells(&self, display_offset: usize) -> Vec<PlaceholderCell> {
907        let mut cells = Vec::new();
908        // Nothing can name an image that was never transmitted, so a session that has seen no
909        // graphics never pays for this walk.
910        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    /// Bring image placements back in line with the grid after a chunk.
940    #[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            // The alternate screen is gone, and so is everything drawn on it.
946            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    /// Set the host's cell size in pixels.
955    ///
956    /// This is what `CSI 14 t` reports and, with the `terminal-images` feature, what decides how
957    /// many cells an image covers. Install the same value the child is told through the PTY (see
958    /// [`TerminalPty::resize_with_cell_size`](super::TerminalPty::resize_with_cell_size)), so a
959    /// program that sizes a picture for itself and this screen agree; a mismatch shows up as
960    /// images that overlap the text below them or leave a gap.
961    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    /// The cell size reported to the child.
970    pub fn cell_size(&self) -> TerminalCellSize {
971        self.cell_size
972    }
973
974    /// Whether resizing to `new_cols` would move text between lines.
975    ///
976    /// Only two things can: a line already wrapped into the next one, which widening would pull
977    /// back up, and a line reaching past the new width, which narrowing would push down. With
978    /// neither present every line keeps exactly the text it has, and so does anything anchored to
979    /// it. Worth the scan - the alternative is treating every width change as a rewrap, which in a
980    /// tiling multiplexer costs a pane every image in it each time a neighbour opens.
981    #[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            // Anything past the new width has to go somewhere once the grid narrows.
991            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    /// Republish the viewport to the listener that answers pixel-size queries.
1002    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    /// Cap the decoded pixels this screen retains, in bytes.
1011    ///
1012    /// Images past the cap are dropped least-recently-used, placements included. The default is
1013    /// 96 MiB, which is roughly sixteen 1080p frames.
1014    #[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    /// Return the current working-directory/command-lifecycle state accumulated from `OSC
1021    /// 7`/`OSC 9;9`/`OSC 133` sequences seen so far.
1022    ///
1023    /// This is runtime metadata, not render state: it is never part of
1024    /// [`TerminalRenderSnapshot`] and does not participate in `dirty`/cache invalidation.
1025    pub fn semantic_state(&self) -> TerminalSemanticState {
1026        self.semantic.state()
1027    }
1028
1029    /// Drain semantic-state changes observed since the last call.
1030    ///
1031    /// Call this after [`process_bytes`](Self::process_bytes) alongside
1032    /// [`drain_responses`](Self::drain_responses) to react to CWD/command-phase/executable
1033    /// changes without re-deriving them from [`semantic_state`](Self::semantic_state) on every
1034    /// poll.
1035    pub fn drain_semantic_events(&mut self) -> Vec<TerminalSemanticEvent> {
1036        self.semantic_events_seen = 0;
1037        self.semantic.drain_events()
1038    }
1039
1040    /// Current scrollback lineage counters for selection rebasing.
1041    pub fn scrollback_lineage(&self) -> ScrollbackLineage {
1042        ScrollbackLineage {
1043            evicted_lines: self.evicted_lines,
1044            history_epoch: self.history_epoch,
1045        }
1046    }
1047
1048    /// Export an absolute selection using display columns across retained scrollback lines.
1049    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    /// Reapply previously captured semantic state without replaying escape sequences.
1092    ///
1093    /// Intended for restoring state across a fresh `TerminalScreen` (e.g. session
1094    /// resurrection/reattach) where the byte stream that originally produced it is not being
1095    /// replayed. Does not emit [`TerminalSemanticEvent`]s - the caller already knows the state
1096    /// it is installing.
1097    pub fn restore_semantic_state(&mut self, state: TerminalSemanticState) {
1098        self.semantic.restore_state(state);
1099    }
1100
1101    /// Drain and return any PTY responses that need to be written back.
1102    ///
1103    /// Call this after `process_bytes()` to get responses like device attribute
1104    /// queries, cursor position reports, etc. These should be written back to
1105    /// the PTY stdin.
1106    pub fn drain_responses(&mut self) -> Vec<Vec<u8>> {
1107        std::mem::take(&mut *self.listener.responses.borrow_mut())
1108    }
1109
1110    /// Return the number of BEL events received since this screen was created.
1111    pub fn bell_count(&self) -> u64 {
1112        self.listener.bell_count.get()
1113    }
1114
1115    /// Serialize the current terminal state as bytes that can be replayed by a
1116    /// fresh same-sized [`TerminalScreen`].
1117    ///
1118    /// The stream captures scrollback, primary/alternate screen contents, the
1119    /// current cursor position/template, title, and common terminal modes. It is
1120    /// intentionally a replay stream rather than a stable data format: replaying
1121    /// it goes through the normal VTE parser and future parser fixes naturally
1122    /// apply to exported state.
1123    ///
1124    /// Non-goals: tab stops, custom scrolling regions, cursor style, kitty
1125    /// keyboard stack depth (the effective flags are preserved), hyperlinks,
1126    /// and the current display offset. The receiver lands on the live view.
1127    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            // Switching primary -> alt clears the alt grid, so immediately
1144            // replay the synthesized alt repaint to restore the source screen.
1145            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    /// Resize screen dimensions.
1170    ///
1171    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        // `Term::resize` can push lines into history without going through a handler
1184        // call, so the ledger never sees it; trim and account for it here.
1185        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            // A column change rewraps history, so line indices no longer refer to the
1193            // text they were recorded against and cannot be corrected by a shift.
1194            self.semantic_marks.clear();
1195            // Images are dropped on the same reasoning, but only when the rewrap actually
1196            // happened. Treating every width change as a rewrap costs every image in the pane on
1197            // every resize, which in a tiling multiplexer is every split - and a pane full of
1198            // plots going blank because a neighbour opened is worse than the drift this risks.
1199            #[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    /// Return current visible screen contents.
1218    pub fn snapshot(&mut self) -> Arc<str> {
1219        self.render_snapshot().text
1220    }
1221
1222    /// Return full render snapshot (text, colors, cursor).
1223    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    /// Return the current terminal color palette.
1291    pub fn palette(&self) -> TerminalColorPalette {
1292        self.palette
1293    }
1294
1295    /// Set the terminal color palette used for future render snapshots.
1296    pub fn set_palette(&mut self, palette: TerminalColorPalette) {
1297        if self.palette != palette {
1298            self.palette = palette;
1299            // Keep the listener's copy in sync so `OSC 4/10/11 ; ?` color
1300            // queries are answered against the current palette.
1301            *self.listener.palette.borrow_mut() = palette;
1302            self.dirty = true;
1303        }
1304    }
1305
1306    /// Return the current scrollback offset (0 = live view).
1307    pub fn scrollback_offset(&self) -> usize {
1308        self.scrollback_offset
1309    }
1310
1311    /// Set the scrollback viewing offset.
1312    ///
1313    /// 0 = live view (bottom of scrollback), positive values scroll into
1314    /// history. The value is clamped to the actual scrollback size.
1315    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    /// Probe total scrollback rows available.
1328    pub fn total_scrollback_rows(&mut self) -> usize {
1329        self.term.history_size()
1330    }
1331
1332    /// Number of plain-text lines currently retained (scrollback history + visible screen).
1333    ///
1334    /// Absolute line indices used by [`text_lines`](Self::text_lines) /
1335    /// [`export_text`](Self::export_text) count from the oldest retained history line (`0`)
1336    /// through the live bottom (`total_text_lines() - 1`).
1337    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    /// Visit plain-text grid lines in `[start, end)`, addressed from the oldest retained line.
1345    ///
1346    /// Bounds are clamped to the retained line count. Each visit receives its absolute retained-line
1347    /// index and text. The same scratch allocation is reused for every line, so the `&str` passed
1348    /// to `visitor` is valid only for that callback invocation. Returning [`ControlFlow::Break`]
1349    /// stops before any later line is extracted.
1350    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    /// Plain text of grid lines in `[start, end)`, addressed from the oldest retained line.
1372    ///
1373    /// Does not mutate display offset or go through the render pipeline. Out-of-range bounds
1374    /// are clamped; empty ranges yield an empty vec.
1375    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    /// Newline-joined plain text for an absolute line range. See [`text_lines`](Self::text_lines).
1388    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    /// Export a selection across retained scrollback lines.
1403    ///
1404    /// This path is intentionally **character-indexed** and uses the pre-trimmed text returned by
1405    /// [`text_lines`](Self::text_lines). It is separate from the display-column snapshot path:
1406    /// changing `push_plain_line_text` here would alter [`Self::export_text`] and semantic output
1407    /// exports. With [`SelectionEnd::Inclusive`], both the start and end character positions are
1408    /// included.
1409    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    /// Map an absolute text-line index to `(scrollback_offset, viewport_row)`.
1432    ///
1433    /// Returns `None` when the line is outside the currently retained grid (evicted or
1434    /// out of range). History lines are placed at viewport row 0; live-viewport lines use
1435    /// offset 0 and their on-screen row.
1436    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    /// Map an absolute text-line index to a scrollback display offset.
1453    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    /// Retained OSC 133 marks, oldest first (after eviction GC).
1459    pub fn semantic_marks(&self) -> Vec<SemanticMark> {
1460        self.semantic_marks.iter().copied().collect()
1461    }
1462
1463    /// Half-open absolute-line range `[start, end)` of the last command's output.
1464    ///
1465    /// Uses the last `OutputStart` paired with a following `OutputEnd`. While a command is still
1466    /// running (start without end), falls back to `[start, total_text_lines())`.
1467    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    /// Plain text of [`last_command_output_range`](Self::last_command_output_range), when known.
1484    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    /// Shift marks down by the lines that just fell out of scrollback, dropping
1497    /// those whose line is gone.
1498    ///
1499    /// `evicted` comes from [`LedgerTerm`], which counts evictions as they happen.
1500    /// It cannot be re-derived from the grid afterwards: once scrollback is full,
1501    /// `topmost_line()` and `history_size()` are pinned while content shifts, so a
1502    /// post-hoc comparison sees nothing and marks silently drift onto unrelated
1503    /// lines.
1504    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    /// Consume pending semantic events without recording marks for them.
1516    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    /// Clear parser state and screen.
1556    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        // Drop any in-flight partial OSC/CSI sequence, but keep accumulated semantic state
1572        // (cwd/command phase/executable) - a child hard-reset (RIS) does not imply the shell's
1573        // last-known working directory or command lifecycle became invalid.
1574        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        // Images, unlike semantic state, are screen contents: a hard reset clears them with the
1584        // grid they were drawn on.
1585        #[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    /// Get current mouse mode state.
1595    pub fn mouse_mode(&self) -> MouseModeState {
1596        self.mouse_mode
1597    }
1598
1599    /// Get the input-affecting DEC private modes the child has enabled.
1600    ///
1601    /// Pass this to [`key_event_to_bytes`](super::key_event_to_bytes) and
1602    /// [`encode_paste`](super::encode_paste) when wiring a `TerminalPty` by hand.
1603    pub fn key_modes(&self) -> TerminalKeyModes {
1604        key_modes_from_term(*self.term.mode())
1605    }
1606
1607    /// The window title the program has set via OSC 0/2 (e.g. the shell's
1608    /// `$PWD` or a running program's name). Returns `None` if no title has been
1609    /// set or it was reset. Updated as bytes are processed.
1610    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        // No ED 2 here: on alacritty's primary screen it scrolls the cleared
1634        // viewport into history, adding a phantom scrollback row. The preceding
1635        // RIS (primary) or DECSET 1049 (alt) already blanks the target grid.
1636        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    // An image placeholder is not text: it names a picture the renderer paints over these cells.
2089    // Passing it through would put a tofu box under every image, and would put the character into
2090    // anything that reads the snapshot - a search, a copy, an exported log.
2091    #[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/// The low 24 bits of the image id a placeholder cell names, from its foreground colour.
2168///
2169/// The protocol puts the id in the colour so a row of placeholders needs one escape sequence
2170/// rather than one per cell. A cell with no explicit foreground names no image.
2171#[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        // Query ANSI slot 1 (OSC 4), default foreground (OSC 10) and background (OSC 11).
2746        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        // Slot 1 reports the themed palette color (8-bit channels doubled to 16-bit).
2755        assert!(joined.contains("]4;1;rgb:abab/cdcd/efef"), "{joined:?}");
2756        // OSC 10/11 report the configured default fg/bg.
2757        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        // DECSET 1 (DECCKM) and DECSET 2004 (bracketed paste), as ncurses' `smkx` and a
2877        // line editor's paste guard would send them.
2878        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        // DECRST puts them back; a child that exits application mode must stop getting SS3.
2885        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        // `CSI > 3 u`: exactly what tui-lipan's own backend pushes on startup
2897        // (DISAMBIGUATE_ESCAPE_CODES | REPORT_EVENT_TYPES).
2898        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        // `CSI < 1 u` pops the child's push; the encoder must fall back to legacy bytes.
2906        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        // CSI 6 SP q: steady bar (odd id blinks, even is steady).
2915        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        // CSI 3 SP q: blinking underline.
2921        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        // CSI 2 SP q: steady block.
2927        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        // CSI 0 SP q: reset to the configured default (blinking block).
2933        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        // OSC 133 A (prompt), then C (executing), output, then D (completed).
3070        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    /// Marks must keep pointing at their own line once scrollback saturates.
3111    ///
3112    /// This is the case the grid cannot answer for after the fact: `history_size()`
3113    /// and `topmost_line()` are pinned while content shifts, so an eviction count
3114    /// re-derived from the grid is always zero and marks drift onto whatever text
3115    /// later occupies the index.
3116    #[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        // Still retained: the mark must resolve to the line it was recorded on.
3126        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        // Push the marked line out of scrollback entirely; the mark must go with it
3133        // rather than survive pointing at unrelated text.
3134        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        // Shrinking rows pushes lines into history outside any handler call.
3174        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        // A column change rewraps history, so recorded line indices become meaningless.
3189        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        /// Transmit-and-display a solid RGB image of `width` x `height` pixels.
3204        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            // The escape is consumed whole: no stray payload characters land in the cells.
3224            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            // 30x60 pixels in 10x20 cells is 3 columns by 3 rows.
3232            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            // Kitty leaves the cursor on the image's last row, just past its right edge.
3241            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            // 20x100 pixels in 10x20 cells is 2 columns by 5 rows.
3248            screen.process_bytes(&place(20, 100, "i=1,C=1"));
3249            assert_eq!(screen.render_snapshot().images[0].row, 0);
3250
3251            // Three lines still fit on a six-row screen, so nothing moves.
3252            screen.process_bytes(b"\r\n".repeat(3).as_slice());
3253            assert_eq!(screen.render_snapshot().images[0].row, 0);
3254
3255            // Past the bottom the grid scrolls, and the image goes up with the text: its top row
3256            // is now above the viewport, and it reports a negative row so the renderer can crop.
3257            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            // 24 rows of 20px and 80 columns of 10px.
3305            assert_eq!(responses, vec![b"\x1b[4;480;800t".to_vec()]);
3306        }
3307
3308        /// A virtual placement plus the placeholder cells that show it - the shape every terminal
3309        /// UI toolkit emits, and the one `ratatui_image` writes.
3310        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                // Absolute placement of each row, the way a full-screen renderer draws.
3326                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                // The rest of the row inherits its position from the cell to its left.
3335                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            // One placement for the whole picture, not one per row of placeholders.
3350            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            // The transmission alone: no placeholders written yet.
3369            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            // And it must not have moved the cursor either.
3382            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            // Placeholders live in the grid, so scrolling needs no bookkeeping of our own: the top
3406            // row leaves the viewport and the placement is simply the row that is left, showing
3407            // the lower half of the source pixels.
3408            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            // Scrolling back reaches into history and finds the whole picture again.
3416            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            // A second image, drawn to the right of the first on the same rows.
3426            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        /// The regression that made every placeholder image exactly one column wide.
3441        ///
3442        /// An id above 24 bits splits between the foreground colour and a third combining mark,
3443        /// and a sender writes that mark on the first cell of a row only. A continuation cell that
3444        /// defaults the byte to zero instead of inheriting it names a different image, so every
3445        /// cell after the first is dropped and the picture collapses to its left edge.
3446        #[test]
3447        fn a_continuation_cell_inherits_the_high_byte_of_the_image_id() {
3448            use crate::widgets::terminal::graphics::diacritic;
3449
3450            // An id whose top byte is not zero, so the mark actually carries something.
3451            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            // A line long enough to wrap, so widening really does redistribute text and the
3494            // absolute line a placement is anchored to stops naming what it named.
3495            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        /// Resizing a pane must not cost it every picture in it. In a tiling multiplexer a width
3505        /// change is what happens every time a neighbour opens, and treating all of them as a
3506        /// rewrap made a pane full of plots go blank for it.
3507        #[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            // Nothing on screen is long enough to wrap at either width.
3515            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        /// Two placements holding identical pixels must stay distinguishable to the renderer: a
3524        /// host that draws through Kitty keys a placement by its encoding's id, so sharing one
3525        /// encoding between them would draw one and silently drop the other.
3526        #[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        // Ledger headroom must not leak into the depth callers observe.
3554        assert_eq!(screen.total_scrollback_rows(), 3);
3555        assert_eq!(screen.total_text_lines(), 5);
3556    }
3557}