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