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