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
1022/// Fullscreen crossterm terminal backend: owns raw mode + the alternate
1023/// screen, double-buffers cells, and flushes only the diff each frame.
1024///
1025/// Exposed (issue #278) so external integrations can drive SLT's rendering
1026/// with their own event loop instead of reimplementing the backend. Pair with
1027/// [`crate::event::from_crossterm`] to translate input. The built-in
1028/// [`crate::run`] entry point uses this same type internally.
1029pub struct Terminal {
1030    stdout: Sink,
1031    current: Buffer,
1032    previous: Buffer,
1033    cursor_visible: bool,
1034    session: TerminalSessionGuard,
1035    color_depth: ColorDepth,
1036    pub(crate) theme_bg: Option<Color>,
1037    kitty_mgr: KittyImageManager,
1038    /// Reused run-coalescing scratch for `flush_buffer_diff` (issue #269). Its
1039    /// capacity persists across frames so the hot flush loop never allocates a
1040    /// fresh `String` per call.
1041    run_buf: String,
1042}
1043
1044/// Inline crossterm terminal backend: renders into a fixed-height region
1045/// below the cursor instead of taking over the whole screen.
1046///
1047/// Like [`Terminal`], exposed (issue #278) for custom integrations. Backs the
1048/// [`crate::run_inline`] entry point.
1049pub struct InlineTerminal {
1050    stdout: Sink,
1051    current: Buffer,
1052    previous: Buffer,
1053    cursor_visible: bool,
1054    session: TerminalSessionGuard,
1055    height: u32,
1056    start_row: u16,
1057    reserved: bool,
1058    color_depth: ColorDepth,
1059    pub(crate) theme_bg: Option<Color>,
1060    kitty_mgr: KittyImageManager,
1061    /// Reused run-coalescing scratch for `flush_buffer_diff` (issue #269).
1062    run_buf: String,
1063}
1064
1065/// Initial capacity for the reused per-frame run-coalescing buffer. Sized to
1066/// comfortably hold a full wide terminal row of multi-byte graphemes so the
1067/// allocation is paid once at construction, never per frame.
1068const RUN_BUF_INITIAL_CAPACITY: usize = 4096;
1069
1070#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1071enum TerminalSessionMode {
1072    Fullscreen,
1073    Inline,
1074}
1075
1076#[derive(Debug, Clone, Copy)]
1077struct TerminalSessionGuard {
1078    mode: TerminalSessionMode,
1079    mouse_enabled: bool,
1080    kitty_keyboard: bool,
1081    report_all_keys: bool,
1082    /// When `true`, the guard never touched real raw-mode / terminal state
1083    /// (PTY test harness path). `restore` then becomes a no-op so dropping a
1084    /// captured-sink `Terminal` does not call `disable_raw_mode` or emit
1085    /// teardown escapes into the byte capture. Always `false` on the
1086    /// production `enter` path.
1087    harness: bool,
1088}
1089
1090impl TerminalSessionGuard {
1091    fn enter(
1092        mode: TerminalSessionMode,
1093        stdout: &mut impl Write,
1094        mouse_enabled: bool,
1095        kitty_keyboard: bool,
1096        report_all_keys: bool,
1097    ) -> io::Result<Self> {
1098        let guard = Self {
1099            mode,
1100            mouse_enabled,
1101            kitty_keyboard,
1102            report_all_keys,
1103            harness: false,
1104        };
1105
1106        terminal::enable_raw_mode()?;
1107        if let Err(err) = write_session_enter(stdout, &guard) {
1108            guard.restore(stdout, false);
1109            return Err(err);
1110        }
1111
1112        // Issue #264: run the one-shot DA1/DA2/XTGETTCAP capability probe at
1113        // session enter, while raw mode is active so the replies are readable.
1114        // `capabilities()` caches in a `OnceLock`, so the resume re-enter path
1115        // never re-probes. Never runs on the PTY-harness path (`harness` is
1116        // always `false` here, but resume/harness re-entries go through
1117        // `write_session_enter` directly, not `enter`).
1118        let _ = capabilities();
1119
1120        Ok(guard)
1121    }
1122
1123    fn restore(&self, stdout: &mut impl Write, inline_reserved: bool) {
1124        // PTY harness guard: nothing was ever entered, so nothing to restore.
1125        if self.harness {
1126            return;
1127        }
1128        if self.kitty_keyboard {
1129            use crossterm::event::PopKeyboardEnhancementFlags;
1130            let _ = execute!(stdout, PopKeyboardEnhancementFlags);
1131        }
1132        if self.mouse_enabled {
1133            let _ = execute!(stdout, DisableMouseCapture);
1134        }
1135        let _ = execute!(stdout, DisableFocusChange);
1136        let _ = write_session_cleanup(stdout, self.mode, inline_reserved);
1137        let _ = terminal::disable_raw_mode();
1138    }
1139}
1140
1141impl Terminal {
1142    /// Construct a fullscreen terminal backend; enters raw mode and the
1143    /// alternate screen and optionally enables mouse capture and the
1144    /// kitty keyboard protocol. When `report_all_keys` is set (and
1145    /// `kitty_keyboard` is too), bare modifier presses are reported.
1146    pub fn new(
1147        mouse: bool,
1148        kitty_keyboard: bool,
1149        report_all_keys: bool,
1150        color_depth: ColorDepth,
1151    ) -> io::Result<Self> {
1152        let (cols, rows) = terminal::size()?;
1153        let area = Rect::new(0, 0, cols as u32, rows as u32);
1154
1155        let mut raw = io::stdout();
1156        let session = TerminalSessionGuard::enter(
1157            TerminalSessionMode::Fullscreen,
1158            &mut raw,
1159            mouse,
1160            kitty_keyboard,
1161            report_all_keys,
1162        )?;
1163
1164        Ok(Self {
1165            stdout: Sink::Stdout(BufWriter::with_capacity(65536, raw)),
1166            current: Buffer::empty(area),
1167            previous: Buffer::empty(area),
1168            cursor_visible: false,
1169            session,
1170            color_depth,
1171            theme_bg: None,
1172            kitty_mgr: KittyImageManager::new(),
1173            run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
1174        })
1175    }
1176
1177    /// Return the fullscreen terminal's current `(cols, rows)`.
1178    pub fn size(&self) -> (u32, u32) {
1179        (self.current.area.width, self.current.area.height)
1180    }
1181
1182    /// Mutable access to the back buffer used by the next render pass.
1183    pub fn buffer_mut(&mut self) -> &mut Buffer {
1184        &mut self.current
1185    }
1186
1187    /// Diff the back buffer against the front buffer, write the changed
1188    /// cells to stdout under a synchronized-output guard, then swap
1189    /// front and back buffers.
1190    pub fn flush(&mut self) -> io::Result<()> {
1191        if self.current.area.width < self.previous.area.width {
1192            execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
1193        }
1194
1195        // Synchronized output (BSU/ESU) is gated on the DECRQM ?2026 probe
1196        // (v0.21.1): emit unless the terminal definitively reported the mode
1197        // unrecognized. A silent / headless probe keeps emitting as before.
1198        let sync_guard = should_emit_synchronized_update();
1199        if sync_guard {
1200            queue!(self.stdout, BeginSynchronizedUpdate)?;
1201        }
1202        // Issue #171: refresh both buffers' per-row digests so the per-row
1203        // skip inside `flush_buffer_diff` can short-circuit unchanged rows.
1204        // `previous` only needs a recompute when the prior frame mutated
1205        // it (e.g. after a swap); cheap when nothing's dirty.
1206        self.current.recompute_line_hashes();
1207        self.previous.recompute_line_hashes();
1208        flush_buffer_diff(
1209            &mut self.stdout,
1210            &self.current,
1211            &self.previous,
1212            self.color_depth,
1213            0,
1214            &mut self.run_buf,
1215        )?;
1216
1217        // Kitty graphics: structured image management with IDs and compression.
1218        // Full-screen mode has no row offset (issue #206).
1219        self.kitty_mgr
1220            .flush(&mut self.stdout, &self.current.kitty_placements, 0)?;
1221
1222        // Generic raw passthrough sequences (non-sprixel) — simple diff.
1223        flush_raw_sequences(&mut self.stdout, &self.current, &self.previous, 0)?;
1224
1225        // Sprixels (sixel / iTerm2) — per-cell damage-tracked re-blit (#265).
1226        flush_sprixels(&mut self.stdout, &self.current, &self.previous, 0)?;
1227
1228        if sync_guard {
1229            queue!(self.stdout, EndSynchronizedUpdate)?;
1230        }
1231        flush_cursor(
1232            &mut self.stdout,
1233            &mut self.cursor_visible,
1234            self.current.cursor_pos(),
1235            0,
1236            None,
1237        )?;
1238
1239        self.stdout.flush()?;
1240
1241        std::mem::swap(&mut self.current, &mut self.previous);
1242        if let Some(bg) = self.theme_bg {
1243            self.current.reset_with_bg(bg);
1244        } else {
1245            self.current.reset();
1246        }
1247        Ok(())
1248    }
1249
1250    /// Re-query the terminal size and resize the front and back buffers
1251    /// to match. Called from the SIGWINCH handler.
1252    pub fn handle_resize(&mut self) -> io::Result<()> {
1253        let (cols, rows) = terminal::size()?;
1254        let area = Rect::new(0, 0, cols as u32, rows as u32);
1255        self.current.resize(area);
1256        self.previous.resize(area);
1257        execute!(
1258            self.stdout,
1259            terminal::Clear(terminal::ClearType::All),
1260            cursor::MoveTo(0, 0)
1261        )?;
1262        Ok(())
1263    }
1264}
1265
1266#[cfg(any(test, feature = "pty-test"))]
1267impl Terminal {
1268    /// Construct a fullscreen [`Terminal`] whose flush pipeline targets an
1269    /// in-process byte capture instead of stdout.
1270    ///
1271    /// Used **only** by the PTY test harness ([`crate::PtyBackend`]): the
1272    /// production [`Terminal::new`] / [`crate::run`] path is unchanged and
1273    /// still binds `BufWriter<Stdout>`. No raw mode is entered and no session
1274    /// escapes are emitted, so this can run on a headless CI runner with no
1275    /// TTY. The emitted bytes — SGR runs, OSC 8, Sixel, Kitty graphics — flow
1276    /// through the exact same [`flush_buffer_diff`] / [`apply_style_delta`] /
1277    /// Sixel / Kitty emitters that a real terminal sees.
1278    ///
1279    /// `color_depth` selects the SGR encoding (truecolor vs 256-color etc.)
1280    /// exercised by the flush, mirroring [`Terminal::new`]'s argument.
1281    pub(crate) fn with_sink(width: u32, height: u32, color_depth: ColorDepth) -> Self {
1282        let area = Rect::new(0, 0, width, height);
1283        Self {
1284            stdout: Sink::Capture(Vec::new()),
1285            current: Buffer::empty(area),
1286            previous: Buffer::empty(area),
1287            cursor_visible: false,
1288            session: TerminalSessionGuard {
1289                mode: TerminalSessionMode::Fullscreen,
1290                mouse_enabled: false,
1291                kitty_keyboard: false,
1292                report_all_keys: false,
1293                harness: true,
1294            },
1295            color_depth,
1296            theme_bg: None,
1297            kitty_mgr: KittyImageManager::new(),
1298            run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
1299        }
1300    }
1301
1302    /// Drain and return the bytes captured by a [`with_sink`](Terminal::with_sink)
1303    /// terminal since the last call, resetting the capture buffer.
1304    ///
1305    /// Panics if this terminal is not a captured-sink (harness) terminal.
1306    pub(crate) fn take_sink_bytes(&mut self) -> Vec<u8> {
1307        match &mut self.stdout {
1308            Sink::Capture(v) => std::mem::take(v),
1309            Sink::Stdout(_) => panic!("take_sink_bytes called on a non-capture Terminal"),
1310        }
1311    }
1312}
1313
1314impl crate::Backend for Terminal {
1315    fn size(&self) -> (u32, u32) {
1316        Terminal::size(self)
1317    }
1318
1319    fn buffer_mut(&mut self) -> &mut Buffer {
1320        Terminal::buffer_mut(self)
1321    }
1322
1323    fn flush(&mut self) -> io::Result<()> {
1324        Terminal::flush(self)
1325    }
1326}
1327
1328impl InlineTerminal {
1329    /// Construct an inline terminal backend that renders `height` rows
1330    /// below the current cursor without entering the alternate screen.
1331    /// Optionally enables mouse capture and the kitty keyboard protocol.
1332    /// When `report_all_keys` is set (and `kitty_keyboard` is too), bare
1333    /// modifier presses are reported.
1334    pub fn new(
1335        height: u32,
1336        mouse: bool,
1337        kitty_keyboard: bool,
1338        report_all_keys: bool,
1339        color_depth: ColorDepth,
1340    ) -> io::Result<Self> {
1341        let (cols, _) = terminal::size()?;
1342        let area = Rect::new(0, 0, cols as u32, height);
1343
1344        let mut raw = io::stdout();
1345        let session = TerminalSessionGuard::enter(
1346            TerminalSessionMode::Inline,
1347            &mut raw,
1348            mouse,
1349            kitty_keyboard,
1350            report_all_keys,
1351        )?;
1352
1353        let (_, cursor_row) = match cursor::position() {
1354            Ok(pos) => pos,
1355            Err(err) => {
1356                session.restore(&mut raw, false);
1357                return Err(err);
1358            }
1359        };
1360        Ok(Self {
1361            stdout: Sink::Stdout(BufWriter::with_capacity(65536, raw)),
1362            current: Buffer::empty(area),
1363            previous: Buffer::empty(area),
1364            cursor_visible: false,
1365            session,
1366            height,
1367            start_row: cursor_row,
1368            reserved: false,
1369            color_depth,
1370            theme_bg: None,
1371            kitty_mgr: KittyImageManager::new(),
1372            run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
1373        })
1374    }
1375
1376    /// Return the inline terminal's current `(cols, rows)`.
1377    pub fn size(&self) -> (u32, u32) {
1378        (self.current.area.width, self.current.area.height)
1379    }
1380
1381    /// Mutable access to the back buffer used by the next render pass.
1382    pub fn buffer_mut(&mut self) -> &mut Buffer {
1383        &mut self.current
1384    }
1385
1386    /// Diff the back buffer against the front buffer, write changed
1387    /// cells to stdout under a synchronized-output guard at the
1388    /// inline rows reserved below the cursor, then swap buffers.
1389    pub fn flush(&mut self) -> io::Result<()> {
1390        if self.current.area.width < self.previous.area.width {
1391            execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
1392        }
1393
1394        // Synchronized output (BSU/ESU) is gated on the DECRQM ?2026 probe
1395        // (v0.21.1); see `Terminal::flush`. Silent / headless keeps emitting.
1396        let sync_guard = should_emit_synchronized_update();
1397        if sync_guard {
1398            queue!(self.stdout, BeginSynchronizedUpdate)?;
1399        }
1400
1401        if !self.reserved {
1402            queue!(self.stdout, cursor::MoveToColumn(0))?;
1403            for _ in 0..self.height {
1404                queue!(self.stdout, Print("\n"))?;
1405            }
1406            self.reserved = true;
1407
1408            let (_, rows) = terminal::size()?;
1409            let bottom = self.start_row.saturating_add(sat_u16(self.height));
1410            if bottom > rows {
1411                self.start_row = rows.saturating_sub(sat_u16(self.height));
1412            }
1413        }
1414        let row_offset = self.start_row as u32;
1415        // Issue #171: refresh per-row digests before the diff so the
1416        // unchanged-row skip can fire (same call shape as `Terminal::flush`).
1417        self.current.recompute_line_hashes();
1418        self.previous.recompute_line_hashes();
1419        flush_buffer_diff(
1420            &mut self.stdout,
1421            &self.current,
1422            &self.previous,
1423            self.color_depth,
1424            row_offset,
1425            &mut self.run_buf,
1426        )?;
1427
1428        // Kitty graphics: structured image management with IDs and compression.
1429        // Issue #206: pass `row_offset` instead of materializing a translated
1430        // `Vec<KittyPlacement>` copy — `KittyImageManager::flush` applies the
1431        // offset arithmetically at point of use and stores post-offset y in
1432        // `prev_placements` for the next frame's diff.
1433        self.kitty_mgr
1434            .flush(&mut self.stdout, &self.current.kitty_placements, row_offset)?;
1435
1436        // Generic raw passthrough sequences (non-sprixel) — simple diff.
1437        flush_raw_sequences(&mut self.stdout, &self.current, &self.previous, row_offset)?;
1438
1439        // Sprixels (sixel / iTerm2) — per-cell damage-tracked re-blit (#265).
1440        flush_sprixels(&mut self.stdout, &self.current, &self.previous, row_offset)?;
1441
1442        if sync_guard {
1443            queue!(self.stdout, EndSynchronizedUpdate)?;
1444        }
1445        let fallback_row = row_offset + self.height.saturating_sub(1);
1446        flush_cursor(
1447            &mut self.stdout,
1448            &mut self.cursor_visible,
1449            self.current.cursor_pos(),
1450            row_offset,
1451            Some(fallback_row),
1452        )?;
1453
1454        self.stdout.flush()?;
1455
1456        std::mem::swap(&mut self.current, &mut self.previous);
1457        reset_current_buffer(&mut self.current, self.theme_bg);
1458        Ok(())
1459    }
1460
1461    /// Re-query the terminal size and resize the inline buffers to match
1462    /// the new column count, preserving the inline row height.
1463    pub fn handle_resize(&mut self) -> io::Result<()> {
1464        let (cols, _) = terminal::size()?;
1465        let area = Rect::new(0, 0, cols as u32, self.height);
1466        self.current.resize(area);
1467        self.previous.resize(area);
1468        execute!(
1469            self.stdout,
1470            terminal::Clear(terminal::ClearType::All),
1471            cursor::MoveTo(0, 0)
1472        )?;
1473        Ok(())
1474    }
1475}
1476
1477impl crate::Backend for InlineTerminal {
1478    fn size(&self) -> (u32, u32) {
1479        InlineTerminal::size(self)
1480    }
1481
1482    fn buffer_mut(&mut self) -> &mut Buffer {
1483        InlineTerminal::buffer_mut(self)
1484    }
1485
1486    fn flush(&mut self) -> io::Result<()> {
1487        InlineTerminal::flush(self)
1488    }
1489}
1490
1491impl Drop for Terminal {
1492    fn drop(&mut self) {
1493        // Clean up Kitty images before leaving alternate screen
1494        let _ = self.kitty_mgr.delete_all(&mut self.stdout);
1495        let _ = self.stdout.flush();
1496        self.session.restore(&mut self.stdout, false);
1497    }
1498}
1499
1500impl Drop for InlineTerminal {
1501    fn drop(&mut self) {
1502        let _ = self.kitty_mgr.delete_all(&mut self.stdout);
1503        let _ = self.stdout.flush();
1504        self.session.restore(&mut self.stdout, self.reserved);
1505    }
1506}
1507
1508mod selection;
1509pub(crate) use selection::{SelectionState, apply_selection_overlay, extract_selection_text};
1510#[cfg(test)]
1511pub(crate) use selection::{find_innermost_rect, normalize_selection};
1512
1513/// Detected terminal color scheme from OSC 11.
1514#[non_exhaustive]
1515#[cfg(feature = "crossterm")]
1516#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
1517#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1518pub enum ColorScheme {
1519    /// Dark background detected.
1520    Dark,
1521    /// Light background detected.
1522    Light,
1523    /// Could not determine the scheme.
1524    Unknown,
1525}
1526
1527/// Read an OSC-style reply (BEL- or ST-terminated), hard-bounded by `timeout`.
1528#[cfg(feature = "crossterm")]
1529fn read_osc_response(timeout: Duration) -> Option<String> {
1530    read_stdin_reply(timeout, osc_reply_complete)
1531}
1532
1533/// Query the terminal's background color via OSC 11 and return the detected scheme.
1534#[cfg(feature = "crossterm")]
1535#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
1536pub fn detect_color_scheme() -> ColorScheme {
1537    let mut stdout = io::stdout();
1538    if write!(stdout, "\x1b]11;?\x07").is_err() {
1539        return ColorScheme::Unknown;
1540    }
1541    if stdout.flush().is_err() {
1542        return ColorScheme::Unknown;
1543    }
1544
1545    let Some(response) = read_osc_response(Duration::from_millis(100)) else {
1546        return ColorScheme::Unknown;
1547    };
1548
1549    parse_osc11_response(&response)
1550}
1551
1552#[cfg(feature = "crossterm")]
1553pub(crate) fn parse_osc11_response(response: &str) -> ColorScheme {
1554    let Some(rgb_pos) = response.find("rgb:") else {
1555        return ColorScheme::Unknown;
1556    };
1557
1558    let payload = &response[rgb_pos + 4..];
1559    let end = payload
1560        .find(['\x07', '\x1b', '\r', '\n', ' ', '\t'])
1561        .unwrap_or(payload.len());
1562    let rgb = &payload[..end];
1563
1564    let mut channels = rgb.split('/');
1565    let (Some(r), Some(g), Some(b), None) = (
1566        channels.next(),
1567        channels.next(),
1568        channels.next(),
1569        channels.next(),
1570    ) else {
1571        return ColorScheme::Unknown;
1572    };
1573
1574    fn parse_channel(channel: &str) -> Option<f64> {
1575        if channel.is_empty() || channel.len() > 4 {
1576            return None;
1577        }
1578        let value = u16::from_str_radix(channel, 16).ok()? as f64;
1579        let max = ((1u32 << (channel.len() * 4)) - 1) as f64;
1580        if max <= 0.0 {
1581            return None;
1582        }
1583        Some((value / max).clamp(0.0, 1.0))
1584    }
1585
1586    let (Some(r), Some(g), Some(b)) = (parse_channel(r), parse_channel(g), parse_channel(b)) else {
1587        return ColorScheme::Unknown;
1588    };
1589
1590    let luminance = 0.299 * r + 0.587 * g + 0.114 * b;
1591    if luminance < 0.5 {
1592        ColorScheme::Dark
1593    } else {
1594        ColorScheme::Light
1595    }
1596}
1597
1598pub(crate) fn base64_encode(input: &[u8]) -> String {
1599    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1600    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
1601    for chunk in input.chunks(3) {
1602        let b0 = chunk[0] as u32;
1603        let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
1604        let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
1605        let triple = (b0 << 16) | (b1 << 8) | b2;
1606        out.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
1607        out.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
1608        out.push(if chunk.len() > 1 {
1609            CHARS[((triple >> 6) & 0x3F) as usize] as char
1610        } else {
1611            '='
1612        });
1613        out.push(if chunk.len() > 2 {
1614            CHARS[(triple & 0x3F) as usize] as char
1615        } else {
1616            '='
1617        });
1618    }
1619    out
1620}
1621
1622pub(crate) fn copy_to_clipboard(w: &mut impl Write, text: &str) -> io::Result<()> {
1623    let encoded = base64_encode(text.as_bytes());
1624    write!(w, "\x1b]52;c;{encoded}\x1b\\")?;
1625    w.flush()
1626}
1627
1628#[cfg(feature = "crossterm")]
1629fn parse_osc52_response(response: &str) -> Option<String> {
1630    let osc_pos = response.find("]52;")?;
1631    let body = &response[osc_pos + 4..];
1632    let semicolon = body.find(';')?;
1633    let payload = &body[semicolon + 1..];
1634
1635    let end = payload
1636        .find("\x1b\\")
1637        .or_else(|| payload.find('\x07'))
1638        .unwrap_or(payload.len());
1639    let encoded = payload[..end].trim();
1640    if encoded.is_empty() || encoded == "?" {
1641        return None;
1642    }
1643
1644    base64_decode(encoded)
1645}
1646
1647/// Read clipboard contents via an OSC 52 terminal query.
1648///
1649/// Writes the OSC 52 read request (`ESC ] 52 ; c ; ? BEL`) to stdout, then
1650/// blocks reading the terminal's reply from stdin for up to ~200 ms. Returns
1651/// the decoded clipboard text, or `None` if the terminal does not answer, the
1652/// reply is empty, or it cannot be decoded. Many terminals disable OSC 52 reads
1653/// by default for security, in which case this always returns `None`.
1654///
1655/// # Note
1656///
1657/// This call reads the **same stdin** the [`run`](crate::run) event loop polls,
1658/// **synchronously and outside** the loop's own event dispatch. That creates a
1659/// typeahead-swallow hazard: during the blocking read window, any bytes the user
1660/// types — and any other terminal report in flight (mouse, focus, paste, a
1661/// different OSC reply) — land in this function's byte reader instead of the
1662/// event queue. Keystrokes consumed here are silently lost, and a foreign report
1663/// interleaved with the OSC 52 reply can corrupt parsing so the read returns
1664/// `None`. There is no locking between this reader and the run loop's poll, so
1665/// calling it concurrently from another thread while the loop is running races
1666/// on stdin.
1667///
1668/// Recommended usage:
1669///   * Call it from the main thread, **not** from a spawned thread, and never
1670///     concurrently with a running [`run`](crate::run) loop on another thread.
1671///   * Trigger it only in direct response to an explicit user action (e.g. a
1672///     paste keybinding) and keep the window brief, so the typeahead lost to the
1673///     blocking read is bounded to that moment.
1674///   * Prefer the OS clipboard via a dedicated crate when reliable, race-free
1675///     clipboard reads are required; reserve this for the no-dependency,
1676///     terminal-only fallback.
1677///   * For *writing* the clipboard there is no such hazard — that path only
1678///     emits bytes and never reads stdin.
1679#[cfg(feature = "crossterm")]
1680#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
1681pub fn read_clipboard() -> Option<String> {
1682    let mut stdout = io::stdout();
1683    write!(stdout, "\x1b]52;c;?\x07").ok()?;
1684    stdout.flush().ok()?;
1685
1686    let response = read_osc_response(Duration::from_millis(200))?;
1687    parse_osc52_response(&response)
1688}
1689
1690#[cfg(feature = "crossterm")]
1691fn base64_decode(input: &str) -> Option<String> {
1692    let mut filtered: Vec<u8> = input
1693        .bytes()
1694        .filter(|b| !matches!(b, b' ' | b'\n' | b'\r' | b'\t'))
1695        .collect();
1696
1697    match filtered.len() % 4 {
1698        0 => {}
1699        2 => filtered.extend_from_slice(b"=="),
1700        3 => filtered.push(b'='),
1701        _ => return None,
1702    }
1703
1704    fn decode_val(b: u8) -> Option<u8> {
1705        match b {
1706            b'A'..=b'Z' => Some(b - b'A'),
1707            b'a'..=b'z' => Some(b - b'a' + 26),
1708            b'0'..=b'9' => Some(b - b'0' + 52),
1709            b'+' => Some(62),
1710            b'/' => Some(63),
1711            _ => None,
1712        }
1713    }
1714
1715    let mut out = Vec::with_capacity((filtered.len() / 4) * 3);
1716    for chunk in filtered.chunks_exact(4) {
1717        let p2 = chunk[2] == b'=';
1718        let p3 = chunk[3] == b'=';
1719        if p2 && !p3 {
1720            return None;
1721        }
1722
1723        let v0 = decode_val(chunk[0])? as u32;
1724        let v1 = decode_val(chunk[1])? as u32;
1725        let v2 = if p2 { 0 } else { decode_val(chunk[2])? as u32 };
1726        let v3 = if p3 { 0 } else { decode_val(chunk[3])? as u32 };
1727
1728        let triple = (v0 << 18) | (v1 << 12) | (v2 << 6) | v3;
1729        out.push(((triple >> 16) & 0xFF) as u8);
1730        if !p2 {
1731            out.push(((triple >> 8) & 0xFF) as u8);
1732        }
1733        if !p3 {
1734            out.push((triple & 0xFF) as u8);
1735        }
1736    }
1737
1738    String::from_utf8(out).ok()
1739}
1740
1741#[allow(clippy::too_many_arguments)]
1742#[allow(unused_assignments)]
1743fn flush_buffer_diff(
1744    stdout: &mut impl Write,
1745    current: &Buffer,
1746    previous: &Buffer,
1747    color_depth: ColorDepth,
1748    row_offset: u32,
1749    run_buf: &mut String,
1750) -> io::Result<()> {
1751    // Run-coalescing: consecutive changed cells in the same row that share
1752    // `Style` + `hyperlink` + contiguous x-coordinates are emitted as a single
1753    // `Print(run)` after one cursor move and one style delta. This cuts the
1754    // number of `queue!` calls on a full redraw from O(cells) to
1755    // O(style-change boundaries), which is the dominant stdout write cost.
1756    //
1757    // A run is broken whenever:
1758    //   * style, hyperlink, or row changes,
1759    //   * the next cell is not at the expected next column (gap from skipped
1760    //     cells — unchanged, empty wide-char trailer, or end of row),
1761    //   * end-of-row (always flushed before descending to the next row).
1762    let mut last_style = Style::new();
1763    let mut first_style = true;
1764    let mut active_link: Option<&str> = None;
1765    let mut has_updates = false;
1766    // Where we believe the cursor currently sits — lets us skip a redundant
1767    // `MoveTo` when a new run starts exactly where the previous one ended
1768    // (e.g. split only by a style change on otherwise contiguous columns).
1769    let mut last_cursor: Option<(u32, u32)> = None;
1770
1771    // Active run state. `run_next_col` is the column the next cell must
1772    // occupy to extend the run; `run_open` guards the rest of the fields.
1773    // `run_buf` is hoisted to a caller-owned, reused buffer (issue #269): its
1774    // backing allocation persists across frames so the hot flush loop performs
1775    // no per-frame `String` allocation. Start clean but keep capacity.
1776    run_buf.clear();
1777    let mut run_abs_y: u32 = 0;
1778    let mut run_style: Style = Style::new();
1779    let mut run_link: Option<&str> = None;
1780    let mut run_next_col: u32 = 0;
1781    let mut run_open = false;
1782
1783    // Helper: flush the currently open run, if any. Emits a single `Print`
1784    // for the entire accumulated buffer; positioning, style, and OSC 8 were
1785    // already written when the run opened. Updates `last_cursor` to reflect
1786    // where the cursor ends up after the Print.
1787    macro_rules! flush_run {
1788        ($stdout:expr) => {
1789            if run_open {
1790                queue!($stdout, Print(&run_buf))?;
1791                last_cursor = Some((run_next_col, run_abs_y));
1792                run_buf.clear();
1793                run_open = false;
1794            }
1795        };
1796    }
1797
1798    for y in current.area.y..current.area.bottom() {
1799        // Issue #171: skip the per-cell scan for rows that were not touched
1800        // since the last hash refresh AND match the previous frame's
1801        // digest. Both conditions must hold:
1802        //   * `row_clean` rules out rows that received writes this frame
1803        //     even if those writes happened to land on identical cells.
1804        //   * The hash equality is the actual unchanged-row signal.
1805        // Falling through to the per-cell loop on either failure preserves
1806        // legacy behavior; the skip is a pure short-circuit.
1807        if current.row_clean(y)
1808            && current.row_hash(y).is_some()
1809            && current.row_hash(y) == previous.row_hash(y)
1810        {
1811            continue;
1812        }
1813        for x in current.area.x..current.area.right() {
1814            let cell = current.get(x, y);
1815            let prev = previous.get(x, y);
1816            if cell == prev || cell.symbol.is_empty() {
1817                // Gap — any open run on this row must be flushed.
1818                flush_run!(stdout);
1819                continue;
1820            }
1821
1822            let abs_y = row_offset + y;
1823            // Defense-in-depth: `Cell::hyperlink` is a public field that can
1824            // be written directly. `set_string_linked` pre-sanitizes, but a
1825            // direct write could still smuggle control bytes into the OSC 8
1826            // payload. Validate here before flushing to stdout.
1827            let cell_link = cell
1828                .hyperlink
1829                .as_deref()
1830                .filter(|u| crate::buffer::is_valid_osc8_url(u));
1831
1832            // Decide whether this cell extends the open run or starts a new one.
1833            let extends = run_open
1834                && run_abs_y == abs_y
1835                && run_next_col == x
1836                && run_style == cell.style
1837                && run_link == cell_link;
1838
1839            if !extends {
1840                flush_run!(stdout);
1841
1842                // Begin a new run. Emit positioning + style + OSC 8 header now
1843                // (before the Print bytes) so the resulting stream is a valid
1844                // SGR sequence exactly matching the per-cell flush.
1845                has_updates = true;
1846
1847                let need_move = last_cursor.is_none_or(|(lx, ly)| lx != x || ly != abs_y);
1848                if need_move {
1849                    queue!(stdout, cursor::MoveTo(sat_u16(x), sat_u16(abs_y)))?;
1850                }
1851
1852                if cell.style != last_style {
1853                    if first_style {
1854                        queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
1855                        apply_style(stdout, &cell.style, color_depth)?;
1856                        first_style = false;
1857                    } else {
1858                        apply_style_delta(stdout, &last_style, &cell.style, color_depth)?;
1859                    }
1860                    last_style = cell.style;
1861                }
1862
1863                if cell_link != active_link {
1864                    if let Some(url) = cell_link {
1865                        // Emit the OSC 8 open in three borrowed `Print`s instead
1866                        // of `format!`ing a throwaway `String` per link-state
1867                        // change (issue #269). The byte stream is identical to
1868                        // `"\x1b]8;;{url}\x07"`.
1869                        queue!(stdout, Print("\x1b]8;;"))?;
1870                        queue!(stdout, Print(url))?;
1871                        queue!(stdout, Print("\x07"))?;
1872                    } else {
1873                        queue!(stdout, Print("\x1b]8;;\x07"))?;
1874                    }
1875                    active_link = cell_link;
1876                }
1877
1878                run_open = true;
1879                run_abs_y = abs_y;
1880                run_style = cell.style;
1881                run_link = cell_link;
1882            }
1883
1884            // Append the cell's grapheme cluster (possibly multi-char when it
1885            // carries combining marks). Wide chars advance by their column
1886            // width so subsequent cells line up.
1887            run_buf.push_str(&cell.symbol);
1888            let char_width = UnicodeWidthStr::width(cell.symbol.as_str()).max(1) as u32;
1889            if char_width > 1 && cell.symbol.chars().any(|c| c == '\u{FE0F}') {
1890                // Emoji variation selector — terminal renders 2 cols but the
1891                // glyph often measures as 1; pad so the cursor ends up where
1892                // the next cell is drawn.
1893                run_buf.push(' ');
1894            }
1895            run_next_col = x + char_width;
1896        }
1897
1898        // End of row: flush whatever is buffered before moving to the next row.
1899        flush_run!(stdout);
1900    }
1901
1902    if has_updates {
1903        if active_link.is_some() {
1904            queue!(stdout, Print("\x1b]8;;\x07"))?;
1905        }
1906        queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
1907    }
1908
1909    Ok(())
1910}
1911
1912/// Benchmark-only entry point for the per-frame buffer flush.
1913///
1914/// Exposed so criterion benches under `benches/` (an external crate) can
1915/// measure the stdout-emit cost of the per-frame flush against a hermetic
1916/// `Vec<u8>` (or any `Write`) sink, without constructing a real terminal.
1917///
1918/// Not part of the stable API. Do not depend on this in application code —
1919/// prefer the real terminal backend ([`crate::run`]) or
1920/// [`TestBackend`](crate::TestBackend).
1921#[doc(hidden)]
1922pub fn __bench_flush_buffer_diff<W: Write>(
1923    w: &mut W,
1924    current: &Buffer,
1925    previous: &Buffer,
1926    color_depth: ColorDepth,
1927) -> io::Result<()> {
1928    // Own a local run buffer to keep the public bench signature stable
1929    // (issue #269); the real backends pass a reused field instead.
1930    let mut run_buf = String::with_capacity(RUN_BUF_INITIAL_CAPACITY);
1931    flush_buffer_diff(w, current, previous, color_depth, 0, &mut run_buf)
1932}
1933
1934/// Mutable-buffer variant of [`__bench_flush_buffer_diff`] (issue #171).
1935///
1936/// Refreshes per-row digests on both buffers before invoking
1937/// `flush_buffer_diff`, matching what the real `Terminal::flush` and
1938/// `InlineTerminal::flush` paths do. Benches that want to measure the
1939/// flush including the hash-refresh cost should use this entry point;
1940/// the immutable variant is preserved for backwards compatibility with
1941/// existing benches that own only `&Buffer`.
1942#[doc(hidden)]
1943pub fn __bench_flush_buffer_diff_mut<W: Write>(
1944    w: &mut W,
1945    current: &mut Buffer,
1946    previous: &mut Buffer,
1947    color_depth: ColorDepth,
1948) -> io::Result<()> {
1949    // Own a local run buffer to keep the public bench signature stable
1950    // (issue #269). Use `__bench_flush_buffer_diff_mut_with_buf` to exercise
1951    // cross-frame buffer reuse explicitly.
1952    let mut run_buf = String::with_capacity(RUN_BUF_INITIAL_CAPACITY);
1953    __bench_flush_buffer_diff_mut_with_buf(w, current, previous, color_depth, &mut run_buf)
1954}
1955
1956/// Reuse-aware variant of [`__bench_flush_buffer_diff_mut`] that threads a
1957/// caller-owned `run_buf` (issue #269), mirroring how the real backends carry
1958/// the buffer across frames. Refreshes per-row digests before the diff.
1959///
1960/// Not part of the stable API.
1961///
1962/// ```no_run
1963/// # use slt::{Buffer, Rect, ColorDepth, Style};
1964/// let area = Rect::new(0, 0, 8, 2);
1965/// let mut current = Buffer::empty(area);
1966/// let mut previous = Buffer::empty(area);
1967/// current.set_string(0, 0, "hi", Style::new());
1968/// let mut sink: Vec<u8> = Vec::new();
1969/// // The same `run_buf` can be passed across frames — its capacity persists.
1970/// let mut run_buf = String::with_capacity(4096);
1971/// slt::__bench_flush_buffer_diff_mut_with_buf(
1972///     &mut sink,
1973///     &mut current,
1974///     &mut previous,
1975///     ColorDepth::TrueColor,
1976///     &mut run_buf,
1977/// )
1978/// .unwrap();
1979/// ```
1980#[doc(hidden)]
1981pub fn __bench_flush_buffer_diff_mut_with_buf<W: Write>(
1982    w: &mut W,
1983    current: &mut Buffer,
1984    previous: &mut Buffer,
1985    color_depth: ColorDepth,
1986    run_buf: &mut String,
1987) -> io::Result<()> {
1988    current.recompute_line_hashes();
1989    previous.recompute_line_hashes();
1990    flush_buffer_diff(w, current, previous, color_depth, 0, run_buf)
1991}
1992
1993/// Opaque test fixture wrapping `KittyImageManager` + a placements list.
1994///
1995/// Returned by [`__bench_new_kitty_fixture`]. Internal types stay
1996/// `pub(crate)` — only the opaque struct crosses the crate boundary.
1997#[doc(hidden)]
1998pub struct __BenchKittyFixture {
1999    mgr: KittyImageManager,
2000    placements: Vec<KittyPlacement>,
2001}
2002
2003/// Build a self-contained kitty-flush fixture for the perf alloc suite
2004/// (issue #206). `n` is the number of distinct images.
2005#[doc(hidden)]
2006pub fn __bench_new_kitty_fixture(n: usize) -> __BenchKittyFixture {
2007    let mut placements = Vec::with_capacity(n);
2008    for i in 0..n {
2009        // 8x8 RGBA: 64 px * 4 bytes = 256 bytes.
2010        let mut rgba = vec![0u8; 256];
2011        // Vary contents per placement to give each a unique content_hash.
2012        rgba[0] = i as u8;
2013        let content_hash = crate::buffer::hash_rgba(&rgba);
2014        placements.push(KittyPlacement {
2015            content_hash,
2016            rgba: std::sync::Arc::new(rgba),
2017            src_width: 8,
2018            src_height: 8,
2019            x: (i as u32) * 4,
2020            y: (i as u32) * 2,
2021            cols: 4,
2022            rows: 2,
2023            crop_y: 0,
2024            crop_h: 0,
2025        });
2026    }
2027    __BenchKittyFixture {
2028        mgr: KittyImageManager::new(),
2029        placements,
2030    }
2031}
2032
2033impl __BenchKittyFixture {
2034    /// Strong-count snapshot of the inner `Arc<Vec<u8>>` for each placement.
2035    /// Used by the alloc-budget tests to confirm no extra Arc clones leak
2036    /// past the manager's stored `prev_placements`.
2037    #[doc(hidden)]
2038    pub fn rgba_strong_counts(&self) -> Vec<usize> {
2039        self.placements
2040            .iter()
2041            .map(|p| std::sync::Arc::strong_count(&p.rgba))
2042            .collect()
2043    }
2044
2045    /// Run the inline-mode flush path with the given row offset. Writes
2046    /// terminal escapes into `sink` and updates the internal manager state.
2047    #[doc(hidden)]
2048    pub fn flush_inline<W: Write>(&mut self, sink: &mut W, row_offset: u32) -> io::Result<()> {
2049        self.mgr.flush(sink, &self.placements, row_offset)
2050    }
2051
2052    /// Number of placements in this fixture.
2053    #[doc(hidden)]
2054    pub fn len(&self) -> usize {
2055        self.placements.len()
2056    }
2057
2058    /// Whether this fixture has zero placements.
2059    #[doc(hidden)]
2060    pub fn is_empty(&self) -> bool {
2061        self.placements.is_empty()
2062    }
2063}
2064
2065/// Benchmark-only entry point for the Kitty image flush path.
2066///
2067/// Builds an `n`-image fixture and runs [`KittyImageManager::flush`] once into
2068/// the supplied sink at `row_offset`, mirroring the [`__bench_flush_buffer_diff`]
2069/// free-function style. `KittyPlacement` / `KittyImageManager` are `pub(crate)`,
2070/// so an external bench crate cannot construct them directly — this wrapper owns
2071/// the construction and only the `Write` sink crosses the crate boundary.
2072///
2073/// Not part of the stable API.
2074#[doc(hidden)]
2075pub fn __bench_flush_kitty<W: Write>(sink: &mut W, n: usize, row_offset: u32) -> io::Result<()> {
2076    let mut fixture = __bench_new_kitty_fixture(n);
2077    fixture.flush_inline(sink, row_offset)
2078}
2079
2080/// Opaque test/bench fixture wrapping two `Buffer`s populated with structurally
2081/// identical sprixel placements, used to drive the [`flush_sprixels`] re-blit
2082/// path. `SprixelPlacement` is `pub(crate)`, so this fixture owns construction
2083/// and exposes only `Write`-based flush entry points across the crate boundary.
2084///
2085/// Returned by [`__bench_new_sprixel_fixture`].
2086#[doc(hidden)]
2087pub struct __BenchSprixelFixture {
2088    current: Buffer,
2089    previous: Buffer,
2090}
2091
2092/// Build a self-contained sprixel-reblit fixture for the perf suite (v0.21.1).
2093///
2094/// Creates `n` opaque sprixel placements laid out down the buffer and mirrors
2095/// them into both the current and previous frame so the steady-state flush
2096/// re-blits nothing. Per-row digests are refreshed (as the real `flush` does)
2097/// so the per-row clean+hash shortcut in [`sprixel_needs_reblit`] is exercised.
2098///
2099/// Not part of the stable API.
2100#[doc(hidden)]
2101pub fn __bench_new_sprixel_fixture(n: usize) -> __BenchSprixelFixture {
2102    use crate::buffer::{SprixelCell, SprixelPlacement};
2103
2104    // A buffer tall enough to stack `n` 2-row sprixels with a 1-row gap.
2105    let height = (n as u32 * 3).max(1);
2106    let area = Rect::new(0, 0, 8, height);
2107    let mut current = Buffer::empty(area);
2108    let mut previous = Buffer::empty(area);
2109
2110    for i in 0..n {
2111        let placement = SprixelPlacement {
2112            content_hash: 0x5000 + i as u64,
2113            seq: "<SIXEL>".to_string(),
2114            x: 0,
2115            y: i as u32 * 3,
2116            cols: 4,
2117            rows: 2,
2118            cells: vec![SprixelCell::Opaque; 8],
2119        };
2120        current.sprixels.push(placement.clone());
2121        previous.sprixels.push(placement);
2122    }
2123
2124    // Refresh digests so the per-row shortcut can fire, matching the real
2125    // `Terminal::flush` ordering (recompute happens before `flush_sprixels`).
2126    current.recompute_line_hashes();
2127    previous.recompute_line_hashes();
2128
2129    __BenchSprixelFixture { current, previous }
2130}
2131
2132// The bench fixture's inherent methods are reachable only once the crate root
2133// re-exports `__BenchSprixelFixture` (an integrator step listed in the release
2134// notes); until then the lib-target dead-code lint flags them, exactly as it
2135// would the already-shipped `__BenchKittyFixture` methods without their
2136// `lib.rs` re-export. They are also exercised by the in-crate tests below.
2137// Suppress the lint on the impl rather than gating the items behind `cfg(test)`,
2138// which would make them invisible to the external `benches/` crate they exist
2139// to serve.
2140#[allow(dead_code)]
2141impl __BenchSprixelFixture {
2142    /// Run [`flush_sprixels`] once, writing any re-blitted graphics into `sink`.
2143    /// A steady-state fixture emits nothing; this measures the no-damage scan
2144    /// cost (hash-set build + per-row shortcut) on the hot path.
2145    #[doc(hidden)]
2146    pub fn flush<W: Write>(&self, sink: &mut W, row_offset: u32) -> io::Result<()> {
2147        flush_sprixels(sink, &self.current, &self.previous, row_offset)
2148    }
2149
2150    /// Number of sprixel placements in this fixture.
2151    #[doc(hidden)]
2152    pub fn len(&self) -> usize {
2153        self.current.sprixels.len()
2154    }
2155
2156    /// Whether this fixture has zero placements.
2157    #[doc(hidden)]
2158    pub fn is_empty(&self) -> bool {
2159        self.current.sprixels.is_empty()
2160    }
2161}
2162
2163/// Benchmark-only entry point for the optimized sprixel re-blit scan (v0.21.1).
2164///
2165/// Builds an `n`-placement steady-state fixture and runs [`flush_sprixels`] once
2166/// into `sink` at `row_offset`, mirroring the [`__bench_flush_buffer_diff`]
2167/// free-function style. A steady frame re-blits nothing, so this measures the
2168/// no-damage scan cost (hashed-key build + per-row clean/hash shortcut). When
2169/// the fixture is empty the early-out fires and no work is done.
2170///
2171/// Not part of the stable API.
2172#[doc(hidden)]
2173pub fn __bench_flush_sprixels<W: Write>(sink: &mut W, n: usize, row_offset: u32) -> io::Result<()> {
2174    let fixture = __bench_new_sprixel_fixture(n);
2175    if fixture.is_empty() {
2176        return Ok(());
2177    }
2178    debug_assert_eq!(fixture.len(), n);
2179    fixture.flush(sink, row_offset)
2180}
2181
2182fn flush_raw_sequences(
2183    stdout: &mut impl Write,
2184    current: &Buffer,
2185    previous: &Buffer,
2186    row_offset: u32,
2187) -> io::Result<()> {
2188    if current.raw_sequences == previous.raw_sequences {
2189        return Ok(());
2190    }
2191
2192    for (x, y, seq) in &current.raw_sequences {
2193        queue!(
2194            stdout,
2195            cursor::MoveTo(sat_u16(*x), sat_u16(row_offset + *y)),
2196            Print(seq)
2197        )?;
2198    }
2199
2200    Ok(())
2201}
2202
2203/// Structural identity key for a [`crate::buffer::SprixelPlacement`], matching
2204/// its [`PartialEq`] contract (`content_hash`/`x`/`y`/`cols`/`rows`, damage
2205/// matrix excluded). Hashing this lets [`flush_sprixels`] answer "did an equal
2206/// placement exist last frame?" in O(1) instead of an O(n·m) linear scan.
2207type SprixelKey = (u64, u32, u32, u32, u32);
2208
2209/// Build the structural identity key for a placement.
2210#[inline]
2211fn sprixel_key(p: &crate::buffer::SprixelPlacement) -> SprixelKey {
2212    (p.content_hash, p.x, p.y, p.cols, p.rows)
2213}
2214
2215/// Decide whether a sprixel placement must be re-blitted this frame, applying
2216/// the per-cell damage matrix (issue #265).
2217///
2218/// Returns `true` when:
2219///   * the placement is new or its `(x, y, content_hash, cols, rows)` changed
2220///     (its key is absent from `prev_keys`, the precomputed set of last frame's
2221///     placement keys), OR
2222///   * a text cell inside the footprint was overwritten this frame *and* the
2223///     footprint marks that cell as covering graphic ink
2224///     ([`SprixelCell::Opaque`] / [`SprixelCell::Mixed`]) — i.e. the cell is
2225///     [`SprixelCell::Annihilated`].
2226///
2227/// A pure text edit landing on a [`SprixelCell::Transparent`] cell never marks
2228/// damage, so the graphic is not re-emitted.
2229///
2230/// The footprint scan short-circuits an entire footprint row when that row was
2231/// untouched this frame *and* hashes identically to the previous frame
2232/// (`current.row_clean(y) && current.row_hash(y) == previous.row_hash(y)`):
2233/// no cell in such a row can have changed, so no ink can have been annihilated.
2234/// On the headless / direct-call path (where `recompute_line_hashes` was not
2235/// run) every row reports dirty, so the shortcut never fires and the per-cell
2236/// scan runs exactly as before — preserving correctness.
2237fn sprixel_needs_reblit(
2238    placement: &crate::buffer::SprixelPlacement,
2239    current: &Buffer,
2240    previous: &Buffer,
2241    prev_keys: &std::collections::HashSet<SprixelKey>,
2242) -> bool {
2243    use crate::buffer::SprixelCell;
2244
2245    // Position / content change: re-blit if no equal placement existed last
2246    // frame. The key mirrors `SprixelPlacement: PartialEq` (content_hash/x/y/
2247    // cols/rows; damage matrix excluded), so a moved or recolored image
2248    // re-blits. O(1) lookup vs the former O(n·m) `iter().any(..)` scan.
2249    if !prev_keys.contains(&sprixel_key(placement)) {
2250        return true;
2251    }
2252
2253    // Annihilation scan: a covered text cell that changed since last frame and
2254    // now shows ink forces a re-blit. `Transparent` cells are skipped so free
2255    // text edits in graphic gaps emit zero sprixel bytes.
2256    for row in 0..placement.rows {
2257        let y = placement.y + row;
2258        // Per-row shortcut: a row that was not touched this frame and whose
2259        // cached digest matches the previous frame's cannot contain a changed
2260        // cell, so the whole footprint row is skipped without per-cell work.
2261        if current.row_clean(y) && current.row_hash(y) == previous.row_hash(y) {
2262            continue;
2263        }
2264        for col in 0..placement.cols {
2265            let idx = (row * placement.cols + col) as usize;
2266            match placement.cells.get(idx) {
2267                Some(SprixelCell::Opaque) | Some(SprixelCell::Mixed) => {}
2268                // Transparent / Annihilated / out-of-range: not ink-covering,
2269                // so a text write here does not damage the graphic.
2270                _ => continue,
2271            }
2272            let x = placement.x + col;
2273            // A footprint can extend past the buffer edge (a clipped placement,
2274            // or `iterm_image_fit` reserving rows beyond the viewport). Use
2275            // `try_get` so an out-of-bounds footprint cell is simply skipped
2276            // rather than panicking — there is no text there to annihilate it.
2277            let (Some(cell), Some(prev)) = (current.try_get(x, y), previous.try_get(x, y)) else {
2278                continue;
2279            };
2280            // Mirror `flush_buffer_diff`'s write predicate exactly: a cell is
2281            // emitted (and thus overwrites graphic ink) iff it changed since
2282            // last frame and carries a non-empty symbol. Matching the predicate
2283            // keeps the damage matrix in lockstep with what the cell diff
2284            // actually paints over the graphic.
2285            if cell != prev && !cell.symbol.is_empty() {
2286                return true;
2287            }
2288        }
2289    }
2290
2291    false
2292}
2293
2294/// Flush the sprixel (Sixel / iTerm2) layer with per-cell damage tracking.
2295///
2296/// Unlike [`flush_raw_sequences`]' all-or-nothing guard, this re-emits each
2297/// pixel graphic **only** when [`sprixel_needs_reblit`] reports damage, so a
2298/// text edit in a transparent region of a Sixel emits zero passthrough bytes
2299/// (issue #265).
2300///
2301/// The previous frame's placement keys are hashed once up front so the
2302/// position/content change check is O(1) per placement (vs the former O(n·m)
2303/// linear scan), and the per-row clean+hash shortcut inside
2304/// [`sprixel_needs_reblit`] skips untouched footprint rows entirely.
2305fn flush_sprixels(
2306    stdout: &mut impl Write,
2307    current: &Buffer,
2308    previous: &Buffer,
2309    row_offset: u32,
2310) -> io::Result<()> {
2311    // Early out: no graphics to emit. Avoids building the key set on the
2312    // common text-only frame.
2313    if current.sprixels.is_empty() {
2314        return Ok(());
2315    }
2316
2317    let prev_keys: std::collections::HashSet<SprixelKey> =
2318        previous.sprixels.iter().map(sprixel_key).collect();
2319
2320    for placement in &current.sprixels {
2321        if sprixel_needs_reblit(placement, current, previous, &prev_keys) {
2322            queue!(
2323                stdout,
2324                cursor::MoveTo(sat_u16(placement.x), sat_u16(row_offset + placement.y)),
2325                Print(&placement.seq)
2326            )?;
2327        }
2328    }
2329    Ok(())
2330}
2331
2332fn flush_cursor(
2333    stdout: &mut impl Write,
2334    cursor_visible: &mut bool,
2335    cursor_pos: Option<(u32, u32)>,
2336    row_offset: u32,
2337    fallback_row: Option<u32>,
2338) -> io::Result<()> {
2339    match cursor_pos {
2340        Some((cx, cy)) => {
2341            if !*cursor_visible {
2342                queue!(stdout, cursor::Show)?;
2343                *cursor_visible = true;
2344            }
2345            queue!(
2346                stdout,
2347                cursor::MoveTo(sat_u16(cx), sat_u16(row_offset + cy))
2348            )?;
2349        }
2350        None => {
2351            if *cursor_visible {
2352                queue!(stdout, cursor::Hide)?;
2353                *cursor_visible = false;
2354            }
2355            if let Some(row) = fallback_row {
2356                queue!(stdout, cursor::MoveTo(0, sat_u16(row)))?;
2357            }
2358        }
2359    }
2360
2361    Ok(())
2362}
2363
2364fn apply_style_delta(
2365    w: &mut impl Write,
2366    old: &Style,
2367    new: &Style,
2368    depth: ColorDepth,
2369) -> io::Result<()> {
2370    if old.fg != new.fg {
2371        match new.fg {
2372            Some(fg) => queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?,
2373            None => queue!(w, SetForegroundColor(CtColor::Reset))?,
2374        }
2375    }
2376    if old.bg != new.bg {
2377        match new.bg {
2378            Some(bg) => queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?,
2379            None => queue!(w, SetBackgroundColor(CtColor::Reset))?,
2380        }
2381    }
2382    let removed = Modifiers(old.modifiers.0 & !new.modifiers.0);
2383    let added = Modifiers(new.modifiers.0 & !old.modifiers.0);
2384    if removed.contains(Modifiers::BOLD) || removed.contains(Modifiers::DIM) {
2385        queue!(w, SetAttribute(Attribute::NormalIntensity))?;
2386        if new.modifiers.contains(Modifiers::BOLD) {
2387            queue!(w, SetAttribute(Attribute::Bold))?;
2388        }
2389        if new.modifiers.contains(Modifiers::DIM) {
2390            queue!(w, SetAttribute(Attribute::Dim))?;
2391        }
2392    } else {
2393        if added.contains(Modifiers::BOLD) {
2394            queue!(w, SetAttribute(Attribute::Bold))?;
2395        }
2396        if added.contains(Modifiers::DIM) {
2397            queue!(w, SetAttribute(Attribute::Dim))?;
2398        }
2399    }
2400    if removed.contains(Modifiers::ITALIC) {
2401        queue!(w, SetAttribute(Attribute::NoItalic))?;
2402    }
2403    if added.contains(Modifiers::ITALIC) {
2404        queue!(w, SetAttribute(Attribute::Italic))?;
2405    }
2406    if removed.contains(Modifiers::UNDERLINE) {
2407        queue!(w, SetAttribute(Attribute::NoUnderline))?;
2408    }
2409    if added.contains(Modifiers::UNDERLINE) {
2410        queue!(w, SetAttribute(Attribute::Underlined))?;
2411    }
2412    if removed.contains(Modifiers::REVERSED) {
2413        queue!(w, SetAttribute(Attribute::NoReverse))?;
2414    }
2415    if added.contains(Modifiers::REVERSED) {
2416        queue!(w, SetAttribute(Attribute::Reverse))?;
2417    }
2418    if removed.contains(Modifiers::STRIKETHROUGH) {
2419        queue!(w, SetAttribute(Attribute::NotCrossedOut))?;
2420    }
2421    if added.contains(Modifiers::STRIKETHROUGH) {
2422        queue!(w, SetAttribute(Attribute::CrossedOut))?;
2423    }
2424    if removed.contains(Modifiers::BLINK) {
2425        queue!(w, SetAttribute(Attribute::NoBlink))?;
2426    }
2427    if added.contains(Modifiers::BLINK) {
2428        queue!(w, SetAttribute(Attribute::SlowBlink))?;
2429    }
2430    if removed.contains(Modifiers::OVERLINE) {
2431        queue!(w, SetAttribute(Attribute::NotOverLined))?;
2432    }
2433    if added.contains(Modifiers::OVERLINE) {
2434        queue!(w, SetAttribute(Attribute::OverLined))?;
2435    }
2436    // Underline style and color use raw escapes: crossterm 0.28 cannot
2437    // express the `CSI 4:Nm` subparameters or the `SGR 58`/`59` underline
2438    // color reliably (its discriminants collide on these terminals).
2439    if old.underline_style != new.underline_style {
2440        write!(w, "\x1b[4:{}m", underline_style_param(new.underline_style))?;
2441    }
2442    if old.underline_color != new.underline_color {
2443        emit_underline_color(w, new.underline_color, depth)?;
2444    }
2445    Ok(())
2446}
2447
2448/// Map an [`UnderlineStyle`] to its `CSI 4:Nm` subparameter value.
2449fn underline_style_param(style: UnderlineStyle) -> u8 {
2450    match style {
2451        UnderlineStyle::Straight => 1,
2452        UnderlineStyle::Double => 2,
2453        UnderlineStyle::Curly => 3,
2454        UnderlineStyle::Dotted => 4,
2455        UnderlineStyle::Dashed => 5,
2456    }
2457}
2458
2459/// Emit the raw `SGR 58` underline-color sequence (or `SGR 59` to reset).
2460///
2461/// `None` resets the underline color to the foreground (`\x1b[59m`). Otherwise
2462/// the color is downsampled to the terminal's depth: true-color emits
2463/// `\x1b[58:2::r:g:bm`, while indexed/named colors emit `\x1b[58:5:im`.
2464fn emit_underline_color(
2465    w: &mut impl Write,
2466    color: Option<Color>,
2467    depth: ColorDepth,
2468) -> io::Result<()> {
2469    match color {
2470        None => write!(w, "\x1b[59m"),
2471        Some(c) => match c.downsampled(depth) {
2472            Color::Reset => write!(w, "\x1b[59m"),
2473            Color::Rgb(r, g, b) => write!(w, "\x1b[58:2::{r}:{g}:{b}m"),
2474            Color::Indexed(i) => write!(w, "\x1b[58:5:{i}m"),
2475            // Named colors have no direct SGR-58 form; resolve them to their
2476            // RGB equivalent and emit a true-color underline sequence.
2477            named => {
2478                let (r, g, b) = named.to_rgb();
2479                write!(w, "\x1b[58:2::{r}:{g}:{b}m")
2480            }
2481        },
2482    }
2483}
2484
2485fn apply_style(w: &mut impl Write, style: &Style, depth: ColorDepth) -> io::Result<()> {
2486    if let Some(fg) = style.fg {
2487        queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?;
2488    }
2489    if let Some(bg) = style.bg {
2490        queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?;
2491    }
2492    let m = style.modifiers;
2493    if m.contains(Modifiers::BOLD) {
2494        queue!(w, SetAttribute(Attribute::Bold))?;
2495    }
2496    if m.contains(Modifiers::DIM) {
2497        queue!(w, SetAttribute(Attribute::Dim))?;
2498    }
2499    if m.contains(Modifiers::ITALIC) {
2500        queue!(w, SetAttribute(Attribute::Italic))?;
2501    }
2502    if m.contains(Modifiers::UNDERLINE) {
2503        queue!(w, SetAttribute(Attribute::Underlined))?;
2504    }
2505    if m.contains(Modifiers::REVERSED) {
2506        queue!(w, SetAttribute(Attribute::Reverse))?;
2507    }
2508    if m.contains(Modifiers::STRIKETHROUGH) {
2509        queue!(w, SetAttribute(Attribute::CrossedOut))?;
2510    }
2511    if m.contains(Modifiers::BLINK) {
2512        queue!(w, SetAttribute(Attribute::SlowBlink))?;
2513    }
2514    if m.contains(Modifiers::OVERLINE) {
2515        queue!(w, SetAttribute(Attribute::OverLined))?;
2516    }
2517    if style.underline_style != UnderlineStyle::Straight {
2518        write!(
2519            w,
2520            "\x1b[4:{}m",
2521            underline_style_param(style.underline_style)
2522        )?;
2523    }
2524    if style.underline_color.is_some() {
2525        emit_underline_color(w, style.underline_color, depth)?;
2526    }
2527    Ok(())
2528}
2529
2530fn to_crossterm_color(color: Color, depth: ColorDepth) -> CtColor {
2531    let color = color.downsampled(depth);
2532    match color {
2533        Color::Reset => CtColor::Reset,
2534        Color::Black => CtColor::Black,
2535        Color::Red => CtColor::DarkRed,
2536        Color::Green => CtColor::DarkGreen,
2537        Color::Yellow => CtColor::DarkYellow,
2538        Color::Blue => CtColor::DarkBlue,
2539        Color::Magenta => CtColor::DarkMagenta,
2540        Color::Cyan => CtColor::DarkCyan,
2541        Color::White => CtColor::White,
2542        Color::DarkGray => CtColor::DarkGrey,
2543        Color::LightRed => CtColor::Red,
2544        Color::LightGreen => CtColor::Green,
2545        Color::LightYellow => CtColor::Yellow,
2546        Color::LightBlue => CtColor::Blue,
2547        Color::LightMagenta => CtColor::Magenta,
2548        Color::LightCyan => CtColor::Cyan,
2549        Color::LightWhite => CtColor::White,
2550        Color::Rgb(r, g, b) => CtColor::Rgb { r, g, b },
2551        Color::Indexed(i) => CtColor::AnsiValue(i),
2552    }
2553}
2554
2555fn reset_current_buffer(buffer: &mut Buffer, theme_bg: Option<Color>) {
2556    if let Some(bg) = theme_bg {
2557        buffer.reset_with_bg(bg);
2558    } else {
2559        buffer.reset();
2560    }
2561}
2562
2563fn write_session_enter(stdout: &mut impl Write, session: &TerminalSessionGuard) -> io::Result<()> {
2564    match session.mode {
2565        TerminalSessionMode::Fullscreen => {
2566            execute!(
2567                stdout,
2568                terminal::EnterAlternateScreen,
2569                cursor::Hide,
2570                EnableBracketedPaste
2571            )?;
2572        }
2573        TerminalSessionMode::Inline => {
2574            execute!(stdout, cursor::Hide, EnableBracketedPaste)?;
2575        }
2576    }
2577
2578    // Focus-change reporting is independent of mouse capture — callers
2579    // routinely pause animations or clear hover state on focus loss even
2580    // without mouse support. Enabling it unconditionally matches modern
2581    // TUI conventions (zellij, helix, yazi) and the cost is one extra SGR
2582    // per session.
2583    execute!(stdout, EnableFocusChange)?;
2584    if session.mouse_enabled {
2585        execute!(stdout, EnableMouseCapture)?;
2586    }
2587    if session.kitty_keyboard {
2588        use crossterm::event::PushKeyboardEnhancementFlags;
2589        let _ = execute!(
2590            stdout,
2591            PushKeyboardEnhancementFlags(kitty_flags(session.report_all_keys))
2592        );
2593    }
2594
2595    Ok(())
2596}
2597
2598/// Assemble the Kitty keyboard enhancement flags to push.
2599///
2600/// Always sets `DISAMBIGUATE_ESCAPE_CODES | REPORT_EVENT_TYPES`. When
2601/// `report_all_keys` is `true`, also OR-es in
2602/// `REPORT_ALL_KEYS_AS_ESCAPE_CODES`, which is the only mechanism by which a
2603/// spec-compliant terminal emits a bare modifier as a key event.
2604///
2605/// This is a pure helper so the flag assembly can be unit-tested without
2606/// touching stdout.
2607fn kitty_flags(report_all_keys: bool) -> crossterm::event::KeyboardEnhancementFlags {
2608    use crossterm::event::KeyboardEnhancementFlags;
2609    let mut flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
2610        | KeyboardEnhancementFlags::REPORT_EVENT_TYPES;
2611    if report_all_keys {
2612        flags |= KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES;
2613    }
2614    flags
2615}
2616
2617fn write_session_cleanup(
2618    stdout: &mut impl Write,
2619    mode: TerminalSessionMode,
2620    inline_reserved: bool,
2621) -> io::Result<()> {
2622    execute!(
2623        stdout,
2624        ResetColor,
2625        SetAttribute(Attribute::Reset),
2626        cursor::Show,
2627        DisableBracketedPaste
2628    )?;
2629
2630    match mode {
2631        TerminalSessionMode::Fullscreen => {
2632            execute!(stdout, terminal::LeaveAlternateScreen)?;
2633        }
2634        TerminalSessionMode::Inline => {
2635            if inline_reserved {
2636                execute!(
2637                    stdout,
2638                    cursor::MoveToColumn(0),
2639                    cursor::MoveDown(1),
2640                    cursor::MoveToColumn(0),
2641                    Print("\n")
2642                )?;
2643            } else {
2644                execute!(stdout, Print("\n"))?;
2645            }
2646        }
2647    }
2648
2649    Ok(())
2650}
2651
2652// ---------------------------------------------------------------------------
2653// Unix job-control suspend/resume (Ctrl+Z / `fg`) — issue #263
2654// ---------------------------------------------------------------------------
2655//
2656// On Unix, SIGTSTP stops the process in kernel space with no Rust code on the
2657// stack, so neither `Drop` nor the panic hook can restore the terminal. The
2658// run loops install a `signal-hook` background thread that, on SIGTSTP, runs
2659// the same teardown the session guard would (`disable_raw_mode`, leave alt
2660// screen, show cursor, disable paste/focus/mouse/kitty) and then re-raises
2661// SIGTSTP to genuinely stop; on SIGCONT it re-enters the session and flags a
2662// full redraw. The whole feature is `#[cfg(unix)]` and uses only signal-hook's
2663// safe API, preserving `#![forbid(unsafe_code)]`.
2664
2665/// Immutable snapshot of the active terminal session used by the unix
2666/// suspend/resume handler to restore and re-enter the terminal across a
2667/// Ctrl+Z / `fg` cycle without owning the `Terminal`/`InlineTerminal`.
2668#[cfg(unix)]
2669#[derive(Debug, Clone, Copy)]
2670pub(crate) struct SessionSnapshot {
2671    mode: TerminalSessionMode,
2672    mouse_enabled: bool,
2673    kitty_keyboard: bool,
2674    report_all_keys: bool,
2675}
2676
2677/// Set by the SIGCONT handler and consumed once at the top of each run-loop
2678/// iteration to force a full clear + repaint after resuming from suspend.
2679#[cfg(unix)]
2680pub(crate) static NEEDS_FULL_REDRAW: std::sync::atomic::AtomicBool =
2681    std::sync::atomic::AtomicBool::new(false);
2682
2683#[cfg(unix)]
2684impl Terminal {
2685    /// Capture the session state the suspend/resume handler needs to restore
2686    /// and re-enter this fullscreen terminal across Ctrl+Z / `fg`.
2687    pub(crate) fn session_snapshot(&self) -> SessionSnapshot {
2688        SessionSnapshot {
2689            mode: self.session.mode,
2690            mouse_enabled: self.session.mouse_enabled,
2691            kitty_keyboard: self.session.kitty_keyboard,
2692            report_all_keys: self.session.report_all_keys,
2693        }
2694    }
2695}
2696
2697#[cfg(unix)]
2698impl InlineTerminal {
2699    /// Capture the session state the suspend/resume handler needs to restore
2700    /// and re-enter this inline terminal across Ctrl+Z / `fg`.
2701    pub(crate) fn session_snapshot(&self) -> SessionSnapshot {
2702        SessionSnapshot {
2703            mode: self.session.mode,
2704            mouse_enabled: self.session.mouse_enabled,
2705            kitty_keyboard: self.session.kitty_keyboard,
2706            report_all_keys: self.session.report_all_keys,
2707        }
2708    }
2709}
2710
2711/// Write the escape sequences that tear down the TUI session in preparation
2712/// for SIGTSTP (the inverse of [`write_session_enter`]).
2713///
2714/// `inline_reserved` is passed `false` to [`write_session_cleanup`] to avoid
2715/// emitting the inline trailing-newline dance mid-session; the reserved region
2716/// is repainted on resume via the forced full redraw. Pure byte output, no
2717/// raw-mode toggle — split out so it can be unit-tested against a `Vec<u8>`.
2718#[cfg(unix)]
2719fn write_suspend_sequence(stdout: &mut impl Write, snapshot: &SessionSnapshot) -> io::Result<()> {
2720    if snapshot.kitty_keyboard {
2721        use crossterm::event::PopKeyboardEnhancementFlags;
2722        execute!(stdout, PopKeyboardEnhancementFlags)?;
2723    }
2724    if snapshot.mouse_enabled {
2725        execute!(stdout, DisableMouseCapture)?;
2726    }
2727    execute!(stdout, DisableFocusChange)?;
2728    write_session_cleanup(stdout, snapshot.mode, false)
2729}
2730
2731/// Restore the terminal to cooked/non-TUI state in preparation for the process
2732/// being stopped by SIGTSTP.
2733///
2734/// Mirrors [`TerminalSessionGuard::restore`] but writes directly to
2735/// `io::stdout()` (the handler runs on a background thread that does not own
2736/// the buffered terminal stdout).
2737#[cfg(unix)]
2738pub(crate) fn suspend_to_shell(snapshot: &SessionSnapshot) {
2739    let mut out = io::stdout();
2740    let _ = write_suspend_sequence(&mut out, snapshot);
2741    let _ = terminal::disable_raw_mode();
2742    let _ = out.flush();
2743}
2744
2745/// Re-enter the TUI session after a SIGCONT (resume via `fg`), matching the
2746/// original [`SessionSnapshot`], and flag a full redraw for the next frame.
2747///
2748/// Mirrors [`TerminalSessionGuard::enter`] but writes directly to
2749/// `io::stdout()`. Sets [`NEEDS_FULL_REDRAW`] so the next loop iteration clears
2750/// the front buffer and repaints every cell.
2751#[cfg(unix)]
2752pub(crate) fn resume_from_shell(snapshot: &SessionSnapshot) {
2753    let mut out = io::stdout();
2754    let _ = terminal::enable_raw_mode();
2755    let guard = TerminalSessionGuard {
2756        mode: snapshot.mode,
2757        mouse_enabled: snapshot.mouse_enabled,
2758        kitty_keyboard: snapshot.kitty_keyboard,
2759        report_all_keys: snapshot.report_all_keys,
2760        harness: false,
2761    };
2762    let _ = write_session_enter(&mut out, &guard);
2763    let _ = out.flush();
2764    NEEDS_FULL_REDRAW.store(true, std::sync::atomic::Ordering::SeqCst);
2765}
2766
2767/// Construct a [`SessionSnapshot`] for tests without a live terminal.
2768#[cfg(all(unix, test))]
2769fn test_snapshot(mode: TerminalSessionMode, mouse: bool, kitty: bool) -> SessionSnapshot {
2770    SessionSnapshot {
2771        mode,
2772        mouse_enabled: mouse,
2773        kitty_keyboard: kitty,
2774        report_all_keys: false,
2775    }
2776}
2777
2778/// Construct a fullscreen [`SessionSnapshot`] for crate-level tests that drive
2779/// the suspend handler without a live terminal (issue #263).
2780#[cfg(all(unix, test))]
2781pub(crate) fn test_session_snapshot() -> SessionSnapshot {
2782    SessionSnapshot {
2783        mode: TerminalSessionMode::Fullscreen,
2784        mouse_enabled: false,
2785        kitty_keyboard: false,
2786        report_all_keys: false,
2787    }
2788}
2789
2790#[cfg(test)]
2791mod tests {
2792    #![allow(clippy::unwrap_used)]
2793    use super::*;
2794
2795    /// Feed `bytes` to a channel from a helper thread after `delay`, then run
2796    /// [`collect_reply`] against it with the given budget and predicate.
2797    fn collect_with_feed(
2798        bytes: &'static [u8],
2799        delay: Duration,
2800        budget: Duration,
2801        is_complete: &mut dyn FnMut(&[u8]) -> bool,
2802    ) -> (Vec<u8>, Duration) {
2803        let (tx, rx) = std::sync::mpsc::channel::<u8>();
2804        std::thread::spawn(move || {
2805            std::thread::sleep(delay);
2806            for &b in bytes {
2807                if tx.send(b).is_err() {
2808                    return;
2809                }
2810            }
2811            // Keep the sender alive past the collector's budget: the real
2812            // pump thread only drops its sender on stdin EOF, so dropping it
2813            // here right after the payload would disconnect the channel and
2814            // end the wait early, masking deadline behavior.
2815            std::thread::sleep(Duration::from_secs(3));
2816        });
2817        let start = Instant::now();
2818        let out = collect_reply(&rx, start + budget, is_complete);
2819        (out, start.elapsed())
2820    }
2821
2822    #[test]
2823    fn collect_reply_osc_bel_terminator_completes_early() {
2824        let reply = b"\x1b]11;rgb:0000/0000/0000\x07";
2825        let (out, elapsed) = collect_with_feed(
2826            reply,
2827            Duration::ZERO,
2828            Duration::from_secs(2),
2829            &mut osc_reply_complete,
2830        );
2831        assert_eq!(out, reply);
2832        assert!(
2833            elapsed < Duration::from_secs(1),
2834            "should not wait out the budget"
2835        );
2836    }
2837
2838    #[test]
2839    fn collect_reply_osc_st_terminator_completes_early() {
2840        let reply = b"\x1bP>|tmux 3.5a\x1b\\";
2841        let (out, elapsed) = collect_with_feed(
2842            reply,
2843            Duration::ZERO,
2844            Duration::from_secs(2),
2845            &mut osc_reply_complete,
2846        );
2847        assert_eq!(out, reply);
2848        assert!(elapsed < Duration::from_secs(1));
2849    }
2850
2851    #[test]
2852    fn collect_reply_silence_returns_empty_at_deadline() {
2853        // The silent-host case that used to deadlock startup: no bytes ever
2854        // arrive. The collector must give up at the deadline, not block.
2855        let budget = Duration::from_millis(150);
2856        let (out, elapsed) =
2857            collect_with_feed(b"", Duration::from_secs(5), budget, &mut osc_reply_complete);
2858        assert!(out.is_empty());
2859        assert!(elapsed >= budget);
2860        assert!(
2861            elapsed < Duration::from_secs(2),
2862            "must not block past the budget"
2863        );
2864    }
2865
2866    #[test]
2867    fn collect_reply_da_drains_two_replies() {
2868        let reply = b"\x1b[?62;4c\x1b[>1;10;0c";
2869        let (out, elapsed) = collect_with_feed(
2870            reply,
2871            Duration::ZERO,
2872            Duration::from_secs(2),
2873            &mut da_reply_complete(),
2874        );
2875        assert_eq!(out, reply);
2876        assert!(elapsed < Duration::from_secs(1));
2877    }
2878
2879    #[test]
2880    fn collect_reply_da_lone_reply_returns_partial_at_deadline() {
2881        // A terminal that answers DA1 but ignores DA2: the collector waits out
2882        // the budget, then hands back the partial reply for best-effort parse
2883        // (pre-pump behavior, preserved).
2884        let budget = Duration::from_millis(150);
2885        let (out, elapsed) = collect_with_feed(
2886            b"\x1b[?62;4c",
2887            Duration::ZERO,
2888            budget,
2889            &mut da_reply_complete(),
2890        );
2891        assert_eq!(out, b"\x1b[?62;4c");
2892        assert!(elapsed >= budget);
2893    }
2894
2895    #[test]
2896    fn collect_reply_unterminated_caps_at_4096_bytes() {
2897        static BIG: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
2898        let big = BIG.get_or_init(|| vec![b'x'; 5000]).as_slice();
2899        let (tx, rx) = std::sync::mpsc::channel::<u8>();
2900        for &b in big {
2901            tx.send(b).unwrap();
2902        }
2903        let out = collect_reply(
2904            &rx,
2905            Instant::now() + Duration::from_secs(2),
2906            &mut osc_reply_complete,
2907        );
2908        assert_eq!(out.len(), 4096);
2909    }
2910
2911    #[test]
2912    fn decrpm_predicate_terminates_on_y() {
2913        let reply = b"\x1b[?2026;1$y";
2914        let (out, _) = collect_with_feed(
2915            reply,
2916            Duration::ZERO,
2917            Duration::from_secs(2),
2918            &mut decrpm_reply_complete,
2919        );
2920        assert_eq!(out, reply);
2921    }
2922
2923    #[test]
2924    fn reset_current_buffer_applies_theme_background() {
2925        let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 1));
2926
2927        reset_current_buffer(&mut buffer, Some(Color::Rgb(10, 20, 30)));
2928        assert_eq!(buffer.get(0, 0).style.bg, Some(Color::Rgb(10, 20, 30)));
2929
2930        reset_current_buffer(&mut buffer, None);
2931        assert_eq!(buffer.get(0, 0).style.bg, None);
2932    }
2933
2934    #[test]
2935    fn fullscreen_session_enter_writes_alt_screen_sequence() {
2936        let session = TerminalSessionGuard {
2937            mode: TerminalSessionMode::Fullscreen,
2938            mouse_enabled: false,
2939            kitty_keyboard: false,
2940            report_all_keys: false,
2941            harness: false,
2942        };
2943        let mut out = Vec::new();
2944        write_session_enter(&mut out, &session).unwrap();
2945        let output = String::from_utf8(out).unwrap();
2946        assert!(output.contains("\u{1b}[?1049h"));
2947        assert!(output.contains("\u{1b}[?25l"));
2948        assert!(output.contains("\u{1b}[?2004h"));
2949    }
2950
2951    #[test]
2952    fn inline_session_enter_skips_alt_screen_sequence() {
2953        let session = TerminalSessionGuard {
2954            mode: TerminalSessionMode::Inline,
2955            mouse_enabled: false,
2956            kitty_keyboard: false,
2957            report_all_keys: false,
2958            harness: false,
2959        };
2960        let mut out = Vec::new();
2961        write_session_enter(&mut out, &session).unwrap();
2962        let output = String::from_utf8(out).unwrap();
2963        assert!(!output.contains("\u{1b}[?1049h"));
2964        assert!(output.contains("\u{1b}[?25l"));
2965        assert!(output.contains("\u{1b}[?2004h"));
2966    }
2967
2968    #[test]
2969    fn fullscreen_session_cleanup_leaves_alt_screen() {
2970        let mut out = Vec::new();
2971        write_session_cleanup(&mut out, TerminalSessionMode::Fullscreen, false).unwrap();
2972        let output = String::from_utf8(out).unwrap();
2973        assert!(output.contains("\u{1b}[?1049l"));
2974        assert!(output.contains("\u{1b}[?25h"));
2975        assert!(output.contains("\u{1b}[?2004l"));
2976    }
2977
2978    #[test]
2979    fn inline_session_cleanup_keeps_normal_screen() {
2980        let mut out = Vec::new();
2981        write_session_cleanup(&mut out, TerminalSessionMode::Inline, false).unwrap();
2982        let output = String::from_utf8(out).unwrap();
2983        assert!(!output.contains("\u{1b}[?1049l"));
2984        assert!(output.ends_with('\n'));
2985        assert!(output.contains("\u{1b}[?25h"));
2986        assert!(output.contains("\u{1b}[?2004l"));
2987    }
2988
2989    // ── Unix suspend/resume sequence tests (issue #263) ──────────────────
2990
2991    #[cfg(unix)]
2992    #[test]
2993    fn suspend_sequence_fullscreen_leaves_alt_screen() {
2994        let snapshot = test_snapshot(TerminalSessionMode::Fullscreen, 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!(output.contains("\u{1b}[?1049l"), "leaves alt screen");
2999        assert!(output.contains("\u{1b}[?25h"), "shows cursor");
3000        assert!(output.contains("\u{1b}[?2004l"), "disables bracketed paste");
3001    }
3002
3003    #[cfg(unix)]
3004    #[test]
3005    fn suspend_sequence_inline_keeps_normal_screen() {
3006        let snapshot = test_snapshot(TerminalSessionMode::Inline, false, false);
3007        let mut out = Vec::new();
3008        write_suspend_sequence(&mut out, &snapshot).unwrap();
3009        let output = String::from_utf8(out).unwrap();
3010        assert!(
3011            !output.contains("\u{1b}[?1049l"),
3012            "inline must not leave alt screen"
3013        );
3014        assert!(output.contains("\u{1b}[?25h"), "shows cursor");
3015        assert!(output.contains("\u{1b}[?2004l"), "disables bracketed paste");
3016    }
3017
3018    #[cfg(unix)]
3019    #[test]
3020    fn suspend_sequence_disables_mouse_and_kitty_when_enabled() {
3021        let snapshot = test_snapshot(TerminalSessionMode::Fullscreen, true, true);
3022        let mut out = Vec::new();
3023        write_suspend_sequence(&mut out, &snapshot).unwrap();
3024        // DisableMouseCapture emits the SGR-mouse disable (?1006l) among others.
3025        let output = String::from_utf8(out).unwrap();
3026        assert!(output.contains("\u{1b}[?1006l"), "disables SGR mouse mode");
3027    }
3028
3029    #[cfg(unix)]
3030    #[test]
3031    fn resume_sequence_fullscreen_round_trips_enter_and_flags_redraw() {
3032        let snapshot = test_snapshot(TerminalSessionMode::Fullscreen, false, false);
3033
3034        // The resume path re-enters the same byte state as the initial enter.
3035        let guard = TerminalSessionGuard {
3036            mode: snapshot.mode,
3037            mouse_enabled: snapshot.mouse_enabled,
3038            kitty_keyboard: snapshot.kitty_keyboard,
3039            report_all_keys: snapshot.report_all_keys,
3040            harness: false,
3041        };
3042        let mut enter_bytes = Vec::new();
3043        write_session_enter(&mut enter_bytes, &guard).unwrap();
3044        let enter = String::from_utf8(enter_bytes).unwrap();
3045        assert!(enter.contains("\u{1b}[?1049h"));
3046        assert!(enter.contains("\u{1b}[?25l"));
3047        assert!(enter.contains("\u{1b}[?2004h"));
3048
3049        // Drive the public resume entry point and assert the redraw flag flips.
3050        NEEDS_FULL_REDRAW.store(false, std::sync::atomic::Ordering::SeqCst);
3051        resume_from_shell(&snapshot);
3052        assert!(
3053            NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst),
3054            "resume must request a full redraw exactly once"
3055        );
3056        assert!(
3057            !NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst),
3058            "the redraw flag is consumed by the first swap (idempotent)"
3059        );
3060    }
3061
3062    #[cfg(unix)]
3063    #[test]
3064    fn needs_full_redraw_swaps_true_once() {
3065        NEEDS_FULL_REDRAW.store(true, std::sync::atomic::Ordering::SeqCst);
3066        assert!(NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst));
3067        assert!(!NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst));
3068    }
3069
3070    #[test]
3071    fn kitty_flags_base_set_excludes_report_all_keys() {
3072        use crossterm::event::KeyboardEnhancementFlags;
3073        let flags = kitty_flags(false);
3074        assert!(flags.contains(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES));
3075        assert!(flags.contains(KeyboardEnhancementFlags::REPORT_EVENT_TYPES));
3076        assert!(!flags.contains(KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES));
3077    }
3078
3079    #[test]
3080    fn kitty_flags_report_all_keys_sets_flag() {
3081        use crossterm::event::KeyboardEnhancementFlags;
3082        let flags = kitty_flags(true);
3083        assert!(flags.contains(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES));
3084        assert!(flags.contains(KeyboardEnhancementFlags::REPORT_EVENT_TYPES));
3085        assert!(flags.contains(KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES));
3086    }
3087
3088    #[test]
3089    fn base64_encode_empty() {
3090        assert_eq!(base64_encode(b""), "");
3091    }
3092
3093    #[test]
3094    fn base64_encode_hello() {
3095        assert_eq!(base64_encode(b"Hello"), "SGVsbG8=");
3096    }
3097
3098    #[test]
3099    fn base64_encode_padding() {
3100        assert_eq!(base64_encode(b"a"), "YQ==");
3101        assert_eq!(base64_encode(b"ab"), "YWI=");
3102        assert_eq!(base64_encode(b"abc"), "YWJj");
3103    }
3104
3105    #[test]
3106    fn base64_encode_unicode() {
3107        assert_eq!(base64_encode("한글".as_bytes()), "7ZWc6riA");
3108    }
3109
3110    #[cfg(feature = "crossterm")]
3111    #[test]
3112    fn parse_osc11_response_dark_and_light() {
3113        assert_eq!(
3114            parse_osc11_response("\x1b]11;rgb:0000/0000/0000\x1b\\"),
3115            ColorScheme::Dark
3116        );
3117        assert_eq!(
3118            parse_osc11_response("\x1b]11;rgb:ffff/ffff/ffff\x07"),
3119            ColorScheme::Light
3120        );
3121    }
3122
3123    // ---- Capability probe / blitter ladder (issue #264) ----
3124
3125    #[test]
3126    fn blitter_support_default_is_conservative() {
3127        let b = BlitterSupport::default();
3128        assert!(b.half);
3129        assert!(b.quad);
3130        assert!(!b.sextant);
3131    }
3132
3133    #[test]
3134    fn capabilities_default_is_all_false_but_half_block() {
3135        let c = Capabilities::default();
3136        assert!(!c.truecolor);
3137        assert!(!c.sixel);
3138        assert!(!c.iterm2);
3139        assert!(!c.kitty_graphics);
3140        assert!(!c.kitty_keyboard);
3141        assert!(!c.sync_output);
3142        // With nothing negotiated the ladder must still resolve to half-block.
3143        assert_eq!(c.best_blitter(), Blitter::HalfBlock);
3144    }
3145
3146    #[test]
3147    fn best_blitter_ladder_table() {
3148        let kitty = Capabilities {
3149            kitty_graphics: true,
3150            ..Default::default()
3151        };
3152        assert_eq!(kitty.best_blitter(), Blitter::Kitty);
3153
3154        let sixel = Capabilities {
3155            sixel: true,
3156            ..Default::default()
3157        };
3158        assert_eq!(sixel.best_blitter(), Blitter::Sixel);
3159
3160        let iterm2 = Capabilities {
3161            iterm2: true,
3162            ..Default::default()
3163        };
3164        assert_eq!(iterm2.best_blitter(), Blitter::Iterm2);
3165
3166        // iTerm2 sits below Sixel: a host advertising both prefers Sixel.
3167        let sixel_and_iterm2 = Capabilities {
3168            sixel: true,
3169            iterm2: true,
3170            ..Default::default()
3171        };
3172        assert_eq!(sixel_and_iterm2.best_blitter(), Blitter::Sixel);
3173
3174        let sextant = Capabilities {
3175            blitters: BlitterSupport {
3176                sextant: true,
3177                ..Default::default()
3178            },
3179            ..Default::default()
3180        };
3181        assert_eq!(sextant.best_blitter(), Blitter::Sextant);
3182
3183        assert_eq!(Capabilities::default().best_blitter(), Blitter::HalfBlock);
3184    }
3185
3186    #[test]
3187    fn best_blitter_precedence_kitty_over_everything() {
3188        let all = Capabilities {
3189            kitty_graphics: true,
3190            sixel: true,
3191            blitters: BlitterSupport {
3192                sextant: true,
3193                ..Default::default()
3194            },
3195            ..Default::default()
3196        };
3197        assert_eq!(all.best_blitter(), Blitter::Kitty);
3198
3199        let sixel_and_sextant = Capabilities {
3200            sixel: true,
3201            blitters: BlitterSupport {
3202                sextant: true,
3203                ..Default::default()
3204            },
3205            ..Default::default()
3206        };
3207        assert_eq!(sixel_and_sextant.best_blitter(), Blitter::Sixel);
3208    }
3209
3210    #[test]
3211    fn best_blitter_never_picks_unsupported_protocol() {
3212        // Exhaustive sweep over field combinations: the resolver must never
3213        // return Kitty without kitty_graphics, nor Sixel without sixel, etc.
3214        for kitty in [false, true] {
3215            for sixel in [false, true] {
3216                for iterm2 in [false, true] {
3217                    for sextant in [false, true] {
3218                        let caps = Capabilities {
3219                            kitty_graphics: kitty,
3220                            sixel,
3221                            iterm2,
3222                            blitters: BlitterSupport {
3223                                sextant,
3224                                ..Default::default()
3225                            },
3226                            ..Default::default()
3227                        };
3228                        match caps.best_blitter() {
3229                            Blitter::Kitty => assert!(kitty),
3230                            Blitter::Sixel => assert!(sixel && !kitty),
3231                            Blitter::Iterm2 => assert!(iterm2 && !sixel && !kitty),
3232                            Blitter::Sextant => {
3233                                assert!(sextant && !iterm2 && !sixel && !kitty)
3234                            }
3235                            Blitter::HalfBlock => {
3236                                assert!(!kitty && !sixel && !iterm2 && !sextant)
3237                            }
3238                        }
3239                    }
3240                }
3241            }
3242        }
3243    }
3244
3245    #[cfg(feature = "crossterm")]
3246    #[test]
3247    fn parse_da1_attribute_4_sets_sixel() {
3248        let mut caps = Capabilities::default();
3249        parse_da1("\x1b[?62;4;6c", &mut caps);
3250        assert!(caps.sixel);
3251    }
3252
3253    #[cfg(feature = "crossterm")]
3254    #[test]
3255    fn parse_da1_without_4_leaves_sixel_false() {
3256        let mut caps = Capabilities::default();
3257        parse_da1("\x1b[?62;1;6c", &mut caps);
3258        assert!(!caps.sixel);
3259    }
3260
3261    #[cfg(feature = "crossterm")]
3262    #[test]
3263    fn parse_da1_ignores_da2_segment_in_same_string() {
3264        // DA1 (no `4`) followed by DA2 — DA2 must not be mistaken for DA1.
3265        let mut caps = Capabilities::default();
3266        parse_da1("\x1b[?62;1c\x1b[>0;276;0c", &mut caps);
3267        assert!(!caps.sixel);
3268    }
3269
3270    #[cfg(feature = "crossterm")]
3271    #[test]
3272    fn parse_da2_no_panic_on_garbage() {
3273        let mut caps = Capabilities::default();
3274        // Must not panic and must not set kitty_graphics on an unknown id.
3275        parse_da2("\x1b[>99;1;0c", &mut caps);
3276        assert!(!caps.kitty_graphics);
3277        parse_da2("not a da2 reply", &mut caps);
3278        assert!(!caps.kitty_graphics);
3279    }
3280
3281    #[cfg(feature = "crossterm")]
3282    #[test]
3283    fn parse_da2_kitty_id_sets_kitty_graphics() {
3284        let mut caps = Capabilities::default();
3285        // Kitty reports DA2 primary id 41.
3286        parse_da2("\x1b[>41;4000;0c", &mut caps);
3287        assert!(caps.kitty_graphics);
3288    }
3289
3290    #[cfg(feature = "crossterm")]
3291    #[test]
3292    fn parse_da2_identity_extracts_id_and_version() {
3293        assert_eq!(parse_da2_identity("\x1b[>0;276;0c"), Some((0, 276)));
3294        assert_eq!(parse_da2_identity("\x1b[>41;4000;0c"), Some((41, 4000)));
3295        assert_eq!(parse_da2_identity("no reply here"), None);
3296    }
3297
3298    #[cfg(feature = "crossterm")]
3299    #[test]
3300    fn parse_kitty_graphics_ack_ok_sets_flag() {
3301        let mut caps = Capabilities::default();
3302        parse_kitty_graphics_ack("\x1b_Gi=31;OK\x1b\\", &mut caps);
3303        assert!(caps.kitty_graphics);
3304    }
3305
3306    #[cfg(feature = "crossterm")]
3307    #[test]
3308    fn parse_kitty_graphics_ack_error_or_wrong_id_leaves_flag() {
3309        let mut caps = Capabilities::default();
3310        // Error status must not flag support.
3311        parse_kitty_graphics_ack("\x1b_Gi=31;ENOENT:bad\x1b\\", &mut caps);
3312        assert!(!caps.kitty_graphics);
3313        // A different image id is not our query.
3314        parse_kitty_graphics_ack("\x1b_Gi=99;OK\x1b\\", &mut caps);
3315        assert!(!caps.kitty_graphics);
3316        // No APC at all.
3317        parse_kitty_graphics_ack("garbage", &mut caps);
3318        assert!(!caps.kitty_graphics);
3319    }
3320
3321    #[cfg(feature = "crossterm")]
3322    #[test]
3323    fn parse_decrpm_sync_output_recognized_states_are_supported() {
3324        // Ps = 1 (set), 2 (reset), 3 (perm set), 4 (perm reset) all mean the
3325        // mode is recognized → supported.
3326        assert_eq!(parse_decrpm_sync_output("\x1b[?2026;1$y"), Some(true));
3327        assert_eq!(parse_decrpm_sync_output("\x1b[?2026;2$y"), Some(true));
3328        assert_eq!(parse_decrpm_sync_output("\x1b[?2026;3$y"), Some(true));
3329        assert_eq!(parse_decrpm_sync_output("\x1b[?2026;4$y"), Some(true));
3330    }
3331
3332    #[cfg(feature = "crossterm")]
3333    #[test]
3334    fn parse_decrpm_sync_output_ps0_is_unsupported() {
3335        // Ps = 0 → mode not recognized.
3336        assert_eq!(parse_decrpm_sync_output("\x1b[?2026;0$y"), Some(false));
3337    }
3338
3339    #[cfg(feature = "crossterm")]
3340    #[test]
3341    fn parse_decrpm_sync_output_garbage_is_none() {
3342        // No DECRPM reply for mode 2026 in the string → inconclusive.
3343        assert_eq!(parse_decrpm_sync_output("not a decrpm reply"), None);
3344        // A reply for a *different* mode must not match.
3345        assert_eq!(parse_decrpm_sync_output("\x1b[?2004;1$y"), None);
3346        // Truncated reply (missing `$y` terminator) → None, not a panic.
3347        assert_eq!(parse_decrpm_sync_output("\x1b[?2026;1"), None);
3348        // Non-numeric Ps → None.
3349        assert_eq!(parse_decrpm_sync_output("\x1b[?2026;x$y"), None);
3350    }
3351
3352    #[test]
3353    fn sync_output_gate_defaults_to_emit() {
3354        // With the probe never having run (the unit-test process never enters a
3355        // real terminal session), the resolution stays `Unknown`, so the gate
3356        // must keep emitting BSU/ESU — preserving the historic always-emit
3357        // behavior on headless / non-answering hosts.
3358        assert!(should_emit_synchronized_update());
3359    }
3360
3361    #[cfg(feature = "crossterm")]
3362    #[test]
3363    fn parse_xtgettcap_tc_sets_truecolor() {
3364        let mut caps = Capabilities::default();
3365        // DCS 1 + r 5463 (=Tc) ST → truecolor present.
3366        parse_xtgettcap_truecolor("\x1bP1+r5463=\x1b\\", &mut caps);
3367        assert!(caps.truecolor);
3368    }
3369
3370    #[cfg(feature = "crossterm")]
3371    #[test]
3372    fn parse_xtgettcap_invalid_leaves_truecolor_false() {
3373        let mut caps = Capabilities::default();
3374        // DCS 0 + r (capability NOT present) must not set the flag.
3375        parse_xtgettcap_truecolor("\x1bP0+r5463\x1b\\", &mut caps);
3376        assert!(!caps.truecolor);
3377        // Wrong capname hex must not match.
3378        parse_xtgettcap_truecolor("\x1bP1+r1234=\x1b\\", &mut caps);
3379        assert!(!caps.truecolor);
3380    }
3381
3382    #[cfg(feature = "crossterm")]
3383    #[test]
3384    fn base64_decode_round_trip_hello() {
3385        let encoded = base64_encode("hello".as_bytes());
3386        assert_eq!(base64_decode(&encoded), Some("hello".to_string()));
3387    }
3388
3389    #[cfg(feature = "crossterm")]
3390    #[test]
3391    fn color_scheme_equality() {
3392        assert_eq!(ColorScheme::Dark, ColorScheme::Dark);
3393        assert_ne!(ColorScheme::Dark, ColorScheme::Light);
3394        assert_eq!(ColorScheme::Unknown, ColorScheme::Unknown);
3395    }
3396
3397    fn pair(r: Rect) -> (Rect, Rect) {
3398        (r, r)
3399    }
3400
3401    #[test]
3402    fn find_innermost_rect_picks_smallest() {
3403        let rects = vec![
3404            pair(Rect::new(0, 0, 80, 24)),
3405            pair(Rect::new(5, 2, 30, 10)),
3406            pair(Rect::new(10, 4, 10, 5)),
3407        ];
3408        let result = find_innermost_rect(&rects, 12, 5);
3409        assert_eq!(result, Some(Rect::new(10, 4, 10, 5)));
3410    }
3411
3412    #[test]
3413    fn find_innermost_rect_no_match() {
3414        let rects = vec![pair(Rect::new(10, 10, 5, 5))];
3415        assert_eq!(find_innermost_rect(&rects, 0, 0), None);
3416    }
3417
3418    #[test]
3419    fn find_innermost_rect_empty() {
3420        assert_eq!(find_innermost_rect(&[], 5, 5), None);
3421    }
3422
3423    #[test]
3424    fn find_innermost_rect_returns_content_rect() {
3425        let rects = vec![
3426            (Rect::new(0, 0, 80, 24), Rect::new(1, 1, 78, 22)),
3427            (Rect::new(5, 2, 30, 10), Rect::new(6, 3, 28, 8)),
3428        ];
3429        let result = find_innermost_rect(&rects, 10, 5);
3430        assert_eq!(result, Some(Rect::new(6, 3, 28, 8)));
3431    }
3432
3433    #[test]
3434    fn normalize_selection_already_ordered() {
3435        let (s, e) = normalize_selection((2, 1), (5, 3));
3436        assert_eq!(s, (2, 1));
3437        assert_eq!(e, (5, 3));
3438    }
3439
3440    #[test]
3441    fn normalize_selection_reversed() {
3442        let (s, e) = normalize_selection((5, 3), (2, 1));
3443        assert_eq!(s, (2, 1));
3444        assert_eq!(e, (5, 3));
3445    }
3446
3447    #[test]
3448    fn normalize_selection_same_row() {
3449        let (s, e) = normalize_selection((10, 5), (3, 5));
3450        assert_eq!(s, (3, 5));
3451        assert_eq!(e, (10, 5));
3452    }
3453
3454    #[test]
3455    fn selection_state_mouse_down_finds_rect() {
3456        let hit_map = vec![pair(Rect::new(0, 0, 80, 24)), pair(Rect::new(5, 2, 20, 10))];
3457        let mut sel = SelectionState::default();
3458        sel.mouse_down(10, 5, &hit_map);
3459        assert_eq!(sel.anchor, Some((10, 5)));
3460        assert_eq!(sel.current, Some((10, 5)));
3461        assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 20, 10)));
3462        assert!(!sel.active);
3463    }
3464
3465    #[test]
3466    fn selection_state_drag_activates() {
3467        let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
3468        let mut sel = SelectionState {
3469            anchor: Some((10, 5)),
3470            current: Some((10, 5)),
3471            widget_rect: Some(Rect::new(0, 0, 80, 24)),
3472            ..Default::default()
3473        };
3474        sel.mouse_drag(10, 5, &hit_map);
3475        assert!(!sel.active, "no movement = not active");
3476        sel.mouse_drag(11, 5, &hit_map);
3477        assert!(!sel.active, "1 cell horizontal = not active yet");
3478        sel.mouse_drag(13, 5, &hit_map);
3479        assert!(sel.active, ">1 cell horizontal = active");
3480    }
3481
3482    #[test]
3483    fn selection_state_drag_vertical_activates() {
3484        let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
3485        let mut sel = SelectionState {
3486            anchor: Some((10, 5)),
3487            current: Some((10, 5)),
3488            widget_rect: Some(Rect::new(0, 0, 80, 24)),
3489            ..Default::default()
3490        };
3491        sel.mouse_drag(10, 6, &hit_map);
3492        assert!(sel.active, "any vertical movement = active");
3493    }
3494
3495    #[test]
3496    fn selection_state_drag_expands_widget_rect() {
3497        let hit_map = vec![
3498            pair(Rect::new(0, 0, 80, 24)),
3499            pair(Rect::new(5, 2, 30, 10)),
3500            pair(Rect::new(5, 2, 30, 3)),
3501        ];
3502        let mut sel = SelectionState {
3503            anchor: Some((10, 3)),
3504            current: Some((10, 3)),
3505            widget_rect: Some(Rect::new(5, 2, 30, 3)),
3506            ..Default::default()
3507        };
3508        sel.mouse_drag(10, 6, &hit_map);
3509        assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 30, 10)));
3510    }
3511
3512    #[test]
3513    fn selection_state_clear_resets() {
3514        let mut sel = SelectionState {
3515            anchor: Some((1, 2)),
3516            current: Some((3, 4)),
3517            widget_rect: Some(Rect::new(0, 0, 10, 10)),
3518            active: true,
3519        };
3520        sel.clear();
3521        assert_eq!(sel.anchor, None);
3522        assert_eq!(sel.current, None);
3523        assert_eq!(sel.widget_rect, None);
3524        assert!(!sel.active);
3525    }
3526
3527    #[test]
3528    fn extract_selection_text_single_line() {
3529        let area = Rect::new(0, 0, 20, 5);
3530        let mut buf = Buffer::empty(area);
3531        buf.set_string(0, 0, "Hello World", Style::default());
3532        let sel = SelectionState {
3533            anchor: Some((0, 0)),
3534            current: Some((4, 0)),
3535            widget_rect: Some(area),
3536            active: true,
3537        };
3538        let text = extract_selection_text(&buf, &sel, &[]);
3539        assert_eq!(text, "Hello");
3540    }
3541
3542    #[test]
3543    fn extract_selection_text_multi_line() {
3544        let area = Rect::new(0, 0, 20, 5);
3545        let mut buf = Buffer::empty(area);
3546        buf.set_string(0, 0, "Line one", Style::default());
3547        buf.set_string(0, 1, "Line two", Style::default());
3548        buf.set_string(0, 2, "Line three", Style::default());
3549        let sel = SelectionState {
3550            anchor: Some((5, 0)),
3551            current: Some((3, 2)),
3552            widget_rect: Some(area),
3553            active: true,
3554        };
3555        let text = extract_selection_text(&buf, &sel, &[]);
3556        assert_eq!(text, "one\nLine two\nLine");
3557    }
3558
3559    #[test]
3560    fn extract_selection_text_clamped_to_widget() {
3561        let area = Rect::new(0, 0, 40, 10);
3562        let widget = Rect::new(5, 2, 10, 3);
3563        let mut buf = Buffer::empty(area);
3564        buf.set_string(5, 2, "ABCDEFGHIJ", Style::default());
3565        buf.set_string(5, 3, "KLMNOPQRST", Style::default());
3566        let sel = SelectionState {
3567            anchor: Some((3, 1)),
3568            current: Some((20, 5)),
3569            widget_rect: Some(widget),
3570            active: true,
3571        };
3572        let text = extract_selection_text(&buf, &sel, &[]);
3573        assert_eq!(text, "ABCDEFGHIJ\nKLMNOPQRST");
3574    }
3575
3576    #[test]
3577    fn extract_selection_text_inactive_returns_empty() {
3578        let area = Rect::new(0, 0, 10, 5);
3579        let buf = Buffer::empty(area);
3580        let sel = SelectionState {
3581            anchor: Some((0, 0)),
3582            current: Some((5, 2)),
3583            widget_rect: Some(area),
3584            active: false,
3585        };
3586        assert_eq!(extract_selection_text(&buf, &sel, &[]), "");
3587    }
3588
3589    #[test]
3590    fn apply_selection_overlay_reverses_cells() {
3591        let area = Rect::new(0, 0, 10, 3);
3592        let mut buf = Buffer::empty(area);
3593        buf.set_string(0, 0, "ABCDE", Style::default());
3594        let sel = SelectionState {
3595            anchor: Some((1, 0)),
3596            current: Some((3, 0)),
3597            widget_rect: Some(area),
3598            active: true,
3599        };
3600        apply_selection_overlay(&mut buf, &sel, &[]);
3601        assert!(!buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED));
3602        assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
3603        assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
3604        assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
3605        assert!(!buf.get(4, 0).style.modifiers.contains(Modifiers::REVERSED));
3606    }
3607
3608    #[test]
3609    fn extract_selection_text_skips_border_cells() {
3610        // Simulate two bordered columns side by side:
3611        // Col1: full=(0,0,20,5) content=(1,1,18,3)
3612        // Col2: full=(20,0,20,5) content=(21,1,18,3)
3613        // Parent widget_rect covers both: (0,0,40,5)
3614        let area = Rect::new(0, 0, 40, 5);
3615        let mut buf = Buffer::empty(area);
3616        // Col1 border characters
3617        buf.set_string(0, 0, "╭", Style::default());
3618        buf.set_string(0, 1, "│", Style::default());
3619        buf.set_string(0, 2, "│", Style::default());
3620        buf.set_string(0, 3, "│", Style::default());
3621        buf.set_string(0, 4, "╰", Style::default());
3622        buf.set_string(19, 0, "╮", Style::default());
3623        buf.set_string(19, 1, "│", Style::default());
3624        buf.set_string(19, 2, "│", Style::default());
3625        buf.set_string(19, 3, "│", Style::default());
3626        buf.set_string(19, 4, "╯", Style::default());
3627        // Col2 border characters
3628        buf.set_string(20, 0, "╭", Style::default());
3629        buf.set_string(20, 1, "│", Style::default());
3630        buf.set_string(20, 2, "│", Style::default());
3631        buf.set_string(20, 3, "│", Style::default());
3632        buf.set_string(20, 4, "╰", Style::default());
3633        buf.set_string(39, 0, "╮", Style::default());
3634        buf.set_string(39, 1, "│", Style::default());
3635        buf.set_string(39, 2, "│", Style::default());
3636        buf.set_string(39, 3, "│", Style::default());
3637        buf.set_string(39, 4, "╯", Style::default());
3638        // Content inside Col1
3639        buf.set_string(1, 1, "Hello Col1", Style::default());
3640        buf.set_string(1, 2, "Line2 Col1", Style::default());
3641        // Content inside Col2
3642        buf.set_string(21, 1, "Hello Col2", Style::default());
3643        buf.set_string(21, 2, "Line2 Col2", Style::default());
3644
3645        let content_map = vec![
3646            (Rect::new(0, 0, 20, 5), Rect::new(1, 1, 18, 3)),
3647            (Rect::new(20, 0, 20, 5), Rect::new(21, 1, 18, 3)),
3648        ];
3649
3650        // Select across both columns, rows 1-2
3651        let sel = SelectionState {
3652            anchor: Some((0, 1)),
3653            current: Some((39, 2)),
3654            widget_rect: Some(area),
3655            active: true,
3656        };
3657        let text = extract_selection_text(&buf, &sel, &content_map);
3658        // Should NOT contain border characters (│, ╭, ╮, etc.)
3659        assert!(!text.contains('│'), "Border char │ found in: {text}");
3660        assert!(!text.contains('╭'), "Border char ╭ found in: {text}");
3661        assert!(!text.contains('╮'), "Border char ╮ found in: {text}");
3662        // Should contain actual content
3663        assert!(
3664            text.contains("Hello Col1"),
3665            "Missing Col1 content in: {text}"
3666        );
3667        assert!(
3668            text.contains("Hello Col2"),
3669            "Missing Col2 content in: {text}"
3670        );
3671        assert!(text.contains("Line2 Col1"), "Missing Col1 line2 in: {text}");
3672        assert!(text.contains("Line2 Col2"), "Missing Col2 line2 in: {text}");
3673    }
3674
3675    #[test]
3676    fn apply_selection_overlay_skips_border_cells() {
3677        let area = Rect::new(0, 0, 20, 3);
3678        let mut buf = Buffer::empty(area);
3679        buf.set_string(0, 0, "│", Style::default());
3680        buf.set_string(1, 0, "ABC", Style::default());
3681        buf.set_string(19, 0, "│", Style::default());
3682
3683        let content_map = vec![(Rect::new(0, 0, 20, 3), Rect::new(1, 0, 18, 3))];
3684        let sel = SelectionState {
3685            anchor: Some((0, 0)),
3686            current: Some((19, 0)),
3687            widget_rect: Some(area),
3688            active: true,
3689        };
3690        apply_selection_overlay(&mut buf, &sel, &content_map);
3691        // Border cells at x=0 and x=19 should NOT be reversed
3692        assert!(
3693            !buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED),
3694            "Left border cell should not be reversed"
3695        );
3696        assert!(
3697            !buf.get(19, 0).style.modifiers.contains(Modifiers::REVERSED),
3698            "Right border cell should not be reversed"
3699        );
3700        // Content cells should be reversed
3701        assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
3702        assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
3703        assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
3704    }
3705
3706    #[test]
3707    fn copy_to_clipboard_writes_osc52() {
3708        let mut output: Vec<u8> = Vec::new();
3709        copy_to_clipboard(&mut output, "test").unwrap();
3710        let s = String::from_utf8(output).unwrap();
3711        assert!(s.starts_with("\x1b]52;c;"));
3712        assert!(s.ends_with("\x1b\\"));
3713        assert!(s.contains(&base64_encode(b"test")));
3714    }
3715
3716    // Count occurrences of CSI cursor-move (`ESC [ ... H`) in flush output.
3717    fn count_move_tos(s: &str) -> usize {
3718        let bytes = s.as_bytes();
3719        let mut count = 0;
3720        let mut i = 0;
3721        while i + 1 < bytes.len() {
3722            if bytes[i] == 0x1b && bytes[i + 1] == b'[' {
3723                // Scan to the terminator — final byte in 0x40..=0x7e.
3724                let mut j = i + 2;
3725                while j < bytes.len() && !(0x40..=0x7e).contains(&bytes[j]) {
3726                    j += 1;
3727                }
3728                if j < bytes.len() && bytes[j] == b'H' {
3729                    count += 1;
3730                }
3731                i = j + 1;
3732            } else {
3733                i += 1;
3734            }
3735        }
3736        count
3737    }
3738
3739    #[test]
3740    fn flush_coalesces_consecutive_same_style_cells_into_one_run() {
3741        // 10 cells, identical Style, contiguous columns -> 1 MoveTo + 1 Print.
3742        let area = Rect::new(0, 0, 20, 1);
3743        let mut current = Buffer::empty(area);
3744        let previous = Buffer::empty(area);
3745        let style = Style::new().fg(Color::Red);
3746        for x in 0..10u32 {
3747            let cell = current.get_mut(x, 0);
3748            cell.set_char('X');
3749            cell.set_style(style);
3750        }
3751
3752        let mut out: Vec<u8> = Vec::new();
3753        flush_buffer_diff(
3754            &mut out,
3755            &current,
3756            &previous,
3757            ColorDepth::TrueColor,
3758            0,
3759            &mut String::new(),
3760        )
3761        .unwrap();
3762        let s = String::from_utf8(out).unwrap();
3763
3764        // Exactly one cursor move for the whole run.
3765        assert_eq!(
3766            count_move_tos(&s),
3767            1,
3768            "expected 1 MoveTo for a coalesced run, got {} in {:?}",
3769            count_move_tos(&s),
3770            s
3771        );
3772        // The 10 glyphs are emitted contiguously as a single run.
3773        assert!(
3774            s.contains("XXXXXXXXXX"),
3775            "expected contiguous run 'XXXXXXXXXX' in {:?}",
3776            s
3777        );
3778    }
3779
3780    #[test]
3781    fn flush_breaks_run_on_style_change() {
3782        // 5 red cells + 5 blue cells in the same row -> 2 MoveTo calls not 10.
3783        let area = Rect::new(0, 0, 20, 1);
3784        let mut current = Buffer::empty(area);
3785        let previous = Buffer::empty(area);
3786        let red = Style::new().fg(Color::Red);
3787        let blue = Style::new().fg(Color::Blue);
3788        for x in 0..5u32 {
3789            let cell = current.get_mut(x, 0);
3790            cell.set_char('R');
3791            cell.set_style(red);
3792        }
3793        for x in 5..10u32 {
3794            let cell = current.get_mut(x, 0);
3795            cell.set_char('B');
3796            cell.set_style(blue);
3797        }
3798
3799        let mut out: Vec<u8> = Vec::new();
3800        flush_buffer_diff(
3801            &mut out,
3802            &current,
3803            &previous,
3804            ColorDepth::TrueColor,
3805            0,
3806            &mut String::new(),
3807        )
3808        .unwrap();
3809        let s = String::from_utf8(out).unwrap();
3810
3811        // First run needs a MoveTo; the second run starts exactly where the
3812        // cursor already is, so `last_cursor` suppresses a redundant MoveTo.
3813        // Either way, we should see at most 2 MoveTos and far fewer than 10.
3814        let moves = count_move_tos(&s);
3815        assert!(
3816            moves <= 2,
3817            "expected at most 2 MoveTos across a style boundary, got {} in {:?}",
3818            moves,
3819            s
3820        );
3821        assert!(s.contains("RRRRR"), "missing 'RRRRR' run in {:?}", s);
3822        assert!(s.contains("BBBBB"), "missing 'BBBBB' run in {:?}", s);
3823    }
3824
3825    #[test]
3826    fn flush_breaks_run_on_column_gap() {
3827        // Cells at x=0..3 and x=6..9; gap at x=3,4,5 must split runs.
3828        let area = Rect::new(0, 0, 20, 1);
3829        let mut current = Buffer::empty(area);
3830        let previous = Buffer::empty(area);
3831        let style = Style::new().fg(Color::Green);
3832        for x in 0..3u32 {
3833            current.get_mut(x, 0).set_char('A').set_style(style);
3834        }
3835        for x in 6..9u32 {
3836            current.get_mut(x, 0).set_char('B').set_style(style);
3837        }
3838
3839        let mut out: Vec<u8> = Vec::new();
3840        flush_buffer_diff(
3841            &mut out,
3842            &current,
3843            &previous,
3844            ColorDepth::TrueColor,
3845            0,
3846            &mut String::new(),
3847        )
3848        .unwrap();
3849        let s = String::from_utf8(out).unwrap();
3850
3851        // Two separate runs means two MoveTo commands.
3852        assert_eq!(
3853            count_move_tos(&s),
3854            2,
3855            "expected 2 MoveTos across a column gap, got {} in {:?}",
3856            count_move_tos(&s),
3857            s
3858        );
3859        assert!(s.contains("AAA"), "missing 'AAA' run in {:?}", s);
3860        assert!(s.contains("BBB"), "missing 'BBB' run in {:?}", s);
3861    }
3862
3863    /// Verifies that `flush_buffer_diff` produces identical ANSI output whether the
3864    /// destination is a plain `Vec<u8>` or a `BufWriter<Vec<u8>>`. This ensures the
3865    /// BufWriter wrapper introduced for stdout does not alter the byte stream.
3866    #[test]
3867    fn bufwriter_output_identical_to_direct_write() {
3868        let area = Rect::new(0, 0, 5, 1);
3869        let mut current = Buffer::empty(area);
3870        let previous = Buffer::empty(area);
3871        let style = Style::new().fg(Color::Rgb(255, 128, 0));
3872        for x in 0..5u32 {
3873            current.get_mut(x, 0).set_char('X').set_style(style);
3874        }
3875
3876        let mut direct: Vec<u8> = Vec::new();
3877        flush_buffer_diff(
3878            &mut direct,
3879            &current,
3880            &previous,
3881            ColorDepth::TrueColor,
3882            0,
3883            &mut String::new(),
3884        )
3885        .unwrap();
3886
3887        let mut buffered: BufWriter<Vec<u8>> = BufWriter::with_capacity(65536, Vec::new());
3888        flush_buffer_diff(
3889            &mut buffered,
3890            &current,
3891            &previous,
3892            ColorDepth::TrueColor,
3893            0,
3894            &mut String::new(),
3895        )
3896        .unwrap();
3897        buffered.flush().unwrap();
3898        let via_buf = buffered.into_inner().unwrap();
3899
3900        assert_eq!(
3901            direct, via_buf,
3902            "BufWriter output must be byte-for-byte identical to direct write"
3903        );
3904    }
3905
3906    /// Verifies that a `BufWriter<Vec<u8>>` sink accumulates all writes and only
3907    /// issues a single underlying `write` call to the inner sink when flushed.
3908    /// This is a proxy for the syscall-reduction guarantee on the real stdout.
3909    #[test]
3910    fn bufwriter_coalesces_writes_into_single_flush() {
3911        #[derive(Debug)]
3912        struct CountingWriter {
3913            buf: Vec<u8>,
3914            write_call_count: usize,
3915        }
3916        impl Write for CountingWriter {
3917            fn write(&mut self, data: &[u8]) -> io::Result<usize> {
3918                self.write_call_count += 1;
3919                self.buf.extend_from_slice(data);
3920                Ok(data.len())
3921            }
3922            fn flush(&mut self) -> io::Result<()> {
3923                Ok(())
3924            }
3925        }
3926
3927        let area = Rect::new(0, 0, 10, 1);
3928        let mut current = Buffer::empty(area);
3929        let previous = Buffer::empty(area);
3930        // Alternate styles on every cell to maximise queue! calls inside flush_buffer_diff.
3931        for x in 0..10u32 {
3932            let color = if x % 2 == 0 {
3933                Color::Rgb(255, 0, 0)
3934            } else {
3935                Color::Rgb(0, 255, 0)
3936            };
3937            current
3938                .get_mut(x, 0)
3939                .set_char('Z')
3940                .set_style(Style::new().fg(color));
3941        }
3942
3943        let sink = CountingWriter {
3944            buf: Vec::new(),
3945            write_call_count: 0,
3946        };
3947        let mut bw = BufWriter::with_capacity(65536, sink);
3948        flush_buffer_diff(
3949            &mut bw,
3950            &current,
3951            &previous,
3952            ColorDepth::TrueColor,
3953            0,
3954            &mut String::new(),
3955        )
3956        .unwrap();
3957        bw.flush().unwrap();
3958        let inner = bw.into_inner().unwrap();
3959
3960        // BufWriter should have batched everything into 1 write call to the sink.
3961        assert_eq!(
3962            inner.write_call_count, 1,
3963            "expected 1 write syscall to sink, got {}",
3964            inner.write_call_count
3965        );
3966    }
3967
3968    /// Issue #171 regression: identical buffers must produce no flush
3969    /// output once both have refreshed line hashes. Validates that the
3970    /// per-row skip path is correctness-preserving — a skipped row
3971    /// emits zero bytes, exactly like the per-cell path would for an
3972    /// unchanged row.
3973    #[test]
3974    fn flush_skips_unchanged_rows_when_hashes_match() {
3975        let area = Rect::new(0, 0, 20, 4);
3976        let mut current = Buffer::empty(area);
3977        let mut previous = Buffer::empty(area);
3978        // Populate both buffers with identical content.
3979        for y in 0..4u32 {
3980            current.set_string(0, y, "identical-row-content", Style::new());
3981            previous.set_string(0, y, "identical-row-content", Style::new());
3982        }
3983        current.recompute_line_hashes();
3984        previous.recompute_line_hashes();
3985
3986        let mut out: Vec<u8> = Vec::new();
3987        flush_buffer_diff(
3988            &mut out,
3989            &current,
3990            &previous,
3991            ColorDepth::TrueColor,
3992            0,
3993            &mut String::new(),
3994        )
3995        .unwrap();
3996        assert!(
3997            out.is_empty(),
3998            "identical buffers must emit zero flush bytes; got {} bytes: {:?}",
3999            out.len(),
4000            out
4001        );
4002    }
4003
4004    /// Issue #171 regression: when only some rows match, only those rows
4005    /// are skipped. The differing row must still drive its full per-cell
4006    /// flush path so the terminal sees the correct glyphs.
4007    #[test]
4008    fn flush_skips_only_matching_rows_in_mixed_diff() {
4009        let area = Rect::new(0, 0, 6, 3);
4010        let mut current = Buffer::empty(area);
4011        let mut previous = Buffer::empty(area);
4012        current.set_string(0, 0, "abcdef", Style::new());
4013        previous.set_string(0, 0, "abcdef", Style::new());
4014        current.set_string(0, 1, "xxxxxx", Style::new());
4015        previous.set_string(0, 1, "yyyyyy", Style::new());
4016        current.set_string(0, 2, "zzzzzz", Style::new());
4017        previous.set_string(0, 2, "zzzzzz", Style::new());
4018        current.recompute_line_hashes();
4019        previous.recompute_line_hashes();
4020
4021        let mut out: Vec<u8> = Vec::new();
4022        flush_buffer_diff(
4023            &mut out,
4024            &current,
4025            &previous,
4026            ColorDepth::TrueColor,
4027            0,
4028            &mut String::new(),
4029        )
4030        .unwrap();
4031        let s = String::from_utf8_lossy(&out);
4032        // The mismatched row's new content must appear; matching rows'
4033        // glyphs must not (they share content with `previous`).
4034        assert!(s.contains("xxxxxx"), "differing row must flush: {s:?}");
4035        assert!(
4036            !s.contains("abcdef"),
4037            "matching row 0 must not flush: {s:?}"
4038        );
4039        assert!(
4040            !s.contains("zzzzzz"),
4041            "matching row 2 must not flush: {s:?}"
4042        );
4043    }
4044
4045    fn delta_bytes(old: &Style, new: &Style) -> Vec<u8> {
4046        let mut out = Vec::new();
4047        apply_style_delta(&mut out, old, new, ColorDepth::TrueColor).unwrap();
4048        out
4049    }
4050
4051    fn contains_seq(haystack: &[u8], needle: &[u8]) -> bool {
4052        haystack.windows(needle.len()).any(|w| w == needle)
4053    }
4054
4055    #[test]
4056    fn apply_style_delta_emits_blink_set_and_reset() {
4057        let on = delta_bytes(&Style::new(), &Style::new().blink());
4058        // SGR 5 = SlowBlink.
4059        assert!(contains_seq(&on, b"\x1b[5m"), "blink set: {on:?}");
4060        let off = delta_bytes(&Style::new().blink(), &Style::new());
4061        // SGR 25 = NoBlink.
4062        assert!(contains_seq(&off, b"\x1b[25m"), "blink reset: {off:?}");
4063    }
4064
4065    #[test]
4066    fn apply_style_delta_emits_overline_set_and_reset() {
4067        let on = delta_bytes(&Style::new(), &Style::new().overline());
4068        // SGR 53 = OverLined.
4069        assert!(contains_seq(&on, b"\x1b[53m"), "overline set: {on:?}");
4070        let off = delta_bytes(&Style::new().overline(), &Style::new());
4071        // SGR 55 = NotOverLined.
4072        assert!(contains_seq(&off, b"\x1b[55m"), "overline reset: {off:?}");
4073    }
4074
4075    #[test]
4076    fn apply_style_delta_emits_curly_underline_subparameter() {
4077        let out = delta_bytes(
4078            &Style::new(),
4079            &Style::new().underline_style(UnderlineStyle::Curly),
4080        );
4081        assert!(contains_seq(&out, b"\x1b[4:3m"), "curly underline: {out:?}");
4082    }
4083
4084    #[test]
4085    fn apply_style_delta_emits_underline_color_and_reset() {
4086        let set = delta_bytes(
4087            &Style::new(),
4088            &Style::new().underline_color(Color::Rgb(255, 0, 0)),
4089        );
4090        assert!(
4091            contains_seq(&set, b"\x1b[58:2::255:0:0m"),
4092            "underline color set: {set:?}"
4093        );
4094        let clear = delta_bytes(
4095            &Style::new().underline_color(Color::Rgb(255, 0, 0)),
4096            &Style::new(),
4097        );
4098        assert!(
4099            contains_seq(&clear, b"\x1b[59m"),
4100            "underline color reset: {clear:?}"
4101        );
4102    }
4103
4104    #[test]
4105    fn apply_style_delta_underline_color_indexed_uses_sgr_58_5() {
4106        let out = delta_bytes(
4107            &Style::new(),
4108            &Style::new().underline_color(Color::Indexed(42)),
4109        );
4110        assert!(
4111            contains_seq(&out, b"\x1b[58:5:42m"),
4112            "indexed underline: {out:?}"
4113        );
4114    }
4115
4116    #[test]
4117    fn apply_style_full_emits_blink_overline_and_underline() {
4118        let mut out = Vec::new();
4119        let style = Style::new()
4120            .blink()
4121            .overline()
4122            .underline_style(UnderlineStyle::Dotted)
4123            .underline_color(Color::Rgb(0, 0, 255));
4124        apply_style(&mut out, &style, ColorDepth::TrueColor).unwrap();
4125        assert!(contains_seq(&out, b"\x1b[5m"), "blink: {out:?}");
4126        assert!(contains_seq(&out, b"\x1b[53m"), "overline: {out:?}");
4127        assert!(
4128            contains_seq(&out, b"\x1b[4:4m"),
4129            "dotted underline: {out:?}"
4130        );
4131        assert!(
4132            contains_seq(&out, b"\x1b[58:2::0:0:255m"),
4133            "underline color: {out:?}"
4134        );
4135    }
4136    /// Issue #274: a captured-sink `Terminal` routes a styled cell through the
4137    /// real flush pipeline into the in-process byte sink, and dropping it does
4138    /// not emit teardown escapes (no raw mode was entered).
4139    #[test]
4140    fn with_sink_captures_flush_bytes_and_drops_clean() {
4141        let mut term = Terminal::with_sink(10, 1, ColorDepth::TrueColor);
4142        term.buffer_mut()
4143            .set_string(0, 0, "Z", Style::new().fg(Color::Rgb(200, 50, 50)));
4144        term.flush().unwrap();
4145        let bytes = term.take_sink_bytes();
4146        let s = String::from_utf8_lossy(&bytes);
4147        // Real SGR for the truecolor fg + the printed glyph went to the sink.
4148        assert!(s.contains("\u{1b}[38;2;200;50;50m"), "missing SGR: {s:?}");
4149        assert!(s.contains('Z'), "missing glyph: {s:?}");
4150        // A second take after no flush yields nothing (capture was drained).
4151        assert!(term.take_sink_bytes().is_empty());
4152        // Dropping the harness terminal must not panic or emit teardown.
4153        drop(term);
4154    }
4155
4156    /// Issue #269: hoisting `run_buf` to a reused, caller-owned buffer must not
4157    /// change the emitted bytes. Re-running the diff twice through the *same*
4158    /// `run_buf` (which `clear()`s but keeps capacity at the top of each call)
4159    /// produces the same output as a single fresh-buffer run.
4160    #[test]
4161    fn reused_run_buf_byte_identical_across_frames() {
4162        let area = Rect::new(0, 0, 12, 2);
4163        // `Buffer` is not `Clone`, so rebuild the frame pair on demand.
4164        let make_frame = || {
4165            let mut current = Buffer::empty(area);
4166            let previous = Buffer::empty(area);
4167            current.set_string(0, 0, "hello world", Style::new().fg(Color::Rgb(1, 2, 3)));
4168            current.set_string(0, 1, "second line", Style::new().fg(Color::Rgb(4, 5, 6)));
4169            (current, previous)
4170        };
4171
4172        // Baseline: a fresh run_buf per call.
4173        let mut baseline: Vec<u8> = Vec::new();
4174        {
4175            let (mut a, mut b) = make_frame();
4176            __bench_flush_buffer_diff_mut_with_buf(
4177                &mut baseline,
4178                &mut a,
4179                &mut b,
4180                ColorDepth::TrueColor,
4181                &mut String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
4182            )
4183            .unwrap();
4184        }
4185
4186        // Reuse: run a throwaway frame first, then the real frame through the
4187        // SAME run_buf (now carrying leftover capacity, freshly cleared).
4188        let mut shared = String::with_capacity(RUN_BUF_INITIAL_CAPACITY);
4189        {
4190            let mut warm: Vec<u8> = Vec::new();
4191            let (mut a, mut b) = make_frame();
4192            __bench_flush_buffer_diff_mut_with_buf(
4193                &mut warm,
4194                &mut a,
4195                &mut b,
4196                ColorDepth::TrueColor,
4197                &mut shared,
4198            )
4199            .unwrap();
4200        }
4201        let cap_after_warm = shared.capacity();
4202
4203        let mut reused: Vec<u8> = Vec::new();
4204        let (mut current, mut previous) = make_frame();
4205        __bench_flush_buffer_diff_mut_with_buf(
4206            &mut reused,
4207            &mut current,
4208            &mut previous,
4209            ColorDepth::TrueColor,
4210            &mut shared,
4211        )
4212        .unwrap();
4213
4214        assert_eq!(
4215            baseline, reused,
4216            "reused run_buf must emit byte-identical output"
4217        );
4218        // The reuse path keeps capacity across frames (never re-grows below the
4219        // initial reservation) — the whole point of the hoist.
4220        assert!(
4221            shared.capacity() >= cap_after_warm,
4222            "run_buf capacity must persist across frames"
4223        );
4224    }
4225
4226    /// Issue #269: the OSC 8 hyperlink open, rewritten from `format!` to three
4227    /// borrowed `Print`s, must still emit the exact `\x1b]8;;<url>\x07 ...
4228    /// \x1b]8;;\x07` sequence.
4229    #[test]
4230    fn osc8_hyperlink_emitted_verbatim_after_write_rewrite() {
4231        let area = Rect::new(0, 0, 8, 1);
4232        let mut current = Buffer::empty(area);
4233        let previous = Buffer::empty(area);
4234        let url = "https://example.com/x";
4235        // `set_string_linked` sanitizes + attaches the hyperlink to each cell.
4236        current.set_string_linked(0, 0, "link", Style::new(), url);
4237
4238        let mut out: Vec<u8> = Vec::new();
4239        flush_buffer_diff(
4240            &mut out,
4241            &current,
4242            &previous,
4243            ColorDepth::TrueColor,
4244            0,
4245            &mut String::new(),
4246        )
4247        .unwrap();
4248
4249        let open = format!("\x1b]8;;{url}\x07");
4250        assert!(
4251            contains_seq(&out, open.as_bytes()),
4252            "OSC 8 open must appear verbatim: {:?}",
4253            String::from_utf8_lossy(&out)
4254        );
4255        assert!(
4256            contains_seq(&out, b"\x1b]8;;\x07"),
4257            "OSC 8 close must appear: {:?}",
4258            String::from_utf8_lossy(&out)
4259        );
4260    }
4261
4262    /// Build `n` distinct 8x8 RGBA placements for kitty-flush golden tests.
4263    fn kitty_placements(n: usize) -> Vec<KittyPlacement> {
4264        (0..n)
4265            .map(|i| {
4266                let mut rgba = vec![0u8; 256];
4267                rgba[0] = i as u8;
4268                let content_hash = crate::buffer::hash_rgba(&rgba);
4269                KittyPlacement {
4270                    content_hash,
4271                    rgba: std::sync::Arc::new(rgba),
4272                    src_width: 8,
4273                    src_height: 8,
4274                    x: (i as u32) * 4,
4275                    y: (i as u32) * 2,
4276                    cols: 4,
4277                    rows: 2,
4278                    crop_y: 0,
4279                    crop_h: 0,
4280                }
4281            })
4282            .collect()
4283    }
4284
4285    /// Issue #269: replacing the two per-frame `HashSet`s in
4286    /// `KittyImageManager::flush` with reused `SmallVec` dedup scratch must not
4287    /// change the emitted escape stream for the small placement counts (0, 1, 5)
4288    /// the path actually sees. We assert structural invariants of the byte
4289    /// stream rather than an opaque golden blob so the test documents intent.
4290    #[test]
4291    fn kitty_flush_smallvec_dedup_matches_for_small_n() {
4292        for n in [0usize, 1, 5] {
4293            let placements = kitty_placements(n);
4294            let mut mgr = KittyImageManager::new();
4295
4296            // Frame 1: nothing previously placed → upload + place each image.
4297            let mut frame1: Vec<u8> = Vec::new();
4298            mgr.flush(&mut frame1, &placements, 0).unwrap();
4299            let s1 = String::from_utf8_lossy(&frame1);
4300            // One transmit (`a=t`) and one placement (`a=p`) per image.
4301            assert_eq!(
4302                s1.matches("a=t,").count(),
4303                n,
4304                "n={n}: expected {n} uploads in frame 1: {s1:?}"
4305            );
4306            assert_eq!(
4307                s1.matches("a=p,").count(),
4308                n,
4309                "n={n}: expected {n} placements in frame 1: {s1:?}"
4310            );
4311
4312            // Frame 2: identical placements → fast path, zero output.
4313            let mut frame2: Vec<u8> = Vec::new();
4314            mgr.flush(&mut frame2, &placements, 0).unwrap();
4315            assert!(
4316                frame2.is_empty(),
4317                "n={n}: identical frame must hit the kitty fast path, got {} bytes",
4318                frame2.len()
4319            );
4320
4321            // Frame 3: clear all placements → one delete (`a=d,d=i`) per image,
4322            // deduped by the reused SmallVec, plus image-data cleanup
4323            // (`a=d,d=I`) for every now-unused upload.
4324            let mut frame3: Vec<u8> = Vec::new();
4325            mgr.flush(&mut frame3, &[], 0).unwrap();
4326            let s3 = String::from_utf8_lossy(&frame3);
4327            assert_eq!(
4328                s3.matches("a=d,d=i,").count(),
4329                n,
4330                "n={n}: expected {n} placement deletes in frame 3: {s3:?}"
4331            );
4332            assert_eq!(
4333                s3.matches("a=d,d=I,").count(),
4334                n,
4335                "n={n}: expected {n} image-data deletes in frame 3: {s3:?}"
4336            );
4337        }
4338    }
4339
4340    // ---- #265 sprixel damage matrix ----------------------------------------
4341
4342    use crate::buffer::{SprixelCell, SprixelPlacement};
4343
4344    /// Build a 2×2-cell sprixel at (1, 1) with the given footprint states.
4345    fn make_sprixel(cells: Vec<SprixelCell>) -> SprixelPlacement {
4346        SprixelPlacement {
4347            content_hash: 0xABCD,
4348            seq: "<SIXEL>".to_string(),
4349            x: 1,
4350            y: 1,
4351            cols: 2,
4352            rows: 2,
4353            cells,
4354        }
4355    }
4356
4357    #[test]
4358    fn sprixel_no_text_change_emits_zero_bytes() {
4359        // A frame identical to the previous one must emit no sprixel bytes.
4360        let area = Rect::new(0, 0, 10, 5);
4361        let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
4362
4363        let mut current = Buffer::empty(area);
4364        current.sprixels.push(placement.clone());
4365        let mut previous = Buffer::empty(area);
4366        previous.sprixels.push(placement);
4367
4368        let mut out: Vec<u8> = Vec::new();
4369        flush_sprixels(&mut out, &current, &previous, 0).unwrap();
4370        assert!(out.is_empty(), "stable frame should emit no sprixel bytes");
4371    }
4372
4373    #[test]
4374    fn sprixel_first_frame_blits_once() {
4375        // No previous placement -> the graphic must be emitted exactly once.
4376        let area = Rect::new(0, 0, 10, 5);
4377        let mut current = Buffer::empty(area);
4378        current
4379            .sprixels
4380            .push(make_sprixel(vec![SprixelCell::Opaque; 4]));
4381        let previous = Buffer::empty(area);
4382
4383        let mut out: Vec<u8> = Vec::new();
4384        flush_sprixels(&mut out, &current, &previous, 0).unwrap();
4385        let s = String::from_utf8(out).unwrap();
4386        assert_eq!(s.matches("<SIXEL>").count(), 1);
4387    }
4388
4389    #[test]
4390    fn sprixel_text_in_opaque_cell_reblits_once() {
4391        // A text write over an opaque footprint cell annihilates the graphic.
4392        let area = Rect::new(0, 0, 10, 5);
4393        let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
4394
4395        let mut current = Buffer::empty(area);
4396        current.sprixels.push(placement.clone());
4397        // Write a glyph over the top-left footprint cell (1, 1).
4398        current.set_char(1, 1, 'X', Style::new());
4399
4400        let mut previous = Buffer::empty(area);
4401        previous.sprixels.push(placement);
4402
4403        let mut out: Vec<u8> = Vec::new();
4404        flush_sprixels(&mut out, &current, &previous, 0).unwrap();
4405        let s = String::from_utf8(out).unwrap();
4406        assert_eq!(
4407            s.matches("<SIXEL>").count(),
4408            1,
4409            "opaque-cell text write must re-blit the graphic exactly once"
4410        );
4411    }
4412
4413    #[test]
4414    fn sprixel_text_in_transparent_cell_does_not_reblit() {
4415        // The footprint marks (1, 1) transparent; a text write there must NOT
4416        // re-blit the graphic (the core #265 win).
4417        let area = Rect::new(0, 0, 10, 5);
4418        let cells = vec![
4419            SprixelCell::Transparent, // (1, 1)
4420            SprixelCell::Opaque,      // (2, 1)
4421            SprixelCell::Opaque,      // (1, 2)
4422            SprixelCell::Opaque,      // (2, 2)
4423        ];
4424        let placement = make_sprixel(cells);
4425
4426        let mut current = Buffer::empty(area);
4427        current.sprixels.push(placement.clone());
4428        current.set_char(1, 1, 'X', Style::new());
4429
4430        let mut previous = Buffer::empty(area);
4431        previous.sprixels.push(placement);
4432
4433        let mut out: Vec<u8> = Vec::new();
4434        flush_sprixels(&mut out, &current, &previous, 0).unwrap();
4435        assert!(
4436            out.is_empty(),
4437            "text in a transparent footprint cell must emit zero sprixel bytes"
4438        );
4439    }
4440
4441    #[test]
4442    fn sprixel_text_outside_footprint_does_not_reblit() {
4443        // A text write adjacent to (but outside) the footprint is free.
4444        let area = Rect::new(0, 0, 10, 5);
4445        let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
4446
4447        let mut current = Buffer::empty(area);
4448        current.sprixels.push(placement.clone());
4449        // (5, 0) is well outside the (1,1)-(2,2) footprint.
4450        current.set_char(5, 0, 'Z', Style::new());
4451
4452        let mut previous = Buffer::empty(area);
4453        previous.sprixels.push(placement);
4454
4455        let mut out: Vec<u8> = Vec::new();
4456        flush_sprixels(&mut out, &current, &previous, 0).unwrap();
4457        assert!(
4458            out.is_empty(),
4459            "text outside the footprint must not re-blit the graphic"
4460        );
4461    }
4462
4463    #[test]
4464    fn sprixel_position_change_reblits() {
4465        // Moving the graphic (same content, new x/y) must re-blit.
4466        let area = Rect::new(0, 0, 10, 5);
4467        let mut moved = make_sprixel(vec![SprixelCell::Opaque; 4]);
4468        let original = moved.clone();
4469        moved.x = 4;
4470
4471        let mut current = Buffer::empty(area);
4472        current.sprixels.push(moved);
4473        let mut previous = Buffer::empty(area);
4474        previous.sprixels.push(original);
4475
4476        let mut out: Vec<u8> = Vec::new();
4477        flush_sprixels(&mut out, &current, &previous, 0).unwrap();
4478        let s = String::from_utf8(out).unwrap();
4479        assert_eq!(s.matches("<SIXEL>").count(), 1);
4480    }
4481
4482    #[test]
4483    fn sprixel_content_change_reblits() {
4484        // Same position, different content hash -> re-blit.
4485        let area = Rect::new(0, 0, 10, 5);
4486        let mut recolored = make_sprixel(vec![SprixelCell::Opaque; 4]);
4487        let original = recolored.clone();
4488        recolored.content_hash = 0x1234;
4489        recolored.seq = "<SIXEL2>".to_string();
4490
4491        let mut current = Buffer::empty(area);
4492        current.sprixels.push(recolored);
4493        let mut previous = Buffer::empty(area);
4494        previous.sprixels.push(original);
4495
4496        let mut out: Vec<u8> = Vec::new();
4497        flush_sprixels(&mut out, &current, &previous, 0).unwrap();
4498        let s = String::from_utf8(out).unwrap();
4499        assert_eq!(s.matches("<SIXEL2>").count(), 1);
4500    }
4501
4502    #[test]
4503    fn sprixel_reblit_count_invariant_over_single_cell_writes() {
4504        // Invariant (issue #265 proptest spirit, exhaustive here): for a write
4505        // to a single footprint cell, the number of re-emitted sprixels is 0
4506        // iff that cell is Transparent, else 1.
4507        let area = Rect::new(0, 0, 10, 5);
4508        for (idx, (col, row)) in [(0u32, 0u32), (1, 0), (0, 1), (1, 1)]
4509            .into_iter()
4510            .enumerate()
4511        {
4512            for state in [
4513                SprixelCell::Opaque,
4514                SprixelCell::Mixed,
4515                SprixelCell::Transparent,
4516            ] {
4517                let mut cells = vec![SprixelCell::Opaque; 4];
4518                cells[idx] = state;
4519                let placement = make_sprixel(cells);
4520
4521                let mut current = Buffer::empty(area);
4522                current.sprixels.push(placement.clone());
4523                current.set_char(1 + col, 1 + row, 'A', Style::new());
4524
4525                let mut previous = Buffer::empty(area);
4526                previous.sprixels.push(placement);
4527
4528                let mut out: Vec<u8> = Vec::new();
4529                flush_sprixels(&mut out, &current, &previous, 0).unwrap();
4530                let count = String::from_utf8(out).unwrap().matches("<SIXEL>").count();
4531                let expected = if matches!(state, SprixelCell::Transparent) {
4532                    0
4533                } else {
4534                    1
4535                };
4536                assert_eq!(
4537                    count, expected,
4538                    "cell ({col},{row}) state {state:?}: expected {expected} re-blits"
4539                );
4540            }
4541        }
4542    }
4543
4544    // ---- v0.21.1 sprixel reblit-scan optimization regression ---------------
4545    //
4546    // These drive the hashed-key position lookup and the per-row clean+hash
4547    // shortcut with `recompute_line_hashes` engaged (the real `flush` ordering),
4548    // proving the optimization preserves the exact #265 re-blit semantics.
4549
4550    #[test]
4551    fn sprixel_unchanged_with_hashes_engaged_emits_zero_bytes() {
4552        // Regression: a steady frame (identical to previous) with per-row
4553        // digests refreshed must NOT re-blit. This exercises the per-row
4554        // clean+hash shortcut: every footprint row is clean and hash-matched, so
4555        // the per-cell scan is skipped and nothing is emitted.
4556        let area = Rect::new(0, 0, 10, 5);
4557        let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
4558
4559        let mut current = Buffer::empty(area);
4560        current.sprixels.push(placement.clone());
4561        let mut previous = Buffer::empty(area);
4562        previous.sprixels.push(placement);
4563
4564        // Match `Terminal::flush`: refresh digests before the sprixel pass.
4565        current.recompute_line_hashes();
4566        previous.recompute_line_hashes();
4567        // Sanity: the footprint rows are clean and hash-identical, so the
4568        // shortcut is the path actually taken.
4569        assert!(current.row_clean(1) && current.row_clean(2));
4570        assert_eq!(current.row_hash(1), previous.row_hash(1));
4571
4572        let mut out: Vec<u8> = Vec::new();
4573        flush_sprixels(&mut out, &current, &previous, 0).unwrap();
4574        assert!(
4575            out.is_empty(),
4576            "unchanged sprixel must not be re-blitted (per-row shortcut)"
4577        );
4578    }
4579
4580    #[test]
4581    fn sprixel_changed_text_with_hashes_engaged_reblits_once() {
4582        // Regression: a text write over an opaque footprint cell must still
4583        // re-blit exactly once even with digests refreshed. The touched row is
4584        // dirty (or hash-mismatched), so the shortcut correctly does NOT skip it
4585        // and the per-cell annihilation scan fires.
4586        let area = Rect::new(0, 0, 10, 5);
4587        let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
4588
4589        let mut current = Buffer::empty(area);
4590        current.sprixels.push(placement.clone());
4591        current.set_char(1, 1, 'X', Style::new());
4592        let mut previous = Buffer::empty(area);
4593        previous.sprixels.push(placement);
4594
4595        current.recompute_line_hashes();
4596        previous.recompute_line_hashes();
4597        // The footprint's top row differs from the previous frame.
4598        assert_ne!(current.row_hash(1), previous.row_hash(1));
4599
4600        let mut out: Vec<u8> = Vec::new();
4601        flush_sprixels(&mut out, &current, &previous, 0).unwrap();
4602        let s = String::from_utf8(out).unwrap();
4603        assert_eq!(
4604            s.matches("<SIXEL>").count(),
4605            1,
4606            "annihilating text write must re-blit exactly once"
4607        );
4608    }
4609
4610    #[test]
4611    fn sprixel_changed_text_in_transparent_cell_with_hashes_does_not_reblit() {
4612        // Regression edge case: even though the touched row is dirty/hash-mismatched
4613        // (so the per-row shortcut does NOT skip it), a write landing only on a
4614        // Transparent footprint cell must still emit zero bytes — the per-cell
4615        // damage matrix governs, exactly as in the unoptimized path.
4616        let area = Rect::new(0, 0, 10, 5);
4617        let cells = vec![
4618            SprixelCell::Transparent, // (1, 1)
4619            SprixelCell::Opaque,      // (2, 1)
4620            SprixelCell::Opaque,      // (1, 2)
4621            SprixelCell::Opaque,      // (2, 2)
4622        ];
4623        let placement = make_sprixel(cells);
4624
4625        let mut current = Buffer::empty(area);
4626        current.sprixels.push(placement.clone());
4627        current.set_char(1, 1, 'X', Style::new());
4628        let mut previous = Buffer::empty(area);
4629        previous.sprixels.push(placement);
4630
4631        current.recompute_line_hashes();
4632        previous.recompute_line_hashes();
4633
4634        let mut out: Vec<u8> = Vec::new();
4635        flush_sprixels(&mut out, &current, &previous, 0).unwrap();
4636        assert!(
4637            out.is_empty(),
4638            "transparent-cell text write must not re-blit even with hashes engaged"
4639        );
4640    }
4641
4642    #[test]
4643    fn sprixel_key_matches_partial_eq_contract() {
4644        // The hashed identity key must agree with `SprixelPlacement: PartialEq`:
4645        // equal placements share a key; any field the PartialEq compares
4646        // produces a distinct key.
4647        let base = make_sprixel(vec![SprixelCell::Opaque; 4]);
4648        assert_eq!(sprixel_key(&base), sprixel_key(&base.clone()));
4649
4650        let mut moved = base.clone();
4651        moved.x = 7;
4652        assert_ne!(sprixel_key(&base), sprixel_key(&moved));
4653
4654        let mut recolored = base.clone();
4655        recolored.content_hash = 0x9999;
4656        assert_ne!(sprixel_key(&base), sprixel_key(&recolored));
4657
4658        // The damage matrix is excluded from both PartialEq and the key.
4659        let mut annihilated = base.clone();
4660        annihilated.cells = vec![SprixelCell::Annihilated; 4];
4661        assert_eq!(sprixel_key(&base), sprixel_key(&annihilated));
4662        assert_eq!(base, annihilated);
4663    }
4664
4665    #[test]
4666    fn sprixel_multi_placement_only_changed_one_reblits() {
4667        // With several stacked sprixels, moving one must re-blit only that one;
4668        // the others (clean, hash-matched) stay silent. Exercises the hash-set
4669        // position lookup across multiple placements.
4670        let area = Rect::new(0, 0, 10, 9);
4671        let mut current = Buffer::empty(area);
4672        let mut previous = Buffer::empty(area);
4673        for i in 0..3u32 {
4674            let p = SprixelPlacement {
4675                content_hash: 0x100 + i as u64,
4676                seq: format!("<S{i}>"),
4677                x: 0,
4678                y: i * 3,
4679                cols: 2,
4680                rows: 2,
4681                cells: vec![SprixelCell::Opaque; 4],
4682            };
4683            current.sprixels.push(p.clone());
4684            previous.sprixels.push(p);
4685        }
4686        // Move only the middle sprixel.
4687        current.sprixels[1].x = 5;
4688
4689        current.recompute_line_hashes();
4690        previous.recompute_line_hashes();
4691
4692        let mut out: Vec<u8> = Vec::new();
4693        flush_sprixels(&mut out, &current, &previous, 0).unwrap();
4694        let s = String::from_utf8(out).unwrap();
4695        assert_eq!(s.matches("<S0>").count(), 0);
4696        assert_eq!(
4697            s.matches("<S1>").count(),
4698            1,
4699            "only the moved sprixel reblits"
4700        );
4701        assert_eq!(s.matches("<S2>").count(), 0);
4702    }
4703
4704    #[test]
4705    fn bench_sprixel_fixture_steady_state_emits_nothing() {
4706        // The bench fixture must represent a steady frame (no re-blit) so it
4707        // measures the no-damage scan cost. Guards against the wrapper silently
4708        // emitting work.
4709        let fixture = __bench_new_sprixel_fixture(4);
4710        assert_eq!(fixture.len(), 4);
4711        assert!(!fixture.is_empty());
4712        let mut out: Vec<u8> = Vec::new();
4713        fixture.flush(&mut out, 0).unwrap();
4714        assert!(
4715            out.is_empty(),
4716            "steady-state bench fixture re-blits nothing"
4717        );
4718    }
4719}