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