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