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