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#[non_exhaustive]
467#[cfg(feature = "crossterm")]
468#[derive(Debug, Clone, Copy, PartialEq, Eq)]
469pub enum ColorScheme {
470 Dark,
472 Light,
474 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#[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#[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
692const 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 let area = Rect::new(0, 0, 40, 5);
1116 let mut buf = Buffer::empty(area);
1117 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 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 buf.set_string(1, 1, "Hello Col1", Style::default());
1141 buf.set_string(1, 2, "Line2 Col1", Style::default());
1142 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 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 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 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 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 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}