1use std::collections::HashMap;
2use std::io::{self, Read, Stdout, Write};
3use std::time::{Duration, Instant};
4
5use crossterm::event::{
6 DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
7 EnableFocusChange, EnableMouseCapture,
8};
9use crossterm::style::{
10 Attribute, Color as CtColor, Print, ResetColor, SetAttribute, SetBackgroundColor,
11 SetForegroundColor,
12};
13use crossterm::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate};
14use crossterm::{cursor, execute, queue, terminal};
15
16use unicode_width::UnicodeWidthStr;
17
18use crate::buffer::{Buffer, KittyPlacement};
19use crate::rect::Rect;
20use crate::style::{Color, ColorDepth, Modifiers, Style};
21
22pub(crate) struct KittyImageManager {
32 next_id: u32,
33 uploaded: HashMap<u64, u32>,
35 prev_placements: Vec<KittyPlacement>,
37}
38
39impl KittyImageManager {
40 pub fn new() -> Self {
41 Self {
42 next_id: 1,
43 uploaded: HashMap::new(),
44 prev_placements: Vec::new(),
45 }
46 }
47
48 pub fn flush(&mut self, stdout: &mut impl Write, current: &[KittyPlacement]) -> io::Result<()> {
50 if current == self.prev_placements.as_slice() {
52 return Ok(());
53 }
54
55 if !self.prev_placements.is_empty() {
57 let mut deleted_ids = std::collections::HashSet::new();
59 for p in &self.prev_placements {
60 if let Some(&img_id) = self.uploaded.get(&p.content_hash) {
61 if deleted_ids.insert(img_id) {
62 queue!(
64 stdout,
65 Print(format!("\x1b_Ga=d,d=i,i={},q=2\x1b\\", img_id))
66 )?;
67 }
68 }
69 }
70 }
71
72 for (idx, p) in current.iter().enumerate() {
74 let img_id = if let Some(&existing_id) = self.uploaded.get(&p.content_hash) {
75 existing_id
76 } else {
77 let id = self.next_id;
79 self.next_id += 1;
80 self.upload_image(stdout, id, p)?;
81 self.uploaded.insert(p.content_hash, id);
82 id
83 };
84
85 let pid = idx as u32 + 1;
87 self.place_image(stdout, img_id, pid, p)?;
88 }
89
90 let used_hashes: std::collections::HashSet<u64> =
92 current.iter().map(|p| p.content_hash).collect();
93 let stale: Vec<u64> = self
94 .uploaded
95 .keys()
96 .filter(|h| !used_hashes.contains(h))
97 .copied()
98 .collect();
99 for hash in stale {
100 if let Some(id) = self.uploaded.remove(&hash) {
101 queue!(stdout, Print(format!("\x1b_Ga=d,d=I,i={},q=2\x1b\\", id)))?;
103 }
104 }
105
106 self.prev_placements = current.to_vec();
107 Ok(())
108 }
109
110 fn upload_image(&self, stdout: &mut impl Write, id: u32, p: &KittyPlacement) -> io::Result<()> {
112 let (payload, compression) = compress_rgba(&p.rgba);
113 let encoded = base64_encode(&payload);
114 let chunks = split_base64(&encoded, 4096);
115
116 for (i, chunk) in chunks.iter().enumerate() {
117 let more = if i < chunks.len() - 1 { 1 } else { 0 };
118 if i == 0 {
119 queue!(
120 stdout,
121 Print(format!(
122 "\x1b_Ga=t,i={},f=32,{}s={},v={},q=2,m={};{}\x1b\\",
123 id, compression, p.src_width, p.src_height, more, chunk
124 ))
125 )?;
126 } else {
127 queue!(stdout, Print(format!("\x1b_Gm={};{}\x1b\\", more, chunk)))?;
128 }
129 }
130 Ok(())
131 }
132
133 fn place_image(
135 &self,
136 stdout: &mut impl Write,
137 img_id: u32,
138 placement_id: u32,
139 p: &KittyPlacement,
140 ) -> io::Result<()> {
141 queue!(stdout, cursor::MoveTo(p.x as u16, p.y as u16))?;
142
143 let mut cmd = format!(
144 "\x1b_Ga=p,i={},p={},c={},r={},C=1,q=2",
145 img_id, placement_id, p.cols, p.rows
146 );
147
148 if p.crop_y > 0 || p.crop_h > 0 {
150 cmd.push_str(&format!(",y={}", p.crop_y));
151 if p.crop_h > 0 {
152 cmd.push_str(&format!(",h={}", p.crop_h));
153 }
154 }
155
156 cmd.push_str("\x1b\\");
157 queue!(stdout, Print(cmd))?;
158 Ok(())
159 }
160
161 pub fn delete_all(&self, stdout: &mut impl Write) -> io::Result<()> {
163 queue!(stdout, Print("\x1b_Ga=d,d=A,q=2\x1b\\"))
164 }
165}
166
167fn compress_rgba(data: &[u8]) -> (Vec<u8>, &'static str) {
169 #[cfg(feature = "kitty-compress")]
170 {
171 use flate2::write::ZlibEncoder;
172 use flate2::Compression;
173 let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast());
174 if encoder.write_all(data).is_ok() {
175 if let Ok(compressed) = encoder.finish() {
176 if compressed.len() < data.len() {
178 return (compressed, "o=z,");
179 }
180 }
181 }
182 }
183 (data.to_vec(), "")
184}
185
186pub fn cell_pixel_size() -> (u32, u32) {
193 use std::sync::OnceLock;
194 static CACHED: OnceLock<(u32, u32)> = OnceLock::new();
195 *CACHED.get_or_init(|| detect_cell_pixel_size().unwrap_or((8, 16)))
196}
197
198fn detect_cell_pixel_size() -> Option<(u32, u32)> {
199 let mut stdout = io::stdout();
201 write!(stdout, "\x1b[16t").ok()?;
202 stdout.flush().ok()?;
203
204 let response = read_osc_response(Duration::from_millis(100))?;
205
206 let body = response.strip_prefix("\x1b[6;").or_else(|| {
208 let bytes = response.as_bytes();
210 if bytes.len() > 3 && bytes[0] == 0x9b && bytes[1] == b'6' && bytes[2] == b';' {
211 Some(&response[3..])
212 } else {
213 None
214 }
215 })?;
216 let body = body
217 .strip_suffix('t')
218 .or_else(|| body.strip_suffix("t\x1b"))?;
219 let mut parts = body.split(';');
220 let ch: u32 = parts.next()?.parse().ok()?;
221 let cw: u32 = parts.next()?.parse().ok()?;
222 if cw > 0 && ch > 0 {
223 Some((cw, ch))
224 } else {
225 None
226 }
227}
228
229fn split_base64(encoded: &str, chunk_size: usize) -> Vec<&str> {
230 let mut chunks = Vec::new();
231 let bytes = encoded.as_bytes();
232 let mut offset = 0;
233 while offset < bytes.len() {
234 let end = (offset + chunk_size).min(bytes.len());
235 chunks.push(&encoded[offset..end]);
236 offset = end;
237 }
238 if chunks.is_empty() {
239 chunks.push("");
240 }
241 chunks
242}
243
244pub(crate) struct Terminal {
245 stdout: Stdout,
246 current: Buffer,
247 previous: Buffer,
248 mouse_enabled: bool,
249 cursor_visible: bool,
250 kitty_keyboard: bool,
251 color_depth: ColorDepth,
252 pub(crate) theme_bg: Option<Color>,
253 kitty_mgr: KittyImageManager,
254}
255
256pub(crate) struct InlineTerminal {
257 stdout: Stdout,
258 current: Buffer,
259 previous: Buffer,
260 mouse_enabled: bool,
261 cursor_visible: bool,
262 height: u32,
263 start_row: u16,
264 reserved: bool,
265 color_depth: ColorDepth,
266 pub(crate) theme_bg: Option<Color>,
267 kitty_mgr: KittyImageManager,
268}
269
270impl Terminal {
271 pub fn new(mouse: bool, kitty_keyboard: bool, color_depth: ColorDepth) -> io::Result<Self> {
272 let (cols, rows) = terminal::size()?;
273 let area = Rect::new(0, 0, cols as u32, rows as u32);
274
275 let mut stdout = io::stdout();
276 terminal::enable_raw_mode()?;
277 execute!(
278 stdout,
279 terminal::EnterAlternateScreen,
280 cursor::Hide,
281 EnableBracketedPaste
282 )?;
283 if mouse {
284 execute!(stdout, EnableMouseCapture, EnableFocusChange)?;
285 }
286 if kitty_keyboard {
287 use crossterm::event::{KeyboardEnhancementFlags, PushKeyboardEnhancementFlags};
288 let _ = execute!(
289 stdout,
290 PushKeyboardEnhancementFlags(
291 KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
292 | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
293 )
294 );
295 }
296
297 Ok(Self {
298 stdout,
299 current: Buffer::empty(area),
300 previous: Buffer::empty(area),
301 mouse_enabled: mouse,
302 cursor_visible: false,
303 kitty_keyboard,
304 color_depth,
305 theme_bg: None,
306 kitty_mgr: KittyImageManager::new(),
307 })
308 }
309
310 pub fn size(&self) -> (u32, u32) {
311 (self.current.area.width, self.current.area.height)
312 }
313
314 pub fn buffer_mut(&mut self) -> &mut Buffer {
315 &mut self.current
316 }
317
318 pub fn flush(&mut self) -> io::Result<()> {
319 if self.current.area.width < self.previous.area.width {
320 execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
321 }
322
323 queue!(self.stdout, BeginSynchronizedUpdate)?;
324
325 let mut last_style = Style::new();
326 let mut first_style = true;
327 let mut last_pos: Option<(u32, u32)> = None;
328 let mut active_link: Option<&str> = None;
329 let mut has_updates = false;
330
331 for y in self.current.area.y..self.current.area.bottom() {
332 for x in self.current.area.x..self.current.area.right() {
333 let cur = self.current.get(x, y);
334 let prev = self.previous.get(x, y);
335 if cur == prev {
336 continue;
337 }
338 if cur.symbol.is_empty() {
339 continue;
340 }
341 has_updates = true;
342
343 let need_move = last_pos.map_or(true, |(lx, ly)| ly != y || lx != x);
344 if need_move {
345 queue!(self.stdout, cursor::MoveTo(x as u16, y as u16))?;
346 }
347
348 if cur.style != last_style {
349 if first_style {
350 queue!(self.stdout, ResetColor, SetAttribute(Attribute::Reset))?;
351 apply_style(&mut self.stdout, &cur.style, self.color_depth)?;
352 first_style = false;
353 } else {
354 apply_style_delta(
355 &mut self.stdout,
356 &last_style,
357 &cur.style,
358 self.color_depth,
359 )?;
360 }
361 last_style = cur.style;
362 }
363
364 let cell_link = cur.hyperlink.as_deref();
365 if cell_link != active_link {
366 if let Some(url) = cell_link {
367 queue!(self.stdout, Print(format!("\x1b]8;;{url}\x07")))?;
368 } else {
369 queue!(self.stdout, Print("\x1b]8;;\x07"))?;
370 }
371 active_link = cell_link;
372 }
373
374 queue!(self.stdout, Print(&*cur.symbol))?;
375 let char_width = UnicodeWidthStr::width(cur.symbol.as_str()).max(1) as u32;
376 if char_width > 1 && cur.symbol.chars().any(|c| c == '\u{FE0F}') {
377 queue!(self.stdout, Print(" "))?;
378 }
379 last_pos = Some((x + char_width, y));
380 }
381 }
382
383 if has_updates {
384 if active_link.is_some() {
385 queue!(self.stdout, Print("\x1b]8;;\x07"))?;
386 }
387 queue!(self.stdout, ResetColor, SetAttribute(Attribute::Reset))?;
388 }
389
390 self.kitty_mgr
392 .flush(&mut self.stdout, &self.current.kitty_placements)?;
393
394 if self.current.raw_sequences != self.previous.raw_sequences {
396 for (x, y, seq) in &self.current.raw_sequences {
397 queue!(self.stdout, cursor::MoveTo(*x as u16, *y as u16))?;
398 queue!(self.stdout, Print(seq))?;
399 }
400 }
401
402 queue!(self.stdout, EndSynchronizedUpdate)?;
403
404 let cursor_pos = find_cursor_marker(&self.current);
405 match cursor_pos {
406 Some((cx, cy)) => {
407 if !self.cursor_visible {
408 queue!(self.stdout, cursor::Show)?;
409 self.cursor_visible = true;
410 }
411 queue!(self.stdout, cursor::MoveTo(cx as u16, cy as u16))?;
412 }
413 None => {
414 if self.cursor_visible {
415 queue!(self.stdout, cursor::Hide)?;
416 self.cursor_visible = false;
417 }
418 }
419 }
420
421 self.stdout.flush()?;
422
423 std::mem::swap(&mut self.current, &mut self.previous);
424 if let Some(bg) = self.theme_bg {
425 self.current.reset_with_bg(bg);
426 } else {
427 self.current.reset();
428 }
429 Ok(())
430 }
431
432 pub fn handle_resize(&mut self) -> io::Result<()> {
433 let (cols, rows) = terminal::size()?;
434 let area = Rect::new(0, 0, cols as u32, rows as u32);
435 self.current.resize(area);
436 self.previous.resize(area);
437 execute!(
438 self.stdout,
439 terminal::Clear(terminal::ClearType::All),
440 cursor::MoveTo(0, 0)
441 )?;
442 Ok(())
443 }
444}
445
446impl crate::Backend for Terminal {
447 fn size(&self) -> (u32, u32) {
448 Terminal::size(self)
449 }
450
451 fn buffer_mut(&mut self) -> &mut Buffer {
452 Terminal::buffer_mut(self)
453 }
454
455 fn flush(&mut self) -> io::Result<()> {
456 Terminal::flush(self)
457 }
458}
459
460impl InlineTerminal {
461 pub fn new(height: u32, mouse: bool, color_depth: ColorDepth) -> io::Result<Self> {
462 let (cols, _) = terminal::size()?;
463 let area = Rect::new(0, 0, cols as u32, height);
464
465 let mut stdout = io::stdout();
466 terminal::enable_raw_mode()?;
467 execute!(stdout, cursor::Hide, EnableBracketedPaste)?;
468 if mouse {
469 execute!(stdout, EnableMouseCapture, EnableFocusChange)?;
470 }
471
472 let (_, cursor_row) = cursor::position()?;
473 Ok(Self {
474 stdout,
475 current: Buffer::empty(area),
476 previous: Buffer::empty(area),
477 mouse_enabled: mouse,
478 cursor_visible: false,
479 height,
480 start_row: cursor_row,
481 reserved: false,
482 color_depth,
483 theme_bg: None,
484 kitty_mgr: KittyImageManager::new(),
485 })
486 }
487
488 pub fn size(&self) -> (u32, u32) {
489 (self.current.area.width, self.current.area.height)
490 }
491
492 pub fn buffer_mut(&mut self) -> &mut Buffer {
493 &mut self.current
494 }
495
496 pub fn flush(&mut self) -> io::Result<()> {
497 if self.current.area.width < self.previous.area.width {
498 execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
499 }
500
501 queue!(self.stdout, BeginSynchronizedUpdate)?;
502
503 if !self.reserved {
504 queue!(self.stdout, cursor::MoveToColumn(0))?;
505 for _ in 0..self.height {
506 queue!(self.stdout, Print("\n"))?;
507 }
508 self.reserved = true;
509
510 let (_, rows) = terminal::size()?;
511 let bottom = self.start_row + self.height as u16;
512 if bottom > rows {
513 self.start_row = rows.saturating_sub(self.height as u16);
514 }
515 }
516
517 let updates = self.current.diff(&self.previous);
518 if !updates.is_empty() {
519 let mut last_style = Style::new();
520 let mut first_style = true;
521 let mut last_pos: Option<(u32, u32)> = None;
522 let mut active_link: Option<&str> = None;
523
524 for &(x, y, cell) in &updates {
525 if cell.symbol.is_empty() {
526 continue;
527 }
528
529 let abs_y = self.start_row as u32 + y;
530 let need_move = last_pos.map_or(true, |(lx, ly)| ly != abs_y || lx != x);
531 if need_move {
532 queue!(self.stdout, cursor::MoveTo(x as u16, abs_y as u16))?;
533 }
534
535 if cell.style != last_style {
536 if first_style {
537 queue!(self.stdout, ResetColor, SetAttribute(Attribute::Reset))?;
538 apply_style(&mut self.stdout, &cell.style, self.color_depth)?;
539 first_style = false;
540 } else {
541 apply_style_delta(
542 &mut self.stdout,
543 &last_style,
544 &cell.style,
545 self.color_depth,
546 )?;
547 }
548 last_style = cell.style;
549 }
550
551 let cell_link = cell.hyperlink.as_deref();
552 if cell_link != active_link {
553 if let Some(url) = cell_link {
554 queue!(self.stdout, Print(format!("\x1b]8;;{url}\x07")))?;
555 } else {
556 queue!(self.stdout, Print("\x1b]8;;\x07"))?;
557 }
558 active_link = cell_link;
559 }
560
561 queue!(self.stdout, Print(&cell.symbol))?;
562 let char_width = UnicodeWidthStr::width(cell.symbol.as_str()).max(1) as u32;
563 if char_width > 1 && cell.symbol.chars().any(|c| c == '\u{FE0F}') {
564 queue!(self.stdout, Print(" "))?;
565 }
566 last_pos = Some((x + char_width, abs_y));
567 }
568
569 if active_link.is_some() {
570 queue!(self.stdout, Print("\x1b]8;;\x07"))?;
571 }
572 queue!(self.stdout, ResetColor, SetAttribute(Attribute::Reset))?;
573 }
574
575 let adjusted: Vec<KittyPlacement> = self
578 .current
579 .kitty_placements
580 .iter()
581 .map(|p| {
582 let mut ap = p.clone();
583 ap.y += self.start_row as u32;
584 ap
585 })
586 .collect();
587 self.kitty_mgr.flush(&mut self.stdout, &adjusted)?;
588
589 if self.current.raw_sequences != self.previous.raw_sequences {
591 for (x, y, seq) in &self.current.raw_sequences {
592 let abs_y = self.start_row as u32 + *y;
593 queue!(self.stdout, cursor::MoveTo(*x as u16, abs_y as u16))?;
594 queue!(self.stdout, Print(seq))?;
595 }
596 }
597
598 queue!(self.stdout, EndSynchronizedUpdate)?;
599
600 let cursor_pos = find_cursor_marker(&self.current);
601 match cursor_pos {
602 Some((cx, cy)) => {
603 let abs_cy = self.start_row as u32 + cy;
604 if !self.cursor_visible {
605 queue!(self.stdout, cursor::Show)?;
606 self.cursor_visible = true;
607 }
608 queue!(self.stdout, cursor::MoveTo(cx as u16, abs_cy as u16))?;
609 }
610 None => {
611 if self.cursor_visible {
612 queue!(self.stdout, cursor::Hide)?;
613 self.cursor_visible = false;
614 }
615 let end_row = self.start_row + self.height.saturating_sub(1) as u16;
616 queue!(self.stdout, cursor::MoveTo(0, end_row))?;
617 }
618 }
619
620 self.stdout.flush()?;
621
622 std::mem::swap(&mut self.current, &mut self.previous);
623 reset_current_buffer(&mut self.current, self.theme_bg);
624 Ok(())
625 }
626
627 pub fn handle_resize(&mut self) -> io::Result<()> {
628 let (cols, _) = terminal::size()?;
629 let area = Rect::new(0, 0, cols as u32, self.height);
630 self.current.resize(area);
631 self.previous.resize(area);
632 execute!(
633 self.stdout,
634 terminal::Clear(terminal::ClearType::All),
635 cursor::MoveTo(0, 0)
636 )?;
637 Ok(())
638 }
639}
640
641impl crate::Backend for InlineTerminal {
642 fn size(&self) -> (u32, u32) {
643 InlineTerminal::size(self)
644 }
645
646 fn buffer_mut(&mut self) -> &mut Buffer {
647 InlineTerminal::buffer_mut(self)
648 }
649
650 fn flush(&mut self) -> io::Result<()> {
651 InlineTerminal::flush(self)
652 }
653}
654
655impl Drop for Terminal {
656 fn drop(&mut self) {
657 let _ = self.kitty_mgr.delete_all(&mut self.stdout);
659 let _ = self.stdout.flush();
660 if self.kitty_keyboard {
661 use crossterm::event::PopKeyboardEnhancementFlags;
662 let _ = execute!(self.stdout, PopKeyboardEnhancementFlags);
663 }
664 if self.mouse_enabled {
665 let _ = execute!(self.stdout, DisableMouseCapture, DisableFocusChange);
666 }
667 let _ = execute!(
668 self.stdout,
669 ResetColor,
670 SetAttribute(Attribute::Reset),
671 cursor::Show,
672 DisableBracketedPaste,
673 terminal::LeaveAlternateScreen
674 );
675 let _ = terminal::disable_raw_mode();
676 }
677}
678
679impl Drop for InlineTerminal {
680 fn drop(&mut self) {
681 if self.mouse_enabled {
682 let _ = execute!(self.stdout, DisableMouseCapture, DisableFocusChange);
683 }
684 let _ = execute!(
685 self.stdout,
686 ResetColor,
687 SetAttribute(Attribute::Reset),
688 cursor::Show,
689 DisableBracketedPaste
690 );
691 if self.reserved {
692 let _ = execute!(
693 self.stdout,
694 cursor::MoveToColumn(0),
695 cursor::MoveDown(1),
696 cursor::MoveToColumn(0),
697 Print("\n")
698 );
699 } else {
700 let _ = execute!(self.stdout, Print("\n"));
701 }
702 let _ = terminal::disable_raw_mode();
703 }
704}
705
706mod selection;
707pub(crate) use selection::{apply_selection_overlay, extract_selection_text, SelectionState};
708#[cfg(test)]
709pub(crate) use selection::{find_innermost_rect, normalize_selection};
710
711#[non_exhaustive]
713#[cfg(feature = "crossterm")]
714#[derive(Debug, Clone, Copy, PartialEq, Eq)]
715pub enum ColorScheme {
716 Dark,
718 Light,
720 Unknown,
722}
723
724#[cfg(feature = "crossterm")]
725fn read_osc_response(timeout: Duration) -> Option<String> {
726 let deadline = Instant::now() + timeout;
727 let mut stdin = io::stdin();
728 let mut bytes = Vec::new();
729 let mut buf = [0u8; 1];
730
731 while Instant::now() < deadline {
732 if !crossterm::event::poll(Duration::from_millis(10)).ok()? {
733 continue;
734 }
735
736 let read = stdin.read(&mut buf).ok()?;
737 if read == 0 {
738 continue;
739 }
740
741 bytes.push(buf[0]);
742
743 if buf[0] == b'\x07' {
744 break;
745 }
746 let len = bytes.len();
747 if len >= 2 && bytes[len - 2] == 0x1B && bytes[len - 1] == b'\\' {
748 break;
749 }
750
751 if bytes.len() >= 4096 {
752 break;
753 }
754 }
755
756 if bytes.is_empty() {
757 return None;
758 }
759
760 String::from_utf8(bytes).ok()
761}
762
763#[cfg(feature = "crossterm")]
765pub fn detect_color_scheme() -> ColorScheme {
766 let mut stdout = io::stdout();
767 if write!(stdout, "\x1b]11;?\x07").is_err() {
768 return ColorScheme::Unknown;
769 }
770 if stdout.flush().is_err() {
771 return ColorScheme::Unknown;
772 }
773
774 let Some(response) = read_osc_response(Duration::from_millis(100)) else {
775 return ColorScheme::Unknown;
776 };
777
778 parse_osc11_response(&response)
779}
780
781#[cfg(feature = "crossterm")]
782pub(crate) fn parse_osc11_response(response: &str) -> ColorScheme {
783 let Some(rgb_pos) = response.find("rgb:") else {
784 return ColorScheme::Unknown;
785 };
786
787 let payload = &response[rgb_pos + 4..];
788 let end = payload
789 .find(['\x07', '\x1b', '\r', '\n', ' ', '\t'])
790 .unwrap_or(payload.len());
791 let rgb = &payload[..end];
792
793 let mut channels = rgb.split('/');
794 let (Some(r), Some(g), Some(b), None) = (
795 channels.next(),
796 channels.next(),
797 channels.next(),
798 channels.next(),
799 ) else {
800 return ColorScheme::Unknown;
801 };
802
803 fn parse_channel(channel: &str) -> Option<f64> {
804 if channel.is_empty() || channel.len() > 4 {
805 return None;
806 }
807 let value = u16::from_str_radix(channel, 16).ok()? as f64;
808 let max = ((1u32 << (channel.len() * 4)) - 1) as f64;
809 if max <= 0.0 {
810 return None;
811 }
812 Some((value / max).clamp(0.0, 1.0))
813 }
814
815 let (Some(r), Some(g), Some(b)) = (parse_channel(r), parse_channel(g), parse_channel(b)) else {
816 return ColorScheme::Unknown;
817 };
818
819 let luminance = 0.299 * r + 0.587 * g + 0.114 * b;
820 if luminance < 0.5 {
821 ColorScheme::Dark
822 } else {
823 ColorScheme::Light
824 }
825}
826
827fn base64_encode(input: &[u8]) -> String {
828 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
829 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
830 for chunk in input.chunks(3) {
831 let b0 = chunk[0] as u32;
832 let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
833 let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
834 let triple = (b0 << 16) | (b1 << 8) | b2;
835 out.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
836 out.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
837 out.push(if chunk.len() > 1 {
838 CHARS[((triple >> 6) & 0x3F) as usize] as char
839 } else {
840 '='
841 });
842 out.push(if chunk.len() > 2 {
843 CHARS[(triple & 0x3F) as usize] as char
844 } else {
845 '='
846 });
847 }
848 out
849}
850
851pub(crate) fn copy_to_clipboard(w: &mut impl Write, text: &str) -> io::Result<()> {
852 let encoded = base64_encode(text.as_bytes());
853 write!(w, "\x1b]52;c;{encoded}\x1b\\")?;
854 w.flush()
855}
856
857#[cfg(feature = "crossterm")]
858fn parse_osc52_response(response: &str) -> Option<String> {
859 let osc_pos = response.find("]52;")?;
860 let body = &response[osc_pos + 4..];
861 let semicolon = body.find(';')?;
862 let payload = &body[semicolon + 1..];
863
864 let end = payload
865 .find("\x1b\\")
866 .or_else(|| payload.find('\x07'))
867 .unwrap_or(payload.len());
868 let encoded = payload[..end].trim();
869 if encoded.is_empty() || encoded == "?" {
870 return None;
871 }
872
873 base64_decode(encoded)
874}
875
876#[cfg(feature = "crossterm")]
878pub fn read_clipboard() -> Option<String> {
879 let mut stdout = io::stdout();
880 write!(stdout, "\x1b]52;c;?\x07").ok()?;
881 stdout.flush().ok()?;
882
883 let response = read_osc_response(Duration::from_millis(200))?;
884 parse_osc52_response(&response)
885}
886
887#[cfg(feature = "crossterm")]
888fn base64_decode(input: &str) -> Option<String> {
889 let mut filtered: Vec<u8> = input
890 .bytes()
891 .filter(|b| !matches!(b, b' ' | b'\n' | b'\r' | b'\t'))
892 .collect();
893
894 match filtered.len() % 4 {
895 0 => {}
896 2 => filtered.extend_from_slice(b"=="),
897 3 => filtered.push(b'='),
898 _ => return None,
899 }
900
901 fn decode_val(b: u8) -> Option<u8> {
902 match b {
903 b'A'..=b'Z' => Some(b - b'A'),
904 b'a'..=b'z' => Some(b - b'a' + 26),
905 b'0'..=b'9' => Some(b - b'0' + 52),
906 b'+' => Some(62),
907 b'/' => Some(63),
908 _ => None,
909 }
910 }
911
912 let mut out = Vec::with_capacity((filtered.len() / 4) * 3);
913 for chunk in filtered.chunks_exact(4) {
914 let p2 = chunk[2] == b'=';
915 let p3 = chunk[3] == b'=';
916 if p2 && !p3 {
917 return None;
918 }
919
920 let v0 = decode_val(chunk[0])? as u32;
921 let v1 = decode_val(chunk[1])? as u32;
922 let v2 = if p2 { 0 } else { decode_val(chunk[2])? as u32 };
923 let v3 = if p3 { 0 } else { decode_val(chunk[3])? as u32 };
924
925 let triple = (v0 << 18) | (v1 << 12) | (v2 << 6) | v3;
926 out.push(((triple >> 16) & 0xFF) as u8);
927 if !p2 {
928 out.push(((triple >> 8) & 0xFF) as u8);
929 }
930 if !p3 {
931 out.push((triple & 0xFF) as u8);
932 }
933 }
934
935 String::from_utf8(out).ok()
936}
937
938const CURSOR_MARKER: &str = "▎";
941
942fn find_cursor_marker(buffer: &Buffer) -> Option<(u32, u32)> {
943 let area = buffer.area;
944 for y in area.y..area.bottom() {
945 for x in area.x..area.right() {
946 if buffer.get(x, y).symbol == CURSOR_MARKER {
947 return Some((x, y));
948 }
949 }
950 }
951 None
952}
953
954fn apply_style_delta(
955 w: &mut impl Write,
956 old: &Style,
957 new: &Style,
958 depth: ColorDepth,
959) -> io::Result<()> {
960 if old.fg != new.fg {
961 match new.fg {
962 Some(fg) => queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?,
963 None => queue!(w, SetForegroundColor(CtColor::Reset))?,
964 }
965 }
966 if old.bg != new.bg {
967 match new.bg {
968 Some(bg) => queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?,
969 None => queue!(w, SetBackgroundColor(CtColor::Reset))?,
970 }
971 }
972 let removed = Modifiers(old.modifiers.0 & !new.modifiers.0);
973 let added = Modifiers(new.modifiers.0 & !old.modifiers.0);
974 if removed.contains(Modifiers::BOLD) || removed.contains(Modifiers::DIM) {
975 queue!(w, SetAttribute(Attribute::NormalIntensity))?;
976 if new.modifiers.contains(Modifiers::BOLD) {
977 queue!(w, SetAttribute(Attribute::Bold))?;
978 }
979 if new.modifiers.contains(Modifiers::DIM) {
980 queue!(w, SetAttribute(Attribute::Dim))?;
981 }
982 } else {
983 if added.contains(Modifiers::BOLD) {
984 queue!(w, SetAttribute(Attribute::Bold))?;
985 }
986 if added.contains(Modifiers::DIM) {
987 queue!(w, SetAttribute(Attribute::Dim))?;
988 }
989 }
990 if removed.contains(Modifiers::ITALIC) {
991 queue!(w, SetAttribute(Attribute::NoItalic))?;
992 }
993 if added.contains(Modifiers::ITALIC) {
994 queue!(w, SetAttribute(Attribute::Italic))?;
995 }
996 if removed.contains(Modifiers::UNDERLINE) {
997 queue!(w, SetAttribute(Attribute::NoUnderline))?;
998 }
999 if added.contains(Modifiers::UNDERLINE) {
1000 queue!(w, SetAttribute(Attribute::Underlined))?;
1001 }
1002 if removed.contains(Modifiers::REVERSED) {
1003 queue!(w, SetAttribute(Attribute::NoReverse))?;
1004 }
1005 if added.contains(Modifiers::REVERSED) {
1006 queue!(w, SetAttribute(Attribute::Reverse))?;
1007 }
1008 if removed.contains(Modifiers::STRIKETHROUGH) {
1009 queue!(w, SetAttribute(Attribute::NotCrossedOut))?;
1010 }
1011 if added.contains(Modifiers::STRIKETHROUGH) {
1012 queue!(w, SetAttribute(Attribute::CrossedOut))?;
1013 }
1014 Ok(())
1015}
1016
1017fn apply_style(w: &mut impl Write, style: &Style, depth: ColorDepth) -> io::Result<()> {
1018 if let Some(fg) = style.fg {
1019 queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?;
1020 }
1021 if let Some(bg) = style.bg {
1022 queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?;
1023 }
1024 let m = style.modifiers;
1025 if m.contains(Modifiers::BOLD) {
1026 queue!(w, SetAttribute(Attribute::Bold))?;
1027 }
1028 if m.contains(Modifiers::DIM) {
1029 queue!(w, SetAttribute(Attribute::Dim))?;
1030 }
1031 if m.contains(Modifiers::ITALIC) {
1032 queue!(w, SetAttribute(Attribute::Italic))?;
1033 }
1034 if m.contains(Modifiers::UNDERLINE) {
1035 queue!(w, SetAttribute(Attribute::Underlined))?;
1036 }
1037 if m.contains(Modifiers::REVERSED) {
1038 queue!(w, SetAttribute(Attribute::Reverse))?;
1039 }
1040 if m.contains(Modifiers::STRIKETHROUGH) {
1041 queue!(w, SetAttribute(Attribute::CrossedOut))?;
1042 }
1043 Ok(())
1044}
1045
1046fn to_crossterm_color(color: Color, depth: ColorDepth) -> CtColor {
1047 let color = color.downsampled(depth);
1048 match color {
1049 Color::Reset => CtColor::Reset,
1050 Color::Black => CtColor::Black,
1051 Color::Red => CtColor::DarkRed,
1052 Color::Green => CtColor::DarkGreen,
1053 Color::Yellow => CtColor::DarkYellow,
1054 Color::Blue => CtColor::DarkBlue,
1055 Color::Magenta => CtColor::DarkMagenta,
1056 Color::Cyan => CtColor::DarkCyan,
1057 Color::White => CtColor::White,
1058 Color::DarkGray => CtColor::DarkGrey,
1059 Color::LightRed => CtColor::Red,
1060 Color::LightGreen => CtColor::Green,
1061 Color::LightYellow => CtColor::Yellow,
1062 Color::LightBlue => CtColor::Blue,
1063 Color::LightMagenta => CtColor::Magenta,
1064 Color::LightCyan => CtColor::Cyan,
1065 Color::LightWhite => CtColor::White,
1066 Color::Rgb(r, g, b) => CtColor::Rgb { r, g, b },
1067 Color::Indexed(i) => CtColor::AnsiValue(i),
1068 }
1069}
1070
1071fn reset_current_buffer(buffer: &mut Buffer, theme_bg: Option<Color>) {
1072 if let Some(bg) = theme_bg {
1073 buffer.reset_with_bg(bg);
1074 } else {
1075 buffer.reset();
1076 }
1077}
1078
1079#[cfg(test)]
1080mod tests {
1081 use super::*;
1082
1083 #[test]
1084 fn reset_current_buffer_applies_theme_background() {
1085 let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 1));
1086
1087 reset_current_buffer(&mut buffer, Some(Color::Rgb(10, 20, 30)));
1088 assert_eq!(buffer.get(0, 0).style.bg, Some(Color::Rgb(10, 20, 30)));
1089
1090 reset_current_buffer(&mut buffer, None);
1091 assert_eq!(buffer.get(0, 0).style.bg, None);
1092 }
1093
1094 #[test]
1095 fn base64_encode_empty() {
1096 assert_eq!(base64_encode(b""), "");
1097 }
1098
1099 #[test]
1100 fn base64_encode_hello() {
1101 assert_eq!(base64_encode(b"Hello"), "SGVsbG8=");
1102 }
1103
1104 #[test]
1105 fn base64_encode_padding() {
1106 assert_eq!(base64_encode(b"a"), "YQ==");
1107 assert_eq!(base64_encode(b"ab"), "YWI=");
1108 assert_eq!(base64_encode(b"abc"), "YWJj");
1109 }
1110
1111 #[test]
1112 fn base64_encode_unicode() {
1113 assert_eq!(base64_encode("한글".as_bytes()), "7ZWc6riA");
1114 }
1115
1116 #[cfg(feature = "crossterm")]
1117 #[test]
1118 fn parse_osc11_response_dark_and_light() {
1119 assert_eq!(
1120 parse_osc11_response("\x1b]11;rgb:0000/0000/0000\x1b\\"),
1121 ColorScheme::Dark
1122 );
1123 assert_eq!(
1124 parse_osc11_response("\x1b]11;rgb:ffff/ffff/ffff\x07"),
1125 ColorScheme::Light
1126 );
1127 }
1128
1129 #[cfg(feature = "crossterm")]
1130 #[test]
1131 fn base64_decode_round_trip_hello() {
1132 let encoded = base64_encode("hello".as_bytes());
1133 assert_eq!(base64_decode(&encoded), Some("hello".to_string()));
1134 }
1135
1136 #[cfg(feature = "crossterm")]
1137 #[test]
1138 fn color_scheme_equality() {
1139 assert_eq!(ColorScheme::Dark, ColorScheme::Dark);
1140 assert_ne!(ColorScheme::Dark, ColorScheme::Light);
1141 assert_eq!(ColorScheme::Unknown, ColorScheme::Unknown);
1142 }
1143
1144 fn pair(r: Rect) -> (Rect, Rect) {
1145 (r, r)
1146 }
1147
1148 #[test]
1149 fn find_innermost_rect_picks_smallest() {
1150 let rects = vec![
1151 pair(Rect::new(0, 0, 80, 24)),
1152 pair(Rect::new(5, 2, 30, 10)),
1153 pair(Rect::new(10, 4, 10, 5)),
1154 ];
1155 let result = find_innermost_rect(&rects, 12, 5);
1156 assert_eq!(result, Some(Rect::new(10, 4, 10, 5)));
1157 }
1158
1159 #[test]
1160 fn find_innermost_rect_no_match() {
1161 let rects = vec![pair(Rect::new(10, 10, 5, 5))];
1162 assert_eq!(find_innermost_rect(&rects, 0, 0), None);
1163 }
1164
1165 #[test]
1166 fn find_innermost_rect_empty() {
1167 assert_eq!(find_innermost_rect(&[], 5, 5), None);
1168 }
1169
1170 #[test]
1171 fn find_innermost_rect_returns_content_rect() {
1172 let rects = vec![
1173 (Rect::new(0, 0, 80, 24), Rect::new(1, 1, 78, 22)),
1174 (Rect::new(5, 2, 30, 10), Rect::new(6, 3, 28, 8)),
1175 ];
1176 let result = find_innermost_rect(&rects, 10, 5);
1177 assert_eq!(result, Some(Rect::new(6, 3, 28, 8)));
1178 }
1179
1180 #[test]
1181 fn normalize_selection_already_ordered() {
1182 let (s, e) = normalize_selection((2, 1), (5, 3));
1183 assert_eq!(s, (2, 1));
1184 assert_eq!(e, (5, 3));
1185 }
1186
1187 #[test]
1188 fn normalize_selection_reversed() {
1189 let (s, e) = normalize_selection((5, 3), (2, 1));
1190 assert_eq!(s, (2, 1));
1191 assert_eq!(e, (5, 3));
1192 }
1193
1194 #[test]
1195 fn normalize_selection_same_row() {
1196 let (s, e) = normalize_selection((10, 5), (3, 5));
1197 assert_eq!(s, (3, 5));
1198 assert_eq!(e, (10, 5));
1199 }
1200
1201 #[test]
1202 fn selection_state_mouse_down_finds_rect() {
1203 let hit_map = vec![pair(Rect::new(0, 0, 80, 24)), pair(Rect::new(5, 2, 20, 10))];
1204 let mut sel = SelectionState::default();
1205 sel.mouse_down(10, 5, &hit_map);
1206 assert_eq!(sel.anchor, Some((10, 5)));
1207 assert_eq!(sel.current, Some((10, 5)));
1208 assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 20, 10)));
1209 assert!(!sel.active);
1210 }
1211
1212 #[test]
1213 fn selection_state_drag_activates() {
1214 let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
1215 let mut sel = SelectionState {
1216 anchor: Some((10, 5)),
1217 current: Some((10, 5)),
1218 widget_rect: Some(Rect::new(0, 0, 80, 24)),
1219 ..Default::default()
1220 };
1221 sel.mouse_drag(10, 5, &hit_map);
1222 assert!(!sel.active, "no movement = not active");
1223 sel.mouse_drag(11, 5, &hit_map);
1224 assert!(!sel.active, "1 cell horizontal = not active yet");
1225 sel.mouse_drag(13, 5, &hit_map);
1226 assert!(sel.active, ">1 cell horizontal = active");
1227 }
1228
1229 #[test]
1230 fn selection_state_drag_vertical_activates() {
1231 let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
1232 let mut sel = SelectionState {
1233 anchor: Some((10, 5)),
1234 current: Some((10, 5)),
1235 widget_rect: Some(Rect::new(0, 0, 80, 24)),
1236 ..Default::default()
1237 };
1238 sel.mouse_drag(10, 6, &hit_map);
1239 assert!(sel.active, "any vertical movement = active");
1240 }
1241
1242 #[test]
1243 fn selection_state_drag_expands_widget_rect() {
1244 let hit_map = vec![
1245 pair(Rect::new(0, 0, 80, 24)),
1246 pair(Rect::new(5, 2, 30, 10)),
1247 pair(Rect::new(5, 2, 30, 3)),
1248 ];
1249 let mut sel = SelectionState {
1250 anchor: Some((10, 3)),
1251 current: Some((10, 3)),
1252 widget_rect: Some(Rect::new(5, 2, 30, 3)),
1253 ..Default::default()
1254 };
1255 sel.mouse_drag(10, 6, &hit_map);
1256 assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 30, 10)));
1257 }
1258
1259 #[test]
1260 fn selection_state_clear_resets() {
1261 let mut sel = SelectionState {
1262 anchor: Some((1, 2)),
1263 current: Some((3, 4)),
1264 widget_rect: Some(Rect::new(0, 0, 10, 10)),
1265 active: true,
1266 };
1267 sel.clear();
1268 assert_eq!(sel.anchor, None);
1269 assert_eq!(sel.current, None);
1270 assert_eq!(sel.widget_rect, None);
1271 assert!(!sel.active);
1272 }
1273
1274 #[test]
1275 fn extract_selection_text_single_line() {
1276 let area = Rect::new(0, 0, 20, 5);
1277 let mut buf = Buffer::empty(area);
1278 buf.set_string(0, 0, "Hello World", Style::default());
1279 let sel = SelectionState {
1280 anchor: Some((0, 0)),
1281 current: Some((4, 0)),
1282 widget_rect: Some(area),
1283 active: true,
1284 };
1285 let text = extract_selection_text(&buf, &sel, &[]);
1286 assert_eq!(text, "Hello");
1287 }
1288
1289 #[test]
1290 fn extract_selection_text_multi_line() {
1291 let area = Rect::new(0, 0, 20, 5);
1292 let mut buf = Buffer::empty(area);
1293 buf.set_string(0, 0, "Line one", Style::default());
1294 buf.set_string(0, 1, "Line two", Style::default());
1295 buf.set_string(0, 2, "Line three", Style::default());
1296 let sel = SelectionState {
1297 anchor: Some((5, 0)),
1298 current: Some((3, 2)),
1299 widget_rect: Some(area),
1300 active: true,
1301 };
1302 let text = extract_selection_text(&buf, &sel, &[]);
1303 assert_eq!(text, "one\nLine two\nLine");
1304 }
1305
1306 #[test]
1307 fn extract_selection_text_clamped_to_widget() {
1308 let area = Rect::new(0, 0, 40, 10);
1309 let widget = Rect::new(5, 2, 10, 3);
1310 let mut buf = Buffer::empty(area);
1311 buf.set_string(5, 2, "ABCDEFGHIJ", Style::default());
1312 buf.set_string(5, 3, "KLMNOPQRST", Style::default());
1313 let sel = SelectionState {
1314 anchor: Some((3, 1)),
1315 current: Some((20, 5)),
1316 widget_rect: Some(widget),
1317 active: true,
1318 };
1319 let text = extract_selection_text(&buf, &sel, &[]);
1320 assert_eq!(text, "ABCDEFGHIJ\nKLMNOPQRST");
1321 }
1322
1323 #[test]
1324 fn extract_selection_text_inactive_returns_empty() {
1325 let area = Rect::new(0, 0, 10, 5);
1326 let buf = Buffer::empty(area);
1327 let sel = SelectionState {
1328 anchor: Some((0, 0)),
1329 current: Some((5, 2)),
1330 widget_rect: Some(area),
1331 active: false,
1332 };
1333 assert_eq!(extract_selection_text(&buf, &sel, &[]), "");
1334 }
1335
1336 #[test]
1337 fn apply_selection_overlay_reverses_cells() {
1338 let area = Rect::new(0, 0, 10, 3);
1339 let mut buf = Buffer::empty(area);
1340 buf.set_string(0, 0, "ABCDE", Style::default());
1341 let sel = SelectionState {
1342 anchor: Some((1, 0)),
1343 current: Some((3, 0)),
1344 widget_rect: Some(area),
1345 active: true,
1346 };
1347 apply_selection_overlay(&mut buf, &sel, &[]);
1348 assert!(!buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED));
1349 assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
1350 assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
1351 assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
1352 assert!(!buf.get(4, 0).style.modifiers.contains(Modifiers::REVERSED));
1353 }
1354
1355 #[test]
1356 fn extract_selection_text_skips_border_cells() {
1357 let area = Rect::new(0, 0, 40, 5);
1362 let mut buf = Buffer::empty(area);
1363 buf.set_string(0, 0, "╭", Style::default());
1365 buf.set_string(0, 1, "│", Style::default());
1366 buf.set_string(0, 2, "│", Style::default());
1367 buf.set_string(0, 3, "│", Style::default());
1368 buf.set_string(0, 4, "╰", Style::default());
1369 buf.set_string(19, 0, "╮", Style::default());
1370 buf.set_string(19, 1, "│", Style::default());
1371 buf.set_string(19, 2, "│", Style::default());
1372 buf.set_string(19, 3, "│", Style::default());
1373 buf.set_string(19, 4, "╯", Style::default());
1374 buf.set_string(20, 0, "╭", Style::default());
1376 buf.set_string(20, 1, "│", Style::default());
1377 buf.set_string(20, 2, "│", Style::default());
1378 buf.set_string(20, 3, "│", Style::default());
1379 buf.set_string(20, 4, "╰", Style::default());
1380 buf.set_string(39, 0, "╮", Style::default());
1381 buf.set_string(39, 1, "│", Style::default());
1382 buf.set_string(39, 2, "│", Style::default());
1383 buf.set_string(39, 3, "│", Style::default());
1384 buf.set_string(39, 4, "╯", Style::default());
1385 buf.set_string(1, 1, "Hello Col1", Style::default());
1387 buf.set_string(1, 2, "Line2 Col1", Style::default());
1388 buf.set_string(21, 1, "Hello Col2", Style::default());
1390 buf.set_string(21, 2, "Line2 Col2", Style::default());
1391
1392 let content_map = vec![
1393 (Rect::new(0, 0, 20, 5), Rect::new(1, 1, 18, 3)),
1394 (Rect::new(20, 0, 20, 5), Rect::new(21, 1, 18, 3)),
1395 ];
1396
1397 let sel = SelectionState {
1399 anchor: Some((0, 1)),
1400 current: Some((39, 2)),
1401 widget_rect: Some(area),
1402 active: true,
1403 };
1404 let text = extract_selection_text(&buf, &sel, &content_map);
1405 assert!(!text.contains('│'), "Border char │ found in: {text}");
1407 assert!(!text.contains('╭'), "Border char ╭ found in: {text}");
1408 assert!(!text.contains('╮'), "Border char ╮ found in: {text}");
1409 assert!(
1411 text.contains("Hello Col1"),
1412 "Missing Col1 content in: {text}"
1413 );
1414 assert!(
1415 text.contains("Hello Col2"),
1416 "Missing Col2 content in: {text}"
1417 );
1418 assert!(text.contains("Line2 Col1"), "Missing Col1 line2 in: {text}");
1419 assert!(text.contains("Line2 Col2"), "Missing Col2 line2 in: {text}");
1420 }
1421
1422 #[test]
1423 fn apply_selection_overlay_skips_border_cells() {
1424 let area = Rect::new(0, 0, 20, 3);
1425 let mut buf = Buffer::empty(area);
1426 buf.set_string(0, 0, "│", Style::default());
1427 buf.set_string(1, 0, "ABC", Style::default());
1428 buf.set_string(19, 0, "│", Style::default());
1429
1430 let content_map = vec![(Rect::new(0, 0, 20, 3), Rect::new(1, 0, 18, 3))];
1431 let sel = SelectionState {
1432 anchor: Some((0, 0)),
1433 current: Some((19, 0)),
1434 widget_rect: Some(area),
1435 active: true,
1436 };
1437 apply_selection_overlay(&mut buf, &sel, &content_map);
1438 assert!(
1440 !buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED),
1441 "Left border cell should not be reversed"
1442 );
1443 assert!(
1444 !buf.get(19, 0).style.modifiers.contains(Modifiers::REVERSED),
1445 "Right border cell should not be reversed"
1446 );
1447 assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
1449 assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
1450 assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
1451 }
1452
1453 #[test]
1454 fn copy_to_clipboard_writes_osc52() {
1455 let mut output: Vec<u8> = Vec::new();
1456 copy_to_clipboard(&mut output, "test").unwrap();
1457 let s = String::from_utf8(output).unwrap();
1458 assert!(s.starts_with("\x1b]52;c;"));
1459 assert!(s.ends_with("\x1b\\"));
1460 assert!(s.contains(&base64_encode(b"test")));
1461 }
1462}