Skip to main content

slt/
terminal.rs

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