Skip to main content

slt/
terminal.rs

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