Skip to main content

slt/
terminal.rs

1use std::borrow::Cow;
2use std::collections::HashMap;
3use std::io::{self, BufWriter, IsTerminal, Read, Stdout, Write};
4use std::time::{Duration, Instant};
5
6use crossterm::event::{
7    DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
8    EnableFocusChange, EnableMouseCapture,
9};
10use crossterm::style::{Attribute, Print, ResetColor, SetAttribute};
11use crossterm::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate};
12use crossterm::{cursor, execute, queue, terminal};
13
14use unicode_width::UnicodeWidthStr;
15
16use crate::buffer::{Buffer, KittyPlacement};
17use crate::rect::Rect;
18use crate::style::{Color, ColorDepth, Modifiers, Style, UnderlineStyle};
19
20/// Saturating cast from `u32` to `u16` — clamps to `u16::MAX` instead of truncating.
21#[inline]
22fn sat_u16(v: u32) -> u16 {
23    v.min(u16::MAX as u32) as u16
24}
25
26/// Output sink for a [`Terminal`] / [`InlineTerminal`] flush pipeline.
27///
28/// The production path is always [`Sink::Stdout`], a `BufWriter<Stdout>` — its
29/// byte stream and buffering are byte-for-byte identical to the pre-seam code
30/// (the [`Write`] impl below is a thin delegation, so the hot path is
31/// unchanged). When the `pty-test` dev feature (or `cfg(test)`) is enabled, a
32/// second [`Sink::Capture`] variant lets the PTY test harness drive the *real*
33/// flush emitters into an in-process `Vec<u8>` instead of a terminal, so the
34/// emitted escape / image-protocol bytes can be asserted end-to-end. The
35/// capture variant never exists in a default build.
36pub(crate) enum Sink {
37    /// Production sink: buffered stdout.
38    Stdout(BufWriter<Stdout>),
39    /// Test sink: in-process byte capture, used only by the PTY harness.
40    #[cfg(any(test, feature = "pty-test"))]
41    Capture(Vec<u8>),
42}
43
44impl Write for Sink {
45    #[inline]
46    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
47        match self {
48            Sink::Stdout(w) => w.write(buf),
49            #[cfg(any(test, feature = "pty-test"))]
50            Sink::Capture(v) => v.write(buf),
51        }
52    }
53
54    #[inline]
55    fn flush(&mut self) -> io::Result<()> {
56        match self {
57            Sink::Stdout(w) => w.flush(),
58            #[cfg(any(test, feature = "pty-test"))]
59            Sink::Capture(v) => v.flush(),
60        }
61    }
62}
63
64// ---------------------------------------------------------------------------
65// Kitty graphics protocol image manager
66// ---------------------------------------------------------------------------
67
68/// Manages Kitty graphics protocol image IDs, uploads, and placements.
69///
70/// Images are deduplicated by content hash — identical RGBA data is uploaded
71/// only once. Each frame, placements are diffed against the previous frame
72/// to minimize terminal I/O.
73pub(crate) struct KittyImageManager {
74    next_id: u32,
75    /// content_hash → kitty image ID for uploaded images.
76    uploaded: HashMap<u64, u32>,
77    /// Previous frame's placements (for diff).
78    prev_placements: Vec<KittyPlacement>,
79    /// Reused dedup scratch for already-deleted image IDs in `flush`. Typical
80    /// placement counts are 0–8 (well below where a `HashSet` beats a linear /
81    /// sorted scan), so a `SmallVec` stays on the stack and carries its
82    /// capacity across frames — no per-frame heap allocation, no SipHash.
83    scratch_ids: smallvec::SmallVec<[u32; 8]>,
84    /// Reused scratch for content hashes still referenced this frame, used to
85    /// prune stale uploads. Sorted in place for `binary_search` membership.
86    scratch_hashes: smallvec::SmallVec<[u64; 8]>,
87}
88
89impl KittyImageManager {
90    /// Construct a new image manager with no uploaded images.
91    pub(crate) fn new() -> Self {
92        Self {
93            next_id: 1,
94            uploaded: HashMap::new(),
95            prev_placements: Vec::new(),
96            scratch_ids: smallvec::SmallVec::new(),
97            scratch_hashes: smallvec::SmallVec::new(),
98        }
99    }
100
101    /// Flush Kitty image placements: upload new images, manage placements.
102    ///
103    /// `row_offset` shifts `current[i].y` for both terminal output and the
104    /// diff comparison against `prev_placements`. Stored placements always
105    /// include the offset (the displayed `y`) so re-emit detection works
106    /// across resize even when the offset itself changes (issue #206).
107    pub(crate) fn flush(
108        &mut self,
109        stdout: &mut impl Write,
110        current: &[KittyPlacement],
111        row_offset: u32,
112    ) -> io::Result<()> {
113        // Fast path: nothing changed (compare against post-offset y values
114        // stored in `prev_placements`). This avoids materializing a translated
115        // `Vec<KittyPlacement>` in the caller (issue #206).
116        if current.len() == self.prev_placements.len()
117            && current
118                .iter()
119                .zip(self.prev_placements.iter())
120                .all(|(c, p)| placement_eq_with_offset(c, row_offset, p))
121        {
122            return Ok(());
123        }
124
125        // Delete all previous placements (keep uploaded image data for reuse).
126        // Dedup via a reused `SmallVec` instead of a per-frame `HashSet`: at the
127        // 0–8 image counts this path actually sees, a linear membership scan
128        // beats hashing, and the scratch keeps its capacity across frames. The
129        // emit order (first-seen) is unchanged, so the byte stream is identical.
130        if !self.prev_placements.is_empty() {
131            self.scratch_ids.clear();
132            for p in &self.prev_placements {
133                if let Some(&img_id) = self.uploaded.get(&p.content_hash)
134                    && !self.scratch_ids.contains(&img_id)
135                {
136                    self.scratch_ids.push(img_id);
137                    // Delete all placements of this image (but keep image data)
138                    queue!(stdout, Print(format!("\x1b_Ga=d,d=i,i={img_id},q=2\x1b\\")))?;
139                }
140            }
141        }
142
143        // Upload new images and create placements
144        for (idx, p) in current.iter().enumerate() {
145            let img_id = if let Some(&existing_id) = self.uploaded.get(&p.content_hash) {
146                existing_id
147            } else {
148                // Upload new image with zlib compression if available
149                let id = self.next_id;
150                self.next_id += 1;
151                self.upload_image(stdout, id, p)?;
152                self.uploaded.insert(p.content_hash, id);
153                id
154            };
155
156            // Place the image (with row_offset applied to y at point of use).
157            let pid = idx as u32 + 1;
158            self.place_image_offset(stdout, img_id, pid, p, row_offset)?;
159        }
160
161        // Clean up images no longer used by any placement. Build the
162        // still-referenced hash set into a reused `SmallVec`, sort it, and test
163        // membership with `binary_search` instead of a per-frame `HashSet`.
164        // (The set of stale uploads is the same regardless of scan order; the
165        // delete emission was already unordered via `HashMap` key iteration.)
166        self.scratch_hashes.clear();
167        self.scratch_hashes
168            .extend(current.iter().map(|p| p.content_hash));
169        self.scratch_hashes.sort_unstable();
170        let scratch_hashes = &self.scratch_hashes;
171        let stale: smallvec::SmallVec<[u64; 8]> = self
172            .uploaded
173            .keys()
174            .filter(|h| scratch_hashes.binary_search(h).is_err())
175            .copied()
176            .collect();
177        for hash in stale {
178            if let Some(id) = self.uploaded.remove(&hash) {
179                // Delete image data from terminal memory
180                queue!(stdout, Print(format!("\x1b_Ga=d,d=I,i={id},q=2\x1b\\")))?;
181            }
182        }
183
184        // Persist post-offset placements for the next frame's diff. We still
185        // write `current.len()` items but rebuild the Vec in place — capacity
186        // is preserved across frames so this is at most an `Arc::clone` per
187        // image (the `Vec<u8>` is shared via `Arc`, no pixel copy). This
188        // remains the only `Arc::clone` cost; the per-frame `Vec` allocation
189        // in the caller (`InlineTerminal::flush`) is what #206 eliminates.
190        self.prev_placements.clear();
191        self.prev_placements.reserve(current.len());
192        for p in current {
193            let mut copy = p.clone();
194            copy.y = copy.y.saturating_add(row_offset);
195            self.prev_placements.push(copy);
196        }
197        Ok(())
198    }
199
200    /// Upload image data to the terminal with `a=t` (transmit only, no display).
201    fn upload_image(&self, stdout: &mut impl Write, id: u32, p: &KittyPlacement) -> io::Result<()> {
202        let (payload, compression) = compress_rgba(&p.rgba);
203        let encoded = base64_encode(&payload);
204        let chunks = split_base64(&encoded, 4096);
205
206        for (i, chunk) in chunks.iter().enumerate() {
207            let more = if i < chunks.len() - 1 { 1 } else { 0 };
208            if i == 0 {
209                queue!(
210                    stdout,
211                    Print(format!(
212                        "\x1b_Ga=t,i={id},f=32,{compression}s={},v={},q=2,m={more};{chunk}\x1b\\",
213                        p.src_width, p.src_height
214                    ))
215                )?;
216            } else {
217                queue!(stdout, Print(format!("\x1b_Gm={more};{chunk}\x1b\\")))?;
218            }
219        }
220        Ok(())
221    }
222
223    /// Place an already-uploaded image at a screen position with optional crop.
224    ///
225    /// `row_offset` is added to `p.y` at output time so callers (notably
226    /// `InlineTerminal::flush`) can avoid materializing a translated copy of
227    /// the placements list per frame (issue #206).
228    fn place_image_offset(
229        &self,
230        stdout: &mut impl Write,
231        img_id: u32,
232        placement_id: u32,
233        p: &KittyPlacement,
234        row_offset: u32,
235    ) -> io::Result<()> {
236        let display_y = p.y.saturating_add(row_offset);
237        queue!(stdout, cursor::MoveTo(sat_u16(p.x), sat_u16(display_y)))?;
238
239        let mut cmd = format!(
240            "\x1b_Ga=p,i={},p={},c={},r={},C=1,q=2",
241            img_id, placement_id, p.cols, p.rows
242        );
243
244        // Add crop parameters for scroll clipping
245        if p.crop_y > 0 || p.crop_h > 0 {
246            cmd.push_str(&format!(",y={}", p.crop_y));
247            if p.crop_h > 0 {
248                cmd.push_str(&format!(",h={}", p.crop_h));
249            }
250        }
251
252        cmd.push_str("\x1b\\");
253        queue!(stdout, Print(cmd))?;
254        Ok(())
255    }
256
257    /// Delete all images from the terminal (used on drop/cleanup).
258    pub(crate) fn delete_all(&self, stdout: &mut impl Write) -> io::Result<()> {
259        queue!(stdout, Print("\x1b_Ga=d,d=A,q=2\x1b\\"))
260    }
261}
262
263/// Compare a fresh placement (`current`, in pre-offset coordinates) against a
264/// stored placement (`prev`, already includes any prior `row_offset`).
265///
266/// Equivalent to `*current == *prev` after virtually applying `row_offset` to
267/// `current.y`, without materializing the translated copy. Used by
268/// `KittyImageManager::flush` to keep the diff fast-path even when the inline
269/// terminal applies a non-zero offset (issue #206).
270#[inline]
271fn placement_eq_with_offset(
272    current: &KittyPlacement,
273    row_offset: u32,
274    prev: &KittyPlacement,
275) -> bool {
276    current.content_hash == prev.content_hash
277        && current.x == prev.x
278        && current.y.saturating_add(row_offset) == prev.y
279        && current.cols == prev.cols
280        && current.rows == prev.rows
281        && current.crop_y == prev.crop_y
282        && current.crop_h == prev.crop_h
283}
284
285/// Compress RGBA data with zlib if available, returning (payload, format_string).
286///
287/// The payload is returned as a [`Cow`] so the no-compression path (the
288/// `kitty-compress` feature off, or compression that fails to save space)
289/// **borrows** the caller's slice instead of cloning the full RGBA buffer into
290/// a throwaway `Vec` on every `upload_image` call. The compressed path still
291/// returns an owned `Vec`. The downstream `base64_encode(&payload)` call sees
292/// `&[u8]` via `Deref` in both cases, so no signature change ripples out.
293fn compress_rgba(data: &[u8]) -> (Cow<'_, [u8]>, &'static str) {
294    #[cfg(feature = "kitty-compress")]
295    {
296        use flate2::Compression;
297        use flate2::write::ZlibEncoder;
298        let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast());
299        if encoder.write_all(data).is_ok()
300            && let Ok(compressed) = encoder.finish()
301        {
302            // Only use compression if it actually saves space
303            if compressed.len() < data.len() {
304                return (Cow::Owned(compressed), "o=z,");
305            }
306        }
307    }
308    (Cow::Borrowed(data), "")
309}
310
311/// Query the terminal for the actual cell pixel dimensions via CSI 16 t.
312///
313/// Returns `(cell_width, cell_height)` in pixels. Falls back to `(8, 16)` if
314/// detection fails. Used by `kitty_image_fit` for accurate aspect ratio.
315///
316/// Cached after first successful detection.
317pub(crate) fn cell_pixel_size() -> (u32, u32) {
318    use std::sync::OnceLock;
319    static CACHED: OnceLock<(u32, u32)> = OnceLock::new();
320    if let Some(size) = CACHED.get() {
321        return *size;
322    }
323    let Some(size) = detect_cell_pixel_size() else {
324        return (8, 16);
325    };
326    let _ = CACHED.set(size);
327    size
328}
329
330fn detect_cell_pixel_size() -> Option<(u32, u32)> {
331    if !terminal_queries_allowed() {
332        return None;
333    }
334
335    // CSI 16 t → reports cell size as CSI 6 ; height ; width t
336    let mut stdout = io::stdout();
337    write!(stdout, "\x1b[16t").ok()?;
338    stdout.flush().ok()?;
339
340    let response = read_osc_response(Duration::from_millis(100))?;
341
342    // Parse: ESC [ 6 ; <height> ; <width> t
343    // Locate the reply anywhere in the buffer rather than anchoring to its
344    // start/end: interleaved control bytes — e.g. a pump-retirement nudge
345    // answer (`CSI 0 n`) from a previous reply session — may surround it.
346    let bytes = response.as_bytes();
347    let start = bytes
348        .windows(4)
349        .position(|w| w == b"\x1b[6;")
350        .map(|pos| pos + 4)
351        .or_else(|| {
352            // CSI can also start with 0x9B (single-byte CSI).
353            bytes
354                .windows(3)
355                .position(|w| w == [0x9b, b'6', b';'])
356                .map(|pos| pos + 3)
357        })?;
358    let tail = response.get(start..)?;
359    let body = &tail[..tail.find('t')?];
360    let mut parts = body.split(';');
361    let ch: u32 = parts.next()?.parse().ok()?;
362    let cw: u32 = parts.next()?.parse().ok()?;
363    if cw > 0 && ch > 0 {
364        Some((cw, ch))
365    } else {
366        None
367    }
368}
369
370// ---------------------------------------------------------------------------
371// Runtime terminal capability probe (issue #264)
372// ---------------------------------------------------------------------------
373//
374// Historically SLT decided whether a terminal could render images / accept the
375// Kitty keyboard protocol / do truecolor *purely from environment-variable
376// allowlists*, which silently degraded capable modern terminals (WezTerm,
377// Ghostty) to an error string. This block adds a one-shot DA1/DA2/XTGETTCAP
378// probe at session enter, parses the replies into a read-only [`Capabilities`]
379// snapshot, and drives an automatic blitter ladder so app code never has to
380// branch on terminal identity. The data types are always compiled (so the
381// `Context` field exists on every build); only the runtime probe is
382// `crossterm`-gated.
383
384/// Image-rendering primitives the terminal can drive, used to build the
385/// automatic blitter ladder. Each flag is conservative: when the runtime probe
386/// returns no answer the defaults assume only the universally available
387/// primitives (half-block + quadrants).
388///
389/// App code is **not** required to inspect this; it exists for diagnostics and
390/// to feed [`Capabilities::best_blitter`].
391///
392/// # Example
393///
394/// ```no_run
395/// # slt::run(|ui: &mut slt::Context| {
396/// let blitters = ui.capabilities().blitters;
397/// // Half-block is available on any ANSI terminal.
398/// assert!(blitters.half);
399/// # });
400/// ```
401#[derive(Debug, Clone, Copy, PartialEq, Eq)]
402pub struct BlitterSupport {
403    /// `▀` upper-half block — available on any ANSI terminal.
404    pub half: bool,
405    /// `▖▗▘▝` quadrant blocks — available on any Unicode-capable terminal.
406    pub quad: bool,
407    /// `🬀`..`🬻` sextants (Unicode 13+) — off by default until a renderer
408    /// confirms support. This issue wires the capability slot; a sextant
409    /// renderer is a separate feature.
410    pub sextant: bool,
411}
412
413impl Default for BlitterSupport {
414    fn default() -> Self {
415        Self {
416            half: true,
417            quad: true,
418            sextant: false,
419        }
420    }
421}
422
423/// Read-only snapshot of negotiated terminal capabilities, populated once at
424/// session enter via DA1/DA2/XTGETTCAP.
425///
426/// App code **must not** be required to branch on this — it exists for
427/// diagnostics and to drive the automatic blitter ladder (see
428/// [`Capabilities::best_blitter`]). On a headless backend (TestBackend / piped
429/// stdout) or when the probe gets no reply, every field falls back to a
430/// conservative default.
431///
432/// Available since `0.21.0`.
433///
434/// # Example
435///
436/// ```no_run
437/// # slt::run(|ui: &mut slt::Context| {
438/// let caps = ui.capabilities();
439/// if caps.sixel {
440///     // Diagnostics only — image rendering already routes through the ladder.
441/// }
442/// # });
443/// ```
444#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
445pub struct Capabilities {
446    /// 24-bit color confirmed (XTGETTCAP `Tc`/`RGB` or `COLORTERM`).
447    pub truecolor: bool,
448    /// Sixel graphics confirmed (DA1 attribute `4`).
449    pub sixel: bool,
450    /// iTerm2 OSC 1337 inline-image protocol confirmed (env identity for
451    /// iTerm2 / WezTerm / Tabby / mintty; issue #265).
452    pub iterm2: bool,
453    /// Kitty graphics protocol confirmed (DA2 terminal-ID heuristic).
454    pub kitty_graphics: bool,
455    /// Kitty keyboard protocol confirmed.
456    pub kitty_keyboard: bool,
457    /// Synchronized output (DECSET 2026) confirmed.
458    pub sync_output: bool,
459    /// Set of cell-art blitters the terminal can drive.
460    pub blitters: BlitterSupport,
461}
462
463/// Descending image-render preference. The first capability that is available
464/// wins; app code never selects a [`Blitter`] directly.
465///
466/// Ladder order: [`Kitty`](Blitter::Kitty) > [`Sixel`](Blitter::Sixel) >
467/// [`Iterm2`](Blitter::Iterm2) > [`Sextant`](Blitter::Sextant) >
468/// [`HalfBlock`](Blitter::HalfBlock).
469///
470/// Available since `0.21.0`.
471#[derive(Debug, Clone, Copy, PartialEq, Eq)]
472pub enum Blitter {
473    /// Kitty graphics protocol (highest fidelity).
474    Kitty,
475    /// Sixel graphics protocol.
476    Sixel,
477    /// iTerm2 OSC 1337 inline-image protocol (issue #265). Pixel-accurate on
478    /// Tabby, older iTerm2, and WezTerm's iTerm2-compat mode.
479    Iterm2,
480    /// Unicode sextant cell art.
481    Sextant,
482    /// Half-block cell art (universal fallback).
483    HalfBlock,
484}
485
486impl Capabilities {
487    /// Resolve the best available image blitter for this terminal.
488    ///
489    /// Returns the first supported rung of the ladder
490    /// (Kitty > Sixel > iTerm2 > Sextant > HalfBlock). This is total: it always
491    /// returns a [`Blitter`], falling through to [`Blitter::HalfBlock`] which
492    /// every terminal supports.
493    ///
494    /// # Example
495    ///
496    /// ```no_run
497    /// # slt::run(|ui: &mut slt::Context| {
498    /// let _ = ui.capabilities().best_blitter();
499    /// # });
500    /// ```
501    pub fn best_blitter(&self) -> Blitter {
502        if self.kitty_graphics {
503            Blitter::Kitty
504        } else if self.sixel {
505            Blitter::Sixel
506        } else if self.iterm2 {
507            Blitter::Iterm2
508        } else if self.blitters.sextant {
509            Blitter::Sextant
510        } else {
511            Blitter::HalfBlock
512        }
513    }
514}
515
516/// Return the process-global negotiated [`Capabilities`], probing the terminal
517/// exactly once on first call and caching the result.
518///
519/// The probe issues DA1 (`CSI c`), DA2 (`CSI > c`), and XTGETTCAP for the
520/// truecolor capname, reading replies through the existing OSC round-trip
521/// infrastructure with a bounded total timeout (≤150ms). On no reply every
522/// field falls back to a conservative default. Repeated calls are free.
523#[cfg(feature = "crossterm")]
524#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
525pub fn capabilities() -> Capabilities {
526    use std::sync::OnceLock;
527    if !terminal_queries_allowed() {
528        return Capabilities::default();
529    }
530    static CACHED: OnceLock<Capabilities> = OnceLock::new();
531    *CACHED.get_or_init(probe_capabilities)
532}
533
534/// Send DA1/DA2/XTGETTCAP and parse the replies into a [`Capabilities`].
535///
536/// Conservative on failure: any unread / unparsable reply leaves the
537/// corresponding flag at its default. The total stdin wait is bounded to keep
538/// startup latency within the same budget as the existing OSC 11 query.
539#[cfg(feature = "crossterm")]
540fn probe_capabilities() -> Capabilities {
541    let mut caps = Capabilities::default();
542    if !terminal_queries_allowed() {
543        return caps;
544    }
545
546    // Total stdin wait is bounded to ≤180ms (90 + 30 + 30 + 30) so a silent
547    // terminal cannot stall startup beyond a small multiple of the existing
548    // OSC-11 budget. A responsive terminal replies in well under 10ms per
549    // query, so the common path adds negligible latency.
550    let mut out = io::stdout();
551    // DA1 then DA2 in one write — both terminate with `c`, so a single
552    // DA-aware read drains both replies (in order) when supported.
553    if write!(out, "\x1b[c\x1b[>c").is_ok()
554        && out.flush().is_ok()
555        && let Some(resp) = read_da_response(Duration::from_millis(90))
556    {
557        parse_da1(&resp, &mut caps);
558        parse_da2(&resp, &mut caps);
559    }
560
561    // Kitty graphics query: APC G a=q (query) with a 1×1 RGB direct payload.
562    // Supporting terminals ack with `APC G i=31;OK ST`; others stay silent so
563    // the bounded read just times out. Base64 of three zero bytes = "AAAA".
564    if write!(out, "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\").is_ok()
565        && out.flush().is_ok()
566        && let Some(resp) = read_osc_response(Duration::from_millis(30))
567    {
568        parse_kitty_graphics_ack(&resp, &mut caps);
569    }
570
571    // XTGETTCAP for the `Tc` (truecolor) capname: DCS + q <hex> ST.
572    // `Tc` -> hex "5463".
573    if write!(out, "\x1bP+q5463\x1b\\").is_ok()
574        && out.flush().is_ok()
575        && let Some(resp) = read_osc_response(Duration::from_millis(30))
576    {
577        parse_xtgettcap_truecolor(&resp, &mut caps);
578    }
579
580    // DECRQM for synchronized output (mode ?2026): CSI ? 2026 $ p. A supporting
581    // terminal replies CSI ? 2026 ; <Ps> $ y, where Ps ∈ {1,2} (set / reset)
582    // both mean *recognized*; Ps = 0 means the mode is not recognized. The
583    // reply terminates with `y` rather than BEL / ST, so it needs the
584    // DECRPM-aware reader. A silent terminal leaves the resolution `Unknown`,
585    // which the flush gate treats as "keep emitting" — preserving the historic
586    // always-emit behavior on headless / non-answering hosts.
587    if write!(out, "\x1b[?2026$p").is_ok()
588        && out.flush().is_ok()
589        && let Some(resp) = read_decrpm_response(Duration::from_millis(30))
590    {
591        match parse_decrpm_sync_output(&resp) {
592            Some(true) => {
593                caps.sync_output = true;
594                let _ = SYNC_OUTPUT_RESOLUTION.set(SyncOutputResolution::Supported);
595            }
596            Some(false) => {
597                let _ = SYNC_OUTPUT_RESOLUTION.set(SyncOutputResolution::Unsupported);
598            }
599            None => {}
600        }
601    }
602
603    // Env precedence chain stays authoritative for truecolor: a positive
604    // COLORTERM/TERM signal confirms it even when the probe is silent.
605    if matches!(ColorDepth::detect(), ColorDepth::TrueColor) {
606        caps.truecolor = true;
607    }
608
609    if !caps.sixel && term_is_sixel_host() {
610        caps.sixel = true;
611    }
612
613    // Env-fallback: when the runtime queries are silent (no reply within the
614    // timeout), trust the terminal identity for the Kitty-graphics family so a
615    // known-capable host (Kitty, Ghostty, WezTerm) still climbs the top rung.
616    // The query above wins when it answers; this only fills an unknown.
617    if !caps.kitty_graphics && term_is_kitty_graphics_host() {
618        caps.kitty_graphics = true;
619    }
620
621    // iTerm2 OSC 1337 has no DA1/DA2 signal (issue #265): the protocol is
622    // identified purely by terminal identity. Fill the capability slot from the
623    // env so the blitter ladder can offer it below Kitty/Sixel.
624    if term_is_iterm_host() {
625        caps.iterm2 = true;
626    }
627
628    caps
629}
630
631/// Heuristic env-detection for iTerm2 OSC 1337 inline-image hosts (issue #265).
632///
633/// The protocol carries no DA reply, so detection is by `TERM_PROGRAM` identity
634/// only: iTerm2, WezTerm (iTerm2-compat), Tabby, and mintty.
635#[cfg(feature = "crossterm")]
636fn term_is_iterm_host() -> bool {
637    let term_program = std::env::var("TERM_PROGRAM")
638        .unwrap_or_default()
639        .to_ascii_lowercase();
640    term_is_iterm_host_env(
641        &term_program,
642        terminal_is_multiplexed(),
643        force_env_enabled("SLT_FORCE_ITERM"),
644    )
645}
646
647#[cfg(feature = "crossterm")]
648fn term_is_iterm_host_env(term_program: &str, multiplexed: bool, forced: bool) -> bool {
649    if forced {
650        return true;
651    }
652    if multiplexed {
653        return false;
654    }
655    matches!(term_program, "iterm.app" | "wezterm" | "tabby" | "mintty")
656}
657
658#[cfg(feature = "crossterm")]
659fn term_is_sixel_host() -> bool {
660    let term = std::env::var("TERM")
661        .unwrap_or_default()
662        .to_ascii_lowercase();
663    let term_program = std::env::var("TERM_PROGRAM")
664        .unwrap_or_default()
665        .to_ascii_lowercase();
666    term_is_sixel_host_env(
667        &term,
668        &term_program,
669        terminal_is_multiplexed(),
670        force_env_enabled("SLT_FORCE_SIXEL"),
671    )
672}
673
674#[cfg(feature = "crossterm")]
675fn term_is_sixel_host_env(term: &str, term_program: &str, multiplexed: bool, forced: bool) -> bool {
676    if forced {
677        return true;
678    }
679    if multiplexed {
680        return false;
681    }
682    const KNOWN_SIXEL_TERMS: &[&str] = &["mlterm", "foot", "yaft", "xterm-256color-sixel"];
683    const KNOWN_SIXEL_TERM_PROGRAMS: &[&str] = &["foot", "mlterm", "wezterm", "ghostty"];
684    KNOWN_SIXEL_TERMS.contains(&term)
685        || term.contains("sixel")
686        || KNOWN_SIXEL_TERM_PROGRAMS.contains(&term_program)
687}
688
689/// Heuristic env-fallback for Kitty-graphics hosts, consulted only when the
690/// runtime Kitty graphics query returned no reply. Matches the documented
691/// `TERM` / `TERM_PROGRAM` identities of terminals that implement the Kitty
692/// graphics protocol.
693#[cfg(feature = "crossterm")]
694fn term_is_kitty_graphics_host() -> bool {
695    let term = std::env::var("TERM")
696        .unwrap_or_default()
697        .to_ascii_lowercase();
698    let term_program = std::env::var("TERM_PROGRAM")
699        .unwrap_or_default()
700        .to_ascii_lowercase();
701    term_is_kitty_graphics_host_env(
702        &term,
703        &term_program,
704        terminal_is_multiplexed(),
705        force_env_enabled("SLT_FORCE_KITTY"),
706    )
707}
708
709#[cfg(feature = "crossterm")]
710fn term_is_kitty_graphics_host_env(
711    term: &str,
712    term_program: &str,
713    multiplexed: bool,
714    forced: bool,
715) -> bool {
716    if forced {
717        return true;
718    }
719    if multiplexed {
720        return false;
721    }
722    // Kitty sets `TERM=xterm-kitty`; Ghostty/WezTerm advertise via TERM_PROGRAM.
723    term.contains("kitty") || matches!(term_program, "ghostty" | "wezterm" | "kitty")
724}
725
726#[cfg(feature = "crossterm")]
727fn terminal_is_multiplexed() -> bool {
728    if std::env::var_os("TMUX").is_some() || std::env::var_os("STY").is_some() {
729        return true;
730    }
731    let term = std::env::var("TERM")
732        .unwrap_or_default()
733        .to_ascii_lowercase();
734    terminal_is_multiplexed_env(&term, false, false)
735}
736
737#[cfg(feature = "crossterm")]
738fn terminal_is_multiplexed_env(term: &str, has_tmux: bool, has_sty: bool) -> bool {
739    let term = term.to_ascii_lowercase();
740    has_tmux || has_sty || term.starts_with("tmux") || term.starts_with("screen")
741}
742
743#[cfg(feature = "crossterm")]
744fn force_env_enabled(name: &str) -> bool {
745    std::env::var(name)
746        .ok()
747        .is_some_and(|value| truthy_env_value(&value))
748}
749
750#[cfg(feature = "crossterm")]
751fn truthy_env_value(value: &str) -> bool {
752    matches!(
753        value.to_ascii_lowercase().as_str(),
754        "1" | "true" | "yes" | "on"
755    )
756}
757
758#[cfg(feature = "crossterm")]
759fn terminal_queries_allowed() -> bool {
760    terminal_query_allowed(io::stdout().is_terminal(), io::stdin().is_terminal())
761}
762
763fn terminal_query_allowed(stdout_is_terminal: bool, stdin_is_terminal: bool) -> bool {
764    stdout_is_terminal && stdin_is_terminal
765}
766
767/// Process-wide pump that owns the only blocking `stdin` read used for
768/// terminal-reply probing. See [`read_stdin_reply`] for why it exists.
769#[cfg(feature = "crossterm")]
770struct ReplyPump {
771    rx: std::sync::mpsc::Receiver<u8>,
772    /// `true` while a reader session wants bytes. The pump thread re-checks it
773    /// after every successful read and exits once it is cleared.
774    serve: std::sync::Arc<std::sync::atomic::AtomicBool>,
775    /// Set by the pump thread on exit, distinguishing "parked inside a
776    /// blocking `read()`" (reusable) from "gone" (must respawn).
777    exited: std::sync::Arc<std::sync::atomic::AtomicBool>,
778}
779
780#[cfg(feature = "crossterm")]
781static REPLY_PUMP: std::sync::Mutex<Option<ReplyPump>> = std::sync::Mutex::new(None);
782
783/// Read one terminal reply from raw stdin, hard-bounded by `timeout`, stopping
784/// early once `is_complete` recognizes a full reply (or at the 4096-byte cap).
785///
786/// Why a pump thread: the previous readers gated a blocking
787/// `io::stdin().read()` behind `crossterm::event::poll()`. Those two observe
788/// different things — `poll()` answers "does crossterm's *internal event
789/// queue* have something?", while the raw `read()` waits for bytes on the
790/// stdin descriptor — and crossterm's poller consumes bytes from that same
791/// descriptor into its own parser. On a host that never answers probe queries
792/// (a detached tmux pane, `script`-style PTY wrappers, CI runners), `poll()`
793/// could return `true` for a queued non-byte event while raw stdin stayed
794/// empty, so the one-byte `read()` blocked forever *inside* the deadline loop
795/// and the application hung on a blank alternate screen before its first
796/// frame; later keystrokes were swallowed by crossterm's queue instead of
797/// unblocking it. Moving the only blocking `read()` onto a dedicated thread
798/// and waiting on a channel with `recv_timeout` makes every reply read
799/// genuinely bounded by its budget no matter what the host does.
800///
801/// The pump is a process-wide singleton so back-to-back probes share one byte
802/// stream instead of racing two readers for the same reply. After each
803/// session the thread is retired: `serve` is cleared and a DSR status query
804/// (`CSI 5 n`) nudges the terminal — an answering host replies `CSI 0 n`,
805/// which wakes the parked `read()`, the thread observes `serve == false` and
806/// exits, and the nudge bytes stay in the channel where the next session's
807/// drain discards them (they never reach the application's input stream). A
808/// host that answers nothing leaves the thread parked; it is reused by the
809/// next session, and at worst it swallows one byte of typeahead on a host
810/// class where, before this fix, startup deadlocked outright.
811#[cfg(feature = "crossterm")]
812fn read_stdin_reply(
813    timeout: Duration,
814    mut is_complete: impl FnMut(&[u8]) -> bool,
815) -> Option<String> {
816    use std::sync::atomic::{AtomicBool, Ordering};
817    use std::sync::{Arc, mpsc};
818
819    let deadline = Instant::now() + timeout;
820
821    let Ok(mut slot) = REPLY_PUMP.lock() else {
822        // Poisoned: a prior session panicked mid-read. Skip probing entirely
823        // rather than risk a second fault; every caller treats `None` as "the
824        // terminal stayed silent".
825        return None;
826    };
827
828    let pump = match slot.take().filter(|p| !p.exited.load(Ordering::Acquire)) {
829        Some(pump) => {
830            // A parked pump from an earlier session: its thread is still
831            // blocked in `read()` on a silent host. Reusing it (instead of
832            // spawning a second thread) is what prevents two readers from
833            // racing each other for the same reply bytes.
834            pump.serve.store(true, Ordering::Release);
835            pump
836        }
837        None => {
838            let (tx, rx) = mpsc::channel::<u8>();
839            let serve = Arc::new(AtomicBool::new(true));
840            let exited = Arc::new(AtomicBool::new(false));
841            let thread_serve = Arc::clone(&serve);
842            let thread_exited = Arc::clone(&exited);
843            let spawned = std::thread::Builder::new()
844                .name("slt-reply-pump".into())
845                .spawn(move || {
846                    let mut stdin = io::stdin();
847                    // One byte per read on purpose: a parked thread that wakes
848                    // on real key input forwards at most this single byte
849                    // before observing `serve == false` and exiting, so the
850                    // worst-case typeahead loss on a silent host is exactly
851                    // one byte (replies are short; the syscall-per-byte cost
852                    // is irrelevant for one-shot probes).
853                    let mut buf = [0u8; 1];
854                    loop {
855                        match stdin.read(&mut buf) {
856                            Ok(0) | Err(_) => break,
857                            Ok(_) => {
858                                if tx.send(buf[0]).is_err() {
859                                    thread_exited.store(true, Ordering::Release);
860                                    return;
861                                }
862                            }
863                        }
864                        if !thread_serve.load(Ordering::Acquire) {
865                            break;
866                        }
867                    }
868                    thread_exited.store(true, Ordering::Release);
869                });
870            if spawned.is_err() {
871                return None;
872            }
873            ReplyPump { rx, serve, exited }
874        }
875    };
876
877    // Discard bytes left over from a previous session: a reply that arrived
878    // after its deadline, or the retirement nudge's `CSI 0 n` answer.
879    while pump.rx.try_recv().is_ok() {}
880
881    let bytes = collect_reply(&pump.rx, deadline, &mut is_complete);
882
883    // Retire the thread so it does not sit on a pending `read()` competing
884    // with crossterm's event loop for real key input once the session ends.
885    // The nudge fires only under raw mode (the `run()` / session-enter probe
886    // paths): in cooked mode — e.g. a standalone `detect_color_scheme()`
887    // call — the terminal would *echo* its `CSI 0 n` answer into the user's
888    // scrollback as visible garbage, so there the parked thread is simply
889    // left for the next session to reuse.
890    pump.serve.store(false, Ordering::Release);
891    if crossterm::terminal::is_raw_mode_enabled().unwrap_or(false) {
892        let mut out = io::stdout();
893        let _ = write!(out, "\x1b[5n");
894        let _ = out.flush();
895    }
896    *slot = Some(pump);
897    drop(slot);
898
899    if bytes.is_empty() {
900        return None;
901    }
902    String::from_utf8(bytes).ok()
903}
904
905/// Deadline-bounded accumulation loop shared by every reply reader: pull bytes
906/// off the pump channel until `is_complete` fires, the 4096-byte cap is hit,
907/// the deadline passes, or the pump disconnects (stdin EOF). Returns whatever
908/// arrived — callers map an empty buffer to "no reply" and a partial buffer to
909/// a best-effort parse, matching the pre-pump readers exactly.
910#[cfg(feature = "crossterm")]
911fn collect_reply(
912    rx: &std::sync::mpsc::Receiver<u8>,
913    deadline: Instant,
914    is_complete: &mut dyn FnMut(&[u8]) -> bool,
915) -> Vec<u8> {
916    let mut bytes = Vec::new();
917    loop {
918        let now = Instant::now();
919        if now >= deadline {
920            break;
921        }
922        match rx.recv_timeout(deadline - now) {
923            Ok(byte) => {
924                bytes.push(byte);
925                if is_complete(&bytes) || bytes.len() >= 4096 {
926                    break;
927                }
928            }
929            // Timed out, or the pump thread is gone (stdin EOF / error).
930            Err(_) => break,
931        }
932    }
933    bytes
934}
935
936/// Completion predicate for OSC / DCS / CSI-`t` style replies, which terminate
937/// with BEL (`\x07`) or ST (`ESC \`).
938#[cfg(feature = "crossterm")]
939fn osc_reply_complete(bytes: &[u8]) -> bool {
940    let len = bytes.len();
941    bytes[len - 1] == b'\x07' || (len >= 2 && bytes[len - 2] == 0x1B && bytes[len - 1] == b'\\')
942}
943
944/// Completion predicate builder for Device-Attributes replies: `c` is the
945/// final byte of each DA reply, and a combined `CSI c CSI > c` query yields
946/// two of them, so completion fires on the second `c`.
947#[cfg(feature = "crossterm")]
948fn da_reply_complete() -> impl FnMut(&[u8]) -> bool {
949    let mut terminators = 0usize;
950    move |bytes: &[u8]| {
951        if bytes[bytes.len() - 1] == b'c' {
952            terminators += 1;
953        }
954        terminators >= 2
955    }
956}
957
958/// Completion predicate for DECRPM replies (`CSI ? <mode> ; <Ps> $ y`).
959#[cfg(feature = "crossterm")]
960fn decrpm_reply_complete(bytes: &[u8]) -> bool {
961    bytes[bytes.len() - 1] == b'y'
962}
963
964/// Read a Device-Attributes reply, which (unlike OSC) terminates with the byte
965/// `c` rather than BEL / ST. Drains up to two `c`-terminated CSI replies
966/// (DA1 + DA2) within the timeout so a combined `CSI c CSI > c` query yields
967/// both answers in one string.
968#[cfg(feature = "crossterm")]
969fn read_da_response(timeout: Duration) -> Option<String> {
970    read_stdin_reply(timeout, da_reply_complete())
971}
972
973/// Parse a DA1 reply (`CSI ? <attrs> c`). Attribute `4` indicates Sixel
974/// support. Only the DA1 segment is consulted; a trailing DA2 segment in the
975/// same string is ignored here.
976#[cfg(feature = "crossterm")]
977fn parse_da1(response: &str, caps: &mut Capabilities) {
978    // DA1 reply: ESC [ ? <n> ; <n> ; ... c  (no `>` after `[`).
979    let mut search = response;
980    while let Some(pos) = search.find("\x1b[?") {
981        let body = &search[pos + 3..];
982        let Some(end) = body.find('c') else { break };
983        let attrs = &body[..end];
984        for attr in attrs.split(';') {
985            if attr.trim() == "4" {
986                caps.sixel = true;
987            }
988        }
989        search = &body[end + 1..];
990    }
991}
992
993/// Parsed DA2 (secondary device attributes) terminal identity:
994/// `(primary_id, firmware_version)` from `CSI > <id> ; <ver> ; <sub> c`.
995///
996/// Returns `None` if the string contains no DA2 reply. Kept separate from the
997/// `Capabilities` mutation so it is independently testable and so callers that
998/// want the raw identity (e.g. future per-terminal quirks) are not forced
999/// through capability inference.
1000#[cfg(feature = "crossterm")]
1001fn parse_da2(response: &str, caps: &mut Capabilities) {
1002    let Some((id, _ver)) = parse_da2_identity(response) else {
1003        return;
1004    };
1005    // DA2 primary id `41` is the documented Kitty graphics terminal id (Kitty
1006    // reports `\x1b[>41;<ver>;<sub>c`). This is the one unambiguous DA2 graphics
1007    // signal; every other host is resolved by the Kitty graphics query above or
1008    // the env-fallback, so we deliberately do not maintain a wider id registry.
1009    const KITTY_GRAPHICS_DA2_ID: u32 = 41;
1010    if id == KITTY_GRAPHICS_DA2_ID {
1011        caps.kitty_graphics = true;
1012    }
1013}
1014
1015/// Extract `(primary_id, version)` from a DA2 reply, or `None` if absent.
1016#[cfg(feature = "crossterm")]
1017fn parse_da2_identity(response: &str) -> Option<(u32, u32)> {
1018    let pos = response.find("\x1b[>")?;
1019    let body = &response[pos + 3..];
1020    let end = body.find('c')?;
1021    let mut parts = body[..end].split(';');
1022    let id = parts.next()?.trim().parse::<u32>().ok()?;
1023    let ver = parts.next().and_then(|s| s.trim().parse::<u32>().ok());
1024    Some((id, ver.unwrap_or(0)))
1025}
1026
1027/// Parse a Kitty graphics protocol query ack (`APC G i=31;OK ST`). A terminal
1028/// that supports the protocol echoes the image id with an `OK` status; anything
1029/// else (silence, error status) leaves the flag untouched.
1030#[cfg(feature = "crossterm")]
1031fn parse_kitty_graphics_ack(response: &str, caps: &mut Capabilities) {
1032    // Ack form: ESC _ G <key=val>;OK ESC \  — we sent i=31, so look for that id
1033    // paired with an OK status.
1034    if let Some(pos) = response.find("\x1b_G") {
1035        let body = &response[pos + 3..];
1036        let end = body.find("\x1b\\").unwrap_or(body.len());
1037        let payload = &body[..end];
1038        if payload.contains("i=31") && payload.contains("OK") {
1039            caps.kitty_graphics = true;
1040        }
1041    }
1042}
1043
1044/// Parse an XTGETTCAP reply for the `Tc` (truecolor) capname. A valid reply is
1045/// `DCS 1 + r <hex(capname)>[=<hex(value)>] ST`; a leading `1` means the
1046/// capability is present.
1047#[cfg(feature = "crossterm")]
1048fn parse_xtgettcap_truecolor(response: &str, caps: &mut Capabilities) {
1049    // Valid reply prefix: ESC P 1 + r  (DCS 1 + r ...). `Tc` -> hex 5463.
1050    if let Some(pos) = response.find("\x1bP1+r") {
1051        let body = &response[pos + 5..];
1052        if body
1053            .to_ascii_lowercase()
1054            .split([';', '\x1b'])
1055            .any(|seg| seg.starts_with("5463"))
1056        {
1057            caps.truecolor = true;
1058        }
1059    }
1060}
1061
1062/// Tri-state outcome of the DECRQM ?2026 (synchronized output) probe.
1063///
1064/// The synchronized-output BSU/ESU emission is gated on this rather than on the
1065/// public [`Capabilities::sync_output`] bool alone, because the public flag is
1066/// only ever set on *positive* support evidence. Gating emission on that flag
1067/// directly would flip the historic always-emit behavior to never-emit on every
1068/// headless / non-answering host (a regression). This tri-state lets the gate
1069/// suppress BSU/ESU **only** when the terminal definitively reported the mode
1070/// unrecognized, and keep emitting in the `Unknown` (silent / headless) case.
1071#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1072enum SyncOutputResolution {
1073    /// DECRQM confirmed mode ?2026 is recognized (set or reset).
1074    Supported,
1075    /// DECRQM explicitly reported mode ?2026 as not recognized (Ps = 0).
1076    Unsupported,
1077}
1078
1079/// Process-global resolution of the synchronized-output probe, populated at most
1080/// once by [`probe_capabilities`]. Absent (`Unknown`) until the probe answers.
1081static SYNC_OUTPUT_RESOLUTION: std::sync::OnceLock<SyncOutputResolution> =
1082    std::sync::OnceLock::new();
1083
1084/// Whether the flush pipeline should wrap a frame in synchronized-output
1085/// BSU/ESU guards.
1086///
1087/// Returns `true` (emit) unless the DECRQM ?2026 probe *definitively* reported
1088/// the mode as unrecognized. A silent / headless / never-run probe leaves the
1089/// resolution `Unknown`, in which case this keeps emitting exactly as the
1090/// pre-gate code always did. This is the behavior-preserving half of the
1091/// capability gate: positive support and the unknown default both emit; only a
1092/// confirmed-unsupported terminal suppresses.
1093fn should_emit_synchronized_update() -> bool {
1094    !matches!(
1095        SYNC_OUTPUT_RESOLUTION.get(),
1096        Some(SyncOutputResolution::Unsupported)
1097    )
1098}
1099
1100/// Read a DECRPM reply, which terminates with the byte `y` rather than BEL / ST
1101/// (used for the DECRQM ?2026 synchronized-output probe). Bounded by `timeout`
1102/// so a terminal that ignores the query cannot stall startup.
1103#[cfg(feature = "crossterm")]
1104fn read_decrpm_response(timeout: Duration) -> Option<String> {
1105    read_stdin_reply(timeout, decrpm_reply_complete)
1106}
1107
1108/// Parse a DECRPM reply for synchronized output (mode `2026`):
1109/// `CSI ? 2026 ; <Ps> $ y`.
1110///
1111/// Returns:
1112///   * `Some(true)`  — mode recognized (`Ps` ∈ {1, 2, 3, 4}: set / reset /
1113///     permanently-set / permanently-reset all mean *supported*),
1114///   * `Some(false)` — mode not recognized (`Ps` = 0),
1115///   * `None`        — no DECRPM reply for mode 2026 in the string.
1116#[cfg(feature = "crossterm")]
1117fn parse_decrpm_sync_output(response: &str) -> Option<bool> {
1118    // Reply body: ESC [ ? 2026 ; <Ps> $ y
1119    let pos = response.find("\x1b[?2026;")?;
1120    let body = &response[pos + "\x1b[?2026;".len()..];
1121    let end = body.find("$y")?;
1122    let ps = body[..end].trim().parse::<u32>().ok()?;
1123    // Ps = 0 → not recognized; any other reported state means the mode exists.
1124    Some(ps != 0)
1125}
1126
1127fn split_base64(encoded: &str, chunk_size: usize) -> Vec<&str> {
1128    let mut chunks = Vec::new();
1129    let bytes = encoded.as_bytes();
1130    let mut offset = 0;
1131    while offset < bytes.len() {
1132        let end = (offset + chunk_size).min(bytes.len());
1133        chunks.push(&encoded[offset..end]);
1134        offset = end;
1135    }
1136    if chunks.is_empty() {
1137        chunks.push("");
1138    }
1139    chunks
1140}
1141
1142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1143struct GraphicsEmissionSupport {
1144    real_terminal: bool,
1145    capabilities: Capabilities,
1146    force_kitty: bool,
1147    force_sixel: bool,
1148    force_iterm: bool,
1149}
1150
1151impl GraphicsEmissionSupport {
1152    fn detect(capabilities: Capabilities) -> Self {
1153        Self {
1154            real_terminal: true,
1155            capabilities,
1156            force_kitty: force_env_enabled("SLT_FORCE_KITTY"),
1157            force_sixel: force_env_enabled("SLT_FORCE_SIXEL"),
1158            force_iterm: force_env_enabled("SLT_FORCE_ITERM"),
1159        }
1160    }
1161
1162    #[cfg(any(test, feature = "pty-test"))]
1163    fn capture() -> Self {
1164        Self {
1165            real_terminal: true,
1166            capabilities: Capabilities::default(),
1167            force_kitty: force_env_enabled("SLT_FORCE_KITTY"),
1168            force_sixel: force_env_enabled("SLT_FORCE_SIXEL"),
1169            force_iterm: force_env_enabled("SLT_FORCE_ITERM"),
1170        }
1171    }
1172
1173    fn should_emit_kitty(self) -> bool {
1174        self.real_terminal && (self.capabilities.kitty_graphics || self.force_kitty)
1175    }
1176
1177    fn should_emit_sprixel(self, protocol: SprixelProtocol) -> bool {
1178        if !self.real_terminal {
1179            return false;
1180        }
1181        match protocol {
1182            SprixelProtocol::Sixel => self.capabilities.sixel || self.force_sixel,
1183            SprixelProtocol::Iterm2 => self.capabilities.iterm2 || self.force_iterm,
1184            SprixelProtocol::Unknown => false,
1185        }
1186    }
1187}
1188
1189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1190enum SprixelProtocol {
1191    Sixel,
1192    Iterm2,
1193    Unknown,
1194}
1195
1196fn sprixel_protocol(seq: &str) -> SprixelProtocol {
1197    if seq.starts_with("\x1bPq") {
1198        SprixelProtocol::Sixel
1199    } else if seq.starts_with("\x1b]1337;File=") {
1200        SprixelProtocol::Iterm2
1201    } else {
1202        SprixelProtocol::Unknown
1203    }
1204}
1205
1206/// Fullscreen crossterm terminal backend: owns raw mode + the alternate
1207/// screen, double-buffers cells, and flushes only the diff each frame.
1208///
1209/// Exposed (issue #278) so external integrations can drive SLT's rendering
1210/// with their own event loop instead of reimplementing the backend. Pair with
1211/// [`crate::event::from_crossterm`] to translate input. The built-in
1212/// [`crate::run`] entry point uses this same type internally.
1213pub struct Terminal {
1214    stdout: Sink,
1215    current: Buffer,
1216    previous: Buffer,
1217    cursor_visible: bool,
1218    session: TerminalSessionGuard,
1219    color_depth: ColorDepth,
1220    pub(crate) theme_bg: Option<Color>,
1221    kitty_mgr: KittyImageManager,
1222    graphics_support: GraphicsEmissionSupport,
1223    /// Reused run-coalescing scratch for `flush_buffer_diff` (issue #269). Its
1224    /// capacity persists across frames so the hot flush loop never allocates a
1225    /// fresh `String` per call.
1226    run_buf: String,
1227}
1228
1229/// Inline crossterm terminal backend: renders into a fixed-height region
1230/// below the cursor instead of taking over the whole screen.
1231///
1232/// Like [`Terminal`], exposed (issue #278) for custom integrations. Backs the
1233/// [`crate::run_inline`] entry point.
1234pub struct InlineTerminal {
1235    stdout: Sink,
1236    current: Buffer,
1237    previous: Buffer,
1238    cursor_visible: bool,
1239    session: TerminalSessionGuard,
1240    height: u32,
1241    start_row: u16,
1242    reserved: bool,
1243    color_depth: ColorDepth,
1244    pub(crate) theme_bg: Option<Color>,
1245    kitty_mgr: KittyImageManager,
1246    graphics_support: GraphicsEmissionSupport,
1247    /// Reused run-coalescing scratch for `flush_buffer_diff` (issue #269).
1248    run_buf: String,
1249}
1250
1251/// Initial capacity for the reused per-frame run-coalescing buffer. Sized to
1252/// comfortably hold a full wide terminal row of multi-byte graphemes so the
1253/// allocation is paid once at construction, never per frame.
1254const RUN_BUF_INITIAL_CAPACITY: usize = 4096;
1255
1256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1257enum TerminalSessionMode {
1258    Fullscreen,
1259    Inline,
1260}
1261
1262#[derive(Debug, Clone, Copy)]
1263struct TerminalSessionGuard {
1264    mode: TerminalSessionMode,
1265    mouse_enabled: bool,
1266    kitty_keyboard: bool,
1267    report_all_keys: bool,
1268    /// When `true`, the guard never touched real raw-mode / terminal state
1269    /// (PTY test harness path). `restore` then becomes a no-op so dropping a
1270    /// captured-sink `Terminal` does not call `disable_raw_mode` or emit
1271    /// teardown escapes into the byte capture. Always `false` on the
1272    /// production `enter` path.
1273    harness: bool,
1274}
1275
1276impl TerminalSessionGuard {
1277    fn enter(
1278        mode: TerminalSessionMode,
1279        stdout: &mut impl Write,
1280        mouse_enabled: bool,
1281        kitty_keyboard: bool,
1282        report_all_keys: bool,
1283    ) -> io::Result<Self> {
1284        let guard = Self {
1285            mode,
1286            mouse_enabled,
1287            kitty_keyboard,
1288            report_all_keys,
1289            harness: false,
1290        };
1291
1292        terminal::enable_raw_mode()?;
1293        if let Err(err) = write_session_enter(stdout, &guard) {
1294            guard.restore(stdout, false);
1295            return Err(err);
1296        }
1297
1298        // Issue #264: run the one-shot DA1/DA2/XTGETTCAP capability probe at
1299        // session enter, while raw mode is active so the replies are readable.
1300        // `capabilities()` caches in a `OnceLock`, so the resume re-enter path
1301        // never re-probes. Never runs on the PTY-harness path (`harness` is
1302        // always `false` here, but resume/harness re-entries go through
1303        // `write_session_enter` directly, not `enter`).
1304        let _ = capabilities();
1305
1306        Ok(guard)
1307    }
1308
1309    fn restore(&self, stdout: &mut impl Write, inline_reserved: bool) {
1310        // PTY harness guard: nothing was ever entered, so nothing to restore.
1311        if self.harness {
1312            return;
1313        }
1314        let _ = write_session_exit(
1315            stdout,
1316            self.mode,
1317            inline_reserved,
1318            self.mouse_enabled,
1319            self.kitty_keyboard,
1320        );
1321        let _ = terminal::disable_raw_mode();
1322    }
1323}
1324
1325impl Terminal {
1326    /// Construct a fullscreen terminal backend; enters raw mode and the
1327    /// alternate screen and optionally enables mouse capture and the
1328    /// kitty keyboard protocol. When `report_all_keys` is set (and
1329    /// `kitty_keyboard` is too), bare modifier presses are reported.
1330    pub fn new(
1331        mouse: bool,
1332        kitty_keyboard: bool,
1333        report_all_keys: bool,
1334        color_depth: ColorDepth,
1335    ) -> io::Result<Self> {
1336        let (cols, rows) = terminal::size()?;
1337        let area = Rect::new(0, 0, cols as u32, rows as u32);
1338
1339        let mut raw = io::stdout();
1340        let session = TerminalSessionGuard::enter(
1341            TerminalSessionMode::Fullscreen,
1342            &mut raw,
1343            mouse,
1344            kitty_keyboard,
1345            report_all_keys,
1346        )?;
1347        let graphics_support = GraphicsEmissionSupport::detect(capabilities());
1348
1349        Ok(Self {
1350            stdout: Sink::Stdout(BufWriter::with_capacity(65536, raw)),
1351            current: Buffer::empty(area),
1352            previous: Buffer::empty(area),
1353            cursor_visible: false,
1354            session,
1355            color_depth,
1356            theme_bg: None,
1357            kitty_mgr: KittyImageManager::new(),
1358            graphics_support,
1359            run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
1360        })
1361    }
1362
1363    /// Return the fullscreen terminal's current `(cols, rows)`.
1364    pub fn size(&self) -> (u32, u32) {
1365        (self.current.area.width, self.current.area.height)
1366    }
1367
1368    /// Mutable access to the back buffer used by the next render pass.
1369    pub fn buffer_mut(&mut self) -> &mut Buffer {
1370        &mut self.current
1371    }
1372
1373    /// Diff the back buffer against the front buffer, write the changed
1374    /// cells to stdout under a synchronized-output guard, then swap
1375    /// front and back buffers.
1376    pub fn flush(&mut self) -> io::Result<()> {
1377        if self.current.area.width < self.previous.area.width {
1378            execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
1379        }
1380
1381        // Synchronized output (BSU/ESU) is gated on the DECRQM ?2026 probe
1382        // (v0.21.1): emit unless the terminal definitively reported the mode
1383        // unrecognized. A silent / headless probe keeps emitting as before.
1384        let sync_guard = should_emit_synchronized_update();
1385        if sync_guard {
1386            queue!(self.stdout, BeginSynchronizedUpdate)?;
1387        }
1388        // Issue #171: refresh both buffers' per-row digests so the per-row
1389        // skip inside `flush_buffer_diff` can short-circuit unchanged rows.
1390        // `previous` only needs a recompute when the prior frame mutated
1391        // it (e.g. after a swap); cheap when nothing's dirty.
1392        self.current.recompute_line_hashes();
1393        self.previous.recompute_line_hashes();
1394        flush_buffer_diff(
1395            &mut self.stdout,
1396            &self.current,
1397            &self.previous,
1398            self.color_depth,
1399            0,
1400            &mut self.run_buf,
1401        )?;
1402
1403        // Kitty graphics: structured image management with IDs and compression.
1404        // Full-screen mode has no row offset (issue #206).
1405        if self.graphics_support.should_emit_kitty() {
1406            self.kitty_mgr
1407                .flush(&mut self.stdout, &self.current.kitty_placements, 0)?;
1408        }
1409
1410        // Generic raw passthrough sequences (non-sprixel) — simple diff.
1411        flush_raw_sequences(&mut self.stdout, &self.current, &self.previous, 0)?;
1412
1413        // Sprixels (sixel / iTerm2) — per-cell damage-tracked re-blit (#265).
1414        flush_sprixels_checked(
1415            &mut self.stdout,
1416            &self.current,
1417            &self.previous,
1418            0,
1419            self.graphics_support,
1420        )?;
1421
1422        if sync_guard {
1423            queue!(self.stdout, EndSynchronizedUpdate)?;
1424        }
1425        flush_cursor(
1426            &mut self.stdout,
1427            &mut self.cursor_visible,
1428            self.current.cursor_pos(),
1429            0,
1430            None,
1431        )?;
1432
1433        self.stdout.flush()?;
1434
1435        std::mem::swap(&mut self.current, &mut self.previous);
1436        if let Some(bg) = self.theme_bg {
1437            self.current.reset_with_bg(bg);
1438        } else {
1439            self.current.reset();
1440        }
1441        Ok(())
1442    }
1443
1444    /// Re-query the terminal size and resize the front and back buffers
1445    /// to match. Called from the SIGWINCH handler.
1446    pub fn handle_resize(&mut self) -> io::Result<()> {
1447        let (cols, rows) = terminal::size()?;
1448        let area = Rect::new(0, 0, cols as u32, rows as u32);
1449        self.current.resize(area);
1450        self.previous.resize(area);
1451        execute!(
1452            self.stdout,
1453            terminal::Clear(terminal::ClearType::All),
1454            cursor::MoveTo(0, 0)
1455        )?;
1456        Ok(())
1457    }
1458}
1459
1460#[cfg(any(test, feature = "pty-test"))]
1461impl Terminal {
1462    /// Construct a fullscreen [`Terminal`] whose flush pipeline targets an
1463    /// in-process byte capture instead of stdout.
1464    ///
1465    /// Used **only** by the PTY test harness ([`crate::PtyBackend`]): the
1466    /// production [`Terminal::new`] / [`crate::run`] path is unchanged and
1467    /// still binds `BufWriter<Stdout>`. No raw mode is entered and no session
1468    /// escapes are emitted, so this can run on a headless CI runner with no
1469    /// TTY. The emitted bytes — SGR runs, OSC 8, Sixel, Kitty graphics — flow
1470    /// through the exact same [`flush_buffer_diff`] / [`apply_style_delta`] /
1471    /// Sixel / Kitty emitters that a real terminal sees.
1472    ///
1473    /// `color_depth` selects the SGR encoding (truecolor vs 256-color etc.)
1474    /// exercised by the flush, mirroring [`Terminal::new`]'s argument.
1475    pub(crate) fn with_sink(width: u32, height: u32, color_depth: ColorDepth) -> Self {
1476        let area = Rect::new(0, 0, width, height);
1477        Self {
1478            stdout: Sink::Capture(Vec::new()),
1479            current: Buffer::empty(area),
1480            previous: Buffer::empty(area),
1481            cursor_visible: false,
1482            session: TerminalSessionGuard {
1483                mode: TerminalSessionMode::Fullscreen,
1484                mouse_enabled: false,
1485                kitty_keyboard: false,
1486                report_all_keys: false,
1487                harness: true,
1488            },
1489            color_depth,
1490            theme_bg: None,
1491            kitty_mgr: KittyImageManager::new(),
1492            graphics_support: GraphicsEmissionSupport::capture(),
1493            run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
1494        }
1495    }
1496
1497    /// Drain and return the bytes captured by a [`with_sink`](Terminal::with_sink)
1498    /// terminal since the last call, resetting the capture buffer.
1499    ///
1500    /// Panics if this terminal is not a captured-sink (harness) terminal.
1501    pub(crate) fn take_sink_bytes(&mut self) -> Vec<u8> {
1502        match &mut self.stdout {
1503            Sink::Capture(v) => std::mem::take(v),
1504            Sink::Stdout(_) => panic!("take_sink_bytes called on a non-capture Terminal"),
1505        }
1506    }
1507}
1508
1509impl crate::Backend for Terminal {
1510    fn size(&self) -> (u32, u32) {
1511        Terminal::size(self)
1512    }
1513
1514    fn buffer_mut(&mut self) -> &mut Buffer {
1515        Terminal::buffer_mut(self)
1516    }
1517
1518    fn flush(&mut self) -> io::Result<()> {
1519        Terminal::flush(self)
1520    }
1521}
1522
1523impl InlineTerminal {
1524    /// Construct an inline terminal backend that renders `height` rows
1525    /// below the current cursor without entering the alternate screen.
1526    /// Optionally enables mouse capture and the kitty keyboard protocol.
1527    /// When `report_all_keys` is set (and `kitty_keyboard` is too), bare
1528    /// modifier presses are reported.
1529    pub fn new(
1530        height: u32,
1531        mouse: bool,
1532        kitty_keyboard: bool,
1533        report_all_keys: bool,
1534        color_depth: ColorDepth,
1535    ) -> io::Result<Self> {
1536        let (cols, _) = terminal::size()?;
1537        let area = Rect::new(0, 0, cols as u32, height);
1538
1539        let mut raw = io::stdout();
1540        let session = TerminalSessionGuard::enter(
1541            TerminalSessionMode::Inline,
1542            &mut raw,
1543            mouse,
1544            kitty_keyboard,
1545            report_all_keys,
1546        )?;
1547        let graphics_support = GraphicsEmissionSupport::detect(capabilities());
1548
1549        let (_, cursor_row) = match cursor::position() {
1550            Ok(pos) => pos,
1551            Err(err) => {
1552                session.restore(&mut raw, false);
1553                return Err(err);
1554            }
1555        };
1556        Ok(Self {
1557            stdout: Sink::Stdout(BufWriter::with_capacity(65536, raw)),
1558            current: Buffer::empty(area),
1559            previous: Buffer::empty(area),
1560            cursor_visible: false,
1561            session,
1562            height,
1563            start_row: cursor_row,
1564            reserved: false,
1565            color_depth,
1566            theme_bg: None,
1567            kitty_mgr: KittyImageManager::new(),
1568            graphics_support,
1569            run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
1570        })
1571    }
1572
1573    /// Return the inline terminal's current `(cols, rows)`.
1574    pub fn size(&self) -> (u32, u32) {
1575        (self.current.area.width, self.current.area.height)
1576    }
1577
1578    /// Mutable access to the back buffer used by the next render pass.
1579    pub fn buffer_mut(&mut self) -> &mut Buffer {
1580        &mut self.current
1581    }
1582
1583    /// Diff the back buffer against the front buffer, write changed
1584    /// cells to stdout under a synchronized-output guard at the
1585    /// inline rows reserved below the cursor, then swap buffers.
1586    pub fn flush(&mut self) -> io::Result<()> {
1587        if self.current.area.width < self.previous.area.width {
1588            execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
1589        }
1590
1591        // Synchronized output (BSU/ESU) is gated on the DECRQM ?2026 probe
1592        // (v0.21.1); see `Terminal::flush`. Silent / headless keeps emitting.
1593        let sync_guard = should_emit_synchronized_update();
1594        if sync_guard {
1595            queue!(self.stdout, BeginSynchronizedUpdate)?;
1596        }
1597
1598        if !self.reserved {
1599            queue!(self.stdout, cursor::MoveToColumn(0))?;
1600            for _ in 0..self.height {
1601                queue!(self.stdout, Print("\n"))?;
1602            }
1603            self.reserved = true;
1604
1605            let (_, rows) = terminal::size()?;
1606            let bottom = self.start_row.saturating_add(sat_u16(self.height));
1607            if bottom > rows {
1608                self.start_row = rows.saturating_sub(sat_u16(self.height));
1609            }
1610        }
1611        let row_offset = self.start_row as u32;
1612        // Issue #171: refresh per-row digests before the diff so the
1613        // unchanged-row skip can fire (same call shape as `Terminal::flush`).
1614        self.current.recompute_line_hashes();
1615        self.previous.recompute_line_hashes();
1616        flush_buffer_diff(
1617            &mut self.stdout,
1618            &self.current,
1619            &self.previous,
1620            self.color_depth,
1621            row_offset,
1622            &mut self.run_buf,
1623        )?;
1624
1625        // Kitty graphics: structured image management with IDs and compression.
1626        // Issue #206: pass `row_offset` instead of materializing a translated
1627        // `Vec<KittyPlacement>` copy — `KittyImageManager::flush` applies the
1628        // offset arithmetically at point of use and stores post-offset y in
1629        // `prev_placements` for the next frame's diff.
1630        if self.graphics_support.should_emit_kitty() {
1631            self.kitty_mgr
1632                .flush(&mut self.stdout, &self.current.kitty_placements, row_offset)?;
1633        }
1634
1635        // Generic raw passthrough sequences (non-sprixel) — simple diff.
1636        flush_raw_sequences(&mut self.stdout, &self.current, &self.previous, row_offset)?;
1637
1638        // Sprixels (sixel / iTerm2) — per-cell damage-tracked re-blit (#265).
1639        flush_sprixels_checked(
1640            &mut self.stdout,
1641            &self.current,
1642            &self.previous,
1643            row_offset,
1644            self.graphics_support,
1645        )?;
1646
1647        if sync_guard {
1648            queue!(self.stdout, EndSynchronizedUpdate)?;
1649        }
1650        let fallback_row = row_offset + self.height.saturating_sub(1);
1651        flush_cursor(
1652            &mut self.stdout,
1653            &mut self.cursor_visible,
1654            self.current.cursor_pos(),
1655            row_offset,
1656            Some(fallback_row),
1657        )?;
1658
1659        self.stdout.flush()?;
1660
1661        std::mem::swap(&mut self.current, &mut self.previous);
1662        reset_current_buffer(&mut self.current, self.theme_bg);
1663        Ok(())
1664    }
1665
1666    /// Re-query the terminal size and resize the inline buffers to match
1667    /// the new column count, preserving the inline row height.
1668    pub fn handle_resize(&mut self) -> io::Result<()> {
1669        let (cols, _) = terminal::size()?;
1670        let area = Rect::new(0, 0, cols as u32, self.height);
1671        self.current.resize(area);
1672        self.previous.resize(area);
1673        execute!(
1674            self.stdout,
1675            terminal::Clear(terminal::ClearType::All),
1676            cursor::MoveTo(0, 0)
1677        )?;
1678        Ok(())
1679    }
1680}
1681
1682impl crate::Backend for InlineTerminal {
1683    fn size(&self) -> (u32, u32) {
1684        InlineTerminal::size(self)
1685    }
1686
1687    fn buffer_mut(&mut self) -> &mut Buffer {
1688        InlineTerminal::buffer_mut(self)
1689    }
1690
1691    fn flush(&mut self) -> io::Result<()> {
1692        InlineTerminal::flush(self)
1693    }
1694}
1695
1696impl Drop for Terminal {
1697    fn drop(&mut self) {
1698        // Clean up Kitty images before leaving alternate screen
1699        if self.graphics_support.should_emit_kitty() {
1700            let _ = self.kitty_mgr.delete_all(&mut self.stdout);
1701        }
1702        let _ = self.stdout.flush();
1703        self.session.restore(&mut self.stdout, false);
1704    }
1705}
1706
1707impl Drop for InlineTerminal {
1708    fn drop(&mut self) {
1709        if self.graphics_support.should_emit_kitty() {
1710            let _ = self.kitty_mgr.delete_all(&mut self.stdout);
1711        }
1712        let _ = self.stdout.flush();
1713        self.session.restore(&mut self.stdout, self.reserved);
1714    }
1715}
1716
1717mod selection;
1718pub(crate) use selection::{SelectionState, apply_selection_overlay, extract_selection_text};
1719#[cfg(test)]
1720pub(crate) use selection::{find_innermost_rect, normalize_selection};
1721
1722/// Detected terminal color scheme from OSC 11.
1723#[non_exhaustive]
1724#[cfg(feature = "crossterm")]
1725#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
1726#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1727pub enum ColorScheme {
1728    /// Dark background detected.
1729    Dark,
1730    /// Light background detected.
1731    Light,
1732    /// Could not determine the scheme.
1733    Unknown,
1734}
1735
1736/// Read an OSC-style reply (BEL- or ST-terminated), hard-bounded by `timeout`.
1737#[cfg(feature = "crossterm")]
1738fn read_osc_response(timeout: Duration) -> Option<String> {
1739    read_stdin_reply(timeout, osc_reply_complete)
1740}
1741
1742/// Query the terminal's background color via OSC 11 and return the detected scheme.
1743#[cfg(feature = "crossterm")]
1744#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
1745pub fn detect_color_scheme() -> ColorScheme {
1746    if !terminal_queries_allowed() {
1747        return ColorScheme::Unknown;
1748    }
1749
1750    let mut stdout = io::stdout();
1751    if write!(stdout, "\x1b]11;?\x07").is_err() {
1752        return ColorScheme::Unknown;
1753    }
1754    if stdout.flush().is_err() {
1755        return ColorScheme::Unknown;
1756    }
1757
1758    let Some(response) = read_osc_response(Duration::from_millis(100)) else {
1759        return ColorScheme::Unknown;
1760    };
1761
1762    parse_osc11_response(&response)
1763}
1764
1765#[cfg(feature = "crossterm")]
1766pub(crate) fn parse_osc11_response(response: &str) -> ColorScheme {
1767    let Some(rgb_pos) = response.find("rgb:") else {
1768        return ColorScheme::Unknown;
1769    };
1770
1771    let payload = &response[rgb_pos + 4..];
1772    let end = payload
1773        .find(['\x07', '\x1b', '\r', '\n', ' ', '\t'])
1774        .unwrap_or(payload.len());
1775    let rgb = &payload[..end];
1776
1777    let mut channels = rgb.split('/');
1778    let (Some(r), Some(g), Some(b), None) = (
1779        channels.next(),
1780        channels.next(),
1781        channels.next(),
1782        channels.next(),
1783    ) else {
1784        return ColorScheme::Unknown;
1785    };
1786
1787    fn parse_channel(channel: &str) -> Option<f64> {
1788        if channel.is_empty() || channel.len() > 4 {
1789            return None;
1790        }
1791        let value = u16::from_str_radix(channel, 16).ok()? as f64;
1792        let max = ((1u32 << (channel.len() * 4)) - 1) as f64;
1793        if max <= 0.0 {
1794            return None;
1795        }
1796        Some((value / max).clamp(0.0, 1.0))
1797    }
1798
1799    let (Some(r), Some(g), Some(b)) = (parse_channel(r), parse_channel(g), parse_channel(b)) else {
1800        return ColorScheme::Unknown;
1801    };
1802
1803    let luminance = 0.299 * r + 0.587 * g + 0.114 * b;
1804    if luminance < 0.5 {
1805        ColorScheme::Dark
1806    } else {
1807        ColorScheme::Light
1808    }
1809}
1810
1811pub(crate) fn base64_encode(input: &[u8]) -> String {
1812    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1813    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
1814    for chunk in input.chunks(3) {
1815        let b0 = chunk[0] as u32;
1816        let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
1817        let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
1818        let triple = (b0 << 16) | (b1 << 8) | b2;
1819        out.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
1820        out.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
1821        out.push(if chunk.len() > 1 {
1822            CHARS[((triple >> 6) & 0x3F) as usize] as char
1823        } else {
1824            '='
1825        });
1826        out.push(if chunk.len() > 2 {
1827            CHARS[(triple & 0x3F) as usize] as char
1828        } else {
1829            '='
1830        });
1831    }
1832    out
1833}
1834
1835pub(crate) fn copy_to_clipboard(w: &mut impl Write, text: &str) -> io::Result<()> {
1836    let encoded = base64_encode(text.as_bytes());
1837    write!(w, "\x1b]52;c;{encoded}\x1b\\")?;
1838    w.flush()
1839}
1840
1841#[cfg(feature = "crossterm")]
1842fn parse_osc52_response(response: &str) -> Option<String> {
1843    let osc_pos = response.find("]52;")?;
1844    let body = &response[osc_pos + 4..];
1845    let semicolon = body.find(';')?;
1846    let payload = &body[semicolon + 1..];
1847
1848    let end = payload
1849        .find("\x1b\\")
1850        .or_else(|| payload.find('\x07'))
1851        .unwrap_or(payload.len());
1852    let encoded = payload[..end].trim();
1853    if encoded.is_empty() || encoded == "?" {
1854        return None;
1855    }
1856
1857    base64_decode(encoded)
1858}
1859
1860/// Read clipboard contents via an OSC 52 terminal query.
1861///
1862/// Writes the OSC 52 read request (`ESC ] 52 ; c ; ? BEL`) to stdout, then
1863/// blocks reading the terminal's reply from stdin for up to ~200 ms. Returns
1864/// the decoded clipboard text, or `None` if the terminal does not answer, the
1865/// reply is empty, or it cannot be decoded. Many terminals disable OSC 52 reads
1866/// by default for security, in which case this always returns `None`.
1867///
1868/// # Note
1869///
1870/// This call reads the **same stdin** the [`run`](crate::run) event loop polls,
1871/// **synchronously and outside** the loop's own event dispatch. That creates a
1872/// typeahead-swallow hazard: during the blocking read window, any bytes the user
1873/// types — and any other terminal report in flight (mouse, focus, paste, a
1874/// different OSC reply) — land in this function's byte reader instead of the
1875/// event queue. Keystrokes consumed here are silently lost, and a foreign report
1876/// interleaved with the OSC 52 reply can corrupt parsing so the read returns
1877/// `None`. There is no locking between this reader and the run loop's poll, so
1878/// calling it concurrently from another thread while the loop is running races
1879/// on stdin.
1880///
1881/// Recommended usage:
1882///   * Call it from the main thread, **not** from a spawned thread, and never
1883///     concurrently with a running [`run`](crate::run) loop on another thread.
1884///   * Trigger it only in direct response to an explicit user action (e.g. a
1885///     paste keybinding) and keep the window brief, so the typeahead lost to the
1886///     blocking read is bounded to that moment.
1887///   * Prefer the OS clipboard via a dedicated crate when reliable, race-free
1888///     clipboard reads are required; reserve this for the no-dependency,
1889///     terminal-only fallback.
1890///   * For *writing* the clipboard there is no such hazard — that path only
1891///     emits bytes and never reads stdin.
1892#[cfg(feature = "crossterm")]
1893#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
1894pub fn read_clipboard() -> Option<String> {
1895    if !terminal_queries_allowed() {
1896        return None;
1897    }
1898
1899    let mut stdout = io::stdout();
1900    write!(stdout, "\x1b]52;c;?\x07").ok()?;
1901    stdout.flush().ok()?;
1902
1903    let response = read_osc_response(Duration::from_millis(200))?;
1904    parse_osc52_response(&response)
1905}
1906
1907#[cfg(feature = "crossterm")]
1908fn base64_decode(input: &str) -> Option<String> {
1909    let mut filtered: Vec<u8> = input
1910        .bytes()
1911        .filter(|b| !matches!(b, b' ' | b'\n' | b'\r' | b'\t'))
1912        .collect();
1913
1914    match filtered.len() % 4 {
1915        0 => {}
1916        2 => filtered.extend_from_slice(b"=="),
1917        3 => filtered.push(b'='),
1918        _ => return None,
1919    }
1920
1921    fn decode_val(b: u8) -> Option<u8> {
1922        match b {
1923            b'A'..=b'Z' => Some(b - b'A'),
1924            b'a'..=b'z' => Some(b - b'a' + 26),
1925            b'0'..=b'9' => Some(b - b'0' + 52),
1926            b'+' => Some(62),
1927            b'/' => Some(63),
1928            _ => None,
1929        }
1930    }
1931
1932    let mut out = Vec::with_capacity((filtered.len() / 4) * 3);
1933    for chunk in filtered.chunks_exact(4) {
1934        let p2 = chunk[2] == b'=';
1935        let p3 = chunk[3] == b'=';
1936        if p2 && !p3 {
1937            return None;
1938        }
1939
1940        let v0 = decode_val(chunk[0])? as u32;
1941        let v1 = decode_val(chunk[1])? as u32;
1942        let v2 = if p2 { 0 } else { decode_val(chunk[2])? as u32 };
1943        let v3 = if p3 { 0 } else { decode_val(chunk[3])? as u32 };
1944
1945        let triple = (v0 << 18) | (v1 << 12) | (v2 << 6) | v3;
1946        out.push(((triple >> 16) & 0xFF) as u8);
1947        if !p2 {
1948            out.push(((triple >> 8) & 0xFF) as u8);
1949        }
1950        if !p3 {
1951            out.push((triple & 0xFF) as u8);
1952        }
1953    }
1954
1955    String::from_utf8(out).ok()
1956}
1957
1958#[allow(clippy::too_many_arguments)]
1959#[allow(unused_assignments)]
1960fn flush_buffer_diff(
1961    stdout: &mut impl Write,
1962    current: &Buffer,
1963    previous: &Buffer,
1964    color_depth: ColorDepth,
1965    row_offset: u32,
1966    run_buf: &mut String,
1967) -> io::Result<()> {
1968    // Run-coalescing: consecutive changed cells in the same row that share
1969    // `Style` + `hyperlink` + contiguous x-coordinates are emitted as a single
1970    // `Print(run)` after one cursor move and one style delta. This cuts the
1971    // number of `queue!` calls on a full redraw from O(cells) to
1972    // O(style-change boundaries), which is the dominant stdout write cost.
1973    //
1974    // A run is broken whenever:
1975    //   * style, hyperlink, or row changes,
1976    //   * the next cell is not at the expected next column (gap from skipped
1977    //     cells — unchanged, empty wide-char trailer, or end of row),
1978    //   * end-of-row (always flushed before descending to the next row).
1979    let mut last_style = Style::new();
1980    let mut first_style = true;
1981    let mut active_link: Option<&str> = None;
1982    let mut has_updates = false;
1983    // Where we believe the cursor currently sits — lets us skip a redundant
1984    // `MoveTo` when a new run starts exactly where the previous one ended
1985    // (e.g. split only by a style change on otherwise contiguous columns).
1986    let mut last_cursor: Option<(u32, u32)> = None;
1987
1988    // Active run state. `run_next_col` is the column the next cell must
1989    // occupy to extend the run; `run_open` guards the rest of the fields.
1990    // `run_buf` is hoisted to a caller-owned, reused buffer (issue #269): its
1991    // backing allocation persists across frames so the hot flush loop performs
1992    // no per-frame `String` allocation. Start clean but keep capacity.
1993    run_buf.clear();
1994    let mut run_abs_y: u32 = 0;
1995    let mut run_style: Style = Style::new();
1996    let mut run_link: Option<&str> = None;
1997    let mut run_next_col: u32 = 0;
1998    let mut run_open = false;
1999
2000    // Helper: flush the currently open run, if any. Emits a single `Print`
2001    // for the entire accumulated buffer; positioning, style, and OSC 8 were
2002    // already written when the run opened. Updates `last_cursor` to reflect
2003    // where the cursor ends up after the Print.
2004    macro_rules! flush_run {
2005        ($stdout:expr) => {
2006            if run_open {
2007                queue!($stdout, Print(&run_buf))?;
2008                last_cursor = Some((run_next_col, run_abs_y));
2009                run_buf.clear();
2010                run_open = false;
2011            }
2012        };
2013    }
2014
2015    for y in current.area.y..current.area.bottom() {
2016        // Issue #171: skip the per-cell scan for rows that were not touched
2017        // since the last hash refresh AND match the previous frame's
2018        // digest. Both conditions must hold:
2019        //   * `row_clean` rules out rows that received writes this frame
2020        //     even if those writes happened to land on identical cells.
2021        //   * The hash equality is the actual unchanged-row signal.
2022        // Falling through to the per-cell loop on either failure preserves
2023        // legacy behavior; the skip is a pure short-circuit.
2024        if current.row_clean(y)
2025            && current.row_hash(y).is_some()
2026            && current.row_hash(y) == previous.row_hash(y)
2027        {
2028            continue;
2029        }
2030        for x in current.area.x..current.area.right() {
2031            let cell = current.get(x, y);
2032            let prev = previous.get(x, y);
2033            if cell == prev || cell.symbol.is_empty() {
2034                // Gap — any open run on this row must be flushed.
2035                flush_run!(stdout);
2036                continue;
2037            }
2038
2039            let abs_y = row_offset + y;
2040            // Defense-in-depth: `Cell::hyperlink` is a public field that can
2041            // be written directly. `set_string_linked` pre-sanitizes, but a
2042            // direct write could still smuggle control bytes into the OSC 8
2043            // payload. Validate here before flushing to stdout.
2044            let cell_link = cell
2045                .hyperlink
2046                .as_deref()
2047                .filter(|u| crate::buffer::is_valid_osc8_url(u));
2048
2049            // Decide whether this cell extends the open run or starts a new one.
2050            let extends = run_open
2051                && run_abs_y == abs_y
2052                && run_next_col == x
2053                && run_style == cell.style
2054                && run_link == cell_link;
2055
2056            if !extends {
2057                flush_run!(stdout);
2058
2059                // Begin a new run. Emit positioning + style + OSC 8 header now
2060                // (before the Print bytes) so the resulting stream is a valid
2061                // SGR sequence exactly matching the per-cell flush.
2062                has_updates = true;
2063
2064                let need_move = last_cursor.is_none_or(|(lx, ly)| lx != x || ly != abs_y);
2065                if need_move {
2066                    queue!(stdout, cursor::MoveTo(sat_u16(x), sat_u16(abs_y)))?;
2067                }
2068
2069                if cell.style != last_style {
2070                    if first_style {
2071                        queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
2072                        apply_style(stdout, &cell.style, color_depth)?;
2073                        first_style = false;
2074                    } else {
2075                        apply_style_delta(stdout, &last_style, &cell.style, color_depth)?;
2076                    }
2077                    last_style = cell.style;
2078                }
2079
2080                if cell_link != active_link {
2081                    if let Some(url) = cell_link {
2082                        // Emit the OSC 8 open in three borrowed `Print`s instead
2083                        // of `format!`ing a throwaway `String` per link-state
2084                        // change (issue #269). The byte stream is identical to
2085                        // `"\x1b]8;;{url}\x07"`.
2086                        queue!(stdout, Print("\x1b]8;;"))?;
2087                        queue!(stdout, Print(url))?;
2088                        queue!(stdout, Print("\x07"))?;
2089                    } else {
2090                        queue!(stdout, Print("\x1b]8;;\x07"))?;
2091                    }
2092                    active_link = cell_link;
2093                }
2094
2095                run_open = true;
2096                run_abs_y = abs_y;
2097                run_style = cell.style;
2098                run_link = cell_link;
2099            }
2100
2101            // Append the cell's grapheme cluster (possibly multi-char when it
2102            // carries combining marks). Wide chars advance by their column
2103            // width so subsequent cells line up.
2104            run_buf.push_str(&cell.symbol);
2105            let char_width = UnicodeWidthStr::width(cell.symbol.as_str()).max(1) as u32;
2106            if char_width > 1 && cell.symbol.chars().any(|c| c == '\u{FE0F}') {
2107                // Emoji variation selector — terminal renders 2 cols but the
2108                // glyph often measures as 1; pad so the cursor ends up where
2109                // the next cell is drawn.
2110                run_buf.push(' ');
2111            }
2112            run_next_col = x + char_width;
2113        }
2114
2115        // End of row: flush whatever is buffered before moving to the next row.
2116        flush_run!(stdout);
2117    }
2118
2119    if has_updates {
2120        if active_link.is_some() {
2121            queue!(stdout, Print("\x1b]8;;\x07"))?;
2122        }
2123        queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
2124    }
2125
2126    Ok(())
2127}
2128
2129/// Benchmark-only entry point for the per-frame buffer flush.
2130///
2131/// Exposed so criterion benches under `benches/` (an external crate) can
2132/// measure the stdout-emit cost of the per-frame flush against a hermetic
2133/// `Vec<u8>` (or any `Write`) sink, without constructing a real terminal.
2134///
2135/// Not part of the stable API. Do not depend on this in application code —
2136/// prefer the real terminal backend ([`crate::run`]) or
2137/// [`TestBackend`](crate::TestBackend).
2138#[doc(hidden)]
2139pub fn __bench_flush_buffer_diff<W: Write>(
2140    w: &mut W,
2141    current: &Buffer,
2142    previous: &Buffer,
2143    color_depth: ColorDepth,
2144) -> io::Result<()> {
2145    // Own a local run buffer to keep the public bench signature stable
2146    // (issue #269); the real backends pass a reused field instead.
2147    let mut run_buf = String::with_capacity(RUN_BUF_INITIAL_CAPACITY);
2148    flush_buffer_diff(w, current, previous, color_depth, 0, &mut run_buf)
2149}
2150
2151/// Mutable-buffer variant of [`__bench_flush_buffer_diff`] (issue #171).
2152///
2153/// Refreshes per-row digests on both buffers before invoking
2154/// `flush_buffer_diff`, matching what the real `Terminal::flush` and
2155/// `InlineTerminal::flush` paths do. Benches that want to measure the
2156/// flush including the hash-refresh cost should use this entry point;
2157/// the immutable variant is preserved for backwards compatibility with
2158/// existing benches that own only `&Buffer`.
2159#[doc(hidden)]
2160pub fn __bench_flush_buffer_diff_mut<W: Write>(
2161    w: &mut W,
2162    current: &mut Buffer,
2163    previous: &mut Buffer,
2164    color_depth: ColorDepth,
2165) -> io::Result<()> {
2166    // Own a local run buffer to keep the public bench signature stable
2167    // (issue #269). Use `__bench_flush_buffer_diff_mut_with_buf` to exercise
2168    // cross-frame buffer reuse explicitly.
2169    let mut run_buf = String::with_capacity(RUN_BUF_INITIAL_CAPACITY);
2170    __bench_flush_buffer_diff_mut_with_buf(w, current, previous, color_depth, &mut run_buf)
2171}
2172
2173/// Reuse-aware variant of [`__bench_flush_buffer_diff_mut`] that threads a
2174/// caller-owned `run_buf` (issue #269), mirroring how the real backends carry
2175/// the buffer across frames. Refreshes per-row digests before the diff.
2176///
2177/// Not part of the stable API.
2178///
2179/// ```no_run
2180/// # use slt::{Buffer, Rect, ColorDepth, Style};
2181/// let area = Rect::new(0, 0, 8, 2);
2182/// let mut current = Buffer::empty(area);
2183/// let mut previous = Buffer::empty(area);
2184/// current.set_string(0, 0, "hi", Style::new());
2185/// let mut sink: Vec<u8> = Vec::new();
2186/// // The same `run_buf` can be passed across frames — its capacity persists.
2187/// let mut run_buf = String::with_capacity(4096);
2188/// slt::__bench_flush_buffer_diff_mut_with_buf(
2189///     &mut sink,
2190///     &mut current,
2191///     &mut previous,
2192///     ColorDepth::TrueColor,
2193///     &mut run_buf,
2194/// )
2195/// .unwrap();
2196/// ```
2197#[doc(hidden)]
2198pub fn __bench_flush_buffer_diff_mut_with_buf<W: Write>(
2199    w: &mut W,
2200    current: &mut Buffer,
2201    previous: &mut Buffer,
2202    color_depth: ColorDepth,
2203    run_buf: &mut String,
2204) -> io::Result<()> {
2205    current.recompute_line_hashes();
2206    previous.recompute_line_hashes();
2207    flush_buffer_diff(w, current, previous, color_depth, 0, run_buf)
2208}
2209
2210/// Opaque test fixture wrapping `KittyImageManager` + a placements list.
2211///
2212/// Returned by [`__bench_new_kitty_fixture`]. Internal types stay
2213/// `pub(crate)` — only the opaque struct crosses the crate boundary.
2214#[doc(hidden)]
2215pub struct __BenchKittyFixture {
2216    mgr: KittyImageManager,
2217    placements: Vec<KittyPlacement>,
2218}
2219
2220/// Build a self-contained kitty-flush fixture for the perf alloc suite
2221/// (issue #206). `n` is the number of distinct images.
2222#[doc(hidden)]
2223pub fn __bench_new_kitty_fixture(n: usize) -> __BenchKittyFixture {
2224    let mut placements = Vec::with_capacity(n);
2225    for i in 0..n {
2226        // 8x8 RGBA: 64 px * 4 bytes = 256 bytes.
2227        let mut rgba = vec![0u8; 256];
2228        // Vary contents per placement to give each a unique content_hash.
2229        rgba[0] = i as u8;
2230        let content_hash = crate::buffer::hash_rgba(&rgba);
2231        placements.push(KittyPlacement {
2232            content_hash,
2233            rgba: std::sync::Arc::new(rgba),
2234            src_width: 8,
2235            src_height: 8,
2236            x: (i as u32) * 4,
2237            y: (i as u32) * 2,
2238            cols: 4,
2239            rows: 2,
2240            crop_y: 0,
2241            crop_h: 0,
2242        });
2243    }
2244    __BenchKittyFixture {
2245        mgr: KittyImageManager::new(),
2246        placements,
2247    }
2248}
2249
2250impl __BenchKittyFixture {
2251    /// Strong-count snapshot of the inner `Arc<Vec<u8>>` for each placement.
2252    /// Used by the alloc-budget tests to confirm no extra Arc clones leak
2253    /// past the manager's stored `prev_placements`.
2254    #[doc(hidden)]
2255    pub fn rgba_strong_counts(&self) -> Vec<usize> {
2256        self.placements
2257            .iter()
2258            .map(|p| std::sync::Arc::strong_count(&p.rgba))
2259            .collect()
2260    }
2261
2262    /// Run the inline-mode flush path with the given row offset. Writes
2263    /// terminal escapes into `sink` and updates the internal manager state.
2264    #[doc(hidden)]
2265    pub fn flush_inline<W: Write>(&mut self, sink: &mut W, row_offset: u32) -> io::Result<()> {
2266        self.mgr.flush(sink, &self.placements, row_offset)
2267    }
2268
2269    /// Number of placements in this fixture.
2270    #[doc(hidden)]
2271    pub fn len(&self) -> usize {
2272        self.placements.len()
2273    }
2274
2275    /// Whether this fixture has zero placements.
2276    #[doc(hidden)]
2277    pub fn is_empty(&self) -> bool {
2278        self.placements.is_empty()
2279    }
2280}
2281
2282/// Benchmark-only entry point for the Kitty image flush path.
2283///
2284/// Builds an `n`-image fixture and runs [`KittyImageManager::flush`] once into
2285/// the supplied sink at `row_offset`, mirroring the [`__bench_flush_buffer_diff`]
2286/// free-function style. `KittyPlacement` / `KittyImageManager` are `pub(crate)`,
2287/// so an external bench crate cannot construct them directly — this wrapper owns
2288/// the construction and only the `Write` sink crosses the crate boundary.
2289///
2290/// Not part of the stable API.
2291#[doc(hidden)]
2292pub fn __bench_flush_kitty<W: Write>(sink: &mut W, n: usize, row_offset: u32) -> io::Result<()> {
2293    let mut fixture = __bench_new_kitty_fixture(n);
2294    fixture.flush_inline(sink, row_offset)
2295}
2296
2297/// Opaque test/bench fixture wrapping two `Buffer`s populated with structurally
2298/// identical sprixel placements, used to drive the [`flush_sprixels`] re-blit
2299/// path. `SprixelPlacement` is `pub(crate)`, so this fixture owns construction
2300/// and exposes only `Write`-based flush entry points across the crate boundary.
2301///
2302/// Returned by [`__bench_new_sprixel_fixture`].
2303#[doc(hidden)]
2304pub struct __BenchSprixelFixture {
2305    current: Buffer,
2306    previous: Buffer,
2307}
2308
2309/// Build a self-contained sprixel-reblit fixture for the perf suite (v0.21.1).
2310///
2311/// Creates `n` opaque sprixel placements laid out down the buffer and mirrors
2312/// them into both the current and previous frame so the steady-state flush
2313/// re-blits nothing. Per-row digests are refreshed (as the real `flush` does)
2314/// so the per-row clean+hash shortcut in [`sprixel_needs_reblit`] is exercised.
2315///
2316/// Not part of the stable API.
2317#[doc(hidden)]
2318pub fn __bench_new_sprixel_fixture(n: usize) -> __BenchSprixelFixture {
2319    use crate::buffer::{SprixelCell, SprixelPlacement};
2320
2321    // A buffer tall enough to stack `n` 2-row sprixels with a 1-row gap.
2322    let height = (n as u32 * 3).max(1);
2323    let area = Rect::new(0, 0, 8, height);
2324    let mut current = Buffer::empty(area);
2325    let mut previous = Buffer::empty(area);
2326
2327    for i in 0..n {
2328        let placement = SprixelPlacement {
2329            content_hash: 0x5000 + i as u64,
2330            seq: "<SIXEL>".to_string(),
2331            x: 0,
2332            y: i as u32 * 3,
2333            cols: 4,
2334            rows: 2,
2335            cells: vec![SprixelCell::Opaque; 8],
2336        };
2337        current.sprixels.push(placement.clone());
2338        previous.sprixels.push(placement);
2339    }
2340
2341    // Refresh digests so the per-row shortcut can fire, matching the real
2342    // `Terminal::flush` ordering (recompute happens before `flush_sprixels`).
2343    current.recompute_line_hashes();
2344    previous.recompute_line_hashes();
2345
2346    __BenchSprixelFixture { current, previous }
2347}
2348
2349// The bench fixture's inherent methods are reachable only once the crate root
2350// re-exports `__BenchSprixelFixture` (an integrator step listed in the release
2351// notes); until then the lib-target dead-code lint flags them, exactly as it
2352// would the already-shipped `__BenchKittyFixture` methods without their
2353// `lib.rs` re-export. They are also exercised by the in-crate tests below.
2354// Suppress the lint on the impl rather than gating the items behind `cfg(test)`,
2355// which would make them invisible to the external `benches/` crate they exist
2356// to serve.
2357#[allow(dead_code)]
2358impl __BenchSprixelFixture {
2359    /// Run [`flush_sprixels`] once, writing any re-blitted graphics into `sink`.
2360    /// A steady-state fixture emits nothing; this measures the no-damage scan
2361    /// cost (hash-set build + per-row shortcut) on the hot path.
2362    #[doc(hidden)]
2363    pub fn flush<W: Write>(&self, sink: &mut W, row_offset: u32) -> io::Result<()> {
2364        flush_sprixels(sink, &self.current, &self.previous, row_offset)
2365    }
2366
2367    /// Number of sprixel placements in this fixture.
2368    #[doc(hidden)]
2369    pub fn len(&self) -> usize {
2370        self.current.sprixels.len()
2371    }
2372
2373    /// Whether this fixture has zero placements.
2374    #[doc(hidden)]
2375    pub fn is_empty(&self) -> bool {
2376        self.current.sprixels.is_empty()
2377    }
2378}
2379
2380/// Benchmark-only entry point for the optimized sprixel re-blit scan (v0.21.1).
2381///
2382/// Builds an `n`-placement steady-state fixture and runs [`flush_sprixels`] once
2383/// into `sink` at `row_offset`, mirroring the [`__bench_flush_buffer_diff`]
2384/// free-function style. A steady frame re-blits nothing, so this measures the
2385/// no-damage scan cost (hashed-key build + per-row clean/hash shortcut). When
2386/// the fixture is empty the early-out fires and no work is done.
2387///
2388/// Not part of the stable API.
2389#[doc(hidden)]
2390pub fn __bench_flush_sprixels<W: Write>(sink: &mut W, n: usize, row_offset: u32) -> io::Result<()> {
2391    let fixture = __bench_new_sprixel_fixture(n);
2392    if fixture.is_empty() {
2393        return Ok(());
2394    }
2395    debug_assert_eq!(fixture.len(), n);
2396    fixture.flush(sink, row_offset)
2397}
2398
2399fn flush_raw_sequences(
2400    stdout: &mut impl Write,
2401    current: &Buffer,
2402    previous: &Buffer,
2403    row_offset: u32,
2404) -> io::Result<()> {
2405    if current.raw_sequences == previous.raw_sequences {
2406        return Ok(());
2407    }
2408
2409    for (x, y, seq) in &current.raw_sequences {
2410        queue!(
2411            stdout,
2412            cursor::MoveTo(sat_u16(*x), sat_u16(row_offset + *y)),
2413            Print(seq)
2414        )?;
2415    }
2416
2417    Ok(())
2418}
2419
2420/// Structural identity key for a [`crate::buffer::SprixelPlacement`], matching
2421/// its [`PartialEq`] contract (`content_hash`/`x`/`y`/`cols`/`rows`, damage
2422/// matrix excluded). Hashing this lets [`flush_sprixels`] answer "did an equal
2423/// placement exist last frame?" in O(1) instead of an O(n·m) linear scan.
2424type SprixelKey = (u64, u32, u32, u32, u32);
2425
2426/// Build the structural identity key for a placement.
2427#[inline]
2428fn sprixel_key(p: &crate::buffer::SprixelPlacement) -> SprixelKey {
2429    (p.content_hash, p.x, p.y, p.cols, p.rows)
2430}
2431
2432/// Decide whether a sprixel placement must be re-blitted this frame, applying
2433/// the per-cell damage matrix (issue #265).
2434///
2435/// Returns `true` when:
2436///   * the placement is new or its `(x, y, content_hash, cols, rows)` changed
2437///     (its key is absent from `prev_keys`, the precomputed set of last frame's
2438///     placement keys), OR
2439///   * a text cell inside the footprint was overwritten this frame *and* the
2440///     footprint marks that cell as covering graphic ink
2441///     ([`SprixelCell::Opaque`] / [`SprixelCell::Mixed`]) — i.e. the cell is
2442///     [`SprixelCell::Annihilated`].
2443///
2444/// A pure text edit landing on a [`SprixelCell::Transparent`] cell never marks
2445/// damage, so the graphic is not re-emitted.
2446///
2447/// The footprint scan short-circuits an entire footprint row when that row was
2448/// untouched this frame *and* hashes identically to the previous frame
2449/// (`current.row_clean(y) && current.row_hash(y) == previous.row_hash(y)`):
2450/// no cell in such a row can have changed, so no ink can have been annihilated.
2451/// On the headless / direct-call path (where `recompute_line_hashes` was not
2452/// run) every row reports dirty, so the shortcut never fires and the per-cell
2453/// scan runs exactly as before — preserving correctness.
2454fn sprixel_needs_reblit(
2455    placement: &crate::buffer::SprixelPlacement,
2456    current: &Buffer,
2457    previous: &Buffer,
2458    prev_keys: &std::collections::HashSet<SprixelKey>,
2459) -> bool {
2460    use crate::buffer::SprixelCell;
2461
2462    // Position / content change: re-blit if no equal placement existed last
2463    // frame. The key mirrors `SprixelPlacement: PartialEq` (content_hash/x/y/
2464    // cols/rows; damage matrix excluded), so a moved or recolored image
2465    // re-blits. O(1) lookup vs the former O(n·m) `iter().any(..)` scan.
2466    if !prev_keys.contains(&sprixel_key(placement)) {
2467        return true;
2468    }
2469
2470    // Annihilation scan: a covered text cell that changed since last frame and
2471    // now shows ink forces a re-blit. `Transparent` cells are skipped so free
2472    // text edits in graphic gaps emit zero sprixel bytes.
2473    for row in 0..placement.rows {
2474        let y = placement.y + row;
2475        // Per-row shortcut: a row that was not touched this frame and whose
2476        // cached digest matches the previous frame's cannot contain a changed
2477        // cell, so the whole footprint row is skipped without per-cell work.
2478        if current.row_clean(y) && current.row_hash(y) == previous.row_hash(y) {
2479            continue;
2480        }
2481        for col in 0..placement.cols {
2482            let idx = (row * placement.cols + col) as usize;
2483            match placement.cells.get(idx) {
2484                Some(SprixelCell::Opaque) | Some(SprixelCell::Mixed) => {}
2485                // Transparent / Annihilated / out-of-range: not ink-covering,
2486                // so a text write here does not damage the graphic.
2487                _ => continue,
2488            }
2489            let x = placement.x + col;
2490            // A footprint can extend past the buffer edge (a clipped placement,
2491            // or `iterm_image_fit` reserving rows beyond the viewport). Use
2492            // `try_get` so an out-of-bounds footprint cell is simply skipped
2493            // rather than panicking — there is no text there to annihilate it.
2494            let (Some(cell), Some(prev)) = (current.try_get(x, y), previous.try_get(x, y)) else {
2495                continue;
2496            };
2497            // Mirror `flush_buffer_diff`'s write predicate exactly: a cell is
2498            // emitted (and thus overwrites graphic ink) iff it changed since
2499            // last frame and carries a non-empty symbol. Matching the predicate
2500            // keeps the damage matrix in lockstep with what the cell diff
2501            // actually paints over the graphic.
2502            if cell != prev && !cell.symbol.is_empty() {
2503                return true;
2504            }
2505        }
2506    }
2507
2508    false
2509}
2510
2511/// Flush the sprixel (Sixel / iTerm2) layer with per-cell damage tracking.
2512///
2513/// Unlike [`flush_raw_sequences`]' all-or-nothing guard, this re-emits each
2514/// pixel graphic **only** when [`sprixel_needs_reblit`] reports damage, so a
2515/// text edit in a transparent region of a Sixel emits zero passthrough bytes
2516/// (issue #265).
2517///
2518/// The previous frame's placement keys are hashed once up front so the
2519/// position/content change check is O(1) per placement (vs the former O(n·m)
2520/// linear scan), and the per-row clean+hash shortcut inside
2521/// [`sprixel_needs_reblit`] skips untouched footprint rows entirely.
2522fn flush_sprixels(
2523    stdout: &mut impl Write,
2524    current: &Buffer,
2525    previous: &Buffer,
2526    row_offset: u32,
2527) -> io::Result<()> {
2528    flush_sprixels_inner(stdout, current, previous, row_offset, |_| true)
2529}
2530
2531fn flush_sprixels_checked(
2532    stdout: &mut impl Write,
2533    current: &Buffer,
2534    previous: &Buffer,
2535    row_offset: u32,
2536    graphics_support: GraphicsEmissionSupport,
2537) -> io::Result<()> {
2538    flush_sprixels_inner(stdout, current, previous, row_offset, |placement| {
2539        graphics_support.should_emit_sprixel(sprixel_protocol(&placement.seq))
2540    })
2541}
2542
2543fn flush_sprixels_inner(
2544    stdout: &mut impl Write,
2545    current: &Buffer,
2546    previous: &Buffer,
2547    row_offset: u32,
2548    mut should_emit: impl FnMut(&crate::buffer::SprixelPlacement) -> bool,
2549) -> io::Result<()> {
2550    // Early out: no graphics to emit. Avoids building the key set on the
2551    // common text-only frame.
2552    if current.sprixels.is_empty() {
2553        return Ok(());
2554    }
2555
2556    let prev_keys: std::collections::HashSet<SprixelKey> =
2557        previous.sprixels.iter().map(sprixel_key).collect();
2558
2559    for placement in &current.sprixels {
2560        if should_emit(placement) && sprixel_needs_reblit(placement, current, previous, &prev_keys)
2561        {
2562            queue!(
2563                stdout,
2564                cursor::MoveTo(sat_u16(placement.x), sat_u16(row_offset + placement.y)),
2565                Print(&placement.seq)
2566            )?;
2567        }
2568    }
2569    Ok(())
2570}
2571
2572fn flush_cursor(
2573    stdout: &mut impl Write,
2574    cursor_visible: &mut bool,
2575    cursor_pos: Option<(u32, u32)>,
2576    row_offset: u32,
2577    fallback_row: Option<u32>,
2578) -> io::Result<()> {
2579    match cursor_pos {
2580        Some((cx, cy)) => {
2581            if !*cursor_visible {
2582                queue!(stdout, cursor::Show)?;
2583                *cursor_visible = true;
2584            }
2585            queue!(
2586                stdout,
2587                cursor::MoveTo(sat_u16(cx), sat_u16(row_offset + cy))
2588            )?;
2589        }
2590        None => {
2591            if *cursor_visible {
2592                queue!(stdout, cursor::Hide)?;
2593                *cursor_visible = false;
2594            }
2595            if let Some(row) = fallback_row {
2596                queue!(stdout, cursor::MoveTo(0, sat_u16(row)))?;
2597            }
2598        }
2599    }
2600
2601    Ok(())
2602}
2603
2604fn apply_style_delta(
2605    w: &mut impl Write,
2606    old: &Style,
2607    new: &Style,
2608    depth: ColorDepth,
2609) -> io::Result<()> {
2610    if old.fg != new.fg {
2611        match new.fg {
2612            Some(fg) => emit_fg_color(w, fg, depth)?,
2613            None => write!(w, "\x1b[39m")?,
2614        }
2615    }
2616    if old.bg != new.bg {
2617        match new.bg {
2618            Some(bg) => emit_bg_color(w, bg, depth)?,
2619            None => write!(w, "\x1b[49m")?,
2620        }
2621    }
2622    let removed = Modifiers(old.modifiers.0 & !new.modifiers.0);
2623    let added = Modifiers(new.modifiers.0 & !old.modifiers.0);
2624    if removed.contains(Modifiers::BOLD) || removed.contains(Modifiers::DIM) {
2625        queue!(w, SetAttribute(Attribute::NormalIntensity))?;
2626        if new.modifiers.contains(Modifiers::BOLD) {
2627            queue!(w, SetAttribute(Attribute::Bold))?;
2628        }
2629        if new.modifiers.contains(Modifiers::DIM) {
2630            queue!(w, SetAttribute(Attribute::Dim))?;
2631        }
2632    } else {
2633        if added.contains(Modifiers::BOLD) {
2634            queue!(w, SetAttribute(Attribute::Bold))?;
2635        }
2636        if added.contains(Modifiers::DIM) {
2637            queue!(w, SetAttribute(Attribute::Dim))?;
2638        }
2639    }
2640    if removed.contains(Modifiers::ITALIC) {
2641        queue!(w, SetAttribute(Attribute::NoItalic))?;
2642    }
2643    if added.contains(Modifiers::ITALIC) {
2644        queue!(w, SetAttribute(Attribute::Italic))?;
2645    }
2646    if removed.contains(Modifiers::UNDERLINE) {
2647        queue!(w, SetAttribute(Attribute::NoUnderline))?;
2648    }
2649    if added.contains(Modifiers::UNDERLINE) {
2650        queue!(w, SetAttribute(Attribute::Underlined))?;
2651    }
2652    if removed.contains(Modifiers::REVERSED) {
2653        queue!(w, SetAttribute(Attribute::NoReverse))?;
2654    }
2655    if added.contains(Modifiers::REVERSED) {
2656        queue!(w, SetAttribute(Attribute::Reverse))?;
2657    }
2658    if removed.contains(Modifiers::STRIKETHROUGH) {
2659        queue!(w, SetAttribute(Attribute::NotCrossedOut))?;
2660    }
2661    if added.contains(Modifiers::STRIKETHROUGH) {
2662        queue!(w, SetAttribute(Attribute::CrossedOut))?;
2663    }
2664    if removed.contains(Modifiers::BLINK) {
2665        queue!(w, SetAttribute(Attribute::NoBlink))?;
2666    }
2667    if added.contains(Modifiers::BLINK) {
2668        queue!(w, SetAttribute(Attribute::SlowBlink))?;
2669    }
2670    if removed.contains(Modifiers::OVERLINE) {
2671        queue!(w, SetAttribute(Attribute::NotOverLined))?;
2672    }
2673    if added.contains(Modifiers::OVERLINE) {
2674        queue!(w, SetAttribute(Attribute::OverLined))?;
2675    }
2676    // Underline style and color use raw escapes: crossterm 0.28 cannot
2677    // express the `CSI 4:Nm` subparameters or the `SGR 58`/`59` underline
2678    // color reliably (its discriminants collide on these terminals).
2679    if old.underline_style != new.underline_style {
2680        write!(w, "\x1b[4:{}m", underline_style_param(new.underline_style))?;
2681    }
2682    if old.underline_color != new.underline_color {
2683        emit_underline_color(w, new.underline_color, depth)?;
2684    }
2685    Ok(())
2686}
2687
2688/// Map an [`UnderlineStyle`] to its `CSI 4:Nm` subparameter value.
2689fn underline_style_param(style: UnderlineStyle) -> u8 {
2690    match style {
2691        UnderlineStyle::Straight => 1,
2692        UnderlineStyle::Double => 2,
2693        UnderlineStyle::Curly => 3,
2694        UnderlineStyle::Dotted => 4,
2695        UnderlineStyle::Dashed => 5,
2696    }
2697}
2698
2699/// Emit the raw `SGR 58` underline-color sequence (or `SGR 59` to reset).
2700///
2701/// `None` resets the underline color to the foreground (`\x1b[59m`). Otherwise
2702/// the color is downsampled to the terminal's depth: true-color emits
2703/// `\x1b[58:2::r:g:bm`, while indexed/named colors emit `\x1b[58:5:im`.
2704fn emit_underline_color(
2705    w: &mut impl Write,
2706    color: Option<Color>,
2707    depth: ColorDepth,
2708) -> io::Result<()> {
2709    match color {
2710        None => write!(w, "\x1b[59m"),
2711        Some(c) => match c.downsampled(depth) {
2712            Color::Reset => write!(w, "\x1b[59m"),
2713            Color::Rgb(r, g, b) => write!(w, "\x1b[58:2::{r}:{g}:{b}m"),
2714            Color::Indexed(i) => write!(w, "\x1b[58:5:{i}m"),
2715            // Named colors have no direct SGR-58 form; resolve them to their
2716            // RGB equivalent and emit a true-color underline sequence.
2717            named => {
2718                let (r, g, b) = named.to_rgb();
2719                write!(w, "\x1b[58:2::{r}:{g}:{b}m")
2720            }
2721        },
2722    }
2723}
2724
2725fn apply_style(w: &mut impl Write, style: &Style, depth: ColorDepth) -> io::Result<()> {
2726    if let Some(fg) = style.fg {
2727        emit_fg_color(w, fg, depth)?;
2728    }
2729    if let Some(bg) = style.bg {
2730        emit_bg_color(w, bg, depth)?;
2731    }
2732    let m = style.modifiers;
2733    if m.contains(Modifiers::BOLD) {
2734        queue!(w, SetAttribute(Attribute::Bold))?;
2735    }
2736    if m.contains(Modifiers::DIM) {
2737        queue!(w, SetAttribute(Attribute::Dim))?;
2738    }
2739    if m.contains(Modifiers::ITALIC) {
2740        queue!(w, SetAttribute(Attribute::Italic))?;
2741    }
2742    if m.contains(Modifiers::UNDERLINE) {
2743        queue!(w, SetAttribute(Attribute::Underlined))?;
2744    }
2745    if m.contains(Modifiers::REVERSED) {
2746        queue!(w, SetAttribute(Attribute::Reverse))?;
2747    }
2748    if m.contains(Modifiers::STRIKETHROUGH) {
2749        queue!(w, SetAttribute(Attribute::CrossedOut))?;
2750    }
2751    if m.contains(Modifiers::BLINK) {
2752        queue!(w, SetAttribute(Attribute::SlowBlink))?;
2753    }
2754    if m.contains(Modifiers::OVERLINE) {
2755        queue!(w, SetAttribute(Attribute::OverLined))?;
2756    }
2757    if style.underline_style != UnderlineStyle::Straight {
2758        write!(
2759            w,
2760            "\x1b[4:{}m",
2761            underline_style_param(style.underline_style)
2762        )?;
2763    }
2764    if style.underline_color.is_some() {
2765        emit_underline_color(w, style.underline_color, depth)?;
2766    }
2767    Ok(())
2768}
2769
2770fn emit_fg_color(w: &mut impl Write, color: Color, depth: ColorDepth) -> io::Result<()> {
2771    emit_sgr_color(w, color, depth, true)
2772}
2773
2774fn emit_bg_color(w: &mut impl Write, color: Color, depth: ColorDepth) -> io::Result<()> {
2775    emit_sgr_color(w, color, depth, false)
2776}
2777
2778fn emit_sgr_color(
2779    w: &mut impl Write,
2780    color: Color,
2781    depth: ColorDepth,
2782    foreground: bool,
2783) -> io::Result<()> {
2784    match color.downsampled(depth) {
2785        Color::Reset => {
2786            let reset = if foreground { 39 } else { 49 };
2787            write!(w, "\x1b[{reset}m")
2788        }
2789        Color::Rgb(r, g, b) => {
2790            let channel = if foreground { 38 } else { 48 };
2791            write!(w, "\x1b[{channel};2;{r};{g};{b}m")
2792        }
2793        Color::Indexed(i) => {
2794            let channel = if foreground { 38 } else { 48 };
2795            write!(w, "\x1b[{channel};5;{i}m")
2796        }
2797        named => {
2798            let code = named_sgr_code(named, foreground);
2799            write!(w, "\x1b[{code}m")
2800        }
2801    }
2802}
2803
2804fn named_sgr_code(color: Color, foreground: bool) -> u8 {
2805    let dark_base = if foreground { 30 } else { 40 };
2806    let bright_base = if foreground { 90 } else { 100 };
2807    match color {
2808        Color::Black => dark_base,
2809        Color::Red => dark_base + 1,
2810        Color::Green => dark_base + 2,
2811        Color::Yellow => dark_base + 3,
2812        Color::Blue => dark_base + 4,
2813        Color::Magenta => dark_base + 5,
2814        Color::Cyan => dark_base + 6,
2815        Color::White => dark_base + 7,
2816        Color::DarkGray => bright_base,
2817        Color::LightRed => bright_base + 1,
2818        Color::LightGreen => bright_base + 2,
2819        Color::LightYellow => bright_base + 3,
2820        Color::LightBlue => bright_base + 4,
2821        Color::LightMagenta => bright_base + 5,
2822        Color::LightCyan => bright_base + 6,
2823        Color::LightWhite => bright_base + 7,
2824        Color::Reset | Color::Rgb(..) | Color::Indexed(_) => unreachable!(),
2825    }
2826}
2827
2828fn reset_current_buffer(buffer: &mut Buffer, theme_bg: Option<Color>) {
2829    if let Some(bg) = theme_bg {
2830        buffer.reset_with_bg(bg);
2831    } else {
2832        buffer.reset();
2833    }
2834}
2835
2836fn write_session_enter(stdout: &mut impl Write, session: &TerminalSessionGuard) -> io::Result<()> {
2837    match session.mode {
2838        TerminalSessionMode::Fullscreen => {
2839            execute!(
2840                stdout,
2841                terminal::EnterAlternateScreen,
2842                cursor::Hide,
2843                EnableBracketedPaste
2844            )?;
2845        }
2846        TerminalSessionMode::Inline => {
2847            execute!(stdout, cursor::Hide, EnableBracketedPaste)?;
2848        }
2849    }
2850
2851    // Focus-change reporting is independent of mouse capture — callers
2852    // routinely pause animations or clear hover state on focus loss even
2853    // without mouse support. Enabling it unconditionally matches modern
2854    // TUI conventions (zellij, helix, yazi) and the cost is one extra SGR
2855    // per session.
2856    execute!(stdout, EnableFocusChange)?;
2857    if session.mouse_enabled {
2858        execute!(stdout, EnableMouseCapture)?;
2859    }
2860    if session.kitty_keyboard {
2861        use crossterm::event::PushKeyboardEnhancementFlags;
2862        let _ = execute!(
2863            stdout,
2864            PushKeyboardEnhancementFlags(kitty_flags(session.report_all_keys))
2865        );
2866    }
2867
2868    Ok(())
2869}
2870
2871/// Assemble the Kitty keyboard enhancement flags to push.
2872///
2873/// Always sets `DISAMBIGUATE_ESCAPE_CODES | REPORT_EVENT_TYPES`. When
2874/// `report_all_keys` is `true`, also OR-es in
2875/// `REPORT_ALL_KEYS_AS_ESCAPE_CODES`, which is the only mechanism by which a
2876/// spec-compliant terminal emits a bare modifier as a key event.
2877///
2878/// This is a pure helper so the flag assembly can be unit-tested without
2879/// touching stdout.
2880fn kitty_flags(report_all_keys: bool) -> crossterm::event::KeyboardEnhancementFlags {
2881    use crossterm::event::KeyboardEnhancementFlags;
2882    let mut flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
2883        | KeyboardEnhancementFlags::REPORT_EVENT_TYPES;
2884    if report_all_keys {
2885        flags |= KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES;
2886    }
2887    flags
2888}
2889
2890fn write_session_cleanup(
2891    stdout: &mut impl Write,
2892    mode: TerminalSessionMode,
2893    inline_reserved: bool,
2894) -> io::Result<()> {
2895    execute!(
2896        stdout,
2897        ResetColor,
2898        SetAttribute(Attribute::Reset),
2899        cursor::Show,
2900        DisableBracketedPaste
2901    )?;
2902
2903    match mode {
2904        TerminalSessionMode::Fullscreen => {
2905            execute!(stdout, terminal::LeaveAlternateScreen)?;
2906        }
2907        TerminalSessionMode::Inline => {
2908            if inline_reserved {
2909                execute!(
2910                    stdout,
2911                    cursor::MoveToColumn(0),
2912                    cursor::MoveDown(1),
2913                    cursor::MoveToColumn(0),
2914                    Print("\n")
2915                )?;
2916            } else {
2917                execute!(stdout, Print("\n"))?;
2918            }
2919        }
2920    }
2921
2922    Ok(())
2923}
2924
2925fn write_session_exit(
2926    stdout: &mut impl Write,
2927    mode: TerminalSessionMode,
2928    inline_reserved: bool,
2929    mouse_enabled: bool,
2930    kitty_keyboard: bool,
2931) -> io::Result<()> {
2932    if kitty_keyboard {
2933        use crossterm::event::PopKeyboardEnhancementFlags;
2934        execute!(stdout, PopKeyboardEnhancementFlags)?;
2935    }
2936    if mouse_enabled {
2937        execute!(stdout, DisableMouseCapture)?;
2938    }
2939    execute!(stdout, DisableFocusChange)?;
2940    write_session_cleanup(stdout, mode, inline_reserved)
2941}
2942
2943/// Best-effort terminal restoration used by the panic hook.
2944///
2945/// The hook cannot know which optional modes were active, so it disables every
2946/// mode SLT may have enabled. Extra disables are harmless on terminals that
2947/// ignore unsupported sequences and keep panic teardown aligned with normal
2948/// session teardown.
2949#[cfg(feature = "crossterm")]
2950pub(crate) fn cleanup_after_panic() {
2951    let mut stdout = io::stdout();
2952    let _ = write_panic_cleanup(&mut stdout);
2953    let _ = terminal::disable_raw_mode();
2954    let _ = stdout.flush();
2955}
2956
2957fn write_panic_cleanup(stdout: &mut impl Write) -> io::Result<()> {
2958    write_session_exit(stdout, TerminalSessionMode::Fullscreen, false, true, true)
2959}
2960
2961// ---------------------------------------------------------------------------
2962// Unix job-control suspend/resume (Ctrl+Z / `fg`) — issue #263
2963// ---------------------------------------------------------------------------
2964//
2965// On Unix, SIGTSTP stops the process in kernel space with no Rust code on the
2966// stack, so neither `Drop` nor the panic hook can restore the terminal. The
2967// run loops install a `signal-hook` background thread that, on SIGTSTP, runs
2968// the same teardown the session guard would (`disable_raw_mode`, leave alt
2969// screen, show cursor, disable paste/focus/mouse/kitty) and then re-raises
2970// SIGTSTP to genuinely stop; on SIGCONT it re-enters the session and flags a
2971// full redraw. The whole feature is `#[cfg(unix)]` and uses only signal-hook's
2972// safe API, preserving `#![forbid(unsafe_code)]`.
2973
2974/// Immutable snapshot of the active terminal session used by the unix
2975/// suspend/resume handler to restore and re-enter the terminal across a
2976/// Ctrl+Z / `fg` cycle without owning the `Terminal`/`InlineTerminal`.
2977#[cfg(unix)]
2978#[derive(Debug, Clone, Copy)]
2979pub(crate) struct SessionSnapshot {
2980    mode: TerminalSessionMode,
2981    mouse_enabled: bool,
2982    kitty_keyboard: bool,
2983    report_all_keys: bool,
2984}
2985
2986/// Set by the SIGCONT handler and consumed once at the top of each run-loop
2987/// iteration to force a full clear + repaint after resuming from suspend.
2988#[cfg(unix)]
2989pub(crate) static NEEDS_FULL_REDRAW: std::sync::atomic::AtomicBool =
2990    std::sync::atomic::AtomicBool::new(false);
2991
2992#[cfg(unix)]
2993impl Terminal {
2994    /// Capture the session state the suspend/resume handler needs to restore
2995    /// and re-enter this fullscreen terminal across Ctrl+Z / `fg`.
2996    pub(crate) fn session_snapshot(&self) -> SessionSnapshot {
2997        SessionSnapshot {
2998            mode: self.session.mode,
2999            mouse_enabled: self.session.mouse_enabled,
3000            kitty_keyboard: self.session.kitty_keyboard,
3001            report_all_keys: self.session.report_all_keys,
3002        }
3003    }
3004}
3005
3006#[cfg(unix)]
3007impl InlineTerminal {
3008    /// Capture the session state the suspend/resume handler needs to restore
3009    /// and re-enter this inline terminal across Ctrl+Z / `fg`.
3010    pub(crate) fn session_snapshot(&self) -> SessionSnapshot {
3011        SessionSnapshot {
3012            mode: self.session.mode,
3013            mouse_enabled: self.session.mouse_enabled,
3014            kitty_keyboard: self.session.kitty_keyboard,
3015            report_all_keys: self.session.report_all_keys,
3016        }
3017    }
3018}
3019
3020/// Write the escape sequences that tear down the TUI session in preparation
3021/// for SIGTSTP (the inverse of [`write_session_enter`]).
3022///
3023/// `inline_reserved` is passed `false` to [`write_session_cleanup`] to avoid
3024/// emitting the inline trailing-newline dance mid-session; the reserved region
3025/// is repainted on resume via the forced full redraw. Pure byte output, no
3026/// raw-mode toggle — split out so it can be unit-tested against a `Vec<u8>`.
3027#[cfg(unix)]
3028fn write_suspend_sequence(stdout: &mut impl Write, snapshot: &SessionSnapshot) -> io::Result<()> {
3029    write_session_exit(
3030        stdout,
3031        snapshot.mode,
3032        false,
3033        snapshot.mouse_enabled,
3034        snapshot.kitty_keyboard,
3035    )
3036}
3037
3038/// Restore the terminal to cooked/non-TUI state in preparation for the process
3039/// being stopped by SIGTSTP.
3040///
3041/// Mirrors [`TerminalSessionGuard::restore`] but writes directly to
3042/// `io::stdout()` (the handler runs on a background thread that does not own
3043/// the buffered terminal stdout).
3044#[cfg(unix)]
3045pub(crate) fn suspend_to_shell(snapshot: &SessionSnapshot) {
3046    let mut out = io::stdout();
3047    let _ = write_suspend_sequence(&mut out, snapshot);
3048    let _ = terminal::disable_raw_mode();
3049    let _ = out.flush();
3050}
3051
3052/// Re-enter the TUI session after a SIGCONT (resume via `fg`), matching the
3053/// original [`SessionSnapshot`], and flag a full redraw for the next frame.
3054///
3055/// Mirrors [`TerminalSessionGuard::enter`] but writes directly to
3056/// `io::stdout()`. Sets [`NEEDS_FULL_REDRAW`] so the next loop iteration clears
3057/// the front buffer and repaints every cell.
3058#[cfg(unix)]
3059pub(crate) fn resume_from_shell(snapshot: &SessionSnapshot) {
3060    let mut out = io::stdout();
3061    let _ = terminal::enable_raw_mode();
3062    let _ = resume_from_shell_with_writer(&mut out, snapshot);
3063}
3064
3065#[cfg(unix)]
3066fn resume_from_shell_with_writer(
3067    out: &mut impl Write,
3068    snapshot: &SessionSnapshot,
3069) -> io::Result<()> {
3070    let guard = TerminalSessionGuard {
3071        mode: snapshot.mode,
3072        mouse_enabled: snapshot.mouse_enabled,
3073        kitty_keyboard: snapshot.kitty_keyboard,
3074        report_all_keys: snapshot.report_all_keys,
3075        harness: false,
3076    };
3077    write_session_enter(out, &guard)?;
3078    out.flush()?;
3079    NEEDS_FULL_REDRAW.store(true, std::sync::atomic::Ordering::SeqCst);
3080    Ok(())
3081}
3082
3083/// Construct a [`SessionSnapshot`] for tests without a live terminal.
3084#[cfg(all(unix, test))]
3085fn test_snapshot(mode: TerminalSessionMode, mouse: bool, kitty: bool) -> SessionSnapshot {
3086    SessionSnapshot {
3087        mode,
3088        mouse_enabled: mouse,
3089        kitty_keyboard: kitty,
3090        report_all_keys: false,
3091    }
3092}
3093
3094/// Construct a fullscreen [`SessionSnapshot`] for crate-level tests that drive
3095/// the suspend handler without a live terminal (issue #263).
3096#[cfg(all(unix, test))]
3097pub(crate) fn test_session_snapshot() -> SessionSnapshot {
3098    SessionSnapshot {
3099        mode: TerminalSessionMode::Fullscreen,
3100        mouse_enabled: false,
3101        kitty_keyboard: false,
3102        report_all_keys: false,
3103    }
3104}
3105
3106#[cfg(test)]
3107mod tests {
3108    #![allow(clippy::unwrap_used)]
3109    use super::*;
3110
3111    /// Feed `bytes` to a channel from a helper thread after `delay`, then run
3112    /// [`collect_reply`] against it with the given budget and predicate.
3113    fn collect_with_feed(
3114        bytes: &'static [u8],
3115        delay: Duration,
3116        budget: Duration,
3117        is_complete: &mut dyn FnMut(&[u8]) -> bool,
3118    ) -> (Vec<u8>, Duration) {
3119        let (tx, rx) = std::sync::mpsc::channel::<u8>();
3120        std::thread::spawn(move || {
3121            std::thread::sleep(delay);
3122            for &b in bytes {
3123                if tx.send(b).is_err() {
3124                    return;
3125                }
3126            }
3127            // Keep the sender alive past the collector's budget: the real
3128            // pump thread only drops its sender on stdin EOF, so dropping it
3129            // here right after the payload would disconnect the channel and
3130            // end the wait early, masking deadline behavior.
3131            std::thread::sleep(Duration::from_secs(3));
3132        });
3133        let start = Instant::now();
3134        let out = collect_reply(&rx, start + budget, is_complete);
3135        (out, start.elapsed())
3136    }
3137
3138    #[test]
3139    fn collect_reply_osc_bel_terminator_completes_early() {
3140        let reply = b"\x1b]11;rgb:0000/0000/0000\x07";
3141        let (out, elapsed) = collect_with_feed(
3142            reply,
3143            Duration::ZERO,
3144            Duration::from_secs(2),
3145            &mut osc_reply_complete,
3146        );
3147        assert_eq!(out, reply);
3148        assert!(
3149            elapsed < Duration::from_secs(1),
3150            "should not wait out the budget"
3151        );
3152    }
3153
3154    #[test]
3155    fn collect_reply_osc_st_terminator_completes_early() {
3156        let reply = b"\x1bP>|tmux 3.5a\x1b\\";
3157        let (out, elapsed) = collect_with_feed(
3158            reply,
3159            Duration::ZERO,
3160            Duration::from_secs(2),
3161            &mut osc_reply_complete,
3162        );
3163        assert_eq!(out, reply);
3164        assert!(elapsed < Duration::from_secs(1));
3165    }
3166
3167    #[test]
3168    fn collect_reply_silence_returns_empty_at_deadline() {
3169        // The silent-host case that used to deadlock startup: no bytes ever
3170        // arrive. The collector must give up at the deadline, not block.
3171        let budget = Duration::from_millis(150);
3172        let (out, elapsed) =
3173            collect_with_feed(b"", Duration::from_secs(5), budget, &mut osc_reply_complete);
3174        assert!(out.is_empty());
3175        assert!(elapsed >= budget);
3176        assert!(
3177            elapsed < Duration::from_secs(2),
3178            "must not block past the budget"
3179        );
3180    }
3181
3182    #[test]
3183    fn collect_reply_da_drains_two_replies() {
3184        let reply = b"\x1b[?62;4c\x1b[>1;10;0c";
3185        let (out, elapsed) = collect_with_feed(
3186            reply,
3187            Duration::ZERO,
3188            Duration::from_secs(2),
3189            &mut da_reply_complete(),
3190        );
3191        assert_eq!(out, reply);
3192        assert!(elapsed < Duration::from_secs(1));
3193    }
3194
3195    #[test]
3196    fn collect_reply_da_lone_reply_returns_partial_at_deadline() {
3197        // A terminal that answers DA1 but ignores DA2: the collector waits out
3198        // the budget, then hands back the partial reply for best-effort parse
3199        // (pre-pump behavior, preserved).
3200        let budget = Duration::from_millis(150);
3201        let (out, elapsed) = collect_with_feed(
3202            b"\x1b[?62;4c",
3203            Duration::ZERO,
3204            budget,
3205            &mut da_reply_complete(),
3206        );
3207        assert_eq!(out, b"\x1b[?62;4c");
3208        assert!(elapsed >= budget);
3209    }
3210
3211    #[test]
3212    fn collect_reply_unterminated_caps_at_4096_bytes() {
3213        static BIG: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
3214        let big = BIG.get_or_init(|| vec![b'x'; 5000]).as_slice();
3215        let (tx, rx) = std::sync::mpsc::channel::<u8>();
3216        for &b in big {
3217            tx.send(b).unwrap();
3218        }
3219        let out = collect_reply(
3220            &rx,
3221            Instant::now() + Duration::from_secs(2),
3222            &mut osc_reply_complete,
3223        );
3224        assert_eq!(out.len(), 4096);
3225    }
3226
3227    #[test]
3228    fn decrpm_predicate_terminates_on_y() {
3229        let reply = b"\x1b[?2026;1$y";
3230        let (out, _) = collect_with_feed(
3231            reply,
3232            Duration::ZERO,
3233            Duration::from_secs(2),
3234            &mut decrpm_reply_complete,
3235        );
3236        assert_eq!(out, reply);
3237    }
3238
3239    #[test]
3240    fn reset_current_buffer_applies_theme_background() {
3241        let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 1));
3242
3243        reset_current_buffer(&mut buffer, Some(Color::Rgb(10, 20, 30)));
3244        assert_eq!(buffer.get(0, 0).style.bg, Some(Color::Rgb(10, 20, 30)));
3245
3246        reset_current_buffer(&mut buffer, None);
3247        assert_eq!(buffer.get(0, 0).style.bg, None);
3248    }
3249
3250    #[test]
3251    fn fullscreen_session_enter_writes_alt_screen_sequence() {
3252        let session = TerminalSessionGuard {
3253            mode: TerminalSessionMode::Fullscreen,
3254            mouse_enabled: false,
3255            kitty_keyboard: false,
3256            report_all_keys: false,
3257            harness: false,
3258        };
3259        let mut out = Vec::new();
3260        write_session_enter(&mut out, &session).unwrap();
3261        let output = String::from_utf8(out).unwrap();
3262        assert!(output.contains("\u{1b}[?1049h"));
3263        assert!(output.contains("\u{1b}[?25l"));
3264        assert!(output.contains("\u{1b}[?2004h"));
3265    }
3266
3267    #[test]
3268    fn inline_session_enter_skips_alt_screen_sequence() {
3269        let session = TerminalSessionGuard {
3270            mode: TerminalSessionMode::Inline,
3271            mouse_enabled: false,
3272            kitty_keyboard: false,
3273            report_all_keys: false,
3274            harness: false,
3275        };
3276        let mut out = Vec::new();
3277        write_session_enter(&mut out, &session).unwrap();
3278        let output = String::from_utf8(out).unwrap();
3279        assert!(!output.contains("\u{1b}[?1049h"));
3280        assert!(output.contains("\u{1b}[?25l"));
3281        assert!(output.contains("\u{1b}[?2004h"));
3282    }
3283
3284    #[test]
3285    fn fullscreen_session_cleanup_leaves_alt_screen() {
3286        let mut out = Vec::new();
3287        write_session_cleanup(&mut out, TerminalSessionMode::Fullscreen, false).unwrap();
3288        let output = String::from_utf8(out).unwrap();
3289        assert!(output.contains("\u{1b}[?1049l"));
3290        assert!(output.contains("\u{1b}[?25h"));
3291        assert!(output.contains("\u{1b}[?2004l"));
3292    }
3293
3294    #[test]
3295    fn inline_session_cleanup_keeps_normal_screen() {
3296        let mut out = Vec::new();
3297        write_session_cleanup(&mut out, TerminalSessionMode::Inline, false).unwrap();
3298        let output = String::from_utf8(out).unwrap();
3299        assert!(!output.contains("\u{1b}[?1049l"));
3300        assert!(output.ends_with('\n'));
3301        assert!(output.contains("\u{1b}[?25h"));
3302        assert!(output.contains("\u{1b}[?2004l"));
3303    }
3304
3305    #[test]
3306    fn session_exit_disables_focus_mouse_and_kitty_keyboard() {
3307        let mut out = Vec::new();
3308        write_session_exit(&mut out, TerminalSessionMode::Fullscreen, false, true, true).unwrap();
3309        let output = String::from_utf8(out).unwrap();
3310        assert!(output.contains("\u{1b}[?1004l"), "disables focus reporting");
3311        assert!(output.contains("\u{1b}[?1006l"), "disables SGR mouse mode");
3312        assert!(output.contains("\u{1b}[<1u"), "pops Kitty keyboard flags");
3313        assert!(output.contains("\u{1b}[?1049l"), "leaves alt screen");
3314    }
3315
3316    #[test]
3317    fn panic_cleanup_uses_full_session_exit_path() {
3318        let mut out = Vec::new();
3319        write_panic_cleanup(&mut out).unwrap();
3320        let output = String::from_utf8(out).unwrap();
3321        assert!(output.contains("\u{1b}[?1004l"), "disables focus reporting");
3322        assert!(output.contains("\u{1b}[<1u"), "pops Kitty keyboard flags");
3323        assert!(output.contains("\u{1b}[?1049l"), "leaves alt screen");
3324    }
3325
3326    // ── Unix suspend/resume sequence tests (issue #263) ──────────────────
3327
3328    #[cfg(unix)]
3329    #[test]
3330    fn suspend_sequence_fullscreen_leaves_alt_screen() {
3331        let snapshot = test_snapshot(TerminalSessionMode::Fullscreen, false, false);
3332        let mut out = Vec::new();
3333        write_suspend_sequence(&mut out, &snapshot).unwrap();
3334        let output = String::from_utf8(out).unwrap();
3335        assert!(output.contains("\u{1b}[?1049l"), "leaves alt screen");
3336        assert!(output.contains("\u{1b}[?25h"), "shows cursor");
3337        assert!(output.contains("\u{1b}[?2004l"), "disables bracketed paste");
3338    }
3339
3340    #[cfg(unix)]
3341    #[test]
3342    fn suspend_sequence_inline_keeps_normal_screen() {
3343        let snapshot = test_snapshot(TerminalSessionMode::Inline, false, false);
3344        let mut out = Vec::new();
3345        write_suspend_sequence(&mut out, &snapshot).unwrap();
3346        let output = String::from_utf8(out).unwrap();
3347        assert!(
3348            !output.contains("\u{1b}[?1049l"),
3349            "inline must not leave alt screen"
3350        );
3351        assert!(output.contains("\u{1b}[?25h"), "shows cursor");
3352        assert!(output.contains("\u{1b}[?2004l"), "disables bracketed paste");
3353    }
3354
3355    #[cfg(unix)]
3356    #[test]
3357    fn suspend_sequence_disables_mouse_and_kitty_when_enabled() {
3358        let snapshot = test_snapshot(TerminalSessionMode::Fullscreen, true, true);
3359        let mut out = Vec::new();
3360        write_suspend_sequence(&mut out, &snapshot).unwrap();
3361        // DisableMouseCapture emits the SGR-mouse disable (?1006l) among others.
3362        let output = String::from_utf8(out).unwrap();
3363        assert!(output.contains("\u{1b}[?1006l"), "disables SGR mouse mode");
3364    }
3365
3366    #[cfg(unix)]
3367    #[test]
3368    fn resume_sequence_fullscreen_round_trips_enter_and_flags_redraw() {
3369        let snapshot = test_snapshot(TerminalSessionMode::Fullscreen, false, false);
3370
3371        // The resume path re-enters the same byte state as the initial enter.
3372        let guard = TerminalSessionGuard {
3373            mode: snapshot.mode,
3374            mouse_enabled: snapshot.mouse_enabled,
3375            kitty_keyboard: snapshot.kitty_keyboard,
3376            report_all_keys: snapshot.report_all_keys,
3377            harness: false,
3378        };
3379        let mut enter_bytes = Vec::new();
3380        write_session_enter(&mut enter_bytes, &guard).unwrap();
3381        let enter = String::from_utf8(enter_bytes).unwrap();
3382        assert!(enter.contains("\u{1b}[?1049h"));
3383        assert!(enter.contains("\u{1b}[?25l"));
3384        assert!(enter.contains("\u{1b}[?2004h"));
3385
3386        // Drive the same writer path through an in-process sink and assert the
3387        // redraw flag flips without touching real stdout.
3388        NEEDS_FULL_REDRAW.store(false, std::sync::atomic::Ordering::SeqCst);
3389        let mut resume_bytes = Vec::new();
3390        resume_from_shell_with_writer(&mut resume_bytes, &snapshot).unwrap();
3391        assert_eq!(String::from_utf8(resume_bytes).unwrap(), enter);
3392        assert!(
3393            NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst),
3394            "resume must request a full redraw exactly once"
3395        );
3396        assert!(
3397            !NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst),
3398            "the redraw flag is consumed by the first swap (idempotent)"
3399        );
3400    }
3401
3402    #[cfg(unix)]
3403    #[test]
3404    fn needs_full_redraw_swaps_true_once() {
3405        NEEDS_FULL_REDRAW.store(true, std::sync::atomic::Ordering::SeqCst);
3406        assert!(NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst));
3407        assert!(!NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst));
3408    }
3409
3410    #[test]
3411    fn kitty_flags_base_set_excludes_report_all_keys() {
3412        use crossterm::event::KeyboardEnhancementFlags;
3413        let flags = kitty_flags(false);
3414        assert!(flags.contains(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES));
3415        assert!(flags.contains(KeyboardEnhancementFlags::REPORT_EVENT_TYPES));
3416        assert!(!flags.contains(KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES));
3417    }
3418
3419    #[test]
3420    fn kitty_flags_report_all_keys_sets_flag() {
3421        use crossterm::event::KeyboardEnhancementFlags;
3422        let flags = kitty_flags(true);
3423        assert!(flags.contains(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES));
3424        assert!(flags.contains(KeyboardEnhancementFlags::REPORT_EVENT_TYPES));
3425        assert!(flags.contains(KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES));
3426    }
3427
3428    static ENV_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
3429
3430    #[allow(unsafe_code)]
3431    fn with_terminal_env<F: FnOnce()>(
3432        term: Option<&str>,
3433        term_program: Option<&str>,
3434        tmux: Option<&str>,
3435        sty: Option<&str>,
3436        f: F,
3437    ) {
3438        let _guard = ENV_GUARD.lock().unwrap_or_else(|err| err.into_inner());
3439        let prev_term = std::env::var("TERM").ok();
3440        let prev_program = std::env::var("TERM_PROGRAM").ok();
3441        let prev_tmux = std::env::var("TMUX").ok();
3442        let prev_sty = std::env::var("STY").ok();
3443
3444        unsafe {
3445            match term {
3446                Some(value) => std::env::set_var("TERM", value),
3447                None => std::env::remove_var("TERM"),
3448            }
3449            match term_program {
3450                Some(value) => std::env::set_var("TERM_PROGRAM", value),
3451                None => std::env::remove_var("TERM_PROGRAM"),
3452            }
3453            match tmux {
3454                Some(value) => std::env::set_var("TMUX", value),
3455                None => std::env::remove_var("TMUX"),
3456            }
3457            match sty {
3458                Some(value) => std::env::set_var("STY", value),
3459                None => std::env::remove_var("STY"),
3460            }
3461        }
3462
3463        f();
3464
3465        unsafe {
3466            match prev_term {
3467                Some(value) => std::env::set_var("TERM", value),
3468                None => std::env::remove_var("TERM"),
3469            }
3470            match prev_program {
3471                Some(value) => std::env::set_var("TERM_PROGRAM", value),
3472                None => std::env::remove_var("TERM_PROGRAM"),
3473            }
3474            match prev_tmux {
3475                Some(value) => std::env::set_var("TMUX", value),
3476                None => std::env::remove_var("TMUX"),
3477            }
3478            match prev_sty {
3479                Some(value) => std::env::set_var("STY", value),
3480                None => std::env::remove_var("STY"),
3481            }
3482        }
3483    }
3484
3485    #[test]
3486    fn multiplexers_disable_graphics_env_fallbacks() {
3487        with_terminal_env(
3488            Some("tmux-256color"),
3489            Some("WezTerm"),
3490            Some("/tmp/tmux"),
3491            None,
3492            || {
3493                assert!(terminal_is_multiplexed());
3494                assert!(!term_is_kitty_graphics_host());
3495                assert!(!term_is_sixel_host());
3496                assert!(!term_is_iterm_host());
3497            },
3498        );
3499        with_terminal_env(
3500            Some("screen-256color"),
3501            Some("iTerm.app"),
3502            None,
3503            Some("1234.pts"),
3504            || {
3505                assert!(terminal_is_multiplexed());
3506                assert!(!term_is_kitty_graphics_host());
3507                assert!(!term_is_sixel_host());
3508                assert!(!term_is_iterm_host());
3509            },
3510        );
3511    }
3512
3513    #[test]
3514    fn direct_hosts_keep_graphics_env_fallbacks() {
3515        with_terminal_env(Some("xterm-kitty"), None, None, None, || {
3516            assert!(!terminal_is_multiplexed());
3517            assert!(term_is_kitty_graphics_host());
3518        });
3519        with_terminal_env(Some("xterm-256color"), Some("WezTerm"), None, None, || {
3520            assert!(!terminal_is_multiplexed());
3521            assert!(term_is_sixel_host());
3522        });
3523        with_terminal_env(
3524            Some("xterm-256color"),
3525            Some("iTerm.app"),
3526            None,
3527            None,
3528            || {
3529                assert!(!terminal_is_multiplexed());
3530                assert!(term_is_iterm_host());
3531            },
3532        );
3533    }
3534
3535    #[test]
3536    fn graphics_emission_requires_protocol_support() {
3537        let unsupported = GraphicsEmissionSupport {
3538            real_terminal: true,
3539            capabilities: Capabilities::default(),
3540            force_kitty: false,
3541            force_sixel: false,
3542            force_iterm: false,
3543        };
3544        assert!(!unsupported.should_emit_kitty());
3545        assert!(!unsupported.should_emit_sprixel(SprixelProtocol::Sixel));
3546        assert!(!unsupported.should_emit_sprixel(SprixelProtocol::Iterm2));
3547        assert!(!unsupported.should_emit_sprixel(SprixelProtocol::Unknown));
3548
3549        let sixel = GraphicsEmissionSupport {
3550            capabilities: Capabilities {
3551                sixel: true,
3552                ..Capabilities::default()
3553            },
3554            ..unsupported
3555        };
3556        assert!(sixel.should_emit_sprixel(SprixelProtocol::Sixel));
3557
3558        let forced = GraphicsEmissionSupport {
3559            force_kitty: true,
3560            force_iterm: true,
3561            ..unsupported
3562        };
3563        assert!(forced.should_emit_kitty());
3564        assert!(forced.should_emit_sprixel(SprixelProtocol::Iterm2));
3565    }
3566
3567    #[test]
3568    fn base64_encode_empty() {
3569        assert_eq!(base64_encode(b""), "");
3570    }
3571
3572    #[test]
3573    fn base64_encode_hello() {
3574        assert_eq!(base64_encode(b"Hello"), "SGVsbG8=");
3575    }
3576
3577    #[test]
3578    fn base64_encode_padding() {
3579        assert_eq!(base64_encode(b"a"), "YQ==");
3580        assert_eq!(base64_encode(b"ab"), "YWI=");
3581        assert_eq!(base64_encode(b"abc"), "YWJj");
3582    }
3583
3584    #[test]
3585    fn base64_encode_unicode() {
3586        assert_eq!(base64_encode("한글".as_bytes()), "7ZWc6riA");
3587    }
3588
3589    #[cfg(feature = "crossterm")]
3590    #[test]
3591    fn parse_osc11_response_dark_and_light() {
3592        assert_eq!(
3593            parse_osc11_response("\x1b]11;rgb:0000/0000/0000\x1b\\"),
3594            ColorScheme::Dark
3595        );
3596        assert_eq!(
3597            parse_osc11_response("\x1b]11;rgb:ffff/ffff/ffff\x07"),
3598            ColorScheme::Light
3599        );
3600    }
3601
3602    // ---- Capability probe / blitter ladder (issue #264) ----
3603
3604    #[test]
3605    fn blitter_support_default_is_conservative() {
3606        let b = BlitterSupport::default();
3607        assert!(b.half);
3608        assert!(b.quad);
3609        assert!(!b.sextant);
3610    }
3611
3612    #[test]
3613    fn capabilities_default_is_all_false_but_half_block() {
3614        let c = Capabilities::default();
3615        assert!(!c.truecolor);
3616        assert!(!c.sixel);
3617        assert!(!c.iterm2);
3618        assert!(!c.kitty_graphics);
3619        assert!(!c.kitty_keyboard);
3620        assert!(!c.sync_output);
3621        // With nothing negotiated the ladder must still resolve to half-block.
3622        assert_eq!(c.best_blitter(), Blitter::HalfBlock);
3623    }
3624
3625    #[test]
3626    fn best_blitter_ladder_table() {
3627        let kitty = Capabilities {
3628            kitty_graphics: true,
3629            ..Default::default()
3630        };
3631        assert_eq!(kitty.best_blitter(), Blitter::Kitty);
3632
3633        let sixel = Capabilities {
3634            sixel: true,
3635            ..Default::default()
3636        };
3637        assert_eq!(sixel.best_blitter(), Blitter::Sixel);
3638
3639        let iterm2 = Capabilities {
3640            iterm2: true,
3641            ..Default::default()
3642        };
3643        assert_eq!(iterm2.best_blitter(), Blitter::Iterm2);
3644
3645        // iTerm2 sits below Sixel: a host advertising both prefers Sixel.
3646        let sixel_and_iterm2 = Capabilities {
3647            sixel: true,
3648            iterm2: true,
3649            ..Default::default()
3650        };
3651        assert_eq!(sixel_and_iterm2.best_blitter(), Blitter::Sixel);
3652
3653        let sextant = Capabilities {
3654            blitters: BlitterSupport {
3655                sextant: true,
3656                ..Default::default()
3657            },
3658            ..Default::default()
3659        };
3660        assert_eq!(sextant.best_blitter(), Blitter::Sextant);
3661
3662        assert_eq!(Capabilities::default().best_blitter(), Blitter::HalfBlock);
3663    }
3664
3665    #[test]
3666    fn best_blitter_precedence_kitty_over_everything() {
3667        let all = Capabilities {
3668            kitty_graphics: true,
3669            sixel: true,
3670            blitters: BlitterSupport {
3671                sextant: true,
3672                ..Default::default()
3673            },
3674            ..Default::default()
3675        };
3676        assert_eq!(all.best_blitter(), Blitter::Kitty);
3677
3678        let sixel_and_sextant = Capabilities {
3679            sixel: true,
3680            blitters: BlitterSupport {
3681                sextant: true,
3682                ..Default::default()
3683            },
3684            ..Default::default()
3685        };
3686        assert_eq!(sixel_and_sextant.best_blitter(), Blitter::Sixel);
3687    }
3688
3689    #[test]
3690    fn best_blitter_never_picks_unsupported_protocol() {
3691        // Exhaustive sweep over field combinations: the resolver must never
3692        // return Kitty without kitty_graphics, nor Sixel without sixel, etc.
3693        for kitty in [false, true] {
3694            for sixel in [false, true] {
3695                for iterm2 in [false, true] {
3696                    for sextant in [false, true] {
3697                        let caps = Capabilities {
3698                            kitty_graphics: kitty,
3699                            sixel,
3700                            iterm2,
3701                            blitters: BlitterSupport {
3702                                sextant,
3703                                ..Default::default()
3704                            },
3705                            ..Default::default()
3706                        };
3707                        match caps.best_blitter() {
3708                            Blitter::Kitty => assert!(kitty),
3709                            Blitter::Sixel => assert!(sixel && !kitty),
3710                            Blitter::Iterm2 => assert!(iterm2 && !sixel && !kitty),
3711                            Blitter::Sextant => {
3712                                assert!(sextant && !iterm2 && !sixel && !kitty)
3713                            }
3714                            Blitter::HalfBlock => {
3715                                assert!(!kitty && !sixel && !iterm2 && !sextant)
3716                            }
3717                        }
3718                    }
3719                }
3720            }
3721        }
3722    }
3723
3724    #[cfg(feature = "crossterm")]
3725    #[test]
3726    fn parse_da1_attribute_4_sets_sixel() {
3727        let mut caps = Capabilities::default();
3728        parse_da1("\x1b[?62;4;6c", &mut caps);
3729        assert!(caps.sixel);
3730    }
3731
3732    #[cfg(feature = "crossterm")]
3733    #[test]
3734    fn parse_da1_without_4_leaves_sixel_false() {
3735        let mut caps = Capabilities::default();
3736        parse_da1("\x1b[?62;1;6c", &mut caps);
3737        assert!(!caps.sixel);
3738    }
3739
3740    #[cfg(feature = "crossterm")]
3741    #[test]
3742    fn parse_da1_ignores_da2_segment_in_same_string() {
3743        // DA1 (no `4`) followed by DA2 — DA2 must not be mistaken for DA1.
3744        let mut caps = Capabilities::default();
3745        parse_da1("\x1b[?62;1c\x1b[>0;276;0c", &mut caps);
3746        assert!(!caps.sixel);
3747    }
3748
3749    #[cfg(feature = "crossterm")]
3750    #[test]
3751    fn parse_da2_no_panic_on_garbage() {
3752        let mut caps = Capabilities::default();
3753        // Must not panic and must not set kitty_graphics on an unknown id.
3754        parse_da2("\x1b[>99;1;0c", &mut caps);
3755        assert!(!caps.kitty_graphics);
3756        parse_da2("not a da2 reply", &mut caps);
3757        assert!(!caps.kitty_graphics);
3758    }
3759
3760    #[cfg(feature = "crossterm")]
3761    #[test]
3762    fn parse_da2_kitty_id_sets_kitty_graphics() {
3763        let mut caps = Capabilities::default();
3764        // Kitty reports DA2 primary id 41.
3765        parse_da2("\x1b[>41;4000;0c", &mut caps);
3766        assert!(caps.kitty_graphics);
3767    }
3768
3769    #[cfg(feature = "crossterm")]
3770    #[test]
3771    fn parse_da2_identity_extracts_id_and_version() {
3772        assert_eq!(parse_da2_identity("\x1b[>0;276;0c"), Some((0, 276)));
3773        assert_eq!(parse_da2_identity("\x1b[>41;4000;0c"), Some((41, 4000)));
3774        assert_eq!(parse_da2_identity("no reply here"), None);
3775    }
3776
3777    #[cfg(feature = "crossterm")]
3778    #[test]
3779    fn parse_kitty_graphics_ack_ok_sets_flag() {
3780        let mut caps = Capabilities::default();
3781        parse_kitty_graphics_ack("\x1b_Gi=31;OK\x1b\\", &mut caps);
3782        assert!(caps.kitty_graphics);
3783    }
3784
3785    #[cfg(feature = "crossterm")]
3786    #[test]
3787    fn parse_kitty_graphics_ack_error_or_wrong_id_leaves_flag() {
3788        let mut caps = Capabilities::default();
3789        // Error status must not flag support.
3790        parse_kitty_graphics_ack("\x1b_Gi=31;ENOENT:bad\x1b\\", &mut caps);
3791        assert!(!caps.kitty_graphics);
3792        // A different image id is not our query.
3793        parse_kitty_graphics_ack("\x1b_Gi=99;OK\x1b\\", &mut caps);
3794        assert!(!caps.kitty_graphics);
3795        // No APC at all.
3796        parse_kitty_graphics_ack("garbage", &mut caps);
3797        assert!(!caps.kitty_graphics);
3798    }
3799
3800    #[cfg(feature = "crossterm")]
3801    #[test]
3802    fn parse_decrpm_sync_output_recognized_states_are_supported() {
3803        // Ps = 1 (set), 2 (reset), 3 (perm set), 4 (perm reset) all mean the
3804        // mode is recognized → supported.
3805        assert_eq!(parse_decrpm_sync_output("\x1b[?2026;1$y"), Some(true));
3806        assert_eq!(parse_decrpm_sync_output("\x1b[?2026;2$y"), Some(true));
3807        assert_eq!(parse_decrpm_sync_output("\x1b[?2026;3$y"), Some(true));
3808        assert_eq!(parse_decrpm_sync_output("\x1b[?2026;4$y"), Some(true));
3809    }
3810
3811    #[cfg(feature = "crossterm")]
3812    #[test]
3813    fn parse_decrpm_sync_output_ps0_is_unsupported() {
3814        // Ps = 0 → mode not recognized.
3815        assert_eq!(parse_decrpm_sync_output("\x1b[?2026;0$y"), Some(false));
3816    }
3817
3818    #[cfg(feature = "crossterm")]
3819    #[test]
3820    fn parse_decrpm_sync_output_garbage_is_none() {
3821        // No DECRPM reply for mode 2026 in the string → inconclusive.
3822        assert_eq!(parse_decrpm_sync_output("not a decrpm reply"), None);
3823        // A reply for a *different* mode must not match.
3824        assert_eq!(parse_decrpm_sync_output("\x1b[?2004;1$y"), None);
3825        // Truncated reply (missing `$y` terminator) → None, not a panic.
3826        assert_eq!(parse_decrpm_sync_output("\x1b[?2026;1"), None);
3827        // Non-numeric Ps → None.
3828        assert_eq!(parse_decrpm_sync_output("\x1b[?2026;x$y"), None);
3829    }
3830
3831    #[test]
3832    fn sync_output_gate_defaults_to_emit() {
3833        // With the probe never having run (the unit-test process never enters a
3834        // real terminal session), the resolution stays `Unknown`, so the gate
3835        // must keep emitting BSU/ESU — preserving the historic always-emit
3836        // behavior on headless / non-answering hosts.
3837        assert!(should_emit_synchronized_update());
3838    }
3839
3840    #[test]
3841    fn terminal_query_guard_requires_stdin_and_stdout_tty() {
3842        assert!(terminal_query_allowed(true, true));
3843        assert!(!terminal_query_allowed(true, false));
3844        assert!(!terminal_query_allowed(false, true));
3845        assert!(!terminal_query_allowed(false, false));
3846    }
3847
3848    #[test]
3849    fn terminal_multiplexer_detection_is_conservative() {
3850        assert!(terminal_is_multiplexed_env("tmux-256color", false, false));
3851        assert!(terminal_is_multiplexed_env("screen-256color", false, false));
3852        assert!(terminal_is_multiplexed_env("xterm-256color", true, false));
3853        assert!(terminal_is_multiplexed_env("xterm-256color", false, true));
3854        assert!(!terminal_is_multiplexed_env("xterm-kitty", false, false));
3855    }
3856
3857    #[test]
3858    fn kitty_env_fallback_is_blocked_inside_multiplexer_unless_forced() {
3859        assert!(term_is_kitty_graphics_host_env(
3860            "xterm-kitty",
3861            "",
3862            false,
3863            false
3864        ));
3865        assert!(term_is_kitty_graphics_host_env(
3866            "xterm-256color",
3867            "wezterm",
3868            false,
3869            false
3870        ));
3871        assert!(!term_is_kitty_graphics_host_env(
3872            "xterm-kitty",
3873            "wezterm",
3874            true,
3875            false
3876        ));
3877        assert!(term_is_kitty_graphics_host_env(
3878            "xterm-256color",
3879            "",
3880            true,
3881            true
3882        ));
3883    }
3884
3885    #[test]
3886    fn iterm_env_fallback_is_blocked_inside_multiplexer_unless_forced() {
3887        assert!(term_is_iterm_host_env("iterm.app", false, false));
3888        assert!(term_is_iterm_host_env("wezterm", false, false));
3889        assert!(!term_is_iterm_host_env("wezterm", true, false));
3890        assert!(term_is_iterm_host_env("xterm", true, true));
3891    }
3892
3893    #[test]
3894    fn graphics_support_blocks_kitty_without_ack_or_force() {
3895        let support = GraphicsEmissionSupport {
3896            real_terminal: true,
3897            capabilities: Capabilities::default(),
3898            force_kitty: false,
3899            force_sixel: false,
3900            force_iterm: false,
3901        };
3902        assert!(!support.should_emit_kitty());
3903
3904        let acked = GraphicsEmissionSupport {
3905            capabilities: Capabilities {
3906                kitty_graphics: true,
3907                ..Default::default()
3908            },
3909            ..support
3910        };
3911        assert!(acked.should_emit_kitty());
3912
3913        let forced = GraphicsEmissionSupport {
3914            force_kitty: true,
3915            ..support
3916        };
3917        assert!(forced.should_emit_kitty());
3918
3919        let captured = GraphicsEmissionSupport {
3920            real_terminal: false,
3921            force_kitty: true,
3922            ..support
3923        };
3924        assert!(!captured.should_emit_kitty());
3925    }
3926
3927    #[test]
3928    fn graphics_support_blocks_sprixels_without_ack_or_force() {
3929        let support = GraphicsEmissionSupport {
3930            real_terminal: true,
3931            capabilities: Capabilities::default(),
3932            force_kitty: false,
3933            force_sixel: false,
3934            force_iterm: false,
3935        };
3936        assert!(!support.should_emit_sprixel(SprixelProtocol::Sixel));
3937        assert!(!support.should_emit_sprixel(SprixelProtocol::Iterm2));
3938        assert!(!support.should_emit_sprixel(SprixelProtocol::Unknown));
3939
3940        let sixel_acked = GraphicsEmissionSupport {
3941            capabilities: Capabilities {
3942                sixel: true,
3943                ..Default::default()
3944            },
3945            ..support
3946        };
3947        assert!(sixel_acked.should_emit_sprixel(SprixelProtocol::Sixel));
3948
3949        let iterm_forced = GraphicsEmissionSupport {
3950            force_iterm: true,
3951            ..support
3952        };
3953        assert!(iterm_forced.should_emit_sprixel(SprixelProtocol::Iterm2));
3954    }
3955
3956    #[test]
3957    fn sprixel_protocol_detects_sixel_and_iterm() {
3958        assert_eq!(
3959            sprixel_protocol("\x1bPqpayload\x1b\\"),
3960            SprixelProtocol::Sixel
3961        );
3962        assert_eq!(
3963            sprixel_protocol("\x1b]1337;File=inline=1:AAAA\x07"),
3964            SprixelProtocol::Iterm2
3965        );
3966        assert_eq!(sprixel_protocol("plain"), SprixelProtocol::Unknown);
3967    }
3968
3969    #[cfg(feature = "crossterm")]
3970    #[test]
3971    fn parse_xtgettcap_tc_sets_truecolor() {
3972        let mut caps = Capabilities::default();
3973        // DCS 1 + r 5463 (=Tc) ST → truecolor present.
3974        parse_xtgettcap_truecolor("\x1bP1+r5463=\x1b\\", &mut caps);
3975        assert!(caps.truecolor);
3976    }
3977
3978    #[cfg(feature = "crossterm")]
3979    #[test]
3980    fn parse_xtgettcap_invalid_leaves_truecolor_false() {
3981        let mut caps = Capabilities::default();
3982        // DCS 0 + r (capability NOT present) must not set the flag.
3983        parse_xtgettcap_truecolor("\x1bP0+r5463\x1b\\", &mut caps);
3984        assert!(!caps.truecolor);
3985        // Wrong capname hex must not match.
3986        parse_xtgettcap_truecolor("\x1bP1+r1234=\x1b\\", &mut caps);
3987        assert!(!caps.truecolor);
3988    }
3989
3990    #[cfg(feature = "crossterm")]
3991    #[test]
3992    fn base64_decode_round_trip_hello() {
3993        let encoded = base64_encode("hello".as_bytes());
3994        assert_eq!(base64_decode(&encoded), Some("hello".to_string()));
3995    }
3996
3997    #[cfg(feature = "crossterm")]
3998    #[test]
3999    fn color_scheme_equality() {
4000        assert_eq!(ColorScheme::Dark, ColorScheme::Dark);
4001        assert_ne!(ColorScheme::Dark, ColorScheme::Light);
4002        assert_eq!(ColorScheme::Unknown, ColorScheme::Unknown);
4003    }
4004
4005    fn pair(r: Rect) -> (Rect, Rect) {
4006        (r, r)
4007    }
4008
4009    #[test]
4010    fn find_innermost_rect_picks_smallest() {
4011        let rects = vec![
4012            pair(Rect::new(0, 0, 80, 24)),
4013            pair(Rect::new(5, 2, 30, 10)),
4014            pair(Rect::new(10, 4, 10, 5)),
4015        ];
4016        let result = find_innermost_rect(&rects, 12, 5);
4017        assert_eq!(result, Some(Rect::new(10, 4, 10, 5)));
4018    }
4019
4020    #[test]
4021    fn find_innermost_rect_no_match() {
4022        let rects = vec![pair(Rect::new(10, 10, 5, 5))];
4023        assert_eq!(find_innermost_rect(&rects, 0, 0), None);
4024    }
4025
4026    #[test]
4027    fn find_innermost_rect_empty() {
4028        assert_eq!(find_innermost_rect(&[], 5, 5), None);
4029    }
4030
4031    #[test]
4032    fn find_innermost_rect_returns_content_rect() {
4033        let rects = vec![
4034            (Rect::new(0, 0, 80, 24), Rect::new(1, 1, 78, 22)),
4035            (Rect::new(5, 2, 30, 10), Rect::new(6, 3, 28, 8)),
4036        ];
4037        let result = find_innermost_rect(&rects, 10, 5);
4038        assert_eq!(result, Some(Rect::new(6, 3, 28, 8)));
4039    }
4040
4041    #[test]
4042    fn normalize_selection_already_ordered() {
4043        let (s, e) = normalize_selection((2, 1), (5, 3));
4044        assert_eq!(s, (2, 1));
4045        assert_eq!(e, (5, 3));
4046    }
4047
4048    #[test]
4049    fn normalize_selection_reversed() {
4050        let (s, e) = normalize_selection((5, 3), (2, 1));
4051        assert_eq!(s, (2, 1));
4052        assert_eq!(e, (5, 3));
4053    }
4054
4055    #[test]
4056    fn normalize_selection_same_row() {
4057        let (s, e) = normalize_selection((10, 5), (3, 5));
4058        assert_eq!(s, (3, 5));
4059        assert_eq!(e, (10, 5));
4060    }
4061
4062    #[test]
4063    fn selection_state_mouse_down_finds_rect() {
4064        let hit_map = vec![pair(Rect::new(0, 0, 80, 24)), pair(Rect::new(5, 2, 20, 10))];
4065        let mut sel = SelectionState::default();
4066        sel.mouse_down(10, 5, &hit_map);
4067        assert_eq!(sel.anchor, Some((10, 5)));
4068        assert_eq!(sel.current, Some((10, 5)));
4069        assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 20, 10)));
4070        assert!(!sel.active);
4071    }
4072
4073    #[test]
4074    fn selection_state_drag_activates() {
4075        let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
4076        let mut sel = SelectionState {
4077            anchor: Some((10, 5)),
4078            current: Some((10, 5)),
4079            widget_rect: Some(Rect::new(0, 0, 80, 24)),
4080            ..Default::default()
4081        };
4082        sel.mouse_drag(10, 5, &hit_map);
4083        assert!(!sel.active, "no movement = not active");
4084        sel.mouse_drag(11, 5, &hit_map);
4085        assert!(!sel.active, "1 cell horizontal = not active yet");
4086        sel.mouse_drag(13, 5, &hit_map);
4087        assert!(sel.active, ">1 cell horizontal = active");
4088    }
4089
4090    #[test]
4091    fn selection_state_drag_vertical_activates() {
4092        let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
4093        let mut sel = SelectionState {
4094            anchor: Some((10, 5)),
4095            current: Some((10, 5)),
4096            widget_rect: Some(Rect::new(0, 0, 80, 24)),
4097            ..Default::default()
4098        };
4099        sel.mouse_drag(10, 6, &hit_map);
4100        assert!(sel.active, "any vertical movement = active");
4101    }
4102
4103    #[test]
4104    fn selection_state_drag_expands_widget_rect() {
4105        let hit_map = vec![
4106            pair(Rect::new(0, 0, 80, 24)),
4107            pair(Rect::new(5, 2, 30, 10)),
4108            pair(Rect::new(5, 2, 30, 3)),
4109        ];
4110        let mut sel = SelectionState {
4111            anchor: Some((10, 3)),
4112            current: Some((10, 3)),
4113            widget_rect: Some(Rect::new(5, 2, 30, 3)),
4114            ..Default::default()
4115        };
4116        sel.mouse_drag(10, 6, &hit_map);
4117        assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 30, 10)));
4118    }
4119
4120    #[test]
4121    fn selection_state_clear_resets() {
4122        let mut sel = SelectionState {
4123            anchor: Some((1, 2)),
4124            current: Some((3, 4)),
4125            widget_rect: Some(Rect::new(0, 0, 10, 10)),
4126            active: true,
4127        };
4128        sel.clear();
4129        assert_eq!(sel.anchor, None);
4130        assert_eq!(sel.current, None);
4131        assert_eq!(sel.widget_rect, None);
4132        assert!(!sel.active);
4133    }
4134
4135    #[test]
4136    fn extract_selection_text_single_line() {
4137        let area = Rect::new(0, 0, 20, 5);
4138        let mut buf = Buffer::empty(area);
4139        buf.set_string(0, 0, "Hello World", Style::default());
4140        let sel = SelectionState {
4141            anchor: Some((0, 0)),
4142            current: Some((4, 0)),
4143            widget_rect: Some(area),
4144            active: true,
4145        };
4146        let text = extract_selection_text(&buf, &sel, &[]);
4147        assert_eq!(text, "Hello");
4148    }
4149
4150    #[test]
4151    fn extract_selection_text_multi_line() {
4152        let area = Rect::new(0, 0, 20, 5);
4153        let mut buf = Buffer::empty(area);
4154        buf.set_string(0, 0, "Line one", Style::default());
4155        buf.set_string(0, 1, "Line two", Style::default());
4156        buf.set_string(0, 2, "Line three", Style::default());
4157        let sel = SelectionState {
4158            anchor: Some((5, 0)),
4159            current: Some((3, 2)),
4160            widget_rect: Some(area),
4161            active: true,
4162        };
4163        let text = extract_selection_text(&buf, &sel, &[]);
4164        assert_eq!(text, "one\nLine two\nLine");
4165    }
4166
4167    #[test]
4168    fn extract_selection_text_clamped_to_widget() {
4169        let area = Rect::new(0, 0, 40, 10);
4170        let widget = Rect::new(5, 2, 10, 3);
4171        let mut buf = Buffer::empty(area);
4172        buf.set_string(5, 2, "ABCDEFGHIJ", Style::default());
4173        buf.set_string(5, 3, "KLMNOPQRST", Style::default());
4174        let sel = SelectionState {
4175            anchor: Some((3, 1)),
4176            current: Some((20, 5)),
4177            widget_rect: Some(widget),
4178            active: true,
4179        };
4180        let text = extract_selection_text(&buf, &sel, &[]);
4181        assert_eq!(text, "ABCDEFGHIJ\nKLMNOPQRST");
4182    }
4183
4184    #[test]
4185    fn extract_selection_text_inactive_returns_empty() {
4186        let area = Rect::new(0, 0, 10, 5);
4187        let buf = Buffer::empty(area);
4188        let sel = SelectionState {
4189            anchor: Some((0, 0)),
4190            current: Some((5, 2)),
4191            widget_rect: Some(area),
4192            active: false,
4193        };
4194        assert_eq!(extract_selection_text(&buf, &sel, &[]), "");
4195    }
4196
4197    #[test]
4198    fn apply_selection_overlay_reverses_cells() {
4199        let area = Rect::new(0, 0, 10, 3);
4200        let mut buf = Buffer::empty(area);
4201        buf.set_string(0, 0, "ABCDE", Style::default());
4202        let sel = SelectionState {
4203            anchor: Some((1, 0)),
4204            current: Some((3, 0)),
4205            widget_rect: Some(area),
4206            active: true,
4207        };
4208        apply_selection_overlay(&mut buf, &sel, &[]);
4209        assert!(!buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED));
4210        assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
4211        assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
4212        assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
4213        assert!(!buf.get(4, 0).style.modifiers.contains(Modifiers::REVERSED));
4214    }
4215
4216    #[test]
4217    fn extract_selection_text_skips_border_cells() {
4218        // Simulate two bordered columns side by side:
4219        // Col1: full=(0,0,20,5) content=(1,1,18,3)
4220        // Col2: full=(20,0,20,5) content=(21,1,18,3)
4221        // Parent widget_rect covers both: (0,0,40,5)
4222        let area = Rect::new(0, 0, 40, 5);
4223        let mut buf = Buffer::empty(area);
4224        // Col1 border characters
4225        buf.set_string(0, 0, "╭", Style::default());
4226        buf.set_string(0, 1, "│", Style::default());
4227        buf.set_string(0, 2, "│", Style::default());
4228        buf.set_string(0, 3, "│", Style::default());
4229        buf.set_string(0, 4, "╰", Style::default());
4230        buf.set_string(19, 0, "╮", Style::default());
4231        buf.set_string(19, 1, "│", Style::default());
4232        buf.set_string(19, 2, "│", Style::default());
4233        buf.set_string(19, 3, "│", Style::default());
4234        buf.set_string(19, 4, "╯", Style::default());
4235        // Col2 border characters
4236        buf.set_string(20, 0, "╭", Style::default());
4237        buf.set_string(20, 1, "│", Style::default());
4238        buf.set_string(20, 2, "│", Style::default());
4239        buf.set_string(20, 3, "│", Style::default());
4240        buf.set_string(20, 4, "╰", Style::default());
4241        buf.set_string(39, 0, "╮", Style::default());
4242        buf.set_string(39, 1, "│", Style::default());
4243        buf.set_string(39, 2, "│", Style::default());
4244        buf.set_string(39, 3, "│", Style::default());
4245        buf.set_string(39, 4, "╯", Style::default());
4246        // Content inside Col1
4247        buf.set_string(1, 1, "Hello Col1", Style::default());
4248        buf.set_string(1, 2, "Line2 Col1", Style::default());
4249        // Content inside Col2
4250        buf.set_string(21, 1, "Hello Col2", Style::default());
4251        buf.set_string(21, 2, "Line2 Col2", Style::default());
4252
4253        let content_map = vec![
4254            (Rect::new(0, 0, 20, 5), Rect::new(1, 1, 18, 3)),
4255            (Rect::new(20, 0, 20, 5), Rect::new(21, 1, 18, 3)),
4256        ];
4257
4258        // Select across both columns, rows 1-2
4259        let sel = SelectionState {
4260            anchor: Some((0, 1)),
4261            current: Some((39, 2)),
4262            widget_rect: Some(area),
4263            active: true,
4264        };
4265        let text = extract_selection_text(&buf, &sel, &content_map);
4266        // Should NOT contain border characters (│, ╭, ╮, etc.)
4267        assert!(!text.contains('│'), "Border char │ found in: {text}");
4268        assert!(!text.contains('╭'), "Border char ╭ found in: {text}");
4269        assert!(!text.contains('╮'), "Border char ╮ found in: {text}");
4270        // Should contain actual content
4271        assert!(
4272            text.contains("Hello Col1"),
4273            "Missing Col1 content in: {text}"
4274        );
4275        assert!(
4276            text.contains("Hello Col2"),
4277            "Missing Col2 content in: {text}"
4278        );
4279        assert!(text.contains("Line2 Col1"), "Missing Col1 line2 in: {text}");
4280        assert!(text.contains("Line2 Col2"), "Missing Col2 line2 in: {text}");
4281    }
4282
4283    #[test]
4284    fn apply_selection_overlay_skips_border_cells() {
4285        let area = Rect::new(0, 0, 20, 3);
4286        let mut buf = Buffer::empty(area);
4287        buf.set_string(0, 0, "│", Style::default());
4288        buf.set_string(1, 0, "ABC", Style::default());
4289        buf.set_string(19, 0, "│", Style::default());
4290
4291        let content_map = vec![(Rect::new(0, 0, 20, 3), Rect::new(1, 0, 18, 3))];
4292        let sel = SelectionState {
4293            anchor: Some((0, 0)),
4294            current: Some((19, 0)),
4295            widget_rect: Some(area),
4296            active: true,
4297        };
4298        apply_selection_overlay(&mut buf, &sel, &content_map);
4299        // Border cells at x=0 and x=19 should NOT be reversed
4300        assert!(
4301            !buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED),
4302            "Left border cell should not be reversed"
4303        );
4304        assert!(
4305            !buf.get(19, 0).style.modifiers.contains(Modifiers::REVERSED),
4306            "Right border cell should not be reversed"
4307        );
4308        // Content cells should be reversed
4309        assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
4310        assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
4311        assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
4312    }
4313
4314    #[test]
4315    fn copy_to_clipboard_writes_osc52() {
4316        let mut output: Vec<u8> = Vec::new();
4317        copy_to_clipboard(&mut output, "test").unwrap();
4318        let s = String::from_utf8(output).unwrap();
4319        assert!(s.starts_with("\x1b]52;c;"));
4320        assert!(s.ends_with("\x1b\\"));
4321        assert!(s.contains(&base64_encode(b"test")));
4322    }
4323
4324    // Count occurrences of CSI cursor-move (`ESC [ ... H`) in flush output.
4325    fn count_move_tos(s: &str) -> usize {
4326        let bytes = s.as_bytes();
4327        let mut count = 0;
4328        let mut i = 0;
4329        while i + 1 < bytes.len() {
4330            if bytes[i] == 0x1b && bytes[i + 1] == b'[' {
4331                // Scan to the terminator — final byte in 0x40..=0x7e.
4332                let mut j = i + 2;
4333                while j < bytes.len() && !(0x40..=0x7e).contains(&bytes[j]) {
4334                    j += 1;
4335                }
4336                if j < bytes.len() && bytes[j] == b'H' {
4337                    count += 1;
4338                }
4339                i = j + 1;
4340            } else {
4341                i += 1;
4342            }
4343        }
4344        count
4345    }
4346
4347    #[test]
4348    fn flush_coalesces_consecutive_same_style_cells_into_one_run() {
4349        // 10 cells, identical Style, contiguous columns -> 1 MoveTo + 1 Print.
4350        let area = Rect::new(0, 0, 20, 1);
4351        let mut current = Buffer::empty(area);
4352        let previous = Buffer::empty(area);
4353        let style = Style::new().fg(Color::Red);
4354        for x in 0..10u32 {
4355            let cell = current.get_mut(x, 0);
4356            cell.set_char('X');
4357            cell.set_style(style);
4358        }
4359
4360        let mut out: Vec<u8> = Vec::new();
4361        flush_buffer_diff(
4362            &mut out,
4363            &current,
4364            &previous,
4365            ColorDepth::TrueColor,
4366            0,
4367            &mut String::new(),
4368        )
4369        .unwrap();
4370        let s = String::from_utf8(out).unwrap();
4371
4372        // Exactly one cursor move for the whole run.
4373        assert_eq!(
4374            count_move_tos(&s),
4375            1,
4376            "expected 1 MoveTo for a coalesced run, got {} in {:?}",
4377            count_move_tos(&s),
4378            s
4379        );
4380        // The 10 glyphs are emitted contiguously as a single run.
4381        assert!(
4382            s.contains("XXXXXXXXXX"),
4383            "expected contiguous run 'XXXXXXXXXX' in {:?}",
4384            s
4385        );
4386    }
4387
4388    #[test]
4389    fn flush_breaks_run_on_style_change() {
4390        // 5 red cells + 5 blue cells in the same row -> 2 MoveTo calls not 10.
4391        let area = Rect::new(0, 0, 20, 1);
4392        let mut current = Buffer::empty(area);
4393        let previous = Buffer::empty(area);
4394        let red = Style::new().fg(Color::Red);
4395        let blue = Style::new().fg(Color::Blue);
4396        for x in 0..5u32 {
4397            let cell = current.get_mut(x, 0);
4398            cell.set_char('R');
4399            cell.set_style(red);
4400        }
4401        for x in 5..10u32 {
4402            let cell = current.get_mut(x, 0);
4403            cell.set_char('B');
4404            cell.set_style(blue);
4405        }
4406
4407        let mut out: Vec<u8> = Vec::new();
4408        flush_buffer_diff(
4409            &mut out,
4410            &current,
4411            &previous,
4412            ColorDepth::TrueColor,
4413            0,
4414            &mut String::new(),
4415        )
4416        .unwrap();
4417        let s = String::from_utf8(out).unwrap();
4418
4419        // First run needs a MoveTo; the second run starts exactly where the
4420        // cursor already is, so `last_cursor` suppresses a redundant MoveTo.
4421        // Either way, we should see at most 2 MoveTos and far fewer than 10.
4422        let moves = count_move_tos(&s);
4423        assert!(
4424            moves <= 2,
4425            "expected at most 2 MoveTos across a style boundary, got {} in {:?}",
4426            moves,
4427            s
4428        );
4429        assert!(s.contains("RRRRR"), "missing 'RRRRR' run in {:?}", s);
4430        assert!(s.contains("BBBBB"), "missing 'BBBBB' run in {:?}", s);
4431    }
4432
4433    #[test]
4434    fn flush_breaks_run_on_column_gap() {
4435        // Cells at x=0..3 and x=6..9; gap at x=3,4,5 must split runs.
4436        let area = Rect::new(0, 0, 20, 1);
4437        let mut current = Buffer::empty(area);
4438        let previous = Buffer::empty(area);
4439        let style = Style::new().fg(Color::Green);
4440        for x in 0..3u32 {
4441            current.get_mut(x, 0).set_char('A').set_style(style);
4442        }
4443        for x in 6..9u32 {
4444            current.get_mut(x, 0).set_char('B').set_style(style);
4445        }
4446
4447        let mut out: Vec<u8> = Vec::new();
4448        flush_buffer_diff(
4449            &mut out,
4450            &current,
4451            &previous,
4452            ColorDepth::TrueColor,
4453            0,
4454            &mut String::new(),
4455        )
4456        .unwrap();
4457        let s = String::from_utf8(out).unwrap();
4458
4459        // Two separate runs means two MoveTo commands.
4460        assert_eq!(
4461            count_move_tos(&s),
4462            2,
4463            "expected 2 MoveTos across a column gap, got {} in {:?}",
4464            count_move_tos(&s),
4465            s
4466        );
4467        assert!(s.contains("AAA"), "missing 'AAA' run in {:?}", s);
4468        assert!(s.contains("BBB"), "missing 'BBB' run in {:?}", s);
4469    }
4470
4471    /// Verifies that `flush_buffer_diff` produces identical ANSI output whether the
4472    /// destination is a plain `Vec<u8>` or a `BufWriter<Vec<u8>>`. This ensures the
4473    /// BufWriter wrapper introduced for stdout does not alter the byte stream.
4474    #[test]
4475    fn bufwriter_output_identical_to_direct_write() {
4476        let area = Rect::new(0, 0, 5, 1);
4477        let mut current = Buffer::empty(area);
4478        let previous = Buffer::empty(area);
4479        let style = Style::new().fg(Color::Rgb(255, 128, 0));
4480        for x in 0..5u32 {
4481            current.get_mut(x, 0).set_char('X').set_style(style);
4482        }
4483
4484        let mut direct: Vec<u8> = Vec::new();
4485        flush_buffer_diff(
4486            &mut direct,
4487            &current,
4488            &previous,
4489            ColorDepth::TrueColor,
4490            0,
4491            &mut String::new(),
4492        )
4493        .unwrap();
4494
4495        let mut buffered: BufWriter<Vec<u8>> = BufWriter::with_capacity(65536, Vec::new());
4496        flush_buffer_diff(
4497            &mut buffered,
4498            &current,
4499            &previous,
4500            ColorDepth::TrueColor,
4501            0,
4502            &mut String::new(),
4503        )
4504        .unwrap();
4505        buffered.flush().unwrap();
4506        let via_buf = buffered.into_inner().unwrap();
4507
4508        assert_eq!(
4509            direct, via_buf,
4510            "BufWriter output must be byte-for-byte identical to direct write"
4511        );
4512    }
4513
4514    /// Verifies that a `BufWriter<Vec<u8>>` sink accumulates all writes and only
4515    /// issues a single underlying `write` call to the inner sink when flushed.
4516    /// This is a proxy for the syscall-reduction guarantee on the real stdout.
4517    #[test]
4518    fn bufwriter_coalesces_writes_into_single_flush() {
4519        #[derive(Debug)]
4520        struct CountingWriter {
4521            buf: Vec<u8>,
4522            write_call_count: usize,
4523        }
4524        impl Write for CountingWriter {
4525            fn write(&mut self, data: &[u8]) -> io::Result<usize> {
4526                self.write_call_count += 1;
4527                self.buf.extend_from_slice(data);
4528                Ok(data.len())
4529            }
4530            fn flush(&mut self) -> io::Result<()> {
4531                Ok(())
4532            }
4533        }
4534
4535        let area = Rect::new(0, 0, 10, 1);
4536        let mut current = Buffer::empty(area);
4537        let previous = Buffer::empty(area);
4538        // Alternate styles on every cell to maximise queue! calls inside flush_buffer_diff.
4539        for x in 0..10u32 {
4540            let color = if x % 2 == 0 {
4541                Color::Rgb(255, 0, 0)
4542            } else {
4543                Color::Rgb(0, 255, 0)
4544            };
4545            current
4546                .get_mut(x, 0)
4547                .set_char('Z')
4548                .set_style(Style::new().fg(color));
4549        }
4550
4551        let sink = CountingWriter {
4552            buf: Vec::new(),
4553            write_call_count: 0,
4554        };
4555        let mut bw = BufWriter::with_capacity(65536, sink);
4556        flush_buffer_diff(
4557            &mut bw,
4558            &current,
4559            &previous,
4560            ColorDepth::TrueColor,
4561            0,
4562            &mut String::new(),
4563        )
4564        .unwrap();
4565        bw.flush().unwrap();
4566        let inner = bw.into_inner().unwrap();
4567
4568        // BufWriter should have batched everything into 1 write call to the sink.
4569        assert_eq!(
4570            inner.write_call_count, 1,
4571            "expected 1 write syscall to sink, got {}",
4572            inner.write_call_count
4573        );
4574    }
4575
4576    /// Issue #171 regression: identical buffers must produce no flush
4577    /// output once both have refreshed line hashes. Validates that the
4578    /// per-row skip path is correctness-preserving — a skipped row
4579    /// emits zero bytes, exactly like the per-cell path would for an
4580    /// unchanged row.
4581    #[test]
4582    fn flush_skips_unchanged_rows_when_hashes_match() {
4583        let area = Rect::new(0, 0, 20, 4);
4584        let mut current = Buffer::empty(area);
4585        let mut previous = Buffer::empty(area);
4586        // Populate both buffers with identical content.
4587        for y in 0..4u32 {
4588            current.set_string(0, y, "identical-row-content", Style::new());
4589            previous.set_string(0, y, "identical-row-content", Style::new());
4590        }
4591        current.recompute_line_hashes();
4592        previous.recompute_line_hashes();
4593
4594        let mut out: Vec<u8> = Vec::new();
4595        flush_buffer_diff(
4596            &mut out,
4597            &current,
4598            &previous,
4599            ColorDepth::TrueColor,
4600            0,
4601            &mut String::new(),
4602        )
4603        .unwrap();
4604        assert!(
4605            out.is_empty(),
4606            "identical buffers must emit zero flush bytes; got {} bytes: {:?}",
4607            out.len(),
4608            out
4609        );
4610    }
4611
4612    /// Issue #171 regression: when only some rows match, only those rows
4613    /// are skipped. The differing row must still drive its full per-cell
4614    /// flush path so the terminal sees the correct glyphs.
4615    #[test]
4616    fn flush_skips_only_matching_rows_in_mixed_diff() {
4617        let area = Rect::new(0, 0, 6, 3);
4618        let mut current = Buffer::empty(area);
4619        let mut previous = Buffer::empty(area);
4620        current.set_string(0, 0, "abcdef", Style::new());
4621        previous.set_string(0, 0, "abcdef", Style::new());
4622        current.set_string(0, 1, "xxxxxx", Style::new());
4623        previous.set_string(0, 1, "yyyyyy", Style::new());
4624        current.set_string(0, 2, "zzzzzz", Style::new());
4625        previous.set_string(0, 2, "zzzzzz", Style::new());
4626        current.recompute_line_hashes();
4627        previous.recompute_line_hashes();
4628
4629        let mut out: Vec<u8> = Vec::new();
4630        flush_buffer_diff(
4631            &mut out,
4632            &current,
4633            &previous,
4634            ColorDepth::TrueColor,
4635            0,
4636            &mut String::new(),
4637        )
4638        .unwrap();
4639        let s = String::from_utf8_lossy(&out);
4640        // The mismatched row's new content must appear; matching rows'
4641        // glyphs must not (they share content with `previous`).
4642        assert!(s.contains("xxxxxx"), "differing row must flush: {s:?}");
4643        assert!(
4644            !s.contains("abcdef"),
4645            "matching row 0 must not flush: {s:?}"
4646        );
4647        assert!(
4648            !s.contains("zzzzzz"),
4649            "matching row 2 must not flush: {s:?}"
4650        );
4651    }
4652
4653    fn delta_bytes(old: &Style, new: &Style) -> Vec<u8> {
4654        let mut out = Vec::new();
4655        apply_style_delta(&mut out, old, new, ColorDepth::TrueColor).unwrap();
4656        out
4657    }
4658
4659    fn contains_seq(haystack: &[u8], needle: &[u8]) -> bool {
4660        haystack.windows(needle.len()).any(|w| w == needle)
4661    }
4662
4663    #[test]
4664    fn apply_style_delta_emits_blink_set_and_reset() {
4665        let on = delta_bytes(&Style::new(), &Style::new().blink());
4666        // SGR 5 = SlowBlink.
4667        assert!(contains_seq(&on, b"\x1b[5m"), "blink set: {on:?}");
4668        let off = delta_bytes(&Style::new().blink(), &Style::new());
4669        // SGR 25 = NoBlink.
4670        assert!(contains_seq(&off, b"\x1b[25m"), "blink reset: {off:?}");
4671    }
4672
4673    #[test]
4674    fn apply_style_delta_emits_overline_set_and_reset() {
4675        let on = delta_bytes(&Style::new(), &Style::new().overline());
4676        // SGR 53 = OverLined.
4677        assert!(contains_seq(&on, b"\x1b[53m"), "overline set: {on:?}");
4678        let off = delta_bytes(&Style::new().overline(), &Style::new());
4679        // SGR 55 = NotOverLined.
4680        assert!(contains_seq(&off, b"\x1b[55m"), "overline reset: {off:?}");
4681    }
4682
4683    #[test]
4684    fn apply_style_delta_emits_curly_underline_subparameter() {
4685        let out = delta_bytes(
4686            &Style::new(),
4687            &Style::new().underline_style(UnderlineStyle::Curly),
4688        );
4689        assert!(contains_seq(&out, b"\x1b[4:3m"), "curly underline: {out:?}");
4690    }
4691
4692    #[test]
4693    fn apply_style_delta_emits_underline_color_and_reset() {
4694        let set = delta_bytes(
4695            &Style::new(),
4696            &Style::new().underline_color(Color::Rgb(255, 0, 0)),
4697        );
4698        assert!(
4699            contains_seq(&set, b"\x1b[58:2::255:0:0m"),
4700            "underline color set: {set:?}"
4701        );
4702        let clear = delta_bytes(
4703            &Style::new().underline_color(Color::Rgb(255, 0, 0)),
4704            &Style::new(),
4705        );
4706        assert!(
4707            contains_seq(&clear, b"\x1b[59m"),
4708            "underline color reset: {clear:?}"
4709        );
4710    }
4711
4712    #[test]
4713    fn apply_style_delta_underline_color_indexed_uses_sgr_58_5() {
4714        let out = delta_bytes(
4715            &Style::new(),
4716            &Style::new().underline_color(Color::Indexed(42)),
4717        );
4718        assert!(
4719            contains_seq(&out, b"\x1b[58:5:42m"),
4720            "indexed underline: {out:?}"
4721        );
4722    }
4723
4724    #[test]
4725    fn apply_style_full_emits_blink_overline_and_underline() {
4726        let mut out = Vec::new();
4727        let style = Style::new()
4728            .blink()
4729            .overline()
4730            .underline_style(UnderlineStyle::Dotted)
4731            .underline_color(Color::Rgb(0, 0, 255));
4732        apply_style(&mut out, &style, ColorDepth::TrueColor).unwrap();
4733        assert!(contains_seq(&out, b"\x1b[5m"), "blink: {out:?}");
4734        assert!(contains_seq(&out, b"\x1b[53m"), "overline: {out:?}");
4735        assert!(
4736            contains_seq(&out, b"\x1b[4:4m"),
4737            "dotted underline: {out:?}"
4738        );
4739        assert!(
4740            contains_seq(&out, b"\x1b[58:2::0:0:255m"),
4741            "underline color: {out:?}"
4742        );
4743    }
4744    /// Issue #274: a captured-sink `Terminal` routes a styled cell through the
4745    /// real flush pipeline into the in-process byte sink, and dropping it does
4746    /// not emit teardown escapes (no raw mode was entered).
4747    #[test]
4748    fn with_sink_captures_flush_bytes_and_drops_clean() {
4749        let mut term = Terminal::with_sink(10, 1, ColorDepth::TrueColor);
4750        term.buffer_mut()
4751            .set_string(0, 0, "Z", Style::new().fg(Color::Rgb(200, 50, 50)));
4752        term.flush().unwrap();
4753        let bytes = term.take_sink_bytes();
4754        let s = String::from_utf8_lossy(&bytes);
4755        // Real terminal control bytes + the printed glyph went to the sink.
4756        assert!(s.contains("\u{1b}["), "missing CSI: {s:?}");
4757        assert!(s.contains('Z'), "missing glyph: {s:?}");
4758        // A second take after no flush yields nothing (capture was drained).
4759        assert!(term.take_sink_bytes().is_empty());
4760        // Dropping the harness terminal must not panic or emit teardown.
4761        drop(term);
4762    }
4763
4764    /// Issue #269: hoisting `run_buf` to a reused, caller-owned buffer must not
4765    /// change the emitted bytes. Re-running the diff twice through the *same*
4766    /// `run_buf` (which `clear()`s but keeps capacity at the top of each call)
4767    /// produces the same output as a single fresh-buffer run.
4768    #[test]
4769    fn reused_run_buf_byte_identical_across_frames() {
4770        let area = Rect::new(0, 0, 12, 2);
4771        // `Buffer` is not `Clone`, so rebuild the frame pair on demand.
4772        let make_frame = || {
4773            let mut current = Buffer::empty(area);
4774            let previous = Buffer::empty(area);
4775            current.set_string(0, 0, "hello world", Style::new().fg(Color::Rgb(1, 2, 3)));
4776            current.set_string(0, 1, "second line", Style::new().fg(Color::Rgb(4, 5, 6)));
4777            (current, previous)
4778        };
4779
4780        // Baseline: a fresh run_buf per call.
4781        let mut baseline: Vec<u8> = Vec::new();
4782        {
4783            let (mut a, mut b) = make_frame();
4784            __bench_flush_buffer_diff_mut_with_buf(
4785                &mut baseline,
4786                &mut a,
4787                &mut b,
4788                ColorDepth::TrueColor,
4789                &mut String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
4790            )
4791            .unwrap();
4792        }
4793
4794        // Reuse: run a throwaway frame first, then the real frame through the
4795        // SAME run_buf (now carrying leftover capacity, freshly cleared).
4796        let mut shared = String::with_capacity(RUN_BUF_INITIAL_CAPACITY);
4797        {
4798            let mut warm: Vec<u8> = Vec::new();
4799            let (mut a, mut b) = make_frame();
4800            __bench_flush_buffer_diff_mut_with_buf(
4801                &mut warm,
4802                &mut a,
4803                &mut b,
4804                ColorDepth::TrueColor,
4805                &mut shared,
4806            )
4807            .unwrap();
4808        }
4809        let cap_after_warm = shared.capacity();
4810
4811        let mut reused: Vec<u8> = Vec::new();
4812        let (mut current, mut previous) = make_frame();
4813        __bench_flush_buffer_diff_mut_with_buf(
4814            &mut reused,
4815            &mut current,
4816            &mut previous,
4817            ColorDepth::TrueColor,
4818            &mut shared,
4819        )
4820        .unwrap();
4821
4822        assert_eq!(
4823            baseline, reused,
4824            "reused run_buf must emit byte-identical output"
4825        );
4826        // The reuse path keeps capacity across frames (never re-grows below the
4827        // initial reservation) — the whole point of the hoist.
4828        assert!(
4829            shared.capacity() >= cap_after_warm,
4830            "run_buf capacity must persist across frames"
4831        );
4832    }
4833
4834    /// Issue #269: the OSC 8 hyperlink open, rewritten from `format!` to three
4835    /// borrowed `Print`s, must still emit the exact `\x1b]8;;<url>\x07 ...
4836    /// \x1b]8;;\x07` sequence.
4837    #[test]
4838    fn osc8_hyperlink_emitted_verbatim_after_write_rewrite() {
4839        let area = Rect::new(0, 0, 8, 1);
4840        let mut current = Buffer::empty(area);
4841        let previous = Buffer::empty(area);
4842        let url = "https://example.com/x";
4843        // `set_string_linked` sanitizes + attaches the hyperlink to each cell.
4844        current.set_string_linked(0, 0, "link", Style::new(), url);
4845
4846        let mut out: Vec<u8> = Vec::new();
4847        flush_buffer_diff(
4848            &mut out,
4849            &current,
4850            &previous,
4851            ColorDepth::TrueColor,
4852            0,
4853            &mut String::new(),
4854        )
4855        .unwrap();
4856
4857        let open = format!("\x1b]8;;{url}\x07");
4858        assert!(
4859            contains_seq(&out, open.as_bytes()),
4860            "OSC 8 open must appear verbatim: {:?}",
4861            String::from_utf8_lossy(&out)
4862        );
4863        assert!(
4864            contains_seq(&out, b"\x1b]8;;\x07"),
4865            "OSC 8 close must appear: {:?}",
4866            String::from_utf8_lossy(&out)
4867        );
4868    }
4869
4870    /// Build `n` distinct 8x8 RGBA placements for kitty-flush golden tests.
4871    fn kitty_placements(n: usize) -> Vec<KittyPlacement> {
4872        (0..n)
4873            .map(|i| {
4874                let mut rgba = vec![0u8; 256];
4875                rgba[0] = i as u8;
4876                let content_hash = crate::buffer::hash_rgba(&rgba);
4877                KittyPlacement {
4878                    content_hash,
4879                    rgba: std::sync::Arc::new(rgba),
4880                    src_width: 8,
4881                    src_height: 8,
4882                    x: (i as u32) * 4,
4883                    y: (i as u32) * 2,
4884                    cols: 4,
4885                    rows: 2,
4886                    crop_y: 0,
4887                    crop_h: 0,
4888                }
4889            })
4890            .collect()
4891    }
4892
4893    #[test]
4894    fn captured_sink_suppresses_kitty_graphics_bytes() {
4895        let mut term = Terminal::with_sink(8, 4, ColorDepth::TrueColor);
4896        term.graphics_support = GraphicsEmissionSupport {
4897            real_terminal: true,
4898            capabilities: Capabilities::default(),
4899            force_kitty: false,
4900            force_sixel: false,
4901            force_iterm: false,
4902        };
4903        for placement in kitty_placements(1) {
4904            term.buffer_mut().kitty_place(placement);
4905        }
4906        term.flush().unwrap();
4907        let bytes = term.take_sink_bytes();
4908        assert!(
4909            !contains_seq(&bytes, b"\x1b_G"),
4910            "captured sink must not emit Kitty APC bytes: {:?}",
4911            String::from_utf8_lossy(&bytes)
4912        );
4913    }
4914
4915    /// Issue #269: replacing the two per-frame `HashSet`s in
4916    /// `KittyImageManager::flush` with reused `SmallVec` dedup scratch must not
4917    /// change the emitted escape stream for the small placement counts (0, 1, 5)
4918    /// the path actually sees. We assert structural invariants of the byte
4919    /// stream rather than an opaque golden blob so the test documents intent.
4920    #[test]
4921    fn kitty_flush_smallvec_dedup_matches_for_small_n() {
4922        for n in [0usize, 1, 5] {
4923            let placements = kitty_placements(n);
4924            let mut mgr = KittyImageManager::new();
4925
4926            // Frame 1: nothing previously placed → upload + place each image.
4927            let mut frame1: Vec<u8> = Vec::new();
4928            mgr.flush(&mut frame1, &placements, 0).unwrap();
4929            let s1 = String::from_utf8_lossy(&frame1);
4930            // One transmit (`a=t`) and one placement (`a=p`) per image.
4931            assert_eq!(
4932                s1.matches("a=t,").count(),
4933                n,
4934                "n={n}: expected {n} uploads in frame 1: {s1:?}"
4935            );
4936            assert_eq!(
4937                s1.matches("a=p,").count(),
4938                n,
4939                "n={n}: expected {n} placements in frame 1: {s1:?}"
4940            );
4941
4942            // Frame 2: identical placements → fast path, zero output.
4943            let mut frame2: Vec<u8> = Vec::new();
4944            mgr.flush(&mut frame2, &placements, 0).unwrap();
4945            assert!(
4946                frame2.is_empty(),
4947                "n={n}: identical frame must hit the kitty fast path, got {} bytes",
4948                frame2.len()
4949            );
4950
4951            // Frame 3: clear all placements → one delete (`a=d,d=i`) per image,
4952            // deduped by the reused SmallVec, plus image-data cleanup
4953            // (`a=d,d=I`) for every now-unused upload.
4954            let mut frame3: Vec<u8> = Vec::new();
4955            mgr.flush(&mut frame3, &[], 0).unwrap();
4956            let s3 = String::from_utf8_lossy(&frame3);
4957            assert_eq!(
4958                s3.matches("a=d,d=i,").count(),
4959                n,
4960                "n={n}: expected {n} placement deletes in frame 3: {s3:?}"
4961            );
4962            assert_eq!(
4963                s3.matches("a=d,d=I,").count(),
4964                n,
4965                "n={n}: expected {n} image-data deletes in frame 3: {s3:?}"
4966            );
4967        }
4968    }
4969
4970    // ---- #265 sprixel damage matrix ----------------------------------------
4971
4972    use crate::buffer::{SprixelCell, SprixelPlacement};
4973
4974    /// Build a 2×2-cell sprixel at (1, 1) with the given footprint states.
4975    fn make_sprixel(cells: Vec<SprixelCell>) -> SprixelPlacement {
4976        SprixelPlacement {
4977            content_hash: 0xABCD,
4978            seq: "<SIXEL>".to_string(),
4979            x: 1,
4980            y: 1,
4981            cols: 2,
4982            rows: 2,
4983            cells,
4984        }
4985    }
4986
4987    #[test]
4988    fn checked_sprixel_flush_suppresses_mux_without_ack_or_force() {
4989        let area = Rect::new(0, 0, 10, 5);
4990        let mut placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
4991        placement.seq = "\x1bPqpayload\x1b\\".to_string();
4992
4993        let mut current = Buffer::empty(area);
4994        current.sprixels.push(placement);
4995        let previous = Buffer::empty(area);
4996        let support = GraphicsEmissionSupport {
4997            real_terminal: true,
4998            capabilities: Capabilities::default(),
4999            force_kitty: false,
5000            force_sixel: false,
5001            force_iterm: false,
5002        };
5003
5004        let mut out = Vec::new();
5005        flush_sprixels_checked(&mut out, &current, &previous, 0, support).unwrap();
5006        assert!(
5007            out.is_empty(),
5008            "tmux/screen without ack must not emit Sixel"
5009        );
5010    }
5011
5012    #[test]
5013    fn checked_sprixel_flush_allows_mux_with_probe_ack() {
5014        let area = Rect::new(0, 0, 10, 5);
5015        let mut placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
5016        placement.seq = "\x1bPqpayload\x1b\\".to_string();
5017
5018        let mut current = Buffer::empty(area);
5019        current.sprixels.push(placement);
5020        let previous = Buffer::empty(area);
5021        let support = GraphicsEmissionSupport {
5022            real_terminal: true,
5023            capabilities: Capabilities {
5024                sixel: true,
5025                ..Default::default()
5026            },
5027            force_kitty: false,
5028            force_sixel: false,
5029            force_iterm: false,
5030        };
5031
5032        let mut out = Vec::new();
5033        flush_sprixels_checked(&mut out, &current, &previous, 0, support).unwrap();
5034        assert!(
5035            contains_seq(&out, b"\x1bPqpayload\x1b\\"),
5036            "probe-acked Sixel should emit: {:?}",
5037            String::from_utf8_lossy(&out)
5038        );
5039    }
5040
5041    #[test]
5042    fn sprixel_no_text_change_emits_zero_bytes() {
5043        // A frame identical to the previous one must emit no sprixel bytes.
5044        let area = Rect::new(0, 0, 10, 5);
5045        let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
5046
5047        let mut current = Buffer::empty(area);
5048        current.sprixels.push(placement.clone());
5049        let mut previous = Buffer::empty(area);
5050        previous.sprixels.push(placement);
5051
5052        let mut out: Vec<u8> = Vec::new();
5053        flush_sprixels(&mut out, &current, &previous, 0).unwrap();
5054        assert!(out.is_empty(), "stable frame should emit no sprixel bytes");
5055    }
5056
5057    #[test]
5058    fn sprixel_first_frame_blits_once() {
5059        // No previous placement -> the graphic must be emitted exactly once.
5060        let area = Rect::new(0, 0, 10, 5);
5061        let mut current = Buffer::empty(area);
5062        current
5063            .sprixels
5064            .push(make_sprixel(vec![SprixelCell::Opaque; 4]));
5065        let previous = Buffer::empty(area);
5066
5067        let mut out: Vec<u8> = Vec::new();
5068        flush_sprixels(&mut out, &current, &previous, 0).unwrap();
5069        let s = String::from_utf8(out).unwrap();
5070        assert_eq!(s.matches("<SIXEL>").count(), 1);
5071    }
5072
5073    #[test]
5074    fn sprixel_text_in_opaque_cell_reblits_once() {
5075        // A text write over an opaque footprint cell annihilates the graphic.
5076        let area = Rect::new(0, 0, 10, 5);
5077        let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
5078
5079        let mut current = Buffer::empty(area);
5080        current.sprixels.push(placement.clone());
5081        // Write a glyph over the top-left footprint cell (1, 1).
5082        current.set_char(1, 1, 'X', Style::new());
5083
5084        let mut previous = Buffer::empty(area);
5085        previous.sprixels.push(placement);
5086
5087        let mut out: Vec<u8> = Vec::new();
5088        flush_sprixels(&mut out, &current, &previous, 0).unwrap();
5089        let s = String::from_utf8(out).unwrap();
5090        assert_eq!(
5091            s.matches("<SIXEL>").count(),
5092            1,
5093            "opaque-cell text write must re-blit the graphic exactly once"
5094        );
5095    }
5096
5097    #[test]
5098    fn sprixel_text_in_transparent_cell_does_not_reblit() {
5099        // The footprint marks (1, 1) transparent; a text write there must NOT
5100        // re-blit the graphic (the core #265 win).
5101        let area = Rect::new(0, 0, 10, 5);
5102        let cells = vec![
5103            SprixelCell::Transparent, // (1, 1)
5104            SprixelCell::Opaque,      // (2, 1)
5105            SprixelCell::Opaque,      // (1, 2)
5106            SprixelCell::Opaque,      // (2, 2)
5107        ];
5108        let placement = make_sprixel(cells);
5109
5110        let mut current = Buffer::empty(area);
5111        current.sprixels.push(placement.clone());
5112        current.set_char(1, 1, 'X', Style::new());
5113
5114        let mut previous = Buffer::empty(area);
5115        previous.sprixels.push(placement);
5116
5117        let mut out: Vec<u8> = Vec::new();
5118        flush_sprixels(&mut out, &current, &previous, 0).unwrap();
5119        assert!(
5120            out.is_empty(),
5121            "text in a transparent footprint cell must emit zero sprixel bytes"
5122        );
5123    }
5124
5125    #[test]
5126    fn sprixel_text_outside_footprint_does_not_reblit() {
5127        // A text write adjacent to (but outside) the footprint is free.
5128        let area = Rect::new(0, 0, 10, 5);
5129        let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
5130
5131        let mut current = Buffer::empty(area);
5132        current.sprixels.push(placement.clone());
5133        // (5, 0) is well outside the (1,1)-(2,2) footprint.
5134        current.set_char(5, 0, 'Z', Style::new());
5135
5136        let mut previous = Buffer::empty(area);
5137        previous.sprixels.push(placement);
5138
5139        let mut out: Vec<u8> = Vec::new();
5140        flush_sprixels(&mut out, &current, &previous, 0).unwrap();
5141        assert!(
5142            out.is_empty(),
5143            "text outside the footprint must not re-blit the graphic"
5144        );
5145    }
5146
5147    #[test]
5148    fn sprixel_position_change_reblits() {
5149        // Moving the graphic (same content, new x/y) must re-blit.
5150        let area = Rect::new(0, 0, 10, 5);
5151        let mut moved = make_sprixel(vec![SprixelCell::Opaque; 4]);
5152        let original = moved.clone();
5153        moved.x = 4;
5154
5155        let mut current = Buffer::empty(area);
5156        current.sprixels.push(moved);
5157        let mut previous = Buffer::empty(area);
5158        previous.sprixels.push(original);
5159
5160        let mut out: Vec<u8> = Vec::new();
5161        flush_sprixels(&mut out, &current, &previous, 0).unwrap();
5162        let s = String::from_utf8(out).unwrap();
5163        assert_eq!(s.matches("<SIXEL>").count(), 1);
5164    }
5165
5166    #[test]
5167    fn sprixel_content_change_reblits() {
5168        // Same position, different content hash -> re-blit.
5169        let area = Rect::new(0, 0, 10, 5);
5170        let mut recolored = make_sprixel(vec![SprixelCell::Opaque; 4]);
5171        let original = recolored.clone();
5172        recolored.content_hash = 0x1234;
5173        recolored.seq = "<SIXEL2>".to_string();
5174
5175        let mut current = Buffer::empty(area);
5176        current.sprixels.push(recolored);
5177        let mut previous = Buffer::empty(area);
5178        previous.sprixels.push(original);
5179
5180        let mut out: Vec<u8> = Vec::new();
5181        flush_sprixels(&mut out, &current, &previous, 0).unwrap();
5182        let s = String::from_utf8(out).unwrap();
5183        assert_eq!(s.matches("<SIXEL2>").count(), 1);
5184    }
5185
5186    #[test]
5187    fn sprixel_reblit_count_invariant_over_single_cell_writes() {
5188        // Invariant (issue #265 proptest spirit, exhaustive here): for a write
5189        // to a single footprint cell, the number of re-emitted sprixels is 0
5190        // iff that cell is Transparent, else 1.
5191        let area = Rect::new(0, 0, 10, 5);
5192        for (idx, (col, row)) in [(0u32, 0u32), (1, 0), (0, 1), (1, 1)]
5193            .into_iter()
5194            .enumerate()
5195        {
5196            for state in [
5197                SprixelCell::Opaque,
5198                SprixelCell::Mixed,
5199                SprixelCell::Transparent,
5200            ] {
5201                let mut cells = vec![SprixelCell::Opaque; 4];
5202                cells[idx] = state;
5203                let placement = make_sprixel(cells);
5204
5205                let mut current = Buffer::empty(area);
5206                current.sprixels.push(placement.clone());
5207                current.set_char(1 + col, 1 + row, 'A', Style::new());
5208
5209                let mut previous = Buffer::empty(area);
5210                previous.sprixels.push(placement);
5211
5212                let mut out: Vec<u8> = Vec::new();
5213                flush_sprixels(&mut out, &current, &previous, 0).unwrap();
5214                let count = String::from_utf8(out).unwrap().matches("<SIXEL>").count();
5215                let expected = if matches!(state, SprixelCell::Transparent) {
5216                    0
5217                } else {
5218                    1
5219                };
5220                assert_eq!(
5221                    count, expected,
5222                    "cell ({col},{row}) state {state:?}: expected {expected} re-blits"
5223                );
5224            }
5225        }
5226    }
5227
5228    // ---- v0.21.1 sprixel reblit-scan optimization regression ---------------
5229    //
5230    // These drive the hashed-key position lookup and the per-row clean+hash
5231    // shortcut with `recompute_line_hashes` engaged (the real `flush` ordering),
5232    // proving the optimization preserves the exact #265 re-blit semantics.
5233
5234    #[test]
5235    fn sprixel_unchanged_with_hashes_engaged_emits_zero_bytes() {
5236        // Regression: a steady frame (identical to previous) with per-row
5237        // digests refreshed must NOT re-blit. This exercises the per-row
5238        // clean+hash shortcut: every footprint row is clean and hash-matched, so
5239        // the per-cell scan is skipped and nothing is emitted.
5240        let area = Rect::new(0, 0, 10, 5);
5241        let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
5242
5243        let mut current = Buffer::empty(area);
5244        current.sprixels.push(placement.clone());
5245        let mut previous = Buffer::empty(area);
5246        previous.sprixels.push(placement);
5247
5248        // Match `Terminal::flush`: refresh digests before the sprixel pass.
5249        current.recompute_line_hashes();
5250        previous.recompute_line_hashes();
5251        // Sanity: the footprint rows are clean and hash-identical, so the
5252        // shortcut is the path actually taken.
5253        assert!(current.row_clean(1) && current.row_clean(2));
5254        assert_eq!(current.row_hash(1), previous.row_hash(1));
5255
5256        let mut out: Vec<u8> = Vec::new();
5257        flush_sprixels(&mut out, &current, &previous, 0).unwrap();
5258        assert!(
5259            out.is_empty(),
5260            "unchanged sprixel must not be re-blitted (per-row shortcut)"
5261        );
5262    }
5263
5264    #[test]
5265    fn sprixel_changed_text_with_hashes_engaged_reblits_once() {
5266        // Regression: a text write over an opaque footprint cell must still
5267        // re-blit exactly once even with digests refreshed. The touched row is
5268        // dirty (or hash-mismatched), so the shortcut correctly does NOT skip it
5269        // and the per-cell annihilation scan fires.
5270        let area = Rect::new(0, 0, 10, 5);
5271        let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
5272
5273        let mut current = Buffer::empty(area);
5274        current.sprixels.push(placement.clone());
5275        current.set_char(1, 1, 'X', Style::new());
5276        let mut previous = Buffer::empty(area);
5277        previous.sprixels.push(placement);
5278
5279        current.recompute_line_hashes();
5280        previous.recompute_line_hashes();
5281        // The footprint's top row differs from the previous frame.
5282        assert_ne!(current.row_hash(1), previous.row_hash(1));
5283
5284        let mut out: Vec<u8> = Vec::new();
5285        flush_sprixels(&mut out, &current, &previous, 0).unwrap();
5286        let s = String::from_utf8(out).unwrap();
5287        assert_eq!(
5288            s.matches("<SIXEL>").count(),
5289            1,
5290            "annihilating text write must re-blit exactly once"
5291        );
5292    }
5293
5294    #[test]
5295    fn sprixel_changed_text_in_transparent_cell_with_hashes_does_not_reblit() {
5296        // Regression edge case: even though the touched row is dirty/hash-mismatched
5297        // (so the per-row shortcut does NOT skip it), a write landing only on a
5298        // Transparent footprint cell must still emit zero bytes — the per-cell
5299        // damage matrix governs, exactly as in the unoptimized path.
5300        let area = Rect::new(0, 0, 10, 5);
5301        let cells = vec![
5302            SprixelCell::Transparent, // (1, 1)
5303            SprixelCell::Opaque,      // (2, 1)
5304            SprixelCell::Opaque,      // (1, 2)
5305            SprixelCell::Opaque,      // (2, 2)
5306        ];
5307        let placement = make_sprixel(cells);
5308
5309        let mut current = Buffer::empty(area);
5310        current.sprixels.push(placement.clone());
5311        current.set_char(1, 1, 'X', Style::new());
5312        let mut previous = Buffer::empty(area);
5313        previous.sprixels.push(placement);
5314
5315        current.recompute_line_hashes();
5316        previous.recompute_line_hashes();
5317
5318        let mut out: Vec<u8> = Vec::new();
5319        flush_sprixels(&mut out, &current, &previous, 0).unwrap();
5320        assert!(
5321            out.is_empty(),
5322            "transparent-cell text write must not re-blit even with hashes engaged"
5323        );
5324    }
5325
5326    #[test]
5327    fn sprixel_key_matches_partial_eq_contract() {
5328        // The hashed identity key must agree with `SprixelPlacement: PartialEq`:
5329        // equal placements share a key; any field the PartialEq compares
5330        // produces a distinct key.
5331        let base = make_sprixel(vec![SprixelCell::Opaque; 4]);
5332        assert_eq!(sprixel_key(&base), sprixel_key(&base.clone()));
5333
5334        let mut moved = base.clone();
5335        moved.x = 7;
5336        assert_ne!(sprixel_key(&base), sprixel_key(&moved));
5337
5338        let mut recolored = base.clone();
5339        recolored.content_hash = 0x9999;
5340        assert_ne!(sprixel_key(&base), sprixel_key(&recolored));
5341
5342        // The damage matrix is excluded from both PartialEq and the key.
5343        let mut annihilated = base.clone();
5344        annihilated.cells = vec![SprixelCell::Annihilated; 4];
5345        assert_eq!(sprixel_key(&base), sprixel_key(&annihilated));
5346        assert_eq!(base, annihilated);
5347    }
5348
5349    #[test]
5350    fn sprixel_multi_placement_only_changed_one_reblits() {
5351        // With several stacked sprixels, moving one must re-blit only that one;
5352        // the others (clean, hash-matched) stay silent. Exercises the hash-set
5353        // position lookup across multiple placements.
5354        let area = Rect::new(0, 0, 10, 9);
5355        let mut current = Buffer::empty(area);
5356        let mut previous = Buffer::empty(area);
5357        for i in 0..3u32 {
5358            let p = SprixelPlacement {
5359                content_hash: 0x100 + i as u64,
5360                seq: format!("<S{i}>"),
5361                x: 0,
5362                y: i * 3,
5363                cols: 2,
5364                rows: 2,
5365                cells: vec![SprixelCell::Opaque; 4],
5366            };
5367            current.sprixels.push(p.clone());
5368            previous.sprixels.push(p);
5369        }
5370        // Move only the middle sprixel.
5371        current.sprixels[1].x = 5;
5372
5373        current.recompute_line_hashes();
5374        previous.recompute_line_hashes();
5375
5376        let mut out: Vec<u8> = Vec::new();
5377        flush_sprixels(&mut out, &current, &previous, 0).unwrap();
5378        let s = String::from_utf8(out).unwrap();
5379        assert_eq!(s.matches("<S0>").count(), 0);
5380        assert_eq!(
5381            s.matches("<S1>").count(),
5382            1,
5383            "only the moved sprixel reblits"
5384        );
5385        assert_eq!(s.matches("<S2>").count(), 0);
5386    }
5387
5388    #[test]
5389    fn bench_sprixel_fixture_steady_state_emits_nothing() {
5390        // The bench fixture must represent a steady frame (no re-blit) so it
5391        // measures the no-damage scan cost. Guards against the wrapper silently
5392        // emitting work.
5393        let fixture = __bench_new_sprixel_fixture(4);
5394        assert_eq!(fixture.len(), 4);
5395        assert!(!fixture.is_empty());
5396        let mut out: Vec<u8> = Vec::new();
5397        fixture.flush(&mut out, 0).unwrap();
5398        assert!(
5399            out.is_empty(),
5400            "steady-state bench fixture re-blits nothing"
5401        );
5402    }
5403}