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