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    mouse_enabled: bool,
255    cursor_visible: bool,
256    kitty_keyboard: bool,
257    color_depth: ColorDepth,
258    pub(crate) theme_bg: Option<Color>,
259    kitty_mgr: KittyImageManager,
260}
261
262pub(crate) struct InlineTerminal {
263    stdout: Stdout,
264    current: Buffer,
265    previous: Buffer,
266    mouse_enabled: bool,
267    cursor_visible: bool,
268    height: u32,
269    start_row: u16,
270    reserved: bool,
271    color_depth: ColorDepth,
272    pub(crate) theme_bg: Option<Color>,
273    kitty_mgr: KittyImageManager,
274}
275
276impl Terminal {
277    pub fn new(mouse: bool, kitty_keyboard: bool, color_depth: ColorDepth) -> io::Result<Self> {
278        let (cols, rows) = terminal::size()?;
279        let area = Rect::new(0, 0, cols as u32, rows as u32);
280
281        let mut stdout = io::stdout();
282        terminal::enable_raw_mode()?;
283        execute!(
284            stdout,
285            terminal::EnterAlternateScreen,
286            cursor::Hide,
287            EnableBracketedPaste
288        )?;
289        if mouse {
290            execute!(stdout, EnableMouseCapture, EnableFocusChange)?;
291        }
292        if kitty_keyboard {
293            use crossterm::event::{KeyboardEnhancementFlags, PushKeyboardEnhancementFlags};
294            let _ = execute!(
295                stdout,
296                PushKeyboardEnhancementFlags(
297                    KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
298                        | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
299                )
300            );
301        }
302
303        Ok(Self {
304            stdout,
305            current: Buffer::empty(area),
306            previous: Buffer::empty(area),
307            mouse_enabled: mouse,
308            cursor_visible: false,
309            kitty_keyboard,
310            color_depth,
311            theme_bg: None,
312            kitty_mgr: KittyImageManager::new(),
313        })
314    }
315
316    pub fn size(&self) -> (u32, u32) {
317        (self.current.area.width, self.current.area.height)
318    }
319
320    pub fn buffer_mut(&mut self) -> &mut Buffer {
321        &mut self.current
322    }
323
324    pub fn flush(&mut self) -> io::Result<()> {
325        if self.current.area.width < self.previous.area.width {
326            execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
327        }
328
329        queue!(self.stdout, BeginSynchronizedUpdate)?;
330
331        let mut last_style = Style::new();
332        let mut first_style = true;
333        let mut last_pos: Option<(u32, u32)> = None;
334        let mut active_link: Option<&str> = None;
335        let mut has_updates = false;
336
337        for y in self.current.area.y..self.current.area.bottom() {
338            for x in self.current.area.x..self.current.area.right() {
339                let cur = self.current.get(x, y);
340                let prev = self.previous.get(x, y);
341                if cur == prev {
342                    continue;
343                }
344                if cur.symbol.is_empty() {
345                    continue;
346                }
347                has_updates = true;
348
349                let need_move = last_pos.map_or(true, |(lx, ly)| ly != y || lx != x);
350                if need_move {
351                    queue!(self.stdout, cursor::MoveTo(sat_u16(x), sat_u16(y)))?;
352                }
353
354                if cur.style != last_style {
355                    if first_style {
356                        queue!(self.stdout, ResetColor, SetAttribute(Attribute::Reset))?;
357                        apply_style(&mut self.stdout, &cur.style, self.color_depth)?;
358                        first_style = false;
359                    } else {
360                        apply_style_delta(
361                            &mut self.stdout,
362                            &last_style,
363                            &cur.style,
364                            self.color_depth,
365                        )?;
366                    }
367                    last_style = cur.style;
368                }
369
370                let cell_link = cur.hyperlink.as_deref();
371                if cell_link != active_link {
372                    if let Some(url) = cell_link {
373                        queue!(self.stdout, Print(format!("\x1b]8;;{url}\x07")))?;
374                    } else {
375                        queue!(self.stdout, Print("\x1b]8;;\x07"))?;
376                    }
377                    active_link = cell_link;
378                }
379
380                queue!(self.stdout, Print(&*cur.symbol))?;
381                let char_width = UnicodeWidthStr::width(cur.symbol.as_str()).max(1) as u32;
382                if char_width > 1 && cur.symbol.chars().any(|c| c == '\u{FE0F}') {
383                    queue!(self.stdout, Print(" "))?;
384                }
385                last_pos = Some((x + char_width, y));
386            }
387        }
388
389        if has_updates {
390            if active_link.is_some() {
391                queue!(self.stdout, Print("\x1b]8;;\x07"))?;
392            }
393            queue!(self.stdout, ResetColor, SetAttribute(Attribute::Reset))?;
394        }
395
396        // Kitty graphics: structured image management with IDs and compression
397        self.kitty_mgr
398            .flush(&mut self.stdout, &self.current.kitty_placements)?;
399
400        // Raw sequences (sixel, other passthrough) — simple diff
401        if self.current.raw_sequences != self.previous.raw_sequences {
402            for (x, y, seq) in &self.current.raw_sequences {
403                queue!(self.stdout, cursor::MoveTo(sat_u16(*x), sat_u16(*y)))?;
404                queue!(self.stdout, Print(seq))?;
405            }
406        }
407
408        queue!(self.stdout, EndSynchronizedUpdate)?;
409
410        let cursor_pos = self.current.cursor_pos();
411        match cursor_pos {
412            Some((cx, cy)) => {
413                if !self.cursor_visible {
414                    queue!(self.stdout, cursor::Show)?;
415                    self.cursor_visible = true;
416                }
417                queue!(self.stdout, cursor::MoveTo(sat_u16(cx), sat_u16(cy)))?;
418            }
419            None => {
420                if self.cursor_visible {
421                    queue!(self.stdout, cursor::Hide)?;
422                    self.cursor_visible = false;
423                }
424            }
425        }
426
427        self.stdout.flush()?;
428
429        std::mem::swap(&mut self.current, &mut self.previous);
430        if let Some(bg) = self.theme_bg {
431            self.current.reset_with_bg(bg);
432        } else {
433            self.current.reset();
434        }
435        Ok(())
436    }
437
438    pub fn handle_resize(&mut self) -> io::Result<()> {
439        let (cols, rows) = terminal::size()?;
440        let area = Rect::new(0, 0, cols as u32, rows as u32);
441        self.current.resize(area);
442        self.previous.resize(area);
443        execute!(
444            self.stdout,
445            terminal::Clear(terminal::ClearType::All),
446            cursor::MoveTo(0, 0)
447        )?;
448        Ok(())
449    }
450}
451
452impl crate::Backend for Terminal {
453    fn size(&self) -> (u32, u32) {
454        Terminal::size(self)
455    }
456
457    fn buffer_mut(&mut self) -> &mut Buffer {
458        Terminal::buffer_mut(self)
459    }
460
461    fn flush(&mut self) -> io::Result<()> {
462        Terminal::flush(self)
463    }
464}
465
466impl InlineTerminal {
467    pub fn new(height: u32, mouse: bool, color_depth: ColorDepth) -> io::Result<Self> {
468        let (cols, _) = terminal::size()?;
469        let area = Rect::new(0, 0, cols as u32, height);
470
471        let mut stdout = io::stdout();
472        terminal::enable_raw_mode()?;
473        execute!(stdout, cursor::Hide, EnableBracketedPaste)?;
474        if mouse {
475            execute!(stdout, EnableMouseCapture, EnableFocusChange)?;
476        }
477
478        let (_, cursor_row) = cursor::position()?;
479        Ok(Self {
480            stdout,
481            current: Buffer::empty(area),
482            previous: Buffer::empty(area),
483            mouse_enabled: mouse,
484            cursor_visible: false,
485            height,
486            start_row: cursor_row,
487            reserved: false,
488            color_depth,
489            theme_bg: None,
490            kitty_mgr: KittyImageManager::new(),
491        })
492    }
493
494    pub fn size(&self) -> (u32, u32) {
495        (self.current.area.width, self.current.area.height)
496    }
497
498    pub fn buffer_mut(&mut self) -> &mut Buffer {
499        &mut self.current
500    }
501
502    pub fn flush(&mut self) -> io::Result<()> {
503        if self.current.area.width < self.previous.area.width {
504            execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
505        }
506
507        queue!(self.stdout, BeginSynchronizedUpdate)?;
508
509        if !self.reserved {
510            queue!(self.stdout, cursor::MoveToColumn(0))?;
511            for _ in 0..self.height {
512                queue!(self.stdout, Print("\n"))?;
513            }
514            self.reserved = true;
515
516            let (_, rows) = terminal::size()?;
517            let bottom = self.start_row.saturating_add(sat_u16(self.height));
518            if bottom > rows {
519                self.start_row = rows.saturating_sub(sat_u16(self.height));
520            }
521        }
522
523        let mut last_style = Style::new();
524        let mut first_style = true;
525        let mut last_pos: Option<(u32, u32)> = None;
526        let mut active_link: Option<&str> = None;
527        let mut has_updates = false;
528
529        for y in self.current.area.y..self.current.area.bottom() {
530            for x in self.current.area.x..self.current.area.right() {
531                let cell = self.current.get(x, y);
532                let prev = self.previous.get(x, y);
533                if cell == prev || cell.symbol.is_empty() {
534                    continue;
535                }
536                has_updates = true;
537
538                let abs_y = self.start_row as u32 + y;
539                let need_move = last_pos.map_or(true, |(lx, ly)| ly != abs_y || lx != x);
540                if need_move {
541                    queue!(self.stdout, cursor::MoveTo(sat_u16(x), sat_u16(abs_y)))?;
542                }
543
544                if cell.style != last_style {
545                    if first_style {
546                        queue!(self.stdout, ResetColor, SetAttribute(Attribute::Reset))?;
547                        apply_style(&mut self.stdout, &cell.style, self.color_depth)?;
548                        first_style = false;
549                    } else {
550                        apply_style_delta(
551                            &mut self.stdout,
552                            &last_style,
553                            &cell.style,
554                            self.color_depth,
555                        )?;
556                    }
557                    last_style = cell.style;
558                }
559
560                let cell_link = cell.hyperlink.as_deref();
561                if cell_link != active_link {
562                    if let Some(url) = cell_link {
563                        queue!(self.stdout, Print(format!("\x1b]8;;{url}\x07")))?;
564                    } else {
565                        queue!(self.stdout, Print("\x1b]8;;\x07"))?;
566                    }
567                    active_link = cell_link;
568                }
569
570                queue!(self.stdout, Print(&cell.symbol))?;
571                let char_width = UnicodeWidthStr::width(cell.symbol.as_str()).max(1) as u32;
572                if char_width > 1 && cell.symbol.chars().any(|c| c == '\u{FE0F}') {
573                    queue!(self.stdout, Print(" "))?;
574                }
575                last_pos = Some((x + char_width, abs_y));
576            }
577        }
578
579        if has_updates {
580            if active_link.is_some() {
581                queue!(self.stdout, Print("\x1b]8;;\x07"))?;
582            }
583            queue!(self.stdout, ResetColor, SetAttribute(Attribute::Reset))?;
584        }
585
586        // Kitty graphics: structured image management with IDs and compression
587        // Adjust Y positions for inline terminal offset
588        let adjusted: Vec<KittyPlacement> = self
589            .current
590            .kitty_placements
591            .iter()
592            .map(|p| {
593                let mut ap = p.clone();
594                ap.y += self.start_row as u32;
595                ap
596            })
597            .collect();
598        self.kitty_mgr.flush(&mut self.stdout, &adjusted)?;
599
600        // Raw sequences (sixel, other passthrough) — simple diff
601        if self.current.raw_sequences != self.previous.raw_sequences {
602            for (x, y, seq) in &self.current.raw_sequences {
603                let abs_y = self.start_row as u32 + *y;
604                queue!(self.stdout, cursor::MoveTo(sat_u16(*x), sat_u16(abs_y)))?;
605                queue!(self.stdout, Print(seq))?;
606            }
607        }
608
609        queue!(self.stdout, EndSynchronizedUpdate)?;
610
611        let cursor_pos = self.current.cursor_pos();
612        match cursor_pos {
613            Some((cx, cy)) => {
614                let abs_cy = self.start_row as u32 + cy;
615                if !self.cursor_visible {
616                    queue!(self.stdout, cursor::Show)?;
617                    self.cursor_visible = true;
618                }
619                queue!(self.stdout, cursor::MoveTo(sat_u16(cx), sat_u16(abs_cy)))?;
620            }
621            None => {
622                if self.cursor_visible {
623                    queue!(self.stdout, cursor::Hide)?;
624                    self.cursor_visible = false;
625                }
626                let end_row = self
627                    .start_row
628                    .saturating_add(sat_u16(self.height.saturating_sub(1)));
629                queue!(self.stdout, cursor::MoveTo(0, end_row))?;
630            }
631        }
632
633        self.stdout.flush()?;
634
635        std::mem::swap(&mut self.current, &mut self.previous);
636        reset_current_buffer(&mut self.current, self.theme_bg);
637        Ok(())
638    }
639
640    pub fn handle_resize(&mut self) -> io::Result<()> {
641        let (cols, _) = terminal::size()?;
642        let area = Rect::new(0, 0, cols as u32, self.height);
643        self.current.resize(area);
644        self.previous.resize(area);
645        execute!(
646            self.stdout,
647            terminal::Clear(terminal::ClearType::All),
648            cursor::MoveTo(0, 0)
649        )?;
650        Ok(())
651    }
652}
653
654impl crate::Backend for InlineTerminal {
655    fn size(&self) -> (u32, u32) {
656        InlineTerminal::size(self)
657    }
658
659    fn buffer_mut(&mut self) -> &mut Buffer {
660        InlineTerminal::buffer_mut(self)
661    }
662
663    fn flush(&mut self) -> io::Result<()> {
664        InlineTerminal::flush(self)
665    }
666}
667
668impl Drop for Terminal {
669    fn drop(&mut self) {
670        // Clean up Kitty images before leaving alternate screen
671        let _ = self.kitty_mgr.delete_all(&mut self.stdout);
672        let _ = self.stdout.flush();
673        if self.kitty_keyboard {
674            use crossterm::event::PopKeyboardEnhancementFlags;
675            let _ = execute!(self.stdout, PopKeyboardEnhancementFlags);
676        }
677        if self.mouse_enabled {
678            let _ = execute!(self.stdout, DisableMouseCapture, DisableFocusChange);
679        }
680        let _ = execute!(
681            self.stdout,
682            ResetColor,
683            SetAttribute(Attribute::Reset),
684            cursor::Show,
685            DisableBracketedPaste,
686            terminal::LeaveAlternateScreen
687        );
688        let _ = terminal::disable_raw_mode();
689    }
690}
691
692impl Drop for InlineTerminal {
693    fn drop(&mut self) {
694        if self.mouse_enabled {
695            let _ = execute!(self.stdout, DisableMouseCapture, DisableFocusChange);
696        }
697        let _ = execute!(
698            self.stdout,
699            ResetColor,
700            SetAttribute(Attribute::Reset),
701            cursor::Show,
702            DisableBracketedPaste
703        );
704        if self.reserved {
705            let _ = execute!(
706                self.stdout,
707                cursor::MoveToColumn(0),
708                cursor::MoveDown(1),
709                cursor::MoveToColumn(0),
710                Print("\n")
711            );
712        } else {
713            let _ = execute!(self.stdout, Print("\n"));
714        }
715        let _ = terminal::disable_raw_mode();
716    }
717}
718
719mod selection;
720pub(crate) use selection::{apply_selection_overlay, extract_selection_text, SelectionState};
721#[cfg(test)]
722pub(crate) use selection::{find_innermost_rect, normalize_selection};
723
724/// Detected terminal color scheme from OSC 11.
725#[non_exhaustive]
726#[cfg(feature = "crossterm")]
727#[derive(Debug, Clone, Copy, PartialEq, Eq)]
728pub enum ColorScheme {
729    /// Dark background detected.
730    Dark,
731    /// Light background detected.
732    Light,
733    /// Could not determine the scheme.
734    Unknown,
735}
736
737#[cfg(feature = "crossterm")]
738fn read_osc_response(timeout: Duration) -> Option<String> {
739    let deadline = Instant::now() + timeout;
740    let mut stdin = io::stdin();
741    let mut bytes = Vec::new();
742    let mut buf = [0u8; 1];
743
744    while Instant::now() < deadline {
745        if !crossterm::event::poll(Duration::from_millis(10)).ok()? {
746            continue;
747        }
748
749        let read = stdin.read(&mut buf).ok()?;
750        if read == 0 {
751            continue;
752        }
753
754        bytes.push(buf[0]);
755
756        if buf[0] == b'\x07' {
757            break;
758        }
759        let len = bytes.len();
760        if len >= 2 && bytes[len - 2] == 0x1B && bytes[len - 1] == b'\\' {
761            break;
762        }
763
764        if bytes.len() >= 4096 {
765            break;
766        }
767    }
768
769    if bytes.is_empty() {
770        return None;
771    }
772
773    String::from_utf8(bytes).ok()
774}
775
776/// Query the terminal's background color via OSC 11 and return the detected scheme.
777#[cfg(feature = "crossterm")]
778pub fn detect_color_scheme() -> ColorScheme {
779    let mut stdout = io::stdout();
780    if write!(stdout, "\x1b]11;?\x07").is_err() {
781        return ColorScheme::Unknown;
782    }
783    if stdout.flush().is_err() {
784        return ColorScheme::Unknown;
785    }
786
787    let Some(response) = read_osc_response(Duration::from_millis(100)) else {
788        return ColorScheme::Unknown;
789    };
790
791    parse_osc11_response(&response)
792}
793
794#[cfg(feature = "crossterm")]
795pub(crate) fn parse_osc11_response(response: &str) -> ColorScheme {
796    let Some(rgb_pos) = response.find("rgb:") else {
797        return ColorScheme::Unknown;
798    };
799
800    let payload = &response[rgb_pos + 4..];
801    let end = payload
802        .find(['\x07', '\x1b', '\r', '\n', ' ', '\t'])
803        .unwrap_or(payload.len());
804    let rgb = &payload[..end];
805
806    let mut channels = rgb.split('/');
807    let (Some(r), Some(g), Some(b), None) = (
808        channels.next(),
809        channels.next(),
810        channels.next(),
811        channels.next(),
812    ) else {
813        return ColorScheme::Unknown;
814    };
815
816    fn parse_channel(channel: &str) -> Option<f64> {
817        if channel.is_empty() || channel.len() > 4 {
818            return None;
819        }
820        let value = u16::from_str_radix(channel, 16).ok()? as f64;
821        let max = ((1u32 << (channel.len() * 4)) - 1) as f64;
822        if max <= 0.0 {
823            return None;
824        }
825        Some((value / max).clamp(0.0, 1.0))
826    }
827
828    let (Some(r), Some(g), Some(b)) = (parse_channel(r), parse_channel(g), parse_channel(b)) else {
829        return ColorScheme::Unknown;
830    };
831
832    let luminance = 0.299 * r + 0.587 * g + 0.114 * b;
833    if luminance < 0.5 {
834        ColorScheme::Dark
835    } else {
836        ColorScheme::Light
837    }
838}
839
840fn base64_encode(input: &[u8]) -> String {
841    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
842    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
843    for chunk in input.chunks(3) {
844        let b0 = chunk[0] as u32;
845        let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
846        let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
847        let triple = (b0 << 16) | (b1 << 8) | b2;
848        out.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
849        out.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
850        out.push(if chunk.len() > 1 {
851            CHARS[((triple >> 6) & 0x3F) as usize] as char
852        } else {
853            '='
854        });
855        out.push(if chunk.len() > 2 {
856            CHARS[(triple & 0x3F) as usize] as char
857        } else {
858            '='
859        });
860    }
861    out
862}
863
864pub(crate) fn copy_to_clipboard(w: &mut impl Write, text: &str) -> io::Result<()> {
865    let encoded = base64_encode(text.as_bytes());
866    write!(w, "\x1b]52;c;{encoded}\x1b\\")?;
867    w.flush()
868}
869
870#[cfg(feature = "crossterm")]
871fn parse_osc52_response(response: &str) -> Option<String> {
872    let osc_pos = response.find("]52;")?;
873    let body = &response[osc_pos + 4..];
874    let semicolon = body.find(';')?;
875    let payload = &body[semicolon + 1..];
876
877    let end = payload
878        .find("\x1b\\")
879        .or_else(|| payload.find('\x07'))
880        .unwrap_or(payload.len());
881    let encoded = payload[..end].trim();
882    if encoded.is_empty() || encoded == "?" {
883        return None;
884    }
885
886    base64_decode(encoded)
887}
888
889/// Read clipboard contents via OSC 52 terminal query.
890#[cfg(feature = "crossterm")]
891pub fn read_clipboard() -> Option<String> {
892    let mut stdout = io::stdout();
893    write!(stdout, "\x1b]52;c;?\x07").ok()?;
894    stdout.flush().ok()?;
895
896    let response = read_osc_response(Duration::from_millis(200))?;
897    parse_osc52_response(&response)
898}
899
900#[cfg(feature = "crossterm")]
901fn base64_decode(input: &str) -> Option<String> {
902    let mut filtered: Vec<u8> = input
903        .bytes()
904        .filter(|b| !matches!(b, b' ' | b'\n' | b'\r' | b'\t'))
905        .collect();
906
907    match filtered.len() % 4 {
908        0 => {}
909        2 => filtered.extend_from_slice(b"=="),
910        3 => filtered.push(b'='),
911        _ => return None,
912    }
913
914    fn decode_val(b: u8) -> Option<u8> {
915        match b {
916            b'A'..=b'Z' => Some(b - b'A'),
917            b'a'..=b'z' => Some(b - b'a' + 26),
918            b'0'..=b'9' => Some(b - b'0' + 52),
919            b'+' => Some(62),
920            b'/' => Some(63),
921            _ => None,
922        }
923    }
924
925    let mut out = Vec::with_capacity((filtered.len() / 4) * 3);
926    for chunk in filtered.chunks_exact(4) {
927        let p2 = chunk[2] == b'=';
928        let p3 = chunk[3] == b'=';
929        if p2 && !p3 {
930            return None;
931        }
932
933        let v0 = decode_val(chunk[0])? as u32;
934        let v1 = decode_val(chunk[1])? as u32;
935        let v2 = if p2 { 0 } else { decode_val(chunk[2])? as u32 };
936        let v3 = if p3 { 0 } else { decode_val(chunk[3])? as u32 };
937
938        let triple = (v0 << 18) | (v1 << 12) | (v2 << 6) | v3;
939        out.push(((triple >> 16) & 0xFF) as u8);
940        if !p2 {
941            out.push(((triple >> 8) & 0xFF) as u8);
942        }
943        if !p3 {
944            out.push((triple & 0xFF) as u8);
945        }
946    }
947
948    String::from_utf8(out).ok()
949}
950
951fn apply_style_delta(
952    w: &mut impl Write,
953    old: &Style,
954    new: &Style,
955    depth: ColorDepth,
956) -> io::Result<()> {
957    if old.fg != new.fg {
958        match new.fg {
959            Some(fg) => queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?,
960            None => queue!(w, SetForegroundColor(CtColor::Reset))?,
961        }
962    }
963    if old.bg != new.bg {
964        match new.bg {
965            Some(bg) => queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?,
966            None => queue!(w, SetBackgroundColor(CtColor::Reset))?,
967        }
968    }
969    let removed = Modifiers(old.modifiers.0 & !new.modifiers.0);
970    let added = Modifiers(new.modifiers.0 & !old.modifiers.0);
971    if removed.contains(Modifiers::BOLD) || removed.contains(Modifiers::DIM) {
972        queue!(w, SetAttribute(Attribute::NormalIntensity))?;
973        if new.modifiers.contains(Modifiers::BOLD) {
974            queue!(w, SetAttribute(Attribute::Bold))?;
975        }
976        if new.modifiers.contains(Modifiers::DIM) {
977            queue!(w, SetAttribute(Attribute::Dim))?;
978        }
979    } else {
980        if added.contains(Modifiers::BOLD) {
981            queue!(w, SetAttribute(Attribute::Bold))?;
982        }
983        if added.contains(Modifiers::DIM) {
984            queue!(w, SetAttribute(Attribute::Dim))?;
985        }
986    }
987    if removed.contains(Modifiers::ITALIC) {
988        queue!(w, SetAttribute(Attribute::NoItalic))?;
989    }
990    if added.contains(Modifiers::ITALIC) {
991        queue!(w, SetAttribute(Attribute::Italic))?;
992    }
993    if removed.contains(Modifiers::UNDERLINE) {
994        queue!(w, SetAttribute(Attribute::NoUnderline))?;
995    }
996    if added.contains(Modifiers::UNDERLINE) {
997        queue!(w, SetAttribute(Attribute::Underlined))?;
998    }
999    if removed.contains(Modifiers::REVERSED) {
1000        queue!(w, SetAttribute(Attribute::NoReverse))?;
1001    }
1002    if added.contains(Modifiers::REVERSED) {
1003        queue!(w, SetAttribute(Attribute::Reverse))?;
1004    }
1005    if removed.contains(Modifiers::STRIKETHROUGH) {
1006        queue!(w, SetAttribute(Attribute::NotCrossedOut))?;
1007    }
1008    if added.contains(Modifiers::STRIKETHROUGH) {
1009        queue!(w, SetAttribute(Attribute::CrossedOut))?;
1010    }
1011    Ok(())
1012}
1013
1014fn apply_style(w: &mut impl Write, style: &Style, depth: ColorDepth) -> io::Result<()> {
1015    if let Some(fg) = style.fg {
1016        queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?;
1017    }
1018    if let Some(bg) = style.bg {
1019        queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?;
1020    }
1021    let m = style.modifiers;
1022    if m.contains(Modifiers::BOLD) {
1023        queue!(w, SetAttribute(Attribute::Bold))?;
1024    }
1025    if m.contains(Modifiers::DIM) {
1026        queue!(w, SetAttribute(Attribute::Dim))?;
1027    }
1028    if m.contains(Modifiers::ITALIC) {
1029        queue!(w, SetAttribute(Attribute::Italic))?;
1030    }
1031    if m.contains(Modifiers::UNDERLINE) {
1032        queue!(w, SetAttribute(Attribute::Underlined))?;
1033    }
1034    if m.contains(Modifiers::REVERSED) {
1035        queue!(w, SetAttribute(Attribute::Reverse))?;
1036    }
1037    if m.contains(Modifiers::STRIKETHROUGH) {
1038        queue!(w, SetAttribute(Attribute::CrossedOut))?;
1039    }
1040    Ok(())
1041}
1042
1043fn to_crossterm_color(color: Color, depth: ColorDepth) -> CtColor {
1044    let color = color.downsampled(depth);
1045    match color {
1046        Color::Reset => CtColor::Reset,
1047        Color::Black => CtColor::Black,
1048        Color::Red => CtColor::DarkRed,
1049        Color::Green => CtColor::DarkGreen,
1050        Color::Yellow => CtColor::DarkYellow,
1051        Color::Blue => CtColor::DarkBlue,
1052        Color::Magenta => CtColor::DarkMagenta,
1053        Color::Cyan => CtColor::DarkCyan,
1054        Color::White => CtColor::White,
1055        Color::DarkGray => CtColor::DarkGrey,
1056        Color::LightRed => CtColor::Red,
1057        Color::LightGreen => CtColor::Green,
1058        Color::LightYellow => CtColor::Yellow,
1059        Color::LightBlue => CtColor::Blue,
1060        Color::LightMagenta => CtColor::Magenta,
1061        Color::LightCyan => CtColor::Cyan,
1062        Color::LightWhite => CtColor::White,
1063        Color::Rgb(r, g, b) => CtColor::Rgb { r, g, b },
1064        Color::Indexed(i) => CtColor::AnsiValue(i),
1065    }
1066}
1067
1068fn reset_current_buffer(buffer: &mut Buffer, theme_bg: Option<Color>) {
1069    if let Some(bg) = theme_bg {
1070        buffer.reset_with_bg(bg);
1071    } else {
1072        buffer.reset();
1073    }
1074}
1075
1076#[cfg(test)]
1077mod tests {
1078    use super::*;
1079
1080    #[test]
1081    fn reset_current_buffer_applies_theme_background() {
1082        let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 1));
1083
1084        reset_current_buffer(&mut buffer, Some(Color::Rgb(10, 20, 30)));
1085        assert_eq!(buffer.get(0, 0).style.bg, Some(Color::Rgb(10, 20, 30)));
1086
1087        reset_current_buffer(&mut buffer, None);
1088        assert_eq!(buffer.get(0, 0).style.bg, None);
1089    }
1090
1091    #[test]
1092    fn base64_encode_empty() {
1093        assert_eq!(base64_encode(b""), "");
1094    }
1095
1096    #[test]
1097    fn base64_encode_hello() {
1098        assert_eq!(base64_encode(b"Hello"), "SGVsbG8=");
1099    }
1100
1101    #[test]
1102    fn base64_encode_padding() {
1103        assert_eq!(base64_encode(b"a"), "YQ==");
1104        assert_eq!(base64_encode(b"ab"), "YWI=");
1105        assert_eq!(base64_encode(b"abc"), "YWJj");
1106    }
1107
1108    #[test]
1109    fn base64_encode_unicode() {
1110        assert_eq!(base64_encode("한글".as_bytes()), "7ZWc6riA");
1111    }
1112
1113    #[cfg(feature = "crossterm")]
1114    #[test]
1115    fn parse_osc11_response_dark_and_light() {
1116        assert_eq!(
1117            parse_osc11_response("\x1b]11;rgb:0000/0000/0000\x1b\\"),
1118            ColorScheme::Dark
1119        );
1120        assert_eq!(
1121            parse_osc11_response("\x1b]11;rgb:ffff/ffff/ffff\x07"),
1122            ColorScheme::Light
1123        );
1124    }
1125
1126    #[cfg(feature = "crossterm")]
1127    #[test]
1128    fn base64_decode_round_trip_hello() {
1129        let encoded = base64_encode("hello".as_bytes());
1130        assert_eq!(base64_decode(&encoded), Some("hello".to_string()));
1131    }
1132
1133    #[cfg(feature = "crossterm")]
1134    #[test]
1135    fn color_scheme_equality() {
1136        assert_eq!(ColorScheme::Dark, ColorScheme::Dark);
1137        assert_ne!(ColorScheme::Dark, ColorScheme::Light);
1138        assert_eq!(ColorScheme::Unknown, ColorScheme::Unknown);
1139    }
1140
1141    fn pair(r: Rect) -> (Rect, Rect) {
1142        (r, r)
1143    }
1144
1145    #[test]
1146    fn find_innermost_rect_picks_smallest() {
1147        let rects = vec![
1148            pair(Rect::new(0, 0, 80, 24)),
1149            pair(Rect::new(5, 2, 30, 10)),
1150            pair(Rect::new(10, 4, 10, 5)),
1151        ];
1152        let result = find_innermost_rect(&rects, 12, 5);
1153        assert_eq!(result, Some(Rect::new(10, 4, 10, 5)));
1154    }
1155
1156    #[test]
1157    fn find_innermost_rect_no_match() {
1158        let rects = vec![pair(Rect::new(10, 10, 5, 5))];
1159        assert_eq!(find_innermost_rect(&rects, 0, 0), None);
1160    }
1161
1162    #[test]
1163    fn find_innermost_rect_empty() {
1164        assert_eq!(find_innermost_rect(&[], 5, 5), None);
1165    }
1166
1167    #[test]
1168    fn find_innermost_rect_returns_content_rect() {
1169        let rects = vec![
1170            (Rect::new(0, 0, 80, 24), Rect::new(1, 1, 78, 22)),
1171            (Rect::new(5, 2, 30, 10), Rect::new(6, 3, 28, 8)),
1172        ];
1173        let result = find_innermost_rect(&rects, 10, 5);
1174        assert_eq!(result, Some(Rect::new(6, 3, 28, 8)));
1175    }
1176
1177    #[test]
1178    fn normalize_selection_already_ordered() {
1179        let (s, e) = normalize_selection((2, 1), (5, 3));
1180        assert_eq!(s, (2, 1));
1181        assert_eq!(e, (5, 3));
1182    }
1183
1184    #[test]
1185    fn normalize_selection_reversed() {
1186        let (s, e) = normalize_selection((5, 3), (2, 1));
1187        assert_eq!(s, (2, 1));
1188        assert_eq!(e, (5, 3));
1189    }
1190
1191    #[test]
1192    fn normalize_selection_same_row() {
1193        let (s, e) = normalize_selection((10, 5), (3, 5));
1194        assert_eq!(s, (3, 5));
1195        assert_eq!(e, (10, 5));
1196    }
1197
1198    #[test]
1199    fn selection_state_mouse_down_finds_rect() {
1200        let hit_map = vec![pair(Rect::new(0, 0, 80, 24)), pair(Rect::new(5, 2, 20, 10))];
1201        let mut sel = SelectionState::default();
1202        sel.mouse_down(10, 5, &hit_map);
1203        assert_eq!(sel.anchor, Some((10, 5)));
1204        assert_eq!(sel.current, Some((10, 5)));
1205        assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 20, 10)));
1206        assert!(!sel.active);
1207    }
1208
1209    #[test]
1210    fn selection_state_drag_activates() {
1211        let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
1212        let mut sel = SelectionState {
1213            anchor: Some((10, 5)),
1214            current: Some((10, 5)),
1215            widget_rect: Some(Rect::new(0, 0, 80, 24)),
1216            ..Default::default()
1217        };
1218        sel.mouse_drag(10, 5, &hit_map);
1219        assert!(!sel.active, "no movement = not active");
1220        sel.mouse_drag(11, 5, &hit_map);
1221        assert!(!sel.active, "1 cell horizontal = not active yet");
1222        sel.mouse_drag(13, 5, &hit_map);
1223        assert!(sel.active, ">1 cell horizontal = active");
1224    }
1225
1226    #[test]
1227    fn selection_state_drag_vertical_activates() {
1228        let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
1229        let mut sel = SelectionState {
1230            anchor: Some((10, 5)),
1231            current: Some((10, 5)),
1232            widget_rect: Some(Rect::new(0, 0, 80, 24)),
1233            ..Default::default()
1234        };
1235        sel.mouse_drag(10, 6, &hit_map);
1236        assert!(sel.active, "any vertical movement = active");
1237    }
1238
1239    #[test]
1240    fn selection_state_drag_expands_widget_rect() {
1241        let hit_map = vec![
1242            pair(Rect::new(0, 0, 80, 24)),
1243            pair(Rect::new(5, 2, 30, 10)),
1244            pair(Rect::new(5, 2, 30, 3)),
1245        ];
1246        let mut sel = SelectionState {
1247            anchor: Some((10, 3)),
1248            current: Some((10, 3)),
1249            widget_rect: Some(Rect::new(5, 2, 30, 3)),
1250            ..Default::default()
1251        };
1252        sel.mouse_drag(10, 6, &hit_map);
1253        assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 30, 10)));
1254    }
1255
1256    #[test]
1257    fn selection_state_clear_resets() {
1258        let mut sel = SelectionState {
1259            anchor: Some((1, 2)),
1260            current: Some((3, 4)),
1261            widget_rect: Some(Rect::new(0, 0, 10, 10)),
1262            active: true,
1263        };
1264        sel.clear();
1265        assert_eq!(sel.anchor, None);
1266        assert_eq!(sel.current, None);
1267        assert_eq!(sel.widget_rect, None);
1268        assert!(!sel.active);
1269    }
1270
1271    #[test]
1272    fn extract_selection_text_single_line() {
1273        let area = Rect::new(0, 0, 20, 5);
1274        let mut buf = Buffer::empty(area);
1275        buf.set_string(0, 0, "Hello World", Style::default());
1276        let sel = SelectionState {
1277            anchor: Some((0, 0)),
1278            current: Some((4, 0)),
1279            widget_rect: Some(area),
1280            active: true,
1281        };
1282        let text = extract_selection_text(&buf, &sel, &[]);
1283        assert_eq!(text, "Hello");
1284    }
1285
1286    #[test]
1287    fn extract_selection_text_multi_line() {
1288        let area = Rect::new(0, 0, 20, 5);
1289        let mut buf = Buffer::empty(area);
1290        buf.set_string(0, 0, "Line one", Style::default());
1291        buf.set_string(0, 1, "Line two", Style::default());
1292        buf.set_string(0, 2, "Line three", Style::default());
1293        let sel = SelectionState {
1294            anchor: Some((5, 0)),
1295            current: Some((3, 2)),
1296            widget_rect: Some(area),
1297            active: true,
1298        };
1299        let text = extract_selection_text(&buf, &sel, &[]);
1300        assert_eq!(text, "one\nLine two\nLine");
1301    }
1302
1303    #[test]
1304    fn extract_selection_text_clamped_to_widget() {
1305        let area = Rect::new(0, 0, 40, 10);
1306        let widget = Rect::new(5, 2, 10, 3);
1307        let mut buf = Buffer::empty(area);
1308        buf.set_string(5, 2, "ABCDEFGHIJ", Style::default());
1309        buf.set_string(5, 3, "KLMNOPQRST", Style::default());
1310        let sel = SelectionState {
1311            anchor: Some((3, 1)),
1312            current: Some((20, 5)),
1313            widget_rect: Some(widget),
1314            active: true,
1315        };
1316        let text = extract_selection_text(&buf, &sel, &[]);
1317        assert_eq!(text, "ABCDEFGHIJ\nKLMNOPQRST");
1318    }
1319
1320    #[test]
1321    fn extract_selection_text_inactive_returns_empty() {
1322        let area = Rect::new(0, 0, 10, 5);
1323        let buf = Buffer::empty(area);
1324        let sel = SelectionState {
1325            anchor: Some((0, 0)),
1326            current: Some((5, 2)),
1327            widget_rect: Some(area),
1328            active: false,
1329        };
1330        assert_eq!(extract_selection_text(&buf, &sel, &[]), "");
1331    }
1332
1333    #[test]
1334    fn apply_selection_overlay_reverses_cells() {
1335        let area = Rect::new(0, 0, 10, 3);
1336        let mut buf = Buffer::empty(area);
1337        buf.set_string(0, 0, "ABCDE", Style::default());
1338        let sel = SelectionState {
1339            anchor: Some((1, 0)),
1340            current: Some((3, 0)),
1341            widget_rect: Some(area),
1342            active: true,
1343        };
1344        apply_selection_overlay(&mut buf, &sel, &[]);
1345        assert!(!buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED));
1346        assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
1347        assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
1348        assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
1349        assert!(!buf.get(4, 0).style.modifiers.contains(Modifiers::REVERSED));
1350    }
1351
1352    #[test]
1353    fn extract_selection_text_skips_border_cells() {
1354        // Simulate two bordered columns side by side:
1355        // Col1: full=(0,0,20,5) content=(1,1,18,3)
1356        // Col2: full=(20,0,20,5) content=(21,1,18,3)
1357        // Parent widget_rect covers both: (0,0,40,5)
1358        let area = Rect::new(0, 0, 40, 5);
1359        let mut buf = Buffer::empty(area);
1360        // Col1 border characters
1361        buf.set_string(0, 0, "╭", Style::default());
1362        buf.set_string(0, 1, "│", Style::default());
1363        buf.set_string(0, 2, "│", Style::default());
1364        buf.set_string(0, 3, "│", Style::default());
1365        buf.set_string(0, 4, "╰", Style::default());
1366        buf.set_string(19, 0, "╮", Style::default());
1367        buf.set_string(19, 1, "│", Style::default());
1368        buf.set_string(19, 2, "│", Style::default());
1369        buf.set_string(19, 3, "│", Style::default());
1370        buf.set_string(19, 4, "╯", Style::default());
1371        // Col2 border characters
1372        buf.set_string(20, 0, "╭", Style::default());
1373        buf.set_string(20, 1, "│", Style::default());
1374        buf.set_string(20, 2, "│", Style::default());
1375        buf.set_string(20, 3, "│", Style::default());
1376        buf.set_string(20, 4, "╰", Style::default());
1377        buf.set_string(39, 0, "╮", Style::default());
1378        buf.set_string(39, 1, "│", Style::default());
1379        buf.set_string(39, 2, "│", Style::default());
1380        buf.set_string(39, 3, "│", Style::default());
1381        buf.set_string(39, 4, "╯", Style::default());
1382        // Content inside Col1
1383        buf.set_string(1, 1, "Hello Col1", Style::default());
1384        buf.set_string(1, 2, "Line2 Col1", Style::default());
1385        // Content inside Col2
1386        buf.set_string(21, 1, "Hello Col2", Style::default());
1387        buf.set_string(21, 2, "Line2 Col2", Style::default());
1388
1389        let content_map = vec![
1390            (Rect::new(0, 0, 20, 5), Rect::new(1, 1, 18, 3)),
1391            (Rect::new(20, 0, 20, 5), Rect::new(21, 1, 18, 3)),
1392        ];
1393
1394        // Select across both columns, rows 1-2
1395        let sel = SelectionState {
1396            anchor: Some((0, 1)),
1397            current: Some((39, 2)),
1398            widget_rect: Some(area),
1399            active: true,
1400        };
1401        let text = extract_selection_text(&buf, &sel, &content_map);
1402        // Should NOT contain border characters (│, ╭, ╮, etc.)
1403        assert!(!text.contains('│'), "Border char │ found in: {text}");
1404        assert!(!text.contains('╭'), "Border char ╭ found in: {text}");
1405        assert!(!text.contains('╮'), "Border char ╮ found in: {text}");
1406        // Should contain actual content
1407        assert!(
1408            text.contains("Hello Col1"),
1409            "Missing Col1 content in: {text}"
1410        );
1411        assert!(
1412            text.contains("Hello Col2"),
1413            "Missing Col2 content in: {text}"
1414        );
1415        assert!(text.contains("Line2 Col1"), "Missing Col1 line2 in: {text}");
1416        assert!(text.contains("Line2 Col2"), "Missing Col2 line2 in: {text}");
1417    }
1418
1419    #[test]
1420    fn apply_selection_overlay_skips_border_cells() {
1421        let area = Rect::new(0, 0, 20, 3);
1422        let mut buf = Buffer::empty(area);
1423        buf.set_string(0, 0, "│", Style::default());
1424        buf.set_string(1, 0, "ABC", Style::default());
1425        buf.set_string(19, 0, "│", Style::default());
1426
1427        let content_map = vec![(Rect::new(0, 0, 20, 3), Rect::new(1, 0, 18, 3))];
1428        let sel = SelectionState {
1429            anchor: Some((0, 0)),
1430            current: Some((19, 0)),
1431            widget_rect: Some(area),
1432            active: true,
1433        };
1434        apply_selection_overlay(&mut buf, &sel, &content_map);
1435        // Border cells at x=0 and x=19 should NOT be reversed
1436        assert!(
1437            !buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED),
1438            "Left border cell should not be reversed"
1439        );
1440        assert!(
1441            !buf.get(19, 0).style.modifiers.contains(Modifiers::REVERSED),
1442            "Right border cell should not be reversed"
1443        );
1444        // Content cells should be reversed
1445        assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
1446        assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
1447        assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
1448    }
1449
1450    #[test]
1451    fn copy_to_clipboard_writes_osc52() {
1452        let mut output: Vec<u8> = Vec::new();
1453        copy_to_clipboard(&mut output, "test").unwrap();
1454        let s = String::from_utf8(output).unwrap();
1455        assert!(s.starts_with("\x1b]52;c;"));
1456        assert!(s.ends_with("\x1b\\"));
1457        assert!(s.contains(&base64_encode(b"test")));
1458    }
1459}