Skip to main content

slt/
terminal.rs

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