Skip to main content

slt/
terminal.rs

1use std::io::{self, Read, Stdout, Write};
2use std::time::{Duration, Instant};
3
4use crossterm::event::{
5    DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
6    EnableFocusChange, EnableMouseCapture,
7};
8use crossterm::style::{
9    Attribute, Color as CtColor, Print, ResetColor, SetAttribute, SetBackgroundColor,
10    SetForegroundColor,
11};
12use crossterm::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate};
13use crossterm::{cursor, execute, queue, terminal};
14
15use unicode_width::UnicodeWidthStr;
16
17use crate::buffer::Buffer;
18use crate::rect::Rect;
19use crate::style::{Color, ColorDepth, Modifiers, Style};
20
21pub(crate) struct Terminal {
22    stdout: Stdout,
23    current: Buffer,
24    previous: Buffer,
25    mouse_enabled: bool,
26    cursor_visible: bool,
27    kitty_keyboard: bool,
28    color_depth: ColorDepth,
29    pub(crate) theme_bg: Option<Color>,
30}
31
32pub(crate) struct InlineTerminal {
33    stdout: Stdout,
34    current: Buffer,
35    previous: Buffer,
36    mouse_enabled: bool,
37    cursor_visible: bool,
38    height: u32,
39    start_row: u16,
40    reserved: bool,
41    color_depth: ColorDepth,
42    pub(crate) theme_bg: Option<Color>,
43}
44
45impl Terminal {
46    pub fn new(mouse: bool, kitty_keyboard: bool, color_depth: ColorDepth) -> io::Result<Self> {
47        let (cols, rows) = terminal::size()?;
48        let area = Rect::new(0, 0, cols as u32, rows as u32);
49
50        let mut stdout = io::stdout();
51        terminal::enable_raw_mode()?;
52        execute!(
53            stdout,
54            terminal::EnterAlternateScreen,
55            cursor::Hide,
56            EnableBracketedPaste
57        )?;
58        if mouse {
59            execute!(stdout, EnableMouseCapture, EnableFocusChange)?;
60        }
61        if kitty_keyboard {
62            use crossterm::event::{KeyboardEnhancementFlags, PushKeyboardEnhancementFlags};
63            let _ = execute!(
64                stdout,
65                PushKeyboardEnhancementFlags(
66                    KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
67                        | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
68                )
69            );
70        }
71
72        Ok(Self {
73            stdout,
74            current: Buffer::empty(area),
75            previous: Buffer::empty(area),
76            mouse_enabled: mouse,
77            cursor_visible: false,
78            kitty_keyboard,
79            color_depth,
80            theme_bg: None,
81        })
82    }
83
84    pub fn size(&self) -> (u32, u32) {
85        (self.current.area.width, self.current.area.height)
86    }
87
88    pub fn buffer_mut(&mut self) -> &mut Buffer {
89        &mut self.current
90    }
91
92    pub fn flush(&mut self) -> io::Result<()> {
93        if self.current.area.width < self.previous.area.width {
94            execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
95        }
96
97        queue!(self.stdout, BeginSynchronizedUpdate)?;
98
99        let mut last_style = Style::new();
100        let mut first_style = true;
101        let mut last_pos: Option<(u32, u32)> = None;
102        let mut active_link: Option<&str> = None;
103        let mut has_updates = false;
104
105        for y in self.current.area.y..self.current.area.bottom() {
106            for x in self.current.area.x..self.current.area.right() {
107                let cur = self.current.get(x, y);
108                let prev = self.previous.get(x, y);
109                if cur == prev {
110                    continue;
111                }
112                if cur.symbol.is_empty() {
113                    continue;
114                }
115                has_updates = true;
116
117                let need_move = last_pos.map_or(true, |(lx, ly)| ly != y || lx != x);
118                if need_move {
119                    queue!(self.stdout, cursor::MoveTo(x as u16, y as u16))?;
120                }
121
122                if cur.style != last_style {
123                    if first_style {
124                        queue!(self.stdout, ResetColor, SetAttribute(Attribute::Reset))?;
125                        apply_style(&mut self.stdout, &cur.style, self.color_depth)?;
126                        first_style = false;
127                    } else {
128                        apply_style_delta(
129                            &mut self.stdout,
130                            &last_style,
131                            &cur.style,
132                            self.color_depth,
133                        )?;
134                    }
135                    last_style = cur.style;
136                }
137
138                let cell_link = cur.hyperlink.as_deref();
139                if cell_link != active_link {
140                    if let Some(url) = cell_link {
141                        queue!(self.stdout, Print(format!("\x1b]8;;{url}\x07")))?;
142                    } else {
143                        queue!(self.stdout, Print("\x1b]8;;\x07"))?;
144                    }
145                    active_link = cell_link;
146                }
147
148                queue!(self.stdout, Print(&*cur.symbol))?;
149                let char_width = UnicodeWidthStr::width(cur.symbol.as_str()).max(1) as u32;
150                if char_width > 1 && cur.symbol.chars().any(|c| c == '\u{FE0F}') {
151                    queue!(self.stdout, Print(" "))?;
152                }
153                last_pos = Some((x + char_width, y));
154            }
155        }
156
157        if has_updates {
158            if active_link.is_some() {
159                queue!(self.stdout, Print("\x1b]8;;\x07"))?;
160            }
161            queue!(self.stdout, ResetColor, SetAttribute(Attribute::Reset))?;
162        }
163
164        if !self.previous.raw_sequences.is_empty() || !self.current.raw_sequences.is_empty() {
165            queue!(self.stdout, Print("\x1b_Ga=d,d=A,q=2\x1b\\"))?;
166        }
167
168        for (x, y, seq) in &self.current.raw_sequences {
169            queue!(self.stdout, cursor::MoveTo(*x as u16, *y as u16))?;
170            queue!(self.stdout, Print(seq))?;
171        }
172
173        queue!(self.stdout, EndSynchronizedUpdate)?;
174
175        let cursor_pos = find_cursor_marker(&self.current);
176        match cursor_pos {
177            Some((cx, cy)) => {
178                if !self.cursor_visible {
179                    queue!(self.stdout, cursor::Show)?;
180                    self.cursor_visible = true;
181                }
182                queue!(self.stdout, cursor::MoveTo(cx as u16, cy as u16))?;
183            }
184            None => {
185                if self.cursor_visible {
186                    queue!(self.stdout, cursor::Hide)?;
187                    self.cursor_visible = false;
188                }
189            }
190        }
191
192        self.stdout.flush()?;
193
194        std::mem::swap(&mut self.current, &mut self.previous);
195        if let Some(bg) = self.theme_bg {
196            self.current.reset_with_bg(bg);
197        } else {
198            self.current.reset();
199        }
200        Ok(())
201    }
202
203    pub fn handle_resize(&mut self) -> io::Result<()> {
204        let (cols, rows) = terminal::size()?;
205        let area = Rect::new(0, 0, cols as u32, rows as u32);
206        self.current.resize(area);
207        self.previous.resize(area);
208        execute!(
209            self.stdout,
210            terminal::Clear(terminal::ClearType::All),
211            cursor::MoveTo(0, 0)
212        )?;
213        Ok(())
214    }
215}
216
217impl crate::Backend for Terminal {
218    fn size(&self) -> (u32, u32) {
219        Terminal::size(self)
220    }
221
222    fn buffer_mut(&mut self) -> &mut Buffer {
223        Terminal::buffer_mut(self)
224    }
225
226    fn flush(&mut self) -> io::Result<()> {
227        Terminal::flush(self)
228    }
229}
230
231impl InlineTerminal {
232    pub fn new(height: u32, mouse: bool, color_depth: ColorDepth) -> io::Result<Self> {
233        let (cols, _) = terminal::size()?;
234        let area = Rect::new(0, 0, cols as u32, height);
235
236        let mut stdout = io::stdout();
237        terminal::enable_raw_mode()?;
238        execute!(stdout, cursor::Hide, EnableBracketedPaste)?;
239        if mouse {
240            execute!(stdout, EnableMouseCapture, EnableFocusChange)?;
241        }
242
243        let (_, cursor_row) = cursor::position()?;
244        Ok(Self {
245            stdout,
246            current: Buffer::empty(area),
247            previous: Buffer::empty(area),
248            mouse_enabled: mouse,
249            cursor_visible: false,
250            height,
251            start_row: cursor_row,
252            reserved: false,
253            color_depth,
254            theme_bg: None,
255        })
256    }
257
258    pub fn size(&self) -> (u32, u32) {
259        (self.current.area.width, self.current.area.height)
260    }
261
262    pub fn buffer_mut(&mut self) -> &mut Buffer {
263        &mut self.current
264    }
265
266    pub fn flush(&mut self) -> io::Result<()> {
267        if self.current.area.width < self.previous.area.width {
268            execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
269        }
270
271        queue!(self.stdout, BeginSynchronizedUpdate)?;
272
273        if !self.reserved {
274            queue!(self.stdout, cursor::MoveToColumn(0))?;
275            for _ in 0..self.height {
276                queue!(self.stdout, Print("\n"))?;
277            }
278            self.reserved = true;
279
280            let (_, rows) = terminal::size()?;
281            let bottom = self.start_row + self.height as u16;
282            if bottom > rows {
283                self.start_row = rows.saturating_sub(self.height as u16);
284            }
285        }
286
287        let updates = self.current.diff(&self.previous);
288        if !updates.is_empty() {
289            let mut last_style = Style::new();
290            let mut first_style = true;
291            let mut last_pos: Option<(u32, u32)> = None;
292            let mut active_link: Option<&str> = None;
293
294            for &(x, y, cell) in &updates {
295                if cell.symbol.is_empty() {
296                    continue;
297                }
298
299                let abs_y = self.start_row as u32 + y;
300                let need_move = last_pos.map_or(true, |(lx, ly)| ly != abs_y || lx != x);
301                if need_move {
302                    queue!(self.stdout, cursor::MoveTo(x as u16, abs_y as u16))?;
303                }
304
305                if cell.style != last_style {
306                    if first_style {
307                        queue!(self.stdout, ResetColor, SetAttribute(Attribute::Reset))?;
308                        apply_style(&mut self.stdout, &cell.style, self.color_depth)?;
309                        first_style = false;
310                    } else {
311                        apply_style_delta(
312                            &mut self.stdout,
313                            &last_style,
314                            &cell.style,
315                            self.color_depth,
316                        )?;
317                    }
318                    last_style = cell.style;
319                }
320
321                let cell_link = cell.hyperlink.as_deref();
322                if cell_link != active_link {
323                    if let Some(url) = cell_link {
324                        queue!(self.stdout, Print(format!("\x1b]8;;{url}\x07")))?;
325                    } else {
326                        queue!(self.stdout, Print("\x1b]8;;\x07"))?;
327                    }
328                    active_link = cell_link;
329                }
330
331                queue!(self.stdout, Print(&cell.symbol))?;
332                let char_width = UnicodeWidthStr::width(cell.symbol.as_str()).max(1) as u32;
333                if char_width > 1 && cell.symbol.chars().any(|c| c == '\u{FE0F}') {
334                    queue!(self.stdout, Print(" "))?;
335                }
336                last_pos = Some((x + char_width, abs_y));
337            }
338
339            if active_link.is_some() {
340                queue!(self.stdout, Print("\x1b]8;;\x07"))?;
341            }
342            queue!(self.stdout, ResetColor, SetAttribute(Attribute::Reset))?;
343        }
344
345        if !self.previous.raw_sequences.is_empty() || !self.current.raw_sequences.is_empty() {
346            queue!(self.stdout, Print("\x1b_Ga=d,d=A,q=2\x1b\\"))?;
347        }
348
349        for (x, y, seq) in &self.current.raw_sequences {
350            let abs_y = self.start_row as u32 + *y;
351            queue!(self.stdout, cursor::MoveTo(*x as u16, abs_y as u16))?;
352            queue!(self.stdout, Print(seq))?;
353        }
354
355        queue!(self.stdout, EndSynchronizedUpdate)?;
356
357        let cursor_pos = find_cursor_marker(&self.current);
358        match cursor_pos {
359            Some((cx, cy)) => {
360                let abs_cy = self.start_row as u32 + cy;
361                if !self.cursor_visible {
362                    queue!(self.stdout, cursor::Show)?;
363                    self.cursor_visible = true;
364                }
365                queue!(self.stdout, cursor::MoveTo(cx as u16, abs_cy as u16))?;
366            }
367            None => {
368                if self.cursor_visible {
369                    queue!(self.stdout, cursor::Hide)?;
370                    self.cursor_visible = false;
371                }
372                let end_row = self.start_row + self.height.saturating_sub(1) as u16;
373                queue!(self.stdout, cursor::MoveTo(0, end_row))?;
374            }
375        }
376
377        self.stdout.flush()?;
378
379        std::mem::swap(&mut self.current, &mut self.previous);
380        reset_current_buffer(&mut self.current, self.theme_bg);
381        Ok(())
382    }
383
384    pub fn handle_resize(&mut self) -> io::Result<()> {
385        let (cols, _) = terminal::size()?;
386        let area = Rect::new(0, 0, cols as u32, self.height);
387        self.current.resize(area);
388        self.previous.resize(area);
389        execute!(
390            self.stdout,
391            terminal::Clear(terminal::ClearType::All),
392            cursor::MoveTo(0, 0)
393        )?;
394        Ok(())
395    }
396}
397
398impl crate::Backend for InlineTerminal {
399    fn size(&self) -> (u32, u32) {
400        InlineTerminal::size(self)
401    }
402
403    fn buffer_mut(&mut self) -> &mut Buffer {
404        InlineTerminal::buffer_mut(self)
405    }
406
407    fn flush(&mut self) -> io::Result<()> {
408        InlineTerminal::flush(self)
409    }
410}
411
412impl Drop for Terminal {
413    fn drop(&mut self) {
414        if self.kitty_keyboard {
415            use crossterm::event::PopKeyboardEnhancementFlags;
416            let _ = execute!(self.stdout, PopKeyboardEnhancementFlags);
417        }
418        if self.mouse_enabled {
419            let _ = execute!(self.stdout, DisableMouseCapture, DisableFocusChange);
420        }
421        let _ = execute!(
422            self.stdout,
423            ResetColor,
424            SetAttribute(Attribute::Reset),
425            cursor::Show,
426            DisableBracketedPaste,
427            terminal::LeaveAlternateScreen
428        );
429        let _ = terminal::disable_raw_mode();
430    }
431}
432
433impl Drop for InlineTerminal {
434    fn drop(&mut self) {
435        if self.mouse_enabled {
436            let _ = execute!(self.stdout, DisableMouseCapture, DisableFocusChange);
437        }
438        let _ = execute!(
439            self.stdout,
440            ResetColor,
441            SetAttribute(Attribute::Reset),
442            cursor::Show,
443            DisableBracketedPaste
444        );
445        if self.reserved {
446            let _ = execute!(
447                self.stdout,
448                cursor::MoveToColumn(0),
449                cursor::MoveDown(1),
450                cursor::MoveToColumn(0),
451                Print("\n")
452            );
453        } else {
454            let _ = execute!(self.stdout, Print("\n"));
455        }
456        let _ = terminal::disable_raw_mode();
457    }
458}
459
460mod selection;
461pub(crate) use selection::{apply_selection_overlay, extract_selection_text, SelectionState};
462#[cfg(test)]
463pub(crate) use selection::{find_innermost_rect, normalize_selection};
464
465/// Detected terminal color scheme from OSC 11.
466#[non_exhaustive]
467#[cfg(feature = "crossterm")]
468#[derive(Debug, Clone, Copy, PartialEq, Eq)]
469pub enum ColorScheme {
470    /// Dark background detected.
471    Dark,
472    /// Light background detected.
473    Light,
474    /// Could not determine the scheme.
475    Unknown,
476}
477
478#[cfg(feature = "crossterm")]
479fn read_osc_response(timeout: Duration) -> Option<String> {
480    let deadline = Instant::now() + timeout;
481    let mut stdin = io::stdin();
482    let mut bytes = Vec::new();
483    let mut buf = [0u8; 1];
484
485    while Instant::now() < deadline {
486        if !crossterm::event::poll(Duration::from_millis(10)).ok()? {
487            continue;
488        }
489
490        let read = stdin.read(&mut buf).ok()?;
491        if read == 0 {
492            continue;
493        }
494
495        bytes.push(buf[0]);
496
497        if buf[0] == b'\x07' {
498            break;
499        }
500        let len = bytes.len();
501        if len >= 2 && bytes[len - 2] == 0x1B && bytes[len - 1] == b'\\' {
502            break;
503        }
504
505        if bytes.len() >= 4096 {
506            break;
507        }
508    }
509
510    if bytes.is_empty() {
511        return None;
512    }
513
514    String::from_utf8(bytes).ok()
515}
516
517/// Query the terminal's background color via OSC 11 and return the detected scheme.
518#[cfg(feature = "crossterm")]
519pub fn detect_color_scheme() -> ColorScheme {
520    let mut stdout = io::stdout();
521    if write!(stdout, "\x1b]11;?\x07").is_err() {
522        return ColorScheme::Unknown;
523    }
524    if stdout.flush().is_err() {
525        return ColorScheme::Unknown;
526    }
527
528    let Some(response) = read_osc_response(Duration::from_millis(100)) else {
529        return ColorScheme::Unknown;
530    };
531
532    parse_osc11_response(&response)
533}
534
535#[cfg(feature = "crossterm")]
536pub(crate) fn parse_osc11_response(response: &str) -> ColorScheme {
537    let Some(rgb_pos) = response.find("rgb:") else {
538        return ColorScheme::Unknown;
539    };
540
541    let payload = &response[rgb_pos + 4..];
542    let end = payload
543        .find(['\x07', '\x1b', '\r', '\n', ' ', '\t'])
544        .unwrap_or(payload.len());
545    let rgb = &payload[..end];
546
547    let mut channels = rgb.split('/');
548    let (Some(r), Some(g), Some(b), None) = (
549        channels.next(),
550        channels.next(),
551        channels.next(),
552        channels.next(),
553    ) else {
554        return ColorScheme::Unknown;
555    };
556
557    fn parse_channel(channel: &str) -> Option<f64> {
558        if channel.is_empty() || channel.len() > 4 {
559            return None;
560        }
561        let value = u16::from_str_radix(channel, 16).ok()? as f64;
562        let max = ((1u32 << (channel.len() * 4)) - 1) as f64;
563        if max <= 0.0 {
564            return None;
565        }
566        Some((value / max).clamp(0.0, 1.0))
567    }
568
569    let (Some(r), Some(g), Some(b)) = (parse_channel(r), parse_channel(g), parse_channel(b)) else {
570        return ColorScheme::Unknown;
571    };
572
573    let luminance = 0.299 * r + 0.587 * g + 0.114 * b;
574    if luminance < 0.5 {
575        ColorScheme::Dark
576    } else {
577        ColorScheme::Light
578    }
579}
580
581fn base64_encode(input: &[u8]) -> String {
582    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
583    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
584    for chunk in input.chunks(3) {
585        let b0 = chunk[0] as u32;
586        let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
587        let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
588        let triple = (b0 << 16) | (b1 << 8) | b2;
589        out.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
590        out.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
591        out.push(if chunk.len() > 1 {
592            CHARS[((triple >> 6) & 0x3F) as usize] as char
593        } else {
594            '='
595        });
596        out.push(if chunk.len() > 2 {
597            CHARS[(triple & 0x3F) as usize] as char
598        } else {
599            '='
600        });
601    }
602    out
603}
604
605pub(crate) fn copy_to_clipboard(w: &mut impl Write, text: &str) -> io::Result<()> {
606    let encoded = base64_encode(text.as_bytes());
607    write!(w, "\x1b]52;c;{encoded}\x1b\\")?;
608    w.flush()
609}
610
611#[cfg(feature = "crossterm")]
612fn parse_osc52_response(response: &str) -> Option<String> {
613    let osc_pos = response.find("]52;")?;
614    let body = &response[osc_pos + 4..];
615    let semicolon = body.find(';')?;
616    let payload = &body[semicolon + 1..];
617
618    let end = payload
619        .find("\x1b\\")
620        .or_else(|| payload.find('\x07'))
621        .unwrap_or(payload.len());
622    let encoded = payload[..end].trim();
623    if encoded.is_empty() || encoded == "?" {
624        return None;
625    }
626
627    base64_decode(encoded)
628}
629
630/// Read clipboard contents via OSC 52 terminal query.
631#[cfg(feature = "crossterm")]
632pub fn read_clipboard() -> Option<String> {
633    let mut stdout = io::stdout();
634    write!(stdout, "\x1b]52;c;?\x07").ok()?;
635    stdout.flush().ok()?;
636
637    let response = read_osc_response(Duration::from_millis(200))?;
638    parse_osc52_response(&response)
639}
640
641#[cfg(feature = "crossterm")]
642fn base64_decode(input: &str) -> Option<String> {
643    let mut filtered: Vec<u8> = input
644        .bytes()
645        .filter(|b| !matches!(b, b' ' | b'\n' | b'\r' | b'\t'))
646        .collect();
647
648    match filtered.len() % 4 {
649        0 => {}
650        2 => filtered.extend_from_slice(b"=="),
651        3 => filtered.push(b'='),
652        _ => return None,
653    }
654
655    fn decode_val(b: u8) -> Option<u8> {
656        match b {
657            b'A'..=b'Z' => Some(b - b'A'),
658            b'a'..=b'z' => Some(b - b'a' + 26),
659            b'0'..=b'9' => Some(b - b'0' + 52),
660            b'+' => Some(62),
661            b'/' => Some(63),
662            _ => None,
663        }
664    }
665
666    let mut out = Vec::with_capacity((filtered.len() / 4) * 3);
667    for chunk in filtered.chunks_exact(4) {
668        let p2 = chunk[2] == b'=';
669        let p3 = chunk[3] == b'=';
670        if p2 && !p3 {
671            return None;
672        }
673
674        let v0 = decode_val(chunk[0])? as u32;
675        let v1 = decode_val(chunk[1])? as u32;
676        let v2 = if p2 { 0 } else { decode_val(chunk[2])? as u32 };
677        let v3 = if p3 { 0 } else { decode_val(chunk[3])? as u32 };
678
679        let triple = (v0 << 18) | (v1 << 12) | (v2 << 6) | v3;
680        out.push(((triple >> 16) & 0xFF) as u8);
681        if !p2 {
682            out.push(((triple >> 8) & 0xFF) as u8);
683        }
684        if !p3 {
685            out.push((triple & 0xFF) as u8);
686        }
687    }
688
689    String::from_utf8(out).ok()
690}
691
692// ── Cursor marker ───────────────────────────────────────────────
693
694const CURSOR_MARKER: &str = "▎";
695
696fn find_cursor_marker(buffer: &Buffer) -> Option<(u32, u32)> {
697    let area = buffer.area;
698    for y in area.y..area.bottom() {
699        for x in area.x..area.right() {
700            if buffer.get(x, y).symbol == CURSOR_MARKER {
701                return Some((x, y));
702            }
703        }
704    }
705    None
706}
707
708fn apply_style_delta(
709    w: &mut impl Write,
710    old: &Style,
711    new: &Style,
712    depth: ColorDepth,
713) -> io::Result<()> {
714    if old.fg != new.fg {
715        match new.fg {
716            Some(fg) => queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?,
717            None => queue!(w, SetForegroundColor(CtColor::Reset))?,
718        }
719    }
720    if old.bg != new.bg {
721        match new.bg {
722            Some(bg) => queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?,
723            None => queue!(w, SetBackgroundColor(CtColor::Reset))?,
724        }
725    }
726    let removed = Modifiers(old.modifiers.0 & !new.modifiers.0);
727    let added = Modifiers(new.modifiers.0 & !old.modifiers.0);
728    if removed.contains(Modifiers::BOLD) || removed.contains(Modifiers::DIM) {
729        queue!(w, SetAttribute(Attribute::NormalIntensity))?;
730        if new.modifiers.contains(Modifiers::BOLD) {
731            queue!(w, SetAttribute(Attribute::Bold))?;
732        }
733        if new.modifiers.contains(Modifiers::DIM) {
734            queue!(w, SetAttribute(Attribute::Dim))?;
735        }
736    } else {
737        if added.contains(Modifiers::BOLD) {
738            queue!(w, SetAttribute(Attribute::Bold))?;
739        }
740        if added.contains(Modifiers::DIM) {
741            queue!(w, SetAttribute(Attribute::Dim))?;
742        }
743    }
744    if removed.contains(Modifiers::ITALIC) {
745        queue!(w, SetAttribute(Attribute::NoItalic))?;
746    }
747    if added.contains(Modifiers::ITALIC) {
748        queue!(w, SetAttribute(Attribute::Italic))?;
749    }
750    if removed.contains(Modifiers::UNDERLINE) {
751        queue!(w, SetAttribute(Attribute::NoUnderline))?;
752    }
753    if added.contains(Modifiers::UNDERLINE) {
754        queue!(w, SetAttribute(Attribute::Underlined))?;
755    }
756    if removed.contains(Modifiers::REVERSED) {
757        queue!(w, SetAttribute(Attribute::NoReverse))?;
758    }
759    if added.contains(Modifiers::REVERSED) {
760        queue!(w, SetAttribute(Attribute::Reverse))?;
761    }
762    if removed.contains(Modifiers::STRIKETHROUGH) {
763        queue!(w, SetAttribute(Attribute::NotCrossedOut))?;
764    }
765    if added.contains(Modifiers::STRIKETHROUGH) {
766        queue!(w, SetAttribute(Attribute::CrossedOut))?;
767    }
768    Ok(())
769}
770
771fn apply_style(w: &mut impl Write, style: &Style, depth: ColorDepth) -> io::Result<()> {
772    if let Some(fg) = style.fg {
773        queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?;
774    }
775    if let Some(bg) = style.bg {
776        queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?;
777    }
778    let m = style.modifiers;
779    if m.contains(Modifiers::BOLD) {
780        queue!(w, SetAttribute(Attribute::Bold))?;
781    }
782    if m.contains(Modifiers::DIM) {
783        queue!(w, SetAttribute(Attribute::Dim))?;
784    }
785    if m.contains(Modifiers::ITALIC) {
786        queue!(w, SetAttribute(Attribute::Italic))?;
787    }
788    if m.contains(Modifiers::UNDERLINE) {
789        queue!(w, SetAttribute(Attribute::Underlined))?;
790    }
791    if m.contains(Modifiers::REVERSED) {
792        queue!(w, SetAttribute(Attribute::Reverse))?;
793    }
794    if m.contains(Modifiers::STRIKETHROUGH) {
795        queue!(w, SetAttribute(Attribute::CrossedOut))?;
796    }
797    Ok(())
798}
799
800fn to_crossterm_color(color: Color, depth: ColorDepth) -> CtColor {
801    let color = color.downsampled(depth);
802    match color {
803        Color::Reset => CtColor::Reset,
804        Color::Black => CtColor::Black,
805        Color::Red => CtColor::DarkRed,
806        Color::Green => CtColor::DarkGreen,
807        Color::Yellow => CtColor::DarkYellow,
808        Color::Blue => CtColor::DarkBlue,
809        Color::Magenta => CtColor::DarkMagenta,
810        Color::Cyan => CtColor::DarkCyan,
811        Color::White => CtColor::White,
812        Color::DarkGray => CtColor::DarkGrey,
813        Color::LightRed => CtColor::Red,
814        Color::LightGreen => CtColor::Green,
815        Color::LightYellow => CtColor::Yellow,
816        Color::LightBlue => CtColor::Blue,
817        Color::LightMagenta => CtColor::Magenta,
818        Color::LightCyan => CtColor::Cyan,
819        Color::LightWhite => CtColor::White,
820        Color::Rgb(r, g, b) => CtColor::Rgb { r, g, b },
821        Color::Indexed(i) => CtColor::AnsiValue(i),
822    }
823}
824
825fn reset_current_buffer(buffer: &mut Buffer, theme_bg: Option<Color>) {
826    if let Some(bg) = theme_bg {
827        buffer.reset_with_bg(bg);
828    } else {
829        buffer.reset();
830    }
831}
832
833#[cfg(test)]
834mod tests {
835    use super::*;
836
837    #[test]
838    fn reset_current_buffer_applies_theme_background() {
839        let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 1));
840
841        reset_current_buffer(&mut buffer, Some(Color::Rgb(10, 20, 30)));
842        assert_eq!(buffer.get(0, 0).style.bg, Some(Color::Rgb(10, 20, 30)));
843
844        reset_current_buffer(&mut buffer, None);
845        assert_eq!(buffer.get(0, 0).style.bg, None);
846    }
847
848    #[test]
849    fn base64_encode_empty() {
850        assert_eq!(base64_encode(b""), "");
851    }
852
853    #[test]
854    fn base64_encode_hello() {
855        assert_eq!(base64_encode(b"Hello"), "SGVsbG8=");
856    }
857
858    #[test]
859    fn base64_encode_padding() {
860        assert_eq!(base64_encode(b"a"), "YQ==");
861        assert_eq!(base64_encode(b"ab"), "YWI=");
862        assert_eq!(base64_encode(b"abc"), "YWJj");
863    }
864
865    #[test]
866    fn base64_encode_unicode() {
867        assert_eq!(base64_encode("한글".as_bytes()), "7ZWc6riA");
868    }
869
870    #[cfg(feature = "crossterm")]
871    #[test]
872    fn parse_osc11_response_dark_and_light() {
873        assert_eq!(
874            parse_osc11_response("\x1b]11;rgb:0000/0000/0000\x1b\\"),
875            ColorScheme::Dark
876        );
877        assert_eq!(
878            parse_osc11_response("\x1b]11;rgb:ffff/ffff/ffff\x07"),
879            ColorScheme::Light
880        );
881    }
882
883    #[cfg(feature = "crossterm")]
884    #[test]
885    fn base64_decode_round_trip_hello() {
886        let encoded = base64_encode("hello".as_bytes());
887        assert_eq!(base64_decode(&encoded), Some("hello".to_string()));
888    }
889
890    #[cfg(feature = "crossterm")]
891    #[test]
892    fn color_scheme_equality() {
893        assert_eq!(ColorScheme::Dark, ColorScheme::Dark);
894        assert_ne!(ColorScheme::Dark, ColorScheme::Light);
895        assert_eq!(ColorScheme::Unknown, ColorScheme::Unknown);
896    }
897
898    fn pair(r: Rect) -> (Rect, Rect) {
899        (r, r)
900    }
901
902    #[test]
903    fn find_innermost_rect_picks_smallest() {
904        let rects = vec![
905            pair(Rect::new(0, 0, 80, 24)),
906            pair(Rect::new(5, 2, 30, 10)),
907            pair(Rect::new(10, 4, 10, 5)),
908        ];
909        let result = find_innermost_rect(&rects, 12, 5);
910        assert_eq!(result, Some(Rect::new(10, 4, 10, 5)));
911    }
912
913    #[test]
914    fn find_innermost_rect_no_match() {
915        let rects = vec![pair(Rect::new(10, 10, 5, 5))];
916        assert_eq!(find_innermost_rect(&rects, 0, 0), None);
917    }
918
919    #[test]
920    fn find_innermost_rect_empty() {
921        assert_eq!(find_innermost_rect(&[], 5, 5), None);
922    }
923
924    #[test]
925    fn find_innermost_rect_returns_content_rect() {
926        let rects = vec![
927            (Rect::new(0, 0, 80, 24), Rect::new(1, 1, 78, 22)),
928            (Rect::new(5, 2, 30, 10), Rect::new(6, 3, 28, 8)),
929        ];
930        let result = find_innermost_rect(&rects, 10, 5);
931        assert_eq!(result, Some(Rect::new(6, 3, 28, 8)));
932    }
933
934    #[test]
935    fn normalize_selection_already_ordered() {
936        let (s, e) = normalize_selection((2, 1), (5, 3));
937        assert_eq!(s, (2, 1));
938        assert_eq!(e, (5, 3));
939    }
940
941    #[test]
942    fn normalize_selection_reversed() {
943        let (s, e) = normalize_selection((5, 3), (2, 1));
944        assert_eq!(s, (2, 1));
945        assert_eq!(e, (5, 3));
946    }
947
948    #[test]
949    fn normalize_selection_same_row() {
950        let (s, e) = normalize_selection((10, 5), (3, 5));
951        assert_eq!(s, (3, 5));
952        assert_eq!(e, (10, 5));
953    }
954
955    #[test]
956    fn selection_state_mouse_down_finds_rect() {
957        let hit_map = vec![pair(Rect::new(0, 0, 80, 24)), pair(Rect::new(5, 2, 20, 10))];
958        let mut sel = SelectionState::default();
959        sel.mouse_down(10, 5, &hit_map);
960        assert_eq!(sel.anchor, Some((10, 5)));
961        assert_eq!(sel.current, Some((10, 5)));
962        assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 20, 10)));
963        assert!(!sel.active);
964    }
965
966    #[test]
967    fn selection_state_drag_activates() {
968        let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
969        let mut sel = SelectionState {
970            anchor: Some((10, 5)),
971            current: Some((10, 5)),
972            widget_rect: Some(Rect::new(0, 0, 80, 24)),
973            ..Default::default()
974        };
975        sel.mouse_drag(10, 5, &hit_map);
976        assert!(!sel.active, "no movement = not active");
977        sel.mouse_drag(11, 5, &hit_map);
978        assert!(!sel.active, "1 cell horizontal = not active yet");
979        sel.mouse_drag(13, 5, &hit_map);
980        assert!(sel.active, ">1 cell horizontal = active");
981    }
982
983    #[test]
984    fn selection_state_drag_vertical_activates() {
985        let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
986        let mut sel = SelectionState {
987            anchor: Some((10, 5)),
988            current: Some((10, 5)),
989            widget_rect: Some(Rect::new(0, 0, 80, 24)),
990            ..Default::default()
991        };
992        sel.mouse_drag(10, 6, &hit_map);
993        assert!(sel.active, "any vertical movement = active");
994    }
995
996    #[test]
997    fn selection_state_drag_expands_widget_rect() {
998        let hit_map = vec![
999            pair(Rect::new(0, 0, 80, 24)),
1000            pair(Rect::new(5, 2, 30, 10)),
1001            pair(Rect::new(5, 2, 30, 3)),
1002        ];
1003        let mut sel = SelectionState {
1004            anchor: Some((10, 3)),
1005            current: Some((10, 3)),
1006            widget_rect: Some(Rect::new(5, 2, 30, 3)),
1007            ..Default::default()
1008        };
1009        sel.mouse_drag(10, 6, &hit_map);
1010        assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 30, 10)));
1011    }
1012
1013    #[test]
1014    fn selection_state_clear_resets() {
1015        let mut sel = SelectionState {
1016            anchor: Some((1, 2)),
1017            current: Some((3, 4)),
1018            widget_rect: Some(Rect::new(0, 0, 10, 10)),
1019            active: true,
1020        };
1021        sel.clear();
1022        assert_eq!(sel.anchor, None);
1023        assert_eq!(sel.current, None);
1024        assert_eq!(sel.widget_rect, None);
1025        assert!(!sel.active);
1026    }
1027
1028    #[test]
1029    fn extract_selection_text_single_line() {
1030        let area = Rect::new(0, 0, 20, 5);
1031        let mut buf = Buffer::empty(area);
1032        buf.set_string(0, 0, "Hello World", Style::default());
1033        let sel = SelectionState {
1034            anchor: Some((0, 0)),
1035            current: Some((4, 0)),
1036            widget_rect: Some(area),
1037            active: true,
1038        };
1039        let text = extract_selection_text(&buf, &sel, &[]);
1040        assert_eq!(text, "Hello");
1041    }
1042
1043    #[test]
1044    fn extract_selection_text_multi_line() {
1045        let area = Rect::new(0, 0, 20, 5);
1046        let mut buf = Buffer::empty(area);
1047        buf.set_string(0, 0, "Line one", Style::default());
1048        buf.set_string(0, 1, "Line two", Style::default());
1049        buf.set_string(0, 2, "Line three", Style::default());
1050        let sel = SelectionState {
1051            anchor: Some((5, 0)),
1052            current: Some((3, 2)),
1053            widget_rect: Some(area),
1054            active: true,
1055        };
1056        let text = extract_selection_text(&buf, &sel, &[]);
1057        assert_eq!(text, "one\nLine two\nLine");
1058    }
1059
1060    #[test]
1061    fn extract_selection_text_clamped_to_widget() {
1062        let area = Rect::new(0, 0, 40, 10);
1063        let widget = Rect::new(5, 2, 10, 3);
1064        let mut buf = Buffer::empty(area);
1065        buf.set_string(5, 2, "ABCDEFGHIJ", Style::default());
1066        buf.set_string(5, 3, "KLMNOPQRST", Style::default());
1067        let sel = SelectionState {
1068            anchor: Some((3, 1)),
1069            current: Some((20, 5)),
1070            widget_rect: Some(widget),
1071            active: true,
1072        };
1073        let text = extract_selection_text(&buf, &sel, &[]);
1074        assert_eq!(text, "ABCDEFGHIJ\nKLMNOPQRST");
1075    }
1076
1077    #[test]
1078    fn extract_selection_text_inactive_returns_empty() {
1079        let area = Rect::new(0, 0, 10, 5);
1080        let buf = Buffer::empty(area);
1081        let sel = SelectionState {
1082            anchor: Some((0, 0)),
1083            current: Some((5, 2)),
1084            widget_rect: Some(area),
1085            active: false,
1086        };
1087        assert_eq!(extract_selection_text(&buf, &sel, &[]), "");
1088    }
1089
1090    #[test]
1091    fn apply_selection_overlay_reverses_cells() {
1092        let area = Rect::new(0, 0, 10, 3);
1093        let mut buf = Buffer::empty(area);
1094        buf.set_string(0, 0, "ABCDE", Style::default());
1095        let sel = SelectionState {
1096            anchor: Some((1, 0)),
1097            current: Some((3, 0)),
1098            widget_rect: Some(area),
1099            active: true,
1100        };
1101        apply_selection_overlay(&mut buf, &sel, &[]);
1102        assert!(!buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED));
1103        assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
1104        assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
1105        assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
1106        assert!(!buf.get(4, 0).style.modifiers.contains(Modifiers::REVERSED));
1107    }
1108
1109    #[test]
1110    fn extract_selection_text_skips_border_cells() {
1111        // Simulate two bordered columns side by side:
1112        // Col1: full=(0,0,20,5) content=(1,1,18,3)
1113        // Col2: full=(20,0,20,5) content=(21,1,18,3)
1114        // Parent widget_rect covers both: (0,0,40,5)
1115        let area = Rect::new(0, 0, 40, 5);
1116        let mut buf = Buffer::empty(area);
1117        // Col1 border characters
1118        buf.set_string(0, 0, "╭", Style::default());
1119        buf.set_string(0, 1, "│", Style::default());
1120        buf.set_string(0, 2, "│", Style::default());
1121        buf.set_string(0, 3, "│", Style::default());
1122        buf.set_string(0, 4, "╰", Style::default());
1123        buf.set_string(19, 0, "╮", Style::default());
1124        buf.set_string(19, 1, "│", Style::default());
1125        buf.set_string(19, 2, "│", Style::default());
1126        buf.set_string(19, 3, "│", Style::default());
1127        buf.set_string(19, 4, "╯", Style::default());
1128        // Col2 border characters
1129        buf.set_string(20, 0, "╭", Style::default());
1130        buf.set_string(20, 1, "│", Style::default());
1131        buf.set_string(20, 2, "│", Style::default());
1132        buf.set_string(20, 3, "│", Style::default());
1133        buf.set_string(20, 4, "╰", Style::default());
1134        buf.set_string(39, 0, "╮", Style::default());
1135        buf.set_string(39, 1, "│", Style::default());
1136        buf.set_string(39, 2, "│", Style::default());
1137        buf.set_string(39, 3, "│", Style::default());
1138        buf.set_string(39, 4, "╯", Style::default());
1139        // Content inside Col1
1140        buf.set_string(1, 1, "Hello Col1", Style::default());
1141        buf.set_string(1, 2, "Line2 Col1", Style::default());
1142        // Content inside Col2
1143        buf.set_string(21, 1, "Hello Col2", Style::default());
1144        buf.set_string(21, 2, "Line2 Col2", Style::default());
1145
1146        let content_map = vec![
1147            (Rect::new(0, 0, 20, 5), Rect::new(1, 1, 18, 3)),
1148            (Rect::new(20, 0, 20, 5), Rect::new(21, 1, 18, 3)),
1149        ];
1150
1151        // Select across both columns, rows 1-2
1152        let sel = SelectionState {
1153            anchor: Some((0, 1)),
1154            current: Some((39, 2)),
1155            widget_rect: Some(area),
1156            active: true,
1157        };
1158        let text = extract_selection_text(&buf, &sel, &content_map);
1159        // Should NOT contain border characters (│, ╭, ╮, etc.)
1160        assert!(!text.contains('│'), "Border char │ found in: {text}");
1161        assert!(!text.contains('╭'), "Border char ╭ found in: {text}");
1162        assert!(!text.contains('╮'), "Border char ╮ found in: {text}");
1163        // Should contain actual content
1164        assert!(
1165            text.contains("Hello Col1"),
1166            "Missing Col1 content in: {text}"
1167        );
1168        assert!(
1169            text.contains("Hello Col2"),
1170            "Missing Col2 content in: {text}"
1171        );
1172        assert!(text.contains("Line2 Col1"), "Missing Col1 line2 in: {text}");
1173        assert!(text.contains("Line2 Col2"), "Missing Col2 line2 in: {text}");
1174    }
1175
1176    #[test]
1177    fn apply_selection_overlay_skips_border_cells() {
1178        let area = Rect::new(0, 0, 20, 3);
1179        let mut buf = Buffer::empty(area);
1180        buf.set_string(0, 0, "│", Style::default());
1181        buf.set_string(1, 0, "ABC", Style::default());
1182        buf.set_string(19, 0, "│", Style::default());
1183
1184        let content_map = vec![(Rect::new(0, 0, 20, 3), Rect::new(1, 0, 18, 3))];
1185        let sel = SelectionState {
1186            anchor: Some((0, 0)),
1187            current: Some((19, 0)),
1188            widget_rect: Some(area),
1189            active: true,
1190        };
1191        apply_selection_overlay(&mut buf, &sel, &content_map);
1192        // Border cells at x=0 and x=19 should NOT be reversed
1193        assert!(
1194            !buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED),
1195            "Left border cell should not be reversed"
1196        );
1197        assert!(
1198            !buf.get(19, 0).style.modifiers.contains(Modifiers::REVERSED),
1199            "Right border cell should not be reversed"
1200        );
1201        // Content cells should be reversed
1202        assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
1203        assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
1204        assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
1205    }
1206
1207    #[test]
1208    fn copy_to_clipboard_writes_osc52() {
1209        let mut output: Vec<u8> = Vec::new();
1210        copy_to_clipboard(&mut output, "test").unwrap();
1211        let s = String::from_utf8(output).unwrap();
1212        assert!(s.starts_with("\x1b]52;c;"));
1213        assert!(s.ends_with("\x1b\\"));
1214        assert!(s.contains(&base64_encode(b"test")));
1215    }
1216}