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