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#[cfg(feature = "crossterm")]
467#[derive(Debug, Clone, Copy, PartialEq, Eq)]
468pub enum ColorScheme {
469 Dark,
471 Light,
473 Unknown,
475}
476
477#[cfg(feature = "crossterm")]
478fn read_osc_response(timeout: Duration) -> Option<String> {
479 let deadline = Instant::now() + timeout;
480 let mut stdin = io::stdin();
481 let mut bytes = Vec::new();
482 let mut buf = [0u8; 1];
483
484 while Instant::now() < deadline {
485 if !crossterm::event::poll(Duration::from_millis(10)).ok()? {
486 continue;
487 }
488
489 let read = stdin.read(&mut buf).ok()?;
490 if read == 0 {
491 continue;
492 }
493
494 bytes.push(buf[0]);
495
496 if buf[0] == b'\x07' {
497 break;
498 }
499 let len = bytes.len();
500 if len >= 2 && bytes[len - 2] == 0x1B && bytes[len - 1] == b'\\' {
501 break;
502 }
503
504 if bytes.len() >= 4096 {
505 break;
506 }
507 }
508
509 if bytes.is_empty() {
510 return None;
511 }
512
513 String::from_utf8(bytes).ok()
514}
515
516#[cfg(feature = "crossterm")]
518pub fn detect_color_scheme() -> ColorScheme {
519 let mut stdout = io::stdout();
520 if write!(stdout, "\x1b]11;?\x07").is_err() {
521 return ColorScheme::Unknown;
522 }
523 if stdout.flush().is_err() {
524 return ColorScheme::Unknown;
525 }
526
527 let Some(response) = read_osc_response(Duration::from_millis(100)) else {
528 return ColorScheme::Unknown;
529 };
530
531 parse_osc11_response(&response)
532}
533
534#[cfg(feature = "crossterm")]
535pub(crate) fn parse_osc11_response(response: &str) -> ColorScheme {
536 let Some(rgb_pos) = response.find("rgb:") else {
537 return ColorScheme::Unknown;
538 };
539
540 let payload = &response[rgb_pos + 4..];
541 let end = payload
542 .find(['\x07', '\x1b', '\r', '\n', ' ', '\t'])
543 .unwrap_or(payload.len());
544 let rgb = &payload[..end];
545
546 let mut channels = rgb.split('/');
547 let (Some(r), Some(g), Some(b), None) = (
548 channels.next(),
549 channels.next(),
550 channels.next(),
551 channels.next(),
552 ) else {
553 return ColorScheme::Unknown;
554 };
555
556 fn parse_channel(channel: &str) -> Option<f64> {
557 if channel.is_empty() || channel.len() > 4 {
558 return None;
559 }
560 let value = u16::from_str_radix(channel, 16).ok()? as f64;
561 let max = ((1u32 << (channel.len() * 4)) - 1) as f64;
562 if max <= 0.0 {
563 return None;
564 }
565 Some((value / max).clamp(0.0, 1.0))
566 }
567
568 let (Some(r), Some(g), Some(b)) = (parse_channel(r), parse_channel(g), parse_channel(b)) else {
569 return ColorScheme::Unknown;
570 };
571
572 let luminance = 0.299 * r + 0.587 * g + 0.114 * b;
573 if luminance < 0.5 {
574 ColorScheme::Dark
575 } else {
576 ColorScheme::Light
577 }
578}
579
580fn base64_encode(input: &[u8]) -> String {
581 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
582 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
583 for chunk in input.chunks(3) {
584 let b0 = chunk[0] as u32;
585 let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
586 let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
587 let triple = (b0 << 16) | (b1 << 8) | b2;
588 out.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
589 out.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
590 out.push(if chunk.len() > 1 {
591 CHARS[((triple >> 6) & 0x3F) as usize] as char
592 } else {
593 '='
594 });
595 out.push(if chunk.len() > 2 {
596 CHARS[(triple & 0x3F) as usize] as char
597 } else {
598 '='
599 });
600 }
601 out
602}
603
604pub(crate) fn copy_to_clipboard(w: &mut impl Write, text: &str) -> io::Result<()> {
605 let encoded = base64_encode(text.as_bytes());
606 write!(w, "\x1b]52;c;{encoded}\x1b\\")?;
607 w.flush()
608}
609
610#[cfg(feature = "crossterm")]
611fn parse_osc52_response(response: &str) -> Option<String> {
612 let osc_pos = response.find("]52;")?;
613 let body = &response[osc_pos + 4..];
614 let semicolon = body.find(';')?;
615 let payload = &body[semicolon + 1..];
616
617 let end = payload
618 .find("\x1b\\")
619 .or_else(|| payload.find('\x07'))
620 .unwrap_or(payload.len());
621 let encoded = payload[..end].trim();
622 if encoded.is_empty() || encoded == "?" {
623 return None;
624 }
625
626 base64_decode(encoded)
627}
628
629#[cfg(feature = "crossterm")]
631pub fn read_clipboard() -> Option<String> {
632 let mut stdout = io::stdout();
633 write!(stdout, "\x1b]52;c;?\x07").ok()?;
634 stdout.flush().ok()?;
635
636 let response = read_osc_response(Duration::from_millis(200))?;
637 parse_osc52_response(&response)
638}
639
640#[cfg(feature = "crossterm")]
641fn base64_decode(input: &str) -> Option<String> {
642 let mut filtered: Vec<u8> = input
643 .bytes()
644 .filter(|b| !matches!(b, b' ' | b'\n' | b'\r' | b'\t'))
645 .collect();
646
647 match filtered.len() % 4 {
648 0 => {}
649 2 => filtered.extend_from_slice(b"=="),
650 3 => filtered.push(b'='),
651 _ => return None,
652 }
653
654 fn decode_val(b: u8) -> Option<u8> {
655 match b {
656 b'A'..=b'Z' => Some(b - b'A'),
657 b'a'..=b'z' => Some(b - b'a' + 26),
658 b'0'..=b'9' => Some(b - b'0' + 52),
659 b'+' => Some(62),
660 b'/' => Some(63),
661 _ => None,
662 }
663 }
664
665 let mut out = Vec::with_capacity((filtered.len() / 4) * 3);
666 for chunk in filtered.chunks_exact(4) {
667 let p2 = chunk[2] == b'=';
668 let p3 = chunk[3] == b'=';
669 if p2 && !p3 {
670 return None;
671 }
672
673 let v0 = decode_val(chunk[0])? as u32;
674 let v1 = decode_val(chunk[1])? as u32;
675 let v2 = if p2 { 0 } else { decode_val(chunk[2])? as u32 };
676 let v3 = if p3 { 0 } else { decode_val(chunk[3])? as u32 };
677
678 let triple = (v0 << 18) | (v1 << 12) | (v2 << 6) | v3;
679 out.push(((triple >> 16) & 0xFF) as u8);
680 if !p2 {
681 out.push(((triple >> 8) & 0xFF) as u8);
682 }
683 if !p3 {
684 out.push((triple & 0xFF) as u8);
685 }
686 }
687
688 String::from_utf8(out).ok()
689}
690
691const CURSOR_MARKER: &str = "▎";
694
695fn find_cursor_marker(buffer: &Buffer) -> Option<(u32, u32)> {
696 let area = buffer.area;
697 for y in area.y..area.bottom() {
698 for x in area.x..area.right() {
699 if buffer.get(x, y).symbol == CURSOR_MARKER {
700 return Some((x, y));
701 }
702 }
703 }
704 None
705}
706
707fn apply_style_delta(
708 w: &mut impl Write,
709 old: &Style,
710 new: &Style,
711 depth: ColorDepth,
712) -> io::Result<()> {
713 if old.fg != new.fg {
714 match new.fg {
715 Some(fg) => queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?,
716 None => queue!(w, SetForegroundColor(CtColor::Reset))?,
717 }
718 }
719 if old.bg != new.bg {
720 match new.bg {
721 Some(bg) => queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?,
722 None => queue!(w, SetBackgroundColor(CtColor::Reset))?,
723 }
724 }
725 let removed = Modifiers(old.modifiers.0 & !new.modifiers.0);
726 let added = Modifiers(new.modifiers.0 & !old.modifiers.0);
727 if removed.contains(Modifiers::BOLD) || removed.contains(Modifiers::DIM) {
728 queue!(w, SetAttribute(Attribute::NormalIntensity))?;
729 if new.modifiers.contains(Modifiers::BOLD) {
730 queue!(w, SetAttribute(Attribute::Bold))?;
731 }
732 if new.modifiers.contains(Modifiers::DIM) {
733 queue!(w, SetAttribute(Attribute::Dim))?;
734 }
735 } else {
736 if added.contains(Modifiers::BOLD) {
737 queue!(w, SetAttribute(Attribute::Bold))?;
738 }
739 if added.contains(Modifiers::DIM) {
740 queue!(w, SetAttribute(Attribute::Dim))?;
741 }
742 }
743 if removed.contains(Modifiers::ITALIC) {
744 queue!(w, SetAttribute(Attribute::NoItalic))?;
745 }
746 if added.contains(Modifiers::ITALIC) {
747 queue!(w, SetAttribute(Attribute::Italic))?;
748 }
749 if removed.contains(Modifiers::UNDERLINE) {
750 queue!(w, SetAttribute(Attribute::NoUnderline))?;
751 }
752 if added.contains(Modifiers::UNDERLINE) {
753 queue!(w, SetAttribute(Attribute::Underlined))?;
754 }
755 if removed.contains(Modifiers::REVERSED) {
756 queue!(w, SetAttribute(Attribute::NoReverse))?;
757 }
758 if added.contains(Modifiers::REVERSED) {
759 queue!(w, SetAttribute(Attribute::Reverse))?;
760 }
761 if removed.contains(Modifiers::STRIKETHROUGH) {
762 queue!(w, SetAttribute(Attribute::NotCrossedOut))?;
763 }
764 if added.contains(Modifiers::STRIKETHROUGH) {
765 queue!(w, SetAttribute(Attribute::CrossedOut))?;
766 }
767 Ok(())
768}
769
770fn apply_style(w: &mut impl Write, style: &Style, depth: ColorDepth) -> io::Result<()> {
771 if let Some(fg) = style.fg {
772 queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?;
773 }
774 if let Some(bg) = style.bg {
775 queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?;
776 }
777 let m = style.modifiers;
778 if m.contains(Modifiers::BOLD) {
779 queue!(w, SetAttribute(Attribute::Bold))?;
780 }
781 if m.contains(Modifiers::DIM) {
782 queue!(w, SetAttribute(Attribute::Dim))?;
783 }
784 if m.contains(Modifiers::ITALIC) {
785 queue!(w, SetAttribute(Attribute::Italic))?;
786 }
787 if m.contains(Modifiers::UNDERLINE) {
788 queue!(w, SetAttribute(Attribute::Underlined))?;
789 }
790 if m.contains(Modifiers::REVERSED) {
791 queue!(w, SetAttribute(Attribute::Reverse))?;
792 }
793 if m.contains(Modifiers::STRIKETHROUGH) {
794 queue!(w, SetAttribute(Attribute::CrossedOut))?;
795 }
796 Ok(())
797}
798
799fn to_crossterm_color(color: Color, depth: ColorDepth) -> CtColor {
800 let color = color.downsampled(depth);
801 match color {
802 Color::Reset => CtColor::Reset,
803 Color::Black => CtColor::Black,
804 Color::Red => CtColor::DarkRed,
805 Color::Green => CtColor::DarkGreen,
806 Color::Yellow => CtColor::DarkYellow,
807 Color::Blue => CtColor::DarkBlue,
808 Color::Magenta => CtColor::DarkMagenta,
809 Color::Cyan => CtColor::DarkCyan,
810 Color::White => CtColor::White,
811 Color::DarkGray => CtColor::DarkGrey,
812 Color::LightRed => CtColor::Red,
813 Color::LightGreen => CtColor::Green,
814 Color::LightYellow => CtColor::Yellow,
815 Color::LightBlue => CtColor::Blue,
816 Color::LightMagenta => CtColor::Magenta,
817 Color::LightCyan => CtColor::Cyan,
818 Color::LightWhite => CtColor::White,
819 Color::Rgb(r, g, b) => CtColor::Rgb { r, g, b },
820 Color::Indexed(i) => CtColor::AnsiValue(i),
821 }
822}
823
824fn reset_current_buffer(buffer: &mut Buffer, theme_bg: Option<Color>) {
825 if let Some(bg) = theme_bg {
826 buffer.reset_with_bg(bg);
827 } else {
828 buffer.reset();
829 }
830}
831
832#[cfg(test)]
833mod tests {
834 use super::*;
835
836 #[test]
837 fn reset_current_buffer_applies_theme_background() {
838 let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 1));
839
840 reset_current_buffer(&mut buffer, Some(Color::Rgb(10, 20, 30)));
841 assert_eq!(buffer.get(0, 0).style.bg, Some(Color::Rgb(10, 20, 30)));
842
843 reset_current_buffer(&mut buffer, None);
844 assert_eq!(buffer.get(0, 0).style.bg, None);
845 }
846
847 #[test]
848 fn base64_encode_empty() {
849 assert_eq!(base64_encode(b""), "");
850 }
851
852 #[test]
853 fn base64_encode_hello() {
854 assert_eq!(base64_encode(b"Hello"), "SGVsbG8=");
855 }
856
857 #[test]
858 fn base64_encode_padding() {
859 assert_eq!(base64_encode(b"a"), "YQ==");
860 assert_eq!(base64_encode(b"ab"), "YWI=");
861 assert_eq!(base64_encode(b"abc"), "YWJj");
862 }
863
864 #[test]
865 fn base64_encode_unicode() {
866 assert_eq!(base64_encode("한글".as_bytes()), "7ZWc6riA");
867 }
868
869 #[cfg(feature = "crossterm")]
870 #[test]
871 fn parse_osc11_response_dark_and_light() {
872 assert_eq!(
873 parse_osc11_response("\x1b]11;rgb:0000/0000/0000\x1b\\"),
874 ColorScheme::Dark
875 );
876 assert_eq!(
877 parse_osc11_response("\x1b]11;rgb:ffff/ffff/ffff\x07"),
878 ColorScheme::Light
879 );
880 }
881
882 #[cfg(feature = "crossterm")]
883 #[test]
884 fn base64_decode_round_trip_hello() {
885 let encoded = base64_encode("hello".as_bytes());
886 assert_eq!(base64_decode(&encoded), Some("hello".to_string()));
887 }
888
889 #[cfg(feature = "crossterm")]
890 #[test]
891 fn color_scheme_equality() {
892 assert_eq!(ColorScheme::Dark, ColorScheme::Dark);
893 assert_ne!(ColorScheme::Dark, ColorScheme::Light);
894 assert_eq!(ColorScheme::Unknown, ColorScheme::Unknown);
895 }
896
897 fn pair(r: Rect) -> (Rect, Rect) {
898 (r, r)
899 }
900
901 #[test]
902 fn find_innermost_rect_picks_smallest() {
903 let rects = vec![
904 pair(Rect::new(0, 0, 80, 24)),
905 pair(Rect::new(5, 2, 30, 10)),
906 pair(Rect::new(10, 4, 10, 5)),
907 ];
908 let result = find_innermost_rect(&rects, 12, 5);
909 assert_eq!(result, Some(Rect::new(10, 4, 10, 5)));
910 }
911
912 #[test]
913 fn find_innermost_rect_no_match() {
914 let rects = vec![pair(Rect::new(10, 10, 5, 5))];
915 assert_eq!(find_innermost_rect(&rects, 0, 0), None);
916 }
917
918 #[test]
919 fn find_innermost_rect_empty() {
920 assert_eq!(find_innermost_rect(&[], 5, 5), None);
921 }
922
923 #[test]
924 fn find_innermost_rect_returns_content_rect() {
925 let rects = vec![
926 (Rect::new(0, 0, 80, 24), Rect::new(1, 1, 78, 22)),
927 (Rect::new(5, 2, 30, 10), Rect::new(6, 3, 28, 8)),
928 ];
929 let result = find_innermost_rect(&rects, 10, 5);
930 assert_eq!(result, Some(Rect::new(6, 3, 28, 8)));
931 }
932
933 #[test]
934 fn normalize_selection_already_ordered() {
935 let (s, e) = normalize_selection((2, 1), (5, 3));
936 assert_eq!(s, (2, 1));
937 assert_eq!(e, (5, 3));
938 }
939
940 #[test]
941 fn normalize_selection_reversed() {
942 let (s, e) = normalize_selection((5, 3), (2, 1));
943 assert_eq!(s, (2, 1));
944 assert_eq!(e, (5, 3));
945 }
946
947 #[test]
948 fn normalize_selection_same_row() {
949 let (s, e) = normalize_selection((10, 5), (3, 5));
950 assert_eq!(s, (3, 5));
951 assert_eq!(e, (10, 5));
952 }
953
954 #[test]
955 fn selection_state_mouse_down_finds_rect() {
956 let hit_map = vec![pair(Rect::new(0, 0, 80, 24)), pair(Rect::new(5, 2, 20, 10))];
957 let mut sel = SelectionState::default();
958 sel.mouse_down(10, 5, &hit_map);
959 assert_eq!(sel.anchor, Some((10, 5)));
960 assert_eq!(sel.current, Some((10, 5)));
961 assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 20, 10)));
962 assert!(!sel.active);
963 }
964
965 #[test]
966 fn selection_state_drag_activates() {
967 let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
968 let mut sel = SelectionState {
969 anchor: Some((10, 5)),
970 current: Some((10, 5)),
971 widget_rect: Some(Rect::new(0, 0, 80, 24)),
972 ..Default::default()
973 };
974 sel.mouse_drag(10, 5, &hit_map);
975 assert!(!sel.active, "no movement = not active");
976 sel.mouse_drag(11, 5, &hit_map);
977 assert!(!sel.active, "1 cell horizontal = not active yet");
978 sel.mouse_drag(13, 5, &hit_map);
979 assert!(sel.active, ">1 cell horizontal = active");
980 }
981
982 #[test]
983 fn selection_state_drag_vertical_activates() {
984 let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
985 let mut sel = SelectionState {
986 anchor: Some((10, 5)),
987 current: Some((10, 5)),
988 widget_rect: Some(Rect::new(0, 0, 80, 24)),
989 ..Default::default()
990 };
991 sel.mouse_drag(10, 6, &hit_map);
992 assert!(sel.active, "any vertical movement = active");
993 }
994
995 #[test]
996 fn selection_state_drag_expands_widget_rect() {
997 let hit_map = vec![
998 pair(Rect::new(0, 0, 80, 24)),
999 pair(Rect::new(5, 2, 30, 10)),
1000 pair(Rect::new(5, 2, 30, 3)),
1001 ];
1002 let mut sel = SelectionState {
1003 anchor: Some((10, 3)),
1004 current: Some((10, 3)),
1005 widget_rect: Some(Rect::new(5, 2, 30, 3)),
1006 ..Default::default()
1007 };
1008 sel.mouse_drag(10, 6, &hit_map);
1009 assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 30, 10)));
1010 }
1011
1012 #[test]
1013 fn selection_state_clear_resets() {
1014 let mut sel = SelectionState {
1015 anchor: Some((1, 2)),
1016 current: Some((3, 4)),
1017 widget_rect: Some(Rect::new(0, 0, 10, 10)),
1018 active: true,
1019 };
1020 sel.clear();
1021 assert_eq!(sel.anchor, None);
1022 assert_eq!(sel.current, None);
1023 assert_eq!(sel.widget_rect, None);
1024 assert!(!sel.active);
1025 }
1026
1027 #[test]
1028 fn extract_selection_text_single_line() {
1029 let area = Rect::new(0, 0, 20, 5);
1030 let mut buf = Buffer::empty(area);
1031 buf.set_string(0, 0, "Hello World", Style::default());
1032 let sel = SelectionState {
1033 anchor: Some((0, 0)),
1034 current: Some((4, 0)),
1035 widget_rect: Some(area),
1036 active: true,
1037 };
1038 let text = extract_selection_text(&buf, &sel, &[]);
1039 assert_eq!(text, "Hello");
1040 }
1041
1042 #[test]
1043 fn extract_selection_text_multi_line() {
1044 let area = Rect::new(0, 0, 20, 5);
1045 let mut buf = Buffer::empty(area);
1046 buf.set_string(0, 0, "Line one", Style::default());
1047 buf.set_string(0, 1, "Line two", Style::default());
1048 buf.set_string(0, 2, "Line three", Style::default());
1049 let sel = SelectionState {
1050 anchor: Some((5, 0)),
1051 current: Some((3, 2)),
1052 widget_rect: Some(area),
1053 active: true,
1054 };
1055 let text = extract_selection_text(&buf, &sel, &[]);
1056 assert_eq!(text, "one\nLine two\nLine");
1057 }
1058
1059 #[test]
1060 fn extract_selection_text_clamped_to_widget() {
1061 let area = Rect::new(0, 0, 40, 10);
1062 let widget = Rect::new(5, 2, 10, 3);
1063 let mut buf = Buffer::empty(area);
1064 buf.set_string(5, 2, "ABCDEFGHIJ", Style::default());
1065 buf.set_string(5, 3, "KLMNOPQRST", Style::default());
1066 let sel = SelectionState {
1067 anchor: Some((3, 1)),
1068 current: Some((20, 5)),
1069 widget_rect: Some(widget),
1070 active: true,
1071 };
1072 let text = extract_selection_text(&buf, &sel, &[]);
1073 assert_eq!(text, "ABCDEFGHIJ\nKLMNOPQRST");
1074 }
1075
1076 #[test]
1077 fn extract_selection_text_inactive_returns_empty() {
1078 let area = Rect::new(0, 0, 10, 5);
1079 let buf = Buffer::empty(area);
1080 let sel = SelectionState {
1081 anchor: Some((0, 0)),
1082 current: Some((5, 2)),
1083 widget_rect: Some(area),
1084 active: false,
1085 };
1086 assert_eq!(extract_selection_text(&buf, &sel, &[]), "");
1087 }
1088
1089 #[test]
1090 fn apply_selection_overlay_reverses_cells() {
1091 let area = Rect::new(0, 0, 10, 3);
1092 let mut buf = Buffer::empty(area);
1093 buf.set_string(0, 0, "ABCDE", Style::default());
1094 let sel = SelectionState {
1095 anchor: Some((1, 0)),
1096 current: Some((3, 0)),
1097 widget_rect: Some(area),
1098 active: true,
1099 };
1100 apply_selection_overlay(&mut buf, &sel, &[]);
1101 assert!(!buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED));
1102 assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
1103 assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
1104 assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
1105 assert!(!buf.get(4, 0).style.modifiers.contains(Modifiers::REVERSED));
1106 }
1107
1108 #[test]
1109 fn extract_selection_text_skips_border_cells() {
1110 let area = Rect::new(0, 0, 40, 5);
1115 let mut buf = Buffer::empty(area);
1116 buf.set_string(0, 0, "╭", Style::default());
1118 buf.set_string(0, 1, "│", Style::default());
1119 buf.set_string(0, 2, "│", Style::default());
1120 buf.set_string(0, 3, "│", Style::default());
1121 buf.set_string(0, 4, "╰", Style::default());
1122 buf.set_string(19, 0, "╮", Style::default());
1123 buf.set_string(19, 1, "│", Style::default());
1124 buf.set_string(19, 2, "│", Style::default());
1125 buf.set_string(19, 3, "│", Style::default());
1126 buf.set_string(19, 4, "╯", Style::default());
1127 buf.set_string(20, 0, "╭", Style::default());
1129 buf.set_string(20, 1, "│", Style::default());
1130 buf.set_string(20, 2, "│", Style::default());
1131 buf.set_string(20, 3, "│", Style::default());
1132 buf.set_string(20, 4, "╰", Style::default());
1133 buf.set_string(39, 0, "╮", Style::default());
1134 buf.set_string(39, 1, "│", Style::default());
1135 buf.set_string(39, 2, "│", Style::default());
1136 buf.set_string(39, 3, "│", Style::default());
1137 buf.set_string(39, 4, "╯", Style::default());
1138 buf.set_string(1, 1, "Hello Col1", Style::default());
1140 buf.set_string(1, 2, "Line2 Col1", Style::default());
1141 buf.set_string(21, 1, "Hello Col2", Style::default());
1143 buf.set_string(21, 2, "Line2 Col2", Style::default());
1144
1145 let content_map = vec![
1146 (Rect::new(0, 0, 20, 5), Rect::new(1, 1, 18, 3)),
1147 (Rect::new(20, 0, 20, 5), Rect::new(21, 1, 18, 3)),
1148 ];
1149
1150 let sel = SelectionState {
1152 anchor: Some((0, 1)),
1153 current: Some((39, 2)),
1154 widget_rect: Some(area),
1155 active: true,
1156 };
1157 let text = extract_selection_text(&buf, &sel, &content_map);
1158 assert!(!text.contains('│'), "Border char │ found in: {text}");
1160 assert!(!text.contains('╭'), "Border char ╭ found in: {text}");
1161 assert!(!text.contains('╮'), "Border char ╮ found in: {text}");
1162 assert!(
1164 text.contains("Hello Col1"),
1165 "Missing Col1 content in: {text}"
1166 );
1167 assert!(
1168 text.contains("Hello Col2"),
1169 "Missing Col2 content in: {text}"
1170 );
1171 assert!(text.contains("Line2 Col1"), "Missing Col1 line2 in: {text}");
1172 assert!(text.contains("Line2 Col2"), "Missing Col2 line2 in: {text}");
1173 }
1174
1175 #[test]
1176 fn apply_selection_overlay_skips_border_cells() {
1177 let area = Rect::new(0, 0, 20, 3);
1178 let mut buf = Buffer::empty(area);
1179 buf.set_string(0, 0, "│", Style::default());
1180 buf.set_string(1, 0, "ABC", Style::default());
1181 buf.set_string(19, 0, "│", Style::default());
1182
1183 let content_map = vec![(Rect::new(0, 0, 20, 3), Rect::new(1, 0, 18, 3))];
1184 let sel = SelectionState {
1185 anchor: Some((0, 0)),
1186 current: Some((19, 0)),
1187 widget_rect: Some(area),
1188 active: true,
1189 };
1190 apply_selection_overlay(&mut buf, &sel, &content_map);
1191 assert!(
1193 !buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED),
1194 "Left border cell should not be reversed"
1195 );
1196 assert!(
1197 !buf.get(19, 0).style.modifiers.contains(Modifiers::REVERSED),
1198 "Right border cell should not be reversed"
1199 );
1200 assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
1202 assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
1203 assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
1204 }
1205
1206 #[test]
1207 fn copy_to_clipboard_writes_osc52() {
1208 let mut output: Vec<u8> = Vec::new();
1209 copy_to_clipboard(&mut output, "test").unwrap();
1210 let s = String::from_utf8(output).unwrap();
1211 assert!(s.starts_with("\x1b]52;c;"));
1212 assert!(s.ends_with("\x1b\\"));
1213 assert!(s.contains(&base64_encode(b"test")));
1214 }
1215}