Skip to main content

slt/
terminal.rs

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