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