Skip to main content

slt/
terminal.rs

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