Skip to main content

slt/
terminal.rs

1use std::collections::HashMap;
2use std::io::{self, BufWriter, Read, Stdout, Write};
3use std::time::{Duration, Instant};
4
5use crossterm::event::{
6    DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
7    EnableFocusChange, EnableMouseCapture,
8};
9use crossterm::style::{
10    Attribute, Color as CtColor, Print, ResetColor, SetAttribute, SetBackgroundColor,
11    SetForegroundColor,
12};
13use crossterm::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate};
14use crossterm::{cursor, execute, queue, terminal};
15
16use unicode_width::UnicodeWidthStr;
17
18use crate::buffer::{Buffer, KittyPlacement};
19use crate::rect::Rect;
20use crate::style::{Color, ColorDepth, Modifiers, Style};
21
22/// Saturating cast from `u32` to `u16` — clamps to `u16::MAX` instead of truncating.
23#[inline]
24fn sat_u16(v: u32) -> u16 {
25    v.min(u16::MAX as u32) as u16
26}
27
28// ---------------------------------------------------------------------------
29// Kitty graphics protocol image manager
30// ---------------------------------------------------------------------------
31
32/// Manages Kitty graphics protocol image IDs, uploads, and placements.
33///
34/// Images are deduplicated by content hash — identical RGBA data is uploaded
35/// only once. Each frame, placements are diffed against the previous frame
36/// to minimize terminal I/O.
37pub(crate) struct KittyImageManager {
38    next_id: u32,
39    /// content_hash → kitty image ID for uploaded images.
40    uploaded: HashMap<u64, u32>,
41    /// Previous frame's placements (for diff).
42    prev_placements: Vec<KittyPlacement>,
43}
44
45impl KittyImageManager {
46    /// Construct a new image manager with no uploaded images.
47    pub fn new() -> Self {
48        Self {
49            next_id: 1,
50            uploaded: HashMap::new(),
51            prev_placements: Vec::new(),
52        }
53    }
54
55    /// Flush Kitty image placements: upload new images, manage placements.
56    ///
57    /// `row_offset` shifts `current[i].y` for both terminal output and the
58    /// diff comparison against `prev_placements`. Stored placements always
59    /// include the offset (the displayed `y`) so re-emit detection works
60    /// across resize even when the offset itself changes (issue #206).
61    pub fn flush(
62        &mut self,
63        stdout: &mut impl Write,
64        current: &[KittyPlacement],
65        row_offset: u32,
66    ) -> io::Result<()> {
67        // Fast path: nothing changed (compare against post-offset y values
68        // stored in `prev_placements`). This avoids materializing a translated
69        // `Vec<KittyPlacement>` in the caller (issue #206).
70        if current.len() == self.prev_placements.len()
71            && current
72                .iter()
73                .zip(self.prev_placements.iter())
74                .all(|(c, p)| placement_eq_with_offset(c, row_offset, p))
75        {
76            return Ok(());
77        }
78
79        // Delete all previous placements (keep uploaded image data for reuse)
80        if !self.prev_placements.is_empty() {
81            // Delete all visible placements by ID
82            let mut deleted_ids = std::collections::HashSet::new();
83            for p in &self.prev_placements {
84                if let Some(&img_id) = self.uploaded.get(&p.content_hash) {
85                    if deleted_ids.insert(img_id) {
86                        // Delete all placements of this image (but keep image data)
87                        queue!(
88                            stdout,
89                            Print(format!("\x1b_Ga=d,d=i,i={},q=2\x1b\\", img_id))
90                        )?;
91                    }
92                }
93            }
94        }
95
96        // Upload new images and create placements
97        for (idx, p) in current.iter().enumerate() {
98            let img_id = if let Some(&existing_id) = self.uploaded.get(&p.content_hash) {
99                existing_id
100            } else {
101                // Upload new image with zlib compression if available
102                let id = self.next_id;
103                self.next_id += 1;
104                self.upload_image(stdout, id, p)?;
105                self.uploaded.insert(p.content_hash, id);
106                id
107            };
108
109            // Place the image (with row_offset applied to y at point of use).
110            let pid = idx as u32 + 1;
111            self.place_image_offset(stdout, img_id, pid, p, row_offset)?;
112        }
113
114        // Clean up images no longer used by any placement
115        let used_hashes: std::collections::HashSet<u64> =
116            current.iter().map(|p| p.content_hash).collect();
117        let stale: Vec<u64> = self
118            .uploaded
119            .keys()
120            .filter(|h| !used_hashes.contains(h))
121            .copied()
122            .collect();
123        for hash in stale {
124            if let Some(id) = self.uploaded.remove(&hash) {
125                // Delete image data from terminal memory
126                queue!(stdout, Print(format!("\x1b_Ga=d,d=I,i={},q=2\x1b\\", id)))?;
127            }
128        }
129
130        // Persist post-offset placements for the next frame's diff. We still
131        // write `current.len()` items but rebuild the Vec in place — capacity
132        // is preserved across frames so this is at most an `Arc::clone` per
133        // image (the `Vec<u8>` is shared via `Arc`, no pixel copy). This
134        // remains the only `Arc::clone` cost; the per-frame `Vec` allocation
135        // in the caller (`InlineTerminal::flush`) is what #206 eliminates.
136        self.prev_placements.clear();
137        self.prev_placements.reserve(current.len());
138        for p in current {
139            let mut copy = p.clone();
140            copy.y = copy.y.saturating_add(row_offset);
141            self.prev_placements.push(copy);
142        }
143        Ok(())
144    }
145
146    /// Upload image data to the terminal with `a=t` (transmit only, no display).
147    fn upload_image(&self, stdout: &mut impl Write, id: u32, p: &KittyPlacement) -> io::Result<()> {
148        let (payload, compression) = compress_rgba(&p.rgba);
149        let encoded = base64_encode(&payload);
150        let chunks = split_base64(&encoded, 4096);
151
152        for (i, chunk) in chunks.iter().enumerate() {
153            let more = if i < chunks.len() - 1 { 1 } else { 0 };
154            if i == 0 {
155                queue!(
156                    stdout,
157                    Print(format!(
158                        "\x1b_Ga=t,i={},f=32,{}s={},v={},q=2,m={};{}\x1b\\",
159                        id, compression, p.src_width, p.src_height, more, chunk
160                    ))
161                )?;
162            } else {
163                queue!(stdout, Print(format!("\x1b_Gm={};{}\x1b\\", more, chunk)))?;
164            }
165        }
166        Ok(())
167    }
168
169    /// Place an already-uploaded image at a screen position with optional crop.
170    ///
171    /// `row_offset` is added to `p.y` at output time so callers (notably
172    /// `InlineTerminal::flush`) can avoid materializing a translated copy of
173    /// the placements list per frame (issue #206).
174    fn place_image_offset(
175        &self,
176        stdout: &mut impl Write,
177        img_id: u32,
178        placement_id: u32,
179        p: &KittyPlacement,
180        row_offset: u32,
181    ) -> io::Result<()> {
182        let display_y = p.y.saturating_add(row_offset);
183        queue!(stdout, cursor::MoveTo(sat_u16(p.x), sat_u16(display_y)))?;
184
185        let mut cmd = format!(
186            "\x1b_Ga=p,i={},p={},c={},r={},C=1,q=2",
187            img_id, placement_id, p.cols, p.rows
188        );
189
190        // Add crop parameters for scroll clipping
191        if p.crop_y > 0 || p.crop_h > 0 {
192            cmd.push_str(&format!(",y={}", p.crop_y));
193            if p.crop_h > 0 {
194                cmd.push_str(&format!(",h={}", p.crop_h));
195            }
196        }
197
198        cmd.push_str("\x1b\\");
199        queue!(stdout, Print(cmd))?;
200        Ok(())
201    }
202
203    /// Delete all images from the terminal (used on drop/cleanup).
204    pub fn delete_all(&self, stdout: &mut impl Write) -> io::Result<()> {
205        queue!(stdout, Print("\x1b_Ga=d,d=A,q=2\x1b\\"))
206    }
207}
208
209/// Compare a fresh placement (`current`, in pre-offset coordinates) against a
210/// stored placement (`prev`, already includes any prior `row_offset`).
211///
212/// Equivalent to `*current == *prev` after virtually applying `row_offset` to
213/// `current.y`, without materializing the translated copy. Used by
214/// `KittyImageManager::flush` to keep the diff fast-path even when the inline
215/// terminal applies a non-zero offset (issue #206).
216#[inline]
217fn placement_eq_with_offset(
218    current: &KittyPlacement,
219    row_offset: u32,
220    prev: &KittyPlacement,
221) -> bool {
222    current.content_hash == prev.content_hash
223        && current.x == prev.x
224        && current.y.saturating_add(row_offset) == prev.y
225        && current.cols == prev.cols
226        && current.rows == prev.rows
227        && current.crop_y == prev.crop_y
228        && current.crop_h == prev.crop_h
229}
230
231/// Compress RGBA data with zlib if available, returning (payload, format_string).
232fn compress_rgba(data: &[u8]) -> (Vec<u8>, &'static str) {
233    #[cfg(feature = "kitty-compress")]
234    {
235        use flate2::write::ZlibEncoder;
236        use flate2::Compression;
237        let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast());
238        if encoder.write_all(data).is_ok() {
239            if let Ok(compressed) = encoder.finish() {
240                // Only use compression if it actually saves space
241                if compressed.len() < data.len() {
242                    return (compressed, "o=z,");
243                }
244            }
245        }
246    }
247    (data.to_vec(), "")
248}
249
250/// Query the terminal for the actual cell pixel dimensions via CSI 16 t.
251///
252/// Returns `(cell_width, cell_height)` in pixels. Falls back to `(8, 16)` if
253/// detection fails. Used by `kitty_image_fit` for accurate aspect ratio.
254///
255/// Cached after first successful detection.
256pub fn cell_pixel_size() -> (u32, u32) {
257    use std::sync::OnceLock;
258    static CACHED: OnceLock<(u32, u32)> = OnceLock::new();
259    *CACHED.get_or_init(|| detect_cell_pixel_size().unwrap_or((8, 16)))
260}
261
262fn detect_cell_pixel_size() -> Option<(u32, u32)> {
263    // CSI 16 t → reports cell size as CSI 6 ; height ; width t
264    let mut stdout = io::stdout();
265    write!(stdout, "\x1b[16t").ok()?;
266    stdout.flush().ok()?;
267
268    let response = read_osc_response(Duration::from_millis(100))?;
269
270    // Parse: ESC [ 6 ; <height> ; <width> t
271    let body = response.strip_prefix("\x1b[6;").or_else(|| {
272        // CSI can also start with 0x9B (single-byte CSI)
273        let bytes = response.as_bytes();
274        if bytes.len() > 3 && bytes[0] == 0x9b && bytes[1] == b'6' && bytes[2] == b';' {
275            Some(&response[3..])
276        } else {
277            None
278        }
279    })?;
280    let body = body
281        .strip_suffix('t')
282        .or_else(|| body.strip_suffix("t\x1b"))?;
283    let mut parts = body.split(';');
284    let ch: u32 = parts.next()?.parse().ok()?;
285    let cw: u32 = parts.next()?.parse().ok()?;
286    if cw > 0 && ch > 0 {
287        Some((cw, ch))
288    } else {
289        None
290    }
291}
292
293fn split_base64(encoded: &str, chunk_size: usize) -> Vec<&str> {
294    let mut chunks = Vec::new();
295    let bytes = encoded.as_bytes();
296    let mut offset = 0;
297    while offset < bytes.len() {
298        let end = (offset + chunk_size).min(bytes.len());
299        chunks.push(&encoded[offset..end]);
300        offset = end;
301    }
302    if chunks.is_empty() {
303        chunks.push("");
304    }
305    chunks
306}
307
308pub(crate) struct Terminal {
309    stdout: BufWriter<Stdout>,
310    current: Buffer,
311    previous: Buffer,
312    cursor_visible: bool,
313    session: TerminalSessionGuard,
314    color_depth: ColorDepth,
315    pub(crate) theme_bg: Option<Color>,
316    kitty_mgr: KittyImageManager,
317}
318
319pub(crate) struct InlineTerminal {
320    stdout: BufWriter<Stdout>,
321    current: Buffer,
322    previous: Buffer,
323    cursor_visible: bool,
324    session: TerminalSessionGuard,
325    height: u32,
326    start_row: u16,
327    reserved: bool,
328    color_depth: ColorDepth,
329    pub(crate) theme_bg: Option<Color>,
330    kitty_mgr: KittyImageManager,
331}
332
333#[derive(Debug, Clone, Copy, PartialEq, Eq)]
334enum TerminalSessionMode {
335    Fullscreen,
336    Inline,
337}
338
339#[derive(Debug, Clone, Copy)]
340struct TerminalSessionGuard {
341    mode: TerminalSessionMode,
342    mouse_enabled: bool,
343    kitty_keyboard: bool,
344}
345
346impl TerminalSessionGuard {
347    fn enter(
348        mode: TerminalSessionMode,
349        stdout: &mut impl Write,
350        mouse_enabled: bool,
351        kitty_keyboard: bool,
352    ) -> io::Result<Self> {
353        let guard = Self {
354            mode,
355            mouse_enabled,
356            kitty_keyboard,
357        };
358
359        terminal::enable_raw_mode()?;
360        if let Err(err) = write_session_enter(stdout, &guard) {
361            guard.restore(stdout, false);
362            return Err(err);
363        }
364
365        Ok(guard)
366    }
367
368    fn restore(&self, stdout: &mut impl Write, inline_reserved: bool) {
369        if self.kitty_keyboard {
370            use crossterm::event::PopKeyboardEnhancementFlags;
371            let _ = execute!(stdout, PopKeyboardEnhancementFlags);
372        }
373        if self.mouse_enabled {
374            let _ = execute!(stdout, DisableMouseCapture);
375        }
376        let _ = execute!(stdout, DisableFocusChange);
377        let _ = write_session_cleanup(stdout, self.mode, inline_reserved);
378        let _ = terminal::disable_raw_mode();
379    }
380}
381
382impl Terminal {
383    /// Construct a fullscreen terminal backend; enters raw mode and the
384    /// alternate screen and optionally enables mouse capture and the
385    /// kitty keyboard protocol.
386    pub fn new(mouse: bool, kitty_keyboard: bool, color_depth: ColorDepth) -> io::Result<Self> {
387        let (cols, rows) = terminal::size()?;
388        let area = Rect::new(0, 0, cols as u32, rows as u32);
389
390        let mut raw = io::stdout();
391        let session = TerminalSessionGuard::enter(
392            TerminalSessionMode::Fullscreen,
393            &mut raw,
394            mouse,
395            kitty_keyboard,
396        )?;
397
398        Ok(Self {
399            stdout: BufWriter::with_capacity(65536, raw),
400            current: Buffer::empty(area),
401            previous: Buffer::empty(area),
402            cursor_visible: false,
403            session,
404            color_depth,
405            theme_bg: None,
406            kitty_mgr: KittyImageManager::new(),
407        })
408    }
409
410    /// Return the fullscreen terminal's current `(cols, rows)`.
411    pub fn size(&self) -> (u32, u32) {
412        (self.current.area.width, self.current.area.height)
413    }
414
415    /// Mutable access to the back buffer used by the next render pass.
416    pub fn buffer_mut(&mut self) -> &mut Buffer {
417        &mut self.current
418    }
419
420    /// Diff the back buffer against the front buffer, write the changed
421    /// cells to stdout under a synchronized-output guard, then swap
422    /// front and back buffers.
423    pub fn flush(&mut self) -> io::Result<()> {
424        if self.current.area.width < self.previous.area.width {
425            execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
426        }
427
428        queue!(self.stdout, BeginSynchronizedUpdate)?;
429        // Issue #171: refresh both buffers' per-row digests so the per-row
430        // skip inside `flush_buffer_diff` can short-circuit unchanged rows.
431        // `previous` only needs a recompute when the prior frame mutated
432        // it (e.g. after a swap); cheap when nothing's dirty.
433        self.current.recompute_line_hashes();
434        self.previous.recompute_line_hashes();
435        flush_buffer_diff(
436            &mut self.stdout,
437            &self.current,
438            &self.previous,
439            self.color_depth,
440            0,
441        )?;
442
443        // Kitty graphics: structured image management with IDs and compression.
444        // Full-screen mode has no row offset (issue #206).
445        self.kitty_mgr
446            .flush(&mut self.stdout, &self.current.kitty_placements, 0)?;
447
448        // Raw sequences (sixel, other passthrough) — simple diff
449        flush_raw_sequences(&mut self.stdout, &self.current, &self.previous, 0)?;
450
451        queue!(self.stdout, EndSynchronizedUpdate)?;
452        flush_cursor(
453            &mut self.stdout,
454            &mut self.cursor_visible,
455            self.current.cursor_pos(),
456            0,
457            None,
458        )?;
459
460        self.stdout.flush()?;
461
462        std::mem::swap(&mut self.current, &mut self.previous);
463        if let Some(bg) = self.theme_bg {
464            self.current.reset_with_bg(bg);
465        } else {
466            self.current.reset();
467        }
468        Ok(())
469    }
470
471    /// Re-query the terminal size and resize the front and back buffers
472    /// to match. Called from the SIGWINCH handler.
473    pub fn handle_resize(&mut self) -> io::Result<()> {
474        let (cols, rows) = terminal::size()?;
475        let area = Rect::new(0, 0, cols as u32, rows as u32);
476        self.current.resize(area);
477        self.previous.resize(area);
478        execute!(
479            self.stdout,
480            terminal::Clear(terminal::ClearType::All),
481            cursor::MoveTo(0, 0)
482        )?;
483        Ok(())
484    }
485}
486
487impl crate::Backend for Terminal {
488    fn size(&self) -> (u32, u32) {
489        Terminal::size(self)
490    }
491
492    fn buffer_mut(&mut self) -> &mut Buffer {
493        Terminal::buffer_mut(self)
494    }
495
496    fn flush(&mut self) -> io::Result<()> {
497        Terminal::flush(self)
498    }
499}
500
501impl InlineTerminal {
502    /// Construct an inline terminal backend that renders `height` rows
503    /// below the current cursor without entering the alternate screen.
504    /// Optionally enables mouse capture and the kitty keyboard protocol.
505    pub fn new(
506        height: u32,
507        mouse: bool,
508        kitty_keyboard: bool,
509        color_depth: ColorDepth,
510    ) -> io::Result<Self> {
511        let (cols, _) = terminal::size()?;
512        let area = Rect::new(0, 0, cols as u32, height);
513
514        let mut raw = io::stdout();
515        let session = TerminalSessionGuard::enter(
516            TerminalSessionMode::Inline,
517            &mut raw,
518            mouse,
519            kitty_keyboard,
520        )?;
521
522        let (_, cursor_row) = match cursor::position() {
523            Ok(pos) => pos,
524            Err(err) => {
525                session.restore(&mut raw, false);
526                return Err(err);
527            }
528        };
529        Ok(Self {
530            stdout: BufWriter::with_capacity(65536, raw),
531            current: Buffer::empty(area),
532            previous: Buffer::empty(area),
533            cursor_visible: false,
534            session,
535            height,
536            start_row: cursor_row,
537            reserved: false,
538            color_depth,
539            theme_bg: None,
540            kitty_mgr: KittyImageManager::new(),
541        })
542    }
543
544    /// Return the inline terminal's current `(cols, rows)`.
545    pub fn size(&self) -> (u32, u32) {
546        (self.current.area.width, self.current.area.height)
547    }
548
549    /// Mutable access to the back buffer used by the next render pass.
550    pub fn buffer_mut(&mut self) -> &mut Buffer {
551        &mut self.current
552    }
553
554    /// Diff the back buffer against the front buffer, write changed
555    /// cells to stdout under a synchronized-output guard at the
556    /// inline rows reserved below the cursor, then swap buffers.
557    pub fn flush(&mut self) -> io::Result<()> {
558        if self.current.area.width < self.previous.area.width {
559            execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
560        }
561
562        queue!(self.stdout, BeginSynchronizedUpdate)?;
563
564        if !self.reserved {
565            queue!(self.stdout, cursor::MoveToColumn(0))?;
566            for _ in 0..self.height {
567                queue!(self.stdout, Print("\n"))?;
568            }
569            self.reserved = true;
570
571            let (_, rows) = terminal::size()?;
572            let bottom = self.start_row.saturating_add(sat_u16(self.height));
573            if bottom > rows {
574                self.start_row = rows.saturating_sub(sat_u16(self.height));
575            }
576        }
577        let row_offset = self.start_row as u32;
578        // Issue #171: refresh per-row digests before the diff so the
579        // unchanged-row skip can fire (same call shape as `Terminal::flush`).
580        self.current.recompute_line_hashes();
581        self.previous.recompute_line_hashes();
582        flush_buffer_diff(
583            &mut self.stdout,
584            &self.current,
585            &self.previous,
586            self.color_depth,
587            row_offset,
588        )?;
589
590        // Kitty graphics: structured image management with IDs and compression.
591        // Issue #206: pass `row_offset` instead of materializing a translated
592        // `Vec<KittyPlacement>` copy — `KittyImageManager::flush` applies the
593        // offset arithmetically at point of use and stores post-offset y in
594        // `prev_placements` for the next frame's diff.
595        self.kitty_mgr
596            .flush(&mut self.stdout, &self.current.kitty_placements, row_offset)?;
597
598        // Raw sequences (sixel, other passthrough) — simple diff
599        flush_raw_sequences(&mut self.stdout, &self.current, &self.previous, row_offset)?;
600
601        queue!(self.stdout, EndSynchronizedUpdate)?;
602        let fallback_row = row_offset + self.height.saturating_sub(1);
603        flush_cursor(
604            &mut self.stdout,
605            &mut self.cursor_visible,
606            self.current.cursor_pos(),
607            row_offset,
608            Some(fallback_row),
609        )?;
610
611        self.stdout.flush()?;
612
613        std::mem::swap(&mut self.current, &mut self.previous);
614        reset_current_buffer(&mut self.current, self.theme_bg);
615        Ok(())
616    }
617
618    /// Re-query the terminal size and resize the inline buffers to match
619    /// the new column count, preserving the inline row height.
620    pub fn handle_resize(&mut self) -> io::Result<()> {
621        let (cols, _) = terminal::size()?;
622        let area = Rect::new(0, 0, cols as u32, self.height);
623        self.current.resize(area);
624        self.previous.resize(area);
625        execute!(
626            self.stdout,
627            terminal::Clear(terminal::ClearType::All),
628            cursor::MoveTo(0, 0)
629        )?;
630        Ok(())
631    }
632}
633
634impl crate::Backend for InlineTerminal {
635    fn size(&self) -> (u32, u32) {
636        InlineTerminal::size(self)
637    }
638
639    fn buffer_mut(&mut self) -> &mut Buffer {
640        InlineTerminal::buffer_mut(self)
641    }
642
643    fn flush(&mut self) -> io::Result<()> {
644        InlineTerminal::flush(self)
645    }
646}
647
648impl Drop for Terminal {
649    fn drop(&mut self) {
650        // Clean up Kitty images before leaving alternate screen
651        let _ = self.kitty_mgr.delete_all(&mut self.stdout);
652        let _ = self.stdout.flush();
653        self.session.restore(&mut self.stdout, false);
654    }
655}
656
657impl Drop for InlineTerminal {
658    fn drop(&mut self) {
659        let _ = self.kitty_mgr.delete_all(&mut self.stdout);
660        let _ = self.stdout.flush();
661        self.session.restore(&mut self.stdout, self.reserved);
662    }
663}
664
665mod selection;
666pub(crate) use selection::{apply_selection_overlay, extract_selection_text, SelectionState};
667#[cfg(test)]
668pub(crate) use selection::{find_innermost_rect, normalize_selection};
669
670/// Detected terminal color scheme from OSC 11.
671#[non_exhaustive]
672#[cfg(feature = "crossterm")]
673#[derive(Debug, Clone, Copy, PartialEq, Eq)]
674pub enum ColorScheme {
675    /// Dark background detected.
676    Dark,
677    /// Light background detected.
678    Light,
679    /// Could not determine the scheme.
680    Unknown,
681}
682
683#[cfg(feature = "crossterm")]
684fn read_osc_response(timeout: Duration) -> Option<String> {
685    let deadline = Instant::now() + timeout;
686    let mut stdin = io::stdin();
687    let mut bytes = Vec::new();
688    let mut buf = [0u8; 1];
689
690    while Instant::now() < deadline {
691        if !crossterm::event::poll(Duration::from_millis(10)).ok()? {
692            continue;
693        }
694
695        let read = stdin.read(&mut buf).ok()?;
696        if read == 0 {
697            continue;
698        }
699
700        bytes.push(buf[0]);
701
702        if buf[0] == b'\x07' {
703            break;
704        }
705        let len = bytes.len();
706        if len >= 2 && bytes[len - 2] == 0x1B && bytes[len - 1] == b'\\' {
707            break;
708        }
709
710        if bytes.len() >= 4096 {
711            break;
712        }
713    }
714
715    if bytes.is_empty() {
716        return None;
717    }
718
719    String::from_utf8(bytes).ok()
720}
721
722/// Query the terminal's background color via OSC 11 and return the detected scheme.
723#[cfg(feature = "crossterm")]
724pub fn detect_color_scheme() -> ColorScheme {
725    let mut stdout = io::stdout();
726    if write!(stdout, "\x1b]11;?\x07").is_err() {
727        return ColorScheme::Unknown;
728    }
729    if stdout.flush().is_err() {
730        return ColorScheme::Unknown;
731    }
732
733    let Some(response) = read_osc_response(Duration::from_millis(100)) else {
734        return ColorScheme::Unknown;
735    };
736
737    parse_osc11_response(&response)
738}
739
740#[cfg(feature = "crossterm")]
741pub(crate) fn parse_osc11_response(response: &str) -> ColorScheme {
742    let Some(rgb_pos) = response.find("rgb:") else {
743        return ColorScheme::Unknown;
744    };
745
746    let payload = &response[rgb_pos + 4..];
747    let end = payload
748        .find(['\x07', '\x1b', '\r', '\n', ' ', '\t'])
749        .unwrap_or(payload.len());
750    let rgb = &payload[..end];
751
752    let mut channels = rgb.split('/');
753    let (Some(r), Some(g), Some(b), None) = (
754        channels.next(),
755        channels.next(),
756        channels.next(),
757        channels.next(),
758    ) else {
759        return ColorScheme::Unknown;
760    };
761
762    fn parse_channel(channel: &str) -> Option<f64> {
763        if channel.is_empty() || channel.len() > 4 {
764            return None;
765        }
766        let value = u16::from_str_radix(channel, 16).ok()? as f64;
767        let max = ((1u32 << (channel.len() * 4)) - 1) as f64;
768        if max <= 0.0 {
769            return None;
770        }
771        Some((value / max).clamp(0.0, 1.0))
772    }
773
774    let (Some(r), Some(g), Some(b)) = (parse_channel(r), parse_channel(g), parse_channel(b)) else {
775        return ColorScheme::Unknown;
776    };
777
778    let luminance = 0.299 * r + 0.587 * g + 0.114 * b;
779    if luminance < 0.5 {
780        ColorScheme::Dark
781    } else {
782        ColorScheme::Light
783    }
784}
785
786fn base64_encode(input: &[u8]) -> String {
787    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
788    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
789    for chunk in input.chunks(3) {
790        let b0 = chunk[0] as u32;
791        let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
792        let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
793        let triple = (b0 << 16) | (b1 << 8) | b2;
794        out.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
795        out.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
796        out.push(if chunk.len() > 1 {
797            CHARS[((triple >> 6) & 0x3F) as usize] as char
798        } else {
799            '='
800        });
801        out.push(if chunk.len() > 2 {
802            CHARS[(triple & 0x3F) as usize] as char
803        } else {
804            '='
805        });
806    }
807    out
808}
809
810pub(crate) fn copy_to_clipboard(w: &mut impl Write, text: &str) -> io::Result<()> {
811    let encoded = base64_encode(text.as_bytes());
812    write!(w, "\x1b]52;c;{encoded}\x1b\\")?;
813    w.flush()
814}
815
816#[cfg(feature = "crossterm")]
817fn parse_osc52_response(response: &str) -> Option<String> {
818    let osc_pos = response.find("]52;")?;
819    let body = &response[osc_pos + 4..];
820    let semicolon = body.find(';')?;
821    let payload = &body[semicolon + 1..];
822
823    let end = payload
824        .find("\x1b\\")
825        .or_else(|| payload.find('\x07'))
826        .unwrap_or(payload.len());
827    let encoded = payload[..end].trim();
828    if encoded.is_empty() || encoded == "?" {
829        return None;
830    }
831
832    base64_decode(encoded)
833}
834
835/// Read clipboard contents via OSC 52 terminal query.
836#[cfg(feature = "crossterm")]
837pub fn read_clipboard() -> Option<String> {
838    let mut stdout = io::stdout();
839    write!(stdout, "\x1b]52;c;?\x07").ok()?;
840    stdout.flush().ok()?;
841
842    let response = read_osc_response(Duration::from_millis(200))?;
843    parse_osc52_response(&response)
844}
845
846#[cfg(feature = "crossterm")]
847fn base64_decode(input: &str) -> Option<String> {
848    let mut filtered: Vec<u8> = input
849        .bytes()
850        .filter(|b| !matches!(b, b' ' | b'\n' | b'\r' | b'\t'))
851        .collect();
852
853    match filtered.len() % 4 {
854        0 => {}
855        2 => filtered.extend_from_slice(b"=="),
856        3 => filtered.push(b'='),
857        _ => return None,
858    }
859
860    fn decode_val(b: u8) -> Option<u8> {
861        match b {
862            b'A'..=b'Z' => Some(b - b'A'),
863            b'a'..=b'z' => Some(b - b'a' + 26),
864            b'0'..=b'9' => Some(b - b'0' + 52),
865            b'+' => Some(62),
866            b'/' => Some(63),
867            _ => None,
868        }
869    }
870
871    let mut out = Vec::with_capacity((filtered.len() / 4) * 3);
872    for chunk in filtered.chunks_exact(4) {
873        let p2 = chunk[2] == b'=';
874        let p3 = chunk[3] == b'=';
875        if p2 && !p3 {
876            return None;
877        }
878
879        let v0 = decode_val(chunk[0])? as u32;
880        let v1 = decode_val(chunk[1])? as u32;
881        let v2 = if p2 { 0 } else { decode_val(chunk[2])? as u32 };
882        let v3 = if p3 { 0 } else { decode_val(chunk[3])? as u32 };
883
884        let triple = (v0 << 18) | (v1 << 12) | (v2 << 6) | v3;
885        out.push(((triple >> 16) & 0xFF) as u8);
886        if !p2 {
887            out.push(((triple >> 8) & 0xFF) as u8);
888        }
889        if !p3 {
890            out.push((triple & 0xFF) as u8);
891        }
892    }
893
894    String::from_utf8(out).ok()
895}
896
897#[allow(clippy::too_many_arguments)]
898#[allow(unused_assignments)]
899fn flush_buffer_diff(
900    stdout: &mut impl Write,
901    current: &Buffer,
902    previous: &Buffer,
903    color_depth: ColorDepth,
904    row_offset: u32,
905) -> io::Result<()> {
906    // Run-coalescing: consecutive changed cells in the same row that share
907    // `Style` + `hyperlink` + contiguous x-coordinates are emitted as a single
908    // `Print(run)` after one cursor move and one style delta. This cuts the
909    // number of `queue!` calls on a full redraw from O(cells) to
910    // O(style-change boundaries), which is the dominant stdout write cost.
911    //
912    // A run is broken whenever:
913    //   * style, hyperlink, or row changes,
914    //   * the next cell is not at the expected next column (gap from skipped
915    //     cells — unchanged, empty wide-char trailer, or end of row),
916    //   * end-of-row (always flushed before descending to the next row).
917    let mut last_style = Style::new();
918    let mut first_style = true;
919    let mut active_link: Option<&str> = None;
920    let mut has_updates = false;
921    // Where we believe the cursor currently sits — lets us skip a redundant
922    // `MoveTo` when a new run starts exactly where the previous one ended
923    // (e.g. split only by a style change on otherwise contiguous columns).
924    let mut last_cursor: Option<(u32, u32)> = None;
925
926    // Active run state. `run_next_col` is the column the next cell must
927    // occupy to extend the run; `run_open` guards the rest of the fields.
928    let mut run_buf = String::new();
929    let mut run_abs_y: u32 = 0;
930    let mut run_style: Style = Style::new();
931    let mut run_link: Option<&str> = None;
932    let mut run_next_col: u32 = 0;
933    let mut run_open = false;
934
935    // Helper: flush the currently open run, if any. Emits a single `Print`
936    // for the entire accumulated buffer; positioning, style, and OSC 8 were
937    // already written when the run opened. Updates `last_cursor` to reflect
938    // where the cursor ends up after the Print.
939    macro_rules! flush_run {
940        ($stdout:expr) => {
941            if run_open {
942                queue!($stdout, Print(&run_buf))?;
943                last_cursor = Some((run_next_col, run_abs_y));
944                run_buf.clear();
945                run_open = false;
946            }
947        };
948    }
949
950    for y in current.area.y..current.area.bottom() {
951        // Issue #171: skip the per-cell scan for rows that were not touched
952        // since the last hash refresh AND match the previous frame's
953        // digest. Both conditions must hold:
954        //   * `row_clean` rules out rows that received writes this frame
955        //     even if those writes happened to land on identical cells.
956        //   * The hash equality is the actual unchanged-row signal.
957        // Falling through to the per-cell loop on either failure preserves
958        // legacy behavior; the skip is a pure short-circuit.
959        if current.row_clean(y)
960            && current.row_hash(y).is_some()
961            && current.row_hash(y) == previous.row_hash(y)
962        {
963            continue;
964        }
965        for x in current.area.x..current.area.right() {
966            let cell = current.get(x, y);
967            let prev = previous.get(x, y);
968            if cell == prev || cell.symbol.is_empty() {
969                // Gap — any open run on this row must be flushed.
970                flush_run!(stdout);
971                continue;
972            }
973
974            let abs_y = row_offset + y;
975            // Defense-in-depth: `Cell::hyperlink` is a public field that can
976            // be written directly. `set_string_linked` pre-sanitizes, but a
977            // direct write could still smuggle control bytes into the OSC 8
978            // payload. Validate here before flushing to stdout.
979            let cell_link = cell
980                .hyperlink
981                .as_deref()
982                .filter(|u| crate::buffer::is_valid_osc8_url(u));
983
984            // Decide whether this cell extends the open run or starts a new one.
985            let extends = run_open
986                && run_abs_y == abs_y
987                && run_next_col == x
988                && run_style == cell.style
989                && run_link == cell_link;
990
991            if !extends {
992                flush_run!(stdout);
993
994                // Begin a new run. Emit positioning + style + OSC 8 header now
995                // (before the Print bytes) so the resulting stream is a valid
996                // SGR sequence exactly matching the per-cell flush.
997                has_updates = true;
998
999                let need_move = last_cursor.map_or(true, |(lx, ly)| lx != x || ly != abs_y);
1000                if need_move {
1001                    queue!(stdout, cursor::MoveTo(sat_u16(x), sat_u16(abs_y)))?;
1002                }
1003
1004                if cell.style != last_style {
1005                    if first_style {
1006                        queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
1007                        apply_style(stdout, &cell.style, color_depth)?;
1008                        first_style = false;
1009                    } else {
1010                        apply_style_delta(stdout, &last_style, &cell.style, color_depth)?;
1011                    }
1012                    last_style = cell.style;
1013                }
1014
1015                if cell_link != active_link {
1016                    if let Some(url) = cell_link {
1017                        queue!(stdout, Print(format!("\x1b]8;;{url}\x07")))?;
1018                    } else {
1019                        queue!(stdout, Print("\x1b]8;;\x07"))?;
1020                    }
1021                    active_link = cell_link;
1022                }
1023
1024                run_open = true;
1025                run_abs_y = abs_y;
1026                run_style = cell.style;
1027                run_link = cell_link;
1028            }
1029
1030            // Append the cell's grapheme cluster (possibly multi-char when it
1031            // carries combining marks). Wide chars advance by their column
1032            // width so subsequent cells line up.
1033            run_buf.push_str(&cell.symbol);
1034            let char_width = UnicodeWidthStr::width(cell.symbol.as_str()).max(1) as u32;
1035            if char_width > 1 && cell.symbol.chars().any(|c| c == '\u{FE0F}') {
1036                // Emoji variation selector — terminal renders 2 cols but the
1037                // glyph often measures as 1; pad so the cursor ends up where
1038                // the next cell is drawn.
1039                run_buf.push(' ');
1040            }
1041            run_next_col = x + char_width;
1042        }
1043
1044        // End of row: flush whatever is buffered before moving to the next row.
1045        flush_run!(stdout);
1046    }
1047
1048    if has_updates {
1049        if active_link.is_some() {
1050            queue!(stdout, Print("\x1b]8;;\x07"))?;
1051        }
1052        queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
1053    }
1054
1055    Ok(())
1056}
1057
1058/// Benchmark-only entry point for the per-frame buffer flush.
1059///
1060/// Exposed so criterion benches under `benches/` (an external crate) can
1061/// measure the stdout-emit cost of the per-frame flush against a hermetic
1062/// `Vec<u8>` (or any `Write`) sink, without constructing a real terminal.
1063///
1064/// Not part of the stable API. Do not depend on this in application code —
1065/// prefer the real terminal backend ([`crate::run`]) or
1066/// [`TestBackend`](crate::TestBackend).
1067#[doc(hidden)]
1068pub fn __bench_flush_buffer_diff<W: Write>(
1069    w: &mut W,
1070    current: &Buffer,
1071    previous: &Buffer,
1072    color_depth: ColorDepth,
1073) -> io::Result<()> {
1074    flush_buffer_diff(w, current, previous, color_depth, 0)
1075}
1076
1077/// Mutable-buffer variant of [`__bench_flush_buffer_diff`] (issue #171).
1078///
1079/// Refreshes per-row digests on both buffers before invoking
1080/// `flush_buffer_diff`, matching what the real `Terminal::flush` and
1081/// `InlineTerminal::flush` paths do. Benches that want to measure the
1082/// flush including the hash-refresh cost should use this entry point;
1083/// the immutable variant is preserved for backwards compatibility with
1084/// existing benches that own only `&Buffer`.
1085#[doc(hidden)]
1086pub fn __bench_flush_buffer_diff_mut<W: Write>(
1087    w: &mut W,
1088    current: &mut Buffer,
1089    previous: &mut Buffer,
1090    color_depth: ColorDepth,
1091) -> io::Result<()> {
1092    current.recompute_line_hashes();
1093    previous.recompute_line_hashes();
1094    flush_buffer_diff(w, current, previous, color_depth, 0)
1095}
1096
1097/// Opaque test fixture wrapping `KittyImageManager` + a placements list.
1098///
1099/// Returned by [`__bench_new_kitty_fixture`]. Internal types stay
1100/// `pub(crate)` — only the opaque struct crosses the crate boundary.
1101#[doc(hidden)]
1102pub struct __BenchKittyFixture {
1103    mgr: KittyImageManager,
1104    placements: Vec<KittyPlacement>,
1105}
1106
1107/// Build a self-contained kitty-flush fixture for the perf alloc suite
1108/// (issue #206). `n` is the number of distinct images.
1109#[doc(hidden)]
1110pub fn __bench_new_kitty_fixture(n: usize) -> __BenchKittyFixture {
1111    let mut placements = Vec::with_capacity(n);
1112    for i in 0..n {
1113        // 8x8 RGBA: 64 px * 4 bytes = 256 bytes.
1114        let mut rgba = vec![0u8; 256];
1115        // Vary contents per placement to give each a unique content_hash.
1116        rgba[0] = i as u8;
1117        let content_hash = crate::buffer::hash_rgba(&rgba);
1118        placements.push(KittyPlacement {
1119            content_hash,
1120            rgba: std::sync::Arc::new(rgba),
1121            src_width: 8,
1122            src_height: 8,
1123            x: (i as u32) * 4,
1124            y: (i as u32) * 2,
1125            cols: 4,
1126            rows: 2,
1127            crop_y: 0,
1128            crop_h: 0,
1129        });
1130    }
1131    __BenchKittyFixture {
1132        mgr: KittyImageManager::new(),
1133        placements,
1134    }
1135}
1136
1137impl __BenchKittyFixture {
1138    /// Strong-count snapshot of the inner `Arc<Vec<u8>>` for each placement.
1139    /// Used by the alloc-budget tests to confirm no extra Arc clones leak
1140    /// past the manager's stored `prev_placements`.
1141    #[doc(hidden)]
1142    pub fn rgba_strong_counts(&self) -> Vec<usize> {
1143        self.placements
1144            .iter()
1145            .map(|p| std::sync::Arc::strong_count(&p.rgba))
1146            .collect()
1147    }
1148
1149    /// Run the inline-mode flush path with the given row offset. Writes
1150    /// terminal escapes into `sink` and updates the internal manager state.
1151    #[doc(hidden)]
1152    pub fn flush_inline<W: Write>(&mut self, sink: &mut W, row_offset: u32) -> io::Result<()> {
1153        self.mgr.flush(sink, &self.placements, row_offset)
1154    }
1155
1156    /// Number of placements in this fixture.
1157    #[doc(hidden)]
1158    pub fn len(&self) -> usize {
1159        self.placements.len()
1160    }
1161
1162    /// Whether this fixture has zero placements.
1163    #[doc(hidden)]
1164    pub fn is_empty(&self) -> bool {
1165        self.placements.is_empty()
1166    }
1167}
1168
1169fn flush_raw_sequences(
1170    stdout: &mut impl Write,
1171    current: &Buffer,
1172    previous: &Buffer,
1173    row_offset: u32,
1174) -> io::Result<()> {
1175    if current.raw_sequences == previous.raw_sequences {
1176        return Ok(());
1177    }
1178
1179    for (x, y, seq) in &current.raw_sequences {
1180        queue!(
1181            stdout,
1182            cursor::MoveTo(sat_u16(*x), sat_u16(row_offset + *y)),
1183            Print(seq)
1184        )?;
1185    }
1186
1187    Ok(())
1188}
1189
1190fn flush_cursor(
1191    stdout: &mut impl Write,
1192    cursor_visible: &mut bool,
1193    cursor_pos: Option<(u32, u32)>,
1194    row_offset: u32,
1195    fallback_row: Option<u32>,
1196) -> io::Result<()> {
1197    match cursor_pos {
1198        Some((cx, cy)) => {
1199            if !*cursor_visible {
1200                queue!(stdout, cursor::Show)?;
1201                *cursor_visible = true;
1202            }
1203            queue!(
1204                stdout,
1205                cursor::MoveTo(sat_u16(cx), sat_u16(row_offset + cy))
1206            )?;
1207        }
1208        None => {
1209            if *cursor_visible {
1210                queue!(stdout, cursor::Hide)?;
1211                *cursor_visible = false;
1212            }
1213            if let Some(row) = fallback_row {
1214                queue!(stdout, cursor::MoveTo(0, sat_u16(row)))?;
1215            }
1216        }
1217    }
1218
1219    Ok(())
1220}
1221
1222fn apply_style_delta(
1223    w: &mut impl Write,
1224    old: &Style,
1225    new: &Style,
1226    depth: ColorDepth,
1227) -> io::Result<()> {
1228    if old.fg != new.fg {
1229        match new.fg {
1230            Some(fg) => queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?,
1231            None => queue!(w, SetForegroundColor(CtColor::Reset))?,
1232        }
1233    }
1234    if old.bg != new.bg {
1235        match new.bg {
1236            Some(bg) => queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?,
1237            None => queue!(w, SetBackgroundColor(CtColor::Reset))?,
1238        }
1239    }
1240    let removed = Modifiers(old.modifiers.0 & !new.modifiers.0);
1241    let added = Modifiers(new.modifiers.0 & !old.modifiers.0);
1242    if removed.contains(Modifiers::BOLD) || removed.contains(Modifiers::DIM) {
1243        queue!(w, SetAttribute(Attribute::NormalIntensity))?;
1244        if new.modifiers.contains(Modifiers::BOLD) {
1245            queue!(w, SetAttribute(Attribute::Bold))?;
1246        }
1247        if new.modifiers.contains(Modifiers::DIM) {
1248            queue!(w, SetAttribute(Attribute::Dim))?;
1249        }
1250    } else {
1251        if added.contains(Modifiers::BOLD) {
1252            queue!(w, SetAttribute(Attribute::Bold))?;
1253        }
1254        if added.contains(Modifiers::DIM) {
1255            queue!(w, SetAttribute(Attribute::Dim))?;
1256        }
1257    }
1258    if removed.contains(Modifiers::ITALIC) {
1259        queue!(w, SetAttribute(Attribute::NoItalic))?;
1260    }
1261    if added.contains(Modifiers::ITALIC) {
1262        queue!(w, SetAttribute(Attribute::Italic))?;
1263    }
1264    if removed.contains(Modifiers::UNDERLINE) {
1265        queue!(w, SetAttribute(Attribute::NoUnderline))?;
1266    }
1267    if added.contains(Modifiers::UNDERLINE) {
1268        queue!(w, SetAttribute(Attribute::Underlined))?;
1269    }
1270    if removed.contains(Modifiers::REVERSED) {
1271        queue!(w, SetAttribute(Attribute::NoReverse))?;
1272    }
1273    if added.contains(Modifiers::REVERSED) {
1274        queue!(w, SetAttribute(Attribute::Reverse))?;
1275    }
1276    if removed.contains(Modifiers::STRIKETHROUGH) {
1277        queue!(w, SetAttribute(Attribute::NotCrossedOut))?;
1278    }
1279    if added.contains(Modifiers::STRIKETHROUGH) {
1280        queue!(w, SetAttribute(Attribute::CrossedOut))?;
1281    }
1282    Ok(())
1283}
1284
1285fn apply_style(w: &mut impl Write, style: &Style, depth: ColorDepth) -> io::Result<()> {
1286    if let Some(fg) = style.fg {
1287        queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?;
1288    }
1289    if let Some(bg) = style.bg {
1290        queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?;
1291    }
1292    let m = style.modifiers;
1293    if m.contains(Modifiers::BOLD) {
1294        queue!(w, SetAttribute(Attribute::Bold))?;
1295    }
1296    if m.contains(Modifiers::DIM) {
1297        queue!(w, SetAttribute(Attribute::Dim))?;
1298    }
1299    if m.contains(Modifiers::ITALIC) {
1300        queue!(w, SetAttribute(Attribute::Italic))?;
1301    }
1302    if m.contains(Modifiers::UNDERLINE) {
1303        queue!(w, SetAttribute(Attribute::Underlined))?;
1304    }
1305    if m.contains(Modifiers::REVERSED) {
1306        queue!(w, SetAttribute(Attribute::Reverse))?;
1307    }
1308    if m.contains(Modifiers::STRIKETHROUGH) {
1309        queue!(w, SetAttribute(Attribute::CrossedOut))?;
1310    }
1311    Ok(())
1312}
1313
1314fn to_crossterm_color(color: Color, depth: ColorDepth) -> CtColor {
1315    let color = color.downsampled(depth);
1316    match color {
1317        Color::Reset => CtColor::Reset,
1318        Color::Black => CtColor::Black,
1319        Color::Red => CtColor::DarkRed,
1320        Color::Green => CtColor::DarkGreen,
1321        Color::Yellow => CtColor::DarkYellow,
1322        Color::Blue => CtColor::DarkBlue,
1323        Color::Magenta => CtColor::DarkMagenta,
1324        Color::Cyan => CtColor::DarkCyan,
1325        Color::White => CtColor::White,
1326        Color::DarkGray => CtColor::DarkGrey,
1327        Color::LightRed => CtColor::Red,
1328        Color::LightGreen => CtColor::Green,
1329        Color::LightYellow => CtColor::Yellow,
1330        Color::LightBlue => CtColor::Blue,
1331        Color::LightMagenta => CtColor::Magenta,
1332        Color::LightCyan => CtColor::Cyan,
1333        Color::LightWhite => CtColor::White,
1334        Color::Rgb(r, g, b) => CtColor::Rgb { r, g, b },
1335        Color::Indexed(i) => CtColor::AnsiValue(i),
1336    }
1337}
1338
1339fn reset_current_buffer(buffer: &mut Buffer, theme_bg: Option<Color>) {
1340    if let Some(bg) = theme_bg {
1341        buffer.reset_with_bg(bg);
1342    } else {
1343        buffer.reset();
1344    }
1345}
1346
1347fn write_session_enter(stdout: &mut impl Write, session: &TerminalSessionGuard) -> io::Result<()> {
1348    match session.mode {
1349        TerminalSessionMode::Fullscreen => {
1350            execute!(
1351                stdout,
1352                terminal::EnterAlternateScreen,
1353                cursor::Hide,
1354                EnableBracketedPaste
1355            )?;
1356        }
1357        TerminalSessionMode::Inline => {
1358            execute!(stdout, cursor::Hide, EnableBracketedPaste)?;
1359        }
1360    }
1361
1362    // Focus-change reporting is independent of mouse capture — callers
1363    // routinely pause animations or clear hover state on focus loss even
1364    // without mouse support. Enabling it unconditionally matches modern
1365    // TUI conventions (zellij, helix, yazi) and the cost is one extra SGR
1366    // per session.
1367    execute!(stdout, EnableFocusChange)?;
1368    if session.mouse_enabled {
1369        execute!(stdout, EnableMouseCapture)?;
1370    }
1371    if session.kitty_keyboard {
1372        use crossterm::event::{KeyboardEnhancementFlags, PushKeyboardEnhancementFlags};
1373        let _ = execute!(
1374            stdout,
1375            PushKeyboardEnhancementFlags(
1376                KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
1377                    | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
1378            )
1379        );
1380    }
1381
1382    Ok(())
1383}
1384
1385fn write_session_cleanup(
1386    stdout: &mut impl Write,
1387    mode: TerminalSessionMode,
1388    inline_reserved: bool,
1389) -> io::Result<()> {
1390    execute!(
1391        stdout,
1392        ResetColor,
1393        SetAttribute(Attribute::Reset),
1394        cursor::Show,
1395        DisableBracketedPaste
1396    )?;
1397
1398    match mode {
1399        TerminalSessionMode::Fullscreen => {
1400            execute!(stdout, terminal::LeaveAlternateScreen)?;
1401        }
1402        TerminalSessionMode::Inline => {
1403            if inline_reserved {
1404                execute!(
1405                    stdout,
1406                    cursor::MoveToColumn(0),
1407                    cursor::MoveDown(1),
1408                    cursor::MoveToColumn(0),
1409                    Print("\n")
1410                )?;
1411            } else {
1412                execute!(stdout, Print("\n"))?;
1413            }
1414        }
1415    }
1416
1417    Ok(())
1418}
1419
1420#[cfg(test)]
1421mod tests {
1422    #![allow(clippy::unwrap_used)]
1423    use super::*;
1424
1425    #[test]
1426    fn reset_current_buffer_applies_theme_background() {
1427        let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 1));
1428
1429        reset_current_buffer(&mut buffer, Some(Color::Rgb(10, 20, 30)));
1430        assert_eq!(buffer.get(0, 0).style.bg, Some(Color::Rgb(10, 20, 30)));
1431
1432        reset_current_buffer(&mut buffer, None);
1433        assert_eq!(buffer.get(0, 0).style.bg, None);
1434    }
1435
1436    #[test]
1437    fn fullscreen_session_enter_writes_alt_screen_sequence() {
1438        let session = TerminalSessionGuard {
1439            mode: TerminalSessionMode::Fullscreen,
1440            mouse_enabled: false,
1441            kitty_keyboard: false,
1442        };
1443        let mut out = Vec::new();
1444        write_session_enter(&mut out, &session).unwrap();
1445        let output = String::from_utf8(out).unwrap();
1446        assert!(output.contains("\u{1b}[?1049h"));
1447        assert!(output.contains("\u{1b}[?25l"));
1448        assert!(output.contains("\u{1b}[?2004h"));
1449    }
1450
1451    #[test]
1452    fn inline_session_enter_skips_alt_screen_sequence() {
1453        let session = TerminalSessionGuard {
1454            mode: TerminalSessionMode::Inline,
1455            mouse_enabled: false,
1456            kitty_keyboard: false,
1457        };
1458        let mut out = Vec::new();
1459        write_session_enter(&mut out, &session).unwrap();
1460        let output = String::from_utf8(out).unwrap();
1461        assert!(!output.contains("\u{1b}[?1049h"));
1462        assert!(output.contains("\u{1b}[?25l"));
1463        assert!(output.contains("\u{1b}[?2004h"));
1464    }
1465
1466    #[test]
1467    fn fullscreen_session_cleanup_leaves_alt_screen() {
1468        let mut out = Vec::new();
1469        write_session_cleanup(&mut out, TerminalSessionMode::Fullscreen, false).unwrap();
1470        let output = String::from_utf8(out).unwrap();
1471        assert!(output.contains("\u{1b}[?1049l"));
1472        assert!(output.contains("\u{1b}[?25h"));
1473        assert!(output.contains("\u{1b}[?2004l"));
1474    }
1475
1476    #[test]
1477    fn inline_session_cleanup_keeps_normal_screen() {
1478        let mut out = Vec::new();
1479        write_session_cleanup(&mut out, TerminalSessionMode::Inline, false).unwrap();
1480        let output = String::from_utf8(out).unwrap();
1481        assert!(!output.contains("\u{1b}[?1049l"));
1482        assert!(output.ends_with('\n'));
1483        assert!(output.contains("\u{1b}[?25h"));
1484        assert!(output.contains("\u{1b}[?2004l"));
1485    }
1486
1487    #[test]
1488    fn base64_encode_empty() {
1489        assert_eq!(base64_encode(b""), "");
1490    }
1491
1492    #[test]
1493    fn base64_encode_hello() {
1494        assert_eq!(base64_encode(b"Hello"), "SGVsbG8=");
1495    }
1496
1497    #[test]
1498    fn base64_encode_padding() {
1499        assert_eq!(base64_encode(b"a"), "YQ==");
1500        assert_eq!(base64_encode(b"ab"), "YWI=");
1501        assert_eq!(base64_encode(b"abc"), "YWJj");
1502    }
1503
1504    #[test]
1505    fn base64_encode_unicode() {
1506        assert_eq!(base64_encode("한글".as_bytes()), "7ZWc6riA");
1507    }
1508
1509    #[cfg(feature = "crossterm")]
1510    #[test]
1511    fn parse_osc11_response_dark_and_light() {
1512        assert_eq!(
1513            parse_osc11_response("\x1b]11;rgb:0000/0000/0000\x1b\\"),
1514            ColorScheme::Dark
1515        );
1516        assert_eq!(
1517            parse_osc11_response("\x1b]11;rgb:ffff/ffff/ffff\x07"),
1518            ColorScheme::Light
1519        );
1520    }
1521
1522    #[cfg(feature = "crossterm")]
1523    #[test]
1524    fn base64_decode_round_trip_hello() {
1525        let encoded = base64_encode("hello".as_bytes());
1526        assert_eq!(base64_decode(&encoded), Some("hello".to_string()));
1527    }
1528
1529    #[cfg(feature = "crossterm")]
1530    #[test]
1531    fn color_scheme_equality() {
1532        assert_eq!(ColorScheme::Dark, ColorScheme::Dark);
1533        assert_ne!(ColorScheme::Dark, ColorScheme::Light);
1534        assert_eq!(ColorScheme::Unknown, ColorScheme::Unknown);
1535    }
1536
1537    fn pair(r: Rect) -> (Rect, Rect) {
1538        (r, r)
1539    }
1540
1541    #[test]
1542    fn find_innermost_rect_picks_smallest() {
1543        let rects = vec![
1544            pair(Rect::new(0, 0, 80, 24)),
1545            pair(Rect::new(5, 2, 30, 10)),
1546            pair(Rect::new(10, 4, 10, 5)),
1547        ];
1548        let result = find_innermost_rect(&rects, 12, 5);
1549        assert_eq!(result, Some(Rect::new(10, 4, 10, 5)));
1550    }
1551
1552    #[test]
1553    fn find_innermost_rect_no_match() {
1554        let rects = vec![pair(Rect::new(10, 10, 5, 5))];
1555        assert_eq!(find_innermost_rect(&rects, 0, 0), None);
1556    }
1557
1558    #[test]
1559    fn find_innermost_rect_empty() {
1560        assert_eq!(find_innermost_rect(&[], 5, 5), None);
1561    }
1562
1563    #[test]
1564    fn find_innermost_rect_returns_content_rect() {
1565        let rects = vec![
1566            (Rect::new(0, 0, 80, 24), Rect::new(1, 1, 78, 22)),
1567            (Rect::new(5, 2, 30, 10), Rect::new(6, 3, 28, 8)),
1568        ];
1569        let result = find_innermost_rect(&rects, 10, 5);
1570        assert_eq!(result, Some(Rect::new(6, 3, 28, 8)));
1571    }
1572
1573    #[test]
1574    fn normalize_selection_already_ordered() {
1575        let (s, e) = normalize_selection((2, 1), (5, 3));
1576        assert_eq!(s, (2, 1));
1577        assert_eq!(e, (5, 3));
1578    }
1579
1580    #[test]
1581    fn normalize_selection_reversed() {
1582        let (s, e) = normalize_selection((5, 3), (2, 1));
1583        assert_eq!(s, (2, 1));
1584        assert_eq!(e, (5, 3));
1585    }
1586
1587    #[test]
1588    fn normalize_selection_same_row() {
1589        let (s, e) = normalize_selection((10, 5), (3, 5));
1590        assert_eq!(s, (3, 5));
1591        assert_eq!(e, (10, 5));
1592    }
1593
1594    #[test]
1595    fn selection_state_mouse_down_finds_rect() {
1596        let hit_map = vec![pair(Rect::new(0, 0, 80, 24)), pair(Rect::new(5, 2, 20, 10))];
1597        let mut sel = SelectionState::default();
1598        sel.mouse_down(10, 5, &hit_map);
1599        assert_eq!(sel.anchor, Some((10, 5)));
1600        assert_eq!(sel.current, Some((10, 5)));
1601        assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 20, 10)));
1602        assert!(!sel.active);
1603    }
1604
1605    #[test]
1606    fn selection_state_drag_activates() {
1607        let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
1608        let mut sel = SelectionState {
1609            anchor: Some((10, 5)),
1610            current: Some((10, 5)),
1611            widget_rect: Some(Rect::new(0, 0, 80, 24)),
1612            ..Default::default()
1613        };
1614        sel.mouse_drag(10, 5, &hit_map);
1615        assert!(!sel.active, "no movement = not active");
1616        sel.mouse_drag(11, 5, &hit_map);
1617        assert!(!sel.active, "1 cell horizontal = not active yet");
1618        sel.mouse_drag(13, 5, &hit_map);
1619        assert!(sel.active, ">1 cell horizontal = active");
1620    }
1621
1622    #[test]
1623    fn selection_state_drag_vertical_activates() {
1624        let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
1625        let mut sel = SelectionState {
1626            anchor: Some((10, 5)),
1627            current: Some((10, 5)),
1628            widget_rect: Some(Rect::new(0, 0, 80, 24)),
1629            ..Default::default()
1630        };
1631        sel.mouse_drag(10, 6, &hit_map);
1632        assert!(sel.active, "any vertical movement = active");
1633    }
1634
1635    #[test]
1636    fn selection_state_drag_expands_widget_rect() {
1637        let hit_map = vec![
1638            pair(Rect::new(0, 0, 80, 24)),
1639            pair(Rect::new(5, 2, 30, 10)),
1640            pair(Rect::new(5, 2, 30, 3)),
1641        ];
1642        let mut sel = SelectionState {
1643            anchor: Some((10, 3)),
1644            current: Some((10, 3)),
1645            widget_rect: Some(Rect::new(5, 2, 30, 3)),
1646            ..Default::default()
1647        };
1648        sel.mouse_drag(10, 6, &hit_map);
1649        assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 30, 10)));
1650    }
1651
1652    #[test]
1653    fn selection_state_clear_resets() {
1654        let mut sel = SelectionState {
1655            anchor: Some((1, 2)),
1656            current: Some((3, 4)),
1657            widget_rect: Some(Rect::new(0, 0, 10, 10)),
1658            active: true,
1659        };
1660        sel.clear();
1661        assert_eq!(sel.anchor, None);
1662        assert_eq!(sel.current, None);
1663        assert_eq!(sel.widget_rect, None);
1664        assert!(!sel.active);
1665    }
1666
1667    #[test]
1668    fn extract_selection_text_single_line() {
1669        let area = Rect::new(0, 0, 20, 5);
1670        let mut buf = Buffer::empty(area);
1671        buf.set_string(0, 0, "Hello World", Style::default());
1672        let sel = SelectionState {
1673            anchor: Some((0, 0)),
1674            current: Some((4, 0)),
1675            widget_rect: Some(area),
1676            active: true,
1677        };
1678        let text = extract_selection_text(&buf, &sel, &[]);
1679        assert_eq!(text, "Hello");
1680    }
1681
1682    #[test]
1683    fn extract_selection_text_multi_line() {
1684        let area = Rect::new(0, 0, 20, 5);
1685        let mut buf = Buffer::empty(area);
1686        buf.set_string(0, 0, "Line one", Style::default());
1687        buf.set_string(0, 1, "Line two", Style::default());
1688        buf.set_string(0, 2, "Line three", Style::default());
1689        let sel = SelectionState {
1690            anchor: Some((5, 0)),
1691            current: Some((3, 2)),
1692            widget_rect: Some(area),
1693            active: true,
1694        };
1695        let text = extract_selection_text(&buf, &sel, &[]);
1696        assert_eq!(text, "one\nLine two\nLine");
1697    }
1698
1699    #[test]
1700    fn extract_selection_text_clamped_to_widget() {
1701        let area = Rect::new(0, 0, 40, 10);
1702        let widget = Rect::new(5, 2, 10, 3);
1703        let mut buf = Buffer::empty(area);
1704        buf.set_string(5, 2, "ABCDEFGHIJ", Style::default());
1705        buf.set_string(5, 3, "KLMNOPQRST", Style::default());
1706        let sel = SelectionState {
1707            anchor: Some((3, 1)),
1708            current: Some((20, 5)),
1709            widget_rect: Some(widget),
1710            active: true,
1711        };
1712        let text = extract_selection_text(&buf, &sel, &[]);
1713        assert_eq!(text, "ABCDEFGHIJ\nKLMNOPQRST");
1714    }
1715
1716    #[test]
1717    fn extract_selection_text_inactive_returns_empty() {
1718        let area = Rect::new(0, 0, 10, 5);
1719        let buf = Buffer::empty(area);
1720        let sel = SelectionState {
1721            anchor: Some((0, 0)),
1722            current: Some((5, 2)),
1723            widget_rect: Some(area),
1724            active: false,
1725        };
1726        assert_eq!(extract_selection_text(&buf, &sel, &[]), "");
1727    }
1728
1729    #[test]
1730    fn apply_selection_overlay_reverses_cells() {
1731        let area = Rect::new(0, 0, 10, 3);
1732        let mut buf = Buffer::empty(area);
1733        buf.set_string(0, 0, "ABCDE", Style::default());
1734        let sel = SelectionState {
1735            anchor: Some((1, 0)),
1736            current: Some((3, 0)),
1737            widget_rect: Some(area),
1738            active: true,
1739        };
1740        apply_selection_overlay(&mut buf, &sel, &[]);
1741        assert!(!buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED));
1742        assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
1743        assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
1744        assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
1745        assert!(!buf.get(4, 0).style.modifiers.contains(Modifiers::REVERSED));
1746    }
1747
1748    #[test]
1749    fn extract_selection_text_skips_border_cells() {
1750        // Simulate two bordered columns side by side:
1751        // Col1: full=(0,0,20,5) content=(1,1,18,3)
1752        // Col2: full=(20,0,20,5) content=(21,1,18,3)
1753        // Parent widget_rect covers both: (0,0,40,5)
1754        let area = Rect::new(0, 0, 40, 5);
1755        let mut buf = Buffer::empty(area);
1756        // Col1 border characters
1757        buf.set_string(0, 0, "╭", Style::default());
1758        buf.set_string(0, 1, "│", Style::default());
1759        buf.set_string(0, 2, "│", Style::default());
1760        buf.set_string(0, 3, "│", Style::default());
1761        buf.set_string(0, 4, "╰", Style::default());
1762        buf.set_string(19, 0, "╮", Style::default());
1763        buf.set_string(19, 1, "│", Style::default());
1764        buf.set_string(19, 2, "│", Style::default());
1765        buf.set_string(19, 3, "│", Style::default());
1766        buf.set_string(19, 4, "╯", Style::default());
1767        // Col2 border characters
1768        buf.set_string(20, 0, "╭", Style::default());
1769        buf.set_string(20, 1, "│", Style::default());
1770        buf.set_string(20, 2, "│", Style::default());
1771        buf.set_string(20, 3, "│", Style::default());
1772        buf.set_string(20, 4, "╰", Style::default());
1773        buf.set_string(39, 0, "╮", Style::default());
1774        buf.set_string(39, 1, "│", Style::default());
1775        buf.set_string(39, 2, "│", Style::default());
1776        buf.set_string(39, 3, "│", Style::default());
1777        buf.set_string(39, 4, "╯", Style::default());
1778        // Content inside Col1
1779        buf.set_string(1, 1, "Hello Col1", Style::default());
1780        buf.set_string(1, 2, "Line2 Col1", Style::default());
1781        // Content inside Col2
1782        buf.set_string(21, 1, "Hello Col2", Style::default());
1783        buf.set_string(21, 2, "Line2 Col2", Style::default());
1784
1785        let content_map = vec![
1786            (Rect::new(0, 0, 20, 5), Rect::new(1, 1, 18, 3)),
1787            (Rect::new(20, 0, 20, 5), Rect::new(21, 1, 18, 3)),
1788        ];
1789
1790        // Select across both columns, rows 1-2
1791        let sel = SelectionState {
1792            anchor: Some((0, 1)),
1793            current: Some((39, 2)),
1794            widget_rect: Some(area),
1795            active: true,
1796        };
1797        let text = extract_selection_text(&buf, &sel, &content_map);
1798        // Should NOT contain border characters (│, ╭, ╮, etc.)
1799        assert!(!text.contains('│'), "Border char │ found in: {text}");
1800        assert!(!text.contains('╭'), "Border char ╭ found in: {text}");
1801        assert!(!text.contains('╮'), "Border char ╮ found in: {text}");
1802        // Should contain actual content
1803        assert!(
1804            text.contains("Hello Col1"),
1805            "Missing Col1 content in: {text}"
1806        );
1807        assert!(
1808            text.contains("Hello Col2"),
1809            "Missing Col2 content in: {text}"
1810        );
1811        assert!(text.contains("Line2 Col1"), "Missing Col1 line2 in: {text}");
1812        assert!(text.contains("Line2 Col2"), "Missing Col2 line2 in: {text}");
1813    }
1814
1815    #[test]
1816    fn apply_selection_overlay_skips_border_cells() {
1817        let area = Rect::new(0, 0, 20, 3);
1818        let mut buf = Buffer::empty(area);
1819        buf.set_string(0, 0, "│", Style::default());
1820        buf.set_string(1, 0, "ABC", Style::default());
1821        buf.set_string(19, 0, "│", Style::default());
1822
1823        let content_map = vec![(Rect::new(0, 0, 20, 3), Rect::new(1, 0, 18, 3))];
1824        let sel = SelectionState {
1825            anchor: Some((0, 0)),
1826            current: Some((19, 0)),
1827            widget_rect: Some(area),
1828            active: true,
1829        };
1830        apply_selection_overlay(&mut buf, &sel, &content_map);
1831        // Border cells at x=0 and x=19 should NOT be reversed
1832        assert!(
1833            !buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED),
1834            "Left border cell should not be reversed"
1835        );
1836        assert!(
1837            !buf.get(19, 0).style.modifiers.contains(Modifiers::REVERSED),
1838            "Right border cell should not be reversed"
1839        );
1840        // Content cells should be reversed
1841        assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
1842        assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
1843        assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
1844    }
1845
1846    #[test]
1847    fn copy_to_clipboard_writes_osc52() {
1848        let mut output: Vec<u8> = Vec::new();
1849        copy_to_clipboard(&mut output, "test").unwrap();
1850        let s = String::from_utf8(output).unwrap();
1851        assert!(s.starts_with("\x1b]52;c;"));
1852        assert!(s.ends_with("\x1b\\"));
1853        assert!(s.contains(&base64_encode(b"test")));
1854    }
1855
1856    // Count occurrences of CSI cursor-move (`ESC [ ... H`) in flush output.
1857    fn count_move_tos(s: &str) -> usize {
1858        let bytes = s.as_bytes();
1859        let mut count = 0;
1860        let mut i = 0;
1861        while i + 1 < bytes.len() {
1862            if bytes[i] == 0x1b && bytes[i + 1] == b'[' {
1863                // Scan to the terminator — final byte in 0x40..=0x7e.
1864                let mut j = i + 2;
1865                while j < bytes.len() && !(0x40..=0x7e).contains(&bytes[j]) {
1866                    j += 1;
1867                }
1868                if j < bytes.len() && bytes[j] == b'H' {
1869                    count += 1;
1870                }
1871                i = j + 1;
1872            } else {
1873                i += 1;
1874            }
1875        }
1876        count
1877    }
1878
1879    #[test]
1880    fn flush_coalesces_consecutive_same_style_cells_into_one_run() {
1881        // 10 cells, identical Style, contiguous columns -> 1 MoveTo + 1 Print.
1882        let area = Rect::new(0, 0, 20, 1);
1883        let mut current = Buffer::empty(area);
1884        let previous = Buffer::empty(area);
1885        let style = Style::new().fg(Color::Red);
1886        for x in 0..10u32 {
1887            let cell = current.get_mut(x, 0);
1888            cell.set_char('X');
1889            cell.set_style(style);
1890        }
1891
1892        let mut out: Vec<u8> = Vec::new();
1893        flush_buffer_diff(&mut out, &current, &previous, ColorDepth::TrueColor, 0).unwrap();
1894        let s = String::from_utf8(out).unwrap();
1895
1896        // Exactly one cursor move for the whole run.
1897        assert_eq!(
1898            count_move_tos(&s),
1899            1,
1900            "expected 1 MoveTo for a coalesced run, got {} in {:?}",
1901            count_move_tos(&s),
1902            s
1903        );
1904        // The 10 glyphs are emitted contiguously as a single run.
1905        assert!(
1906            s.contains("XXXXXXXXXX"),
1907            "expected contiguous run 'XXXXXXXXXX' in {:?}",
1908            s
1909        );
1910    }
1911
1912    #[test]
1913    fn flush_breaks_run_on_style_change() {
1914        // 5 red cells + 5 blue cells in the same row -> 2 MoveTo calls not 10.
1915        let area = Rect::new(0, 0, 20, 1);
1916        let mut current = Buffer::empty(area);
1917        let previous = Buffer::empty(area);
1918        let red = Style::new().fg(Color::Red);
1919        let blue = Style::new().fg(Color::Blue);
1920        for x in 0..5u32 {
1921            let cell = current.get_mut(x, 0);
1922            cell.set_char('R');
1923            cell.set_style(red);
1924        }
1925        for x in 5..10u32 {
1926            let cell = current.get_mut(x, 0);
1927            cell.set_char('B');
1928            cell.set_style(blue);
1929        }
1930
1931        let mut out: Vec<u8> = Vec::new();
1932        flush_buffer_diff(&mut out, &current, &previous, ColorDepth::TrueColor, 0).unwrap();
1933        let s = String::from_utf8(out).unwrap();
1934
1935        // First run needs a MoveTo; the second run starts exactly where the
1936        // cursor already is, so `last_cursor` suppresses a redundant MoveTo.
1937        // Either way, we should see at most 2 MoveTos and far fewer than 10.
1938        let moves = count_move_tos(&s);
1939        assert!(
1940            moves <= 2,
1941            "expected at most 2 MoveTos across a style boundary, got {} in {:?}",
1942            moves,
1943            s
1944        );
1945        assert!(s.contains("RRRRR"), "missing 'RRRRR' run in {:?}", s);
1946        assert!(s.contains("BBBBB"), "missing 'BBBBB' run in {:?}", s);
1947    }
1948
1949    #[test]
1950    fn flush_breaks_run_on_column_gap() {
1951        // Cells at x=0..3 and x=6..9; gap at x=3,4,5 must split runs.
1952        let area = Rect::new(0, 0, 20, 1);
1953        let mut current = Buffer::empty(area);
1954        let previous = Buffer::empty(area);
1955        let style = Style::new().fg(Color::Green);
1956        for x in 0..3u32 {
1957            current.get_mut(x, 0).set_char('A').set_style(style);
1958        }
1959        for x in 6..9u32 {
1960            current.get_mut(x, 0).set_char('B').set_style(style);
1961        }
1962
1963        let mut out: Vec<u8> = Vec::new();
1964        flush_buffer_diff(&mut out, &current, &previous, ColorDepth::TrueColor, 0).unwrap();
1965        let s = String::from_utf8(out).unwrap();
1966
1967        // Two separate runs means two MoveTo commands.
1968        assert_eq!(
1969            count_move_tos(&s),
1970            2,
1971            "expected 2 MoveTos across a column gap, got {} in {:?}",
1972            count_move_tos(&s),
1973            s
1974        );
1975        assert!(s.contains("AAA"), "missing 'AAA' run in {:?}", s);
1976        assert!(s.contains("BBB"), "missing 'BBB' run in {:?}", s);
1977    }
1978
1979    /// Verifies that `flush_buffer_diff` produces identical ANSI output whether the
1980    /// destination is a plain `Vec<u8>` or a `BufWriter<Vec<u8>>`. This ensures the
1981    /// BufWriter wrapper introduced for stdout does not alter the byte stream.
1982    #[test]
1983    fn bufwriter_output_identical_to_direct_write() {
1984        let area = Rect::new(0, 0, 5, 1);
1985        let mut current = Buffer::empty(area);
1986        let previous = Buffer::empty(area);
1987        let style = Style::new().fg(Color::Rgb(255, 128, 0));
1988        for x in 0..5u32 {
1989            current.get_mut(x, 0).set_char('X').set_style(style);
1990        }
1991
1992        let mut direct: Vec<u8> = Vec::new();
1993        flush_buffer_diff(&mut direct, &current, &previous, ColorDepth::TrueColor, 0).unwrap();
1994
1995        let mut buffered: BufWriter<Vec<u8>> = BufWriter::with_capacity(65536, Vec::new());
1996        flush_buffer_diff(&mut buffered, &current, &previous, ColorDepth::TrueColor, 0).unwrap();
1997        buffered.flush().unwrap();
1998        let via_buf = buffered.into_inner().unwrap();
1999
2000        assert_eq!(
2001            direct, via_buf,
2002            "BufWriter output must be byte-for-byte identical to direct write"
2003        );
2004    }
2005
2006    /// Verifies that a `BufWriter<Vec<u8>>` sink accumulates all writes and only
2007    /// issues a single underlying `write` call to the inner sink when flushed.
2008    /// This is a proxy for the syscall-reduction guarantee on the real stdout.
2009    #[test]
2010    fn bufwriter_coalesces_writes_into_single_flush() {
2011        #[derive(Debug)]
2012        struct CountingWriter {
2013            buf: Vec<u8>,
2014            write_call_count: usize,
2015        }
2016        impl Write for CountingWriter {
2017            fn write(&mut self, data: &[u8]) -> io::Result<usize> {
2018                self.write_call_count += 1;
2019                self.buf.extend_from_slice(data);
2020                Ok(data.len())
2021            }
2022            fn flush(&mut self) -> io::Result<()> {
2023                Ok(())
2024            }
2025        }
2026
2027        let area = Rect::new(0, 0, 10, 1);
2028        let mut current = Buffer::empty(area);
2029        let previous = Buffer::empty(area);
2030        // Alternate styles on every cell to maximise queue! calls inside flush_buffer_diff.
2031        for x in 0..10u32 {
2032            let color = if x % 2 == 0 {
2033                Color::Rgb(255, 0, 0)
2034            } else {
2035                Color::Rgb(0, 255, 0)
2036            };
2037            current
2038                .get_mut(x, 0)
2039                .set_char('Z')
2040                .set_style(Style::new().fg(color));
2041        }
2042
2043        let sink = CountingWriter {
2044            buf: Vec::new(),
2045            write_call_count: 0,
2046        };
2047        let mut bw = BufWriter::with_capacity(65536, sink);
2048        flush_buffer_diff(&mut bw, &current, &previous, ColorDepth::TrueColor, 0).unwrap();
2049        bw.flush().unwrap();
2050        let inner = bw.into_inner().unwrap();
2051
2052        // BufWriter should have batched everything into 1 write call to the sink.
2053        assert_eq!(
2054            inner.write_call_count, 1,
2055            "expected 1 write syscall to sink, got {}",
2056            inner.write_call_count
2057        );
2058    }
2059
2060    /// Issue #171 regression: identical buffers must produce no flush
2061    /// output once both have refreshed line hashes. Validates that the
2062    /// per-row skip path is correctness-preserving — a skipped row
2063    /// emits zero bytes, exactly like the per-cell path would for an
2064    /// unchanged row.
2065    #[test]
2066    fn flush_skips_unchanged_rows_when_hashes_match() {
2067        let area = Rect::new(0, 0, 20, 4);
2068        let mut current = Buffer::empty(area);
2069        let mut previous = Buffer::empty(area);
2070        // Populate both buffers with identical content.
2071        for y in 0..4u32 {
2072            current.set_string(0, y, "identical-row-content", Style::new());
2073            previous.set_string(0, y, "identical-row-content", Style::new());
2074        }
2075        current.recompute_line_hashes();
2076        previous.recompute_line_hashes();
2077
2078        let mut out: Vec<u8> = Vec::new();
2079        flush_buffer_diff(&mut out, &current, &previous, ColorDepth::TrueColor, 0).unwrap();
2080        assert!(
2081            out.is_empty(),
2082            "identical buffers must emit zero flush bytes; got {} bytes: {:?}",
2083            out.len(),
2084            out
2085        );
2086    }
2087
2088    /// Issue #171 regression: when only some rows match, only those rows
2089    /// are skipped. The differing row must still drive its full per-cell
2090    /// flush path so the terminal sees the correct glyphs.
2091    #[test]
2092    fn flush_skips_only_matching_rows_in_mixed_diff() {
2093        let area = Rect::new(0, 0, 6, 3);
2094        let mut current = Buffer::empty(area);
2095        let mut previous = Buffer::empty(area);
2096        current.set_string(0, 0, "abcdef", Style::new());
2097        previous.set_string(0, 0, "abcdef", Style::new());
2098        current.set_string(0, 1, "xxxxxx", Style::new());
2099        previous.set_string(0, 1, "yyyyyy", Style::new());
2100        current.set_string(0, 2, "zzzzzz", Style::new());
2101        previous.set_string(0, 2, "zzzzzz", Style::new());
2102        current.recompute_line_hashes();
2103        previous.recompute_line_hashes();
2104
2105        let mut out: Vec<u8> = Vec::new();
2106        flush_buffer_diff(&mut out, &current, &previous, ColorDepth::TrueColor, 0).unwrap();
2107        let s = String::from_utf8_lossy(&out);
2108        // The mismatched row's new content must appear; matching rows'
2109        // glyphs must not (they share content with `previous`).
2110        assert!(s.contains("xxxxxx"), "differing row must flush: {s:?}");
2111        assert!(
2112            !s.contains("abcdef"),
2113            "matching row 0 must not flush: {s:?}"
2114        );
2115        assert!(
2116            !s.contains("zzzzzz"),
2117            "matching row 2 must not flush: {s:?}"
2118        );
2119    }
2120}