1use std::collections::HashMap;
2use std::io::{self, BufWriter, 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 {
48 Self {
49 next_id: 1,
50 uploaded: HashMap::new(),
51 prev_placements: Vec::new(),
52 }
53 }
54
55 pub fn flush(
62 &mut self,
63 stdout: &mut impl Write,
64 current: &[KittyPlacement],
65 row_offset: u32,
66 ) -> io::Result<()> {
67 if current.len() == self.prev_placements.len()
71 && current
72 .iter()
73 .zip(self.prev_placements.iter())
74 .all(|(c, p)| placement_eq_with_offset(c, row_offset, p))
75 {
76 return Ok(());
77 }
78
79 if !self.prev_placements.is_empty() {
81 let mut deleted_ids = std::collections::HashSet::new();
83 for p in &self.prev_placements {
84 if let Some(&img_id) = self.uploaded.get(&p.content_hash) {
85 if deleted_ids.insert(img_id) {
86 queue!(
88 stdout,
89 Print(format!("\x1b_Ga=d,d=i,i={},q=2\x1b\\", img_id))
90 )?;
91 }
92 }
93 }
94 }
95
96 for (idx, p) in current.iter().enumerate() {
98 let img_id = if let Some(&existing_id) = self.uploaded.get(&p.content_hash) {
99 existing_id
100 } else {
101 let id = self.next_id;
103 self.next_id += 1;
104 self.upload_image(stdout, id, p)?;
105 self.uploaded.insert(p.content_hash, id);
106 id
107 };
108
109 let pid = idx as u32 + 1;
111 self.place_image_offset(stdout, img_id, pid, p, row_offset)?;
112 }
113
114 let used_hashes: std::collections::HashSet<u64> =
116 current.iter().map(|p| p.content_hash).collect();
117 let stale: Vec<u64> = self
118 .uploaded
119 .keys()
120 .filter(|h| !used_hashes.contains(h))
121 .copied()
122 .collect();
123 for hash in stale {
124 if let Some(id) = self.uploaded.remove(&hash) {
125 queue!(stdout, Print(format!("\x1b_Ga=d,d=I,i={},q=2\x1b\\", id)))?;
127 }
128 }
129
130 self.prev_placements.clear();
137 self.prev_placements.reserve(current.len());
138 for p in current {
139 let mut copy = p.clone();
140 copy.y = copy.y.saturating_add(row_offset);
141 self.prev_placements.push(copy);
142 }
143 Ok(())
144 }
145
146 fn upload_image(&self, stdout: &mut impl Write, id: u32, p: &KittyPlacement) -> io::Result<()> {
148 let (payload, compression) = compress_rgba(&p.rgba);
149 let encoded = base64_encode(&payload);
150 let chunks = split_base64(&encoded, 4096);
151
152 for (i, chunk) in chunks.iter().enumerate() {
153 let more = if i < chunks.len() - 1 { 1 } else { 0 };
154 if i == 0 {
155 queue!(
156 stdout,
157 Print(format!(
158 "\x1b_Ga=t,i={},f=32,{}s={},v={},q=2,m={};{}\x1b\\",
159 id, compression, p.src_width, p.src_height, more, chunk
160 ))
161 )?;
162 } else {
163 queue!(stdout, Print(format!("\x1b_Gm={};{}\x1b\\", more, chunk)))?;
164 }
165 }
166 Ok(())
167 }
168
169 fn place_image_offset(
175 &self,
176 stdout: &mut impl Write,
177 img_id: u32,
178 placement_id: u32,
179 p: &KittyPlacement,
180 row_offset: u32,
181 ) -> io::Result<()> {
182 let display_y = p.y.saturating_add(row_offset);
183 queue!(stdout, cursor::MoveTo(sat_u16(p.x), sat_u16(display_y)))?;
184
185 let mut cmd = format!(
186 "\x1b_Ga=p,i={},p={},c={},r={},C=1,q=2",
187 img_id, placement_id, p.cols, p.rows
188 );
189
190 if p.crop_y > 0 || p.crop_h > 0 {
192 cmd.push_str(&format!(",y={}", p.crop_y));
193 if p.crop_h > 0 {
194 cmd.push_str(&format!(",h={}", p.crop_h));
195 }
196 }
197
198 cmd.push_str("\x1b\\");
199 queue!(stdout, Print(cmd))?;
200 Ok(())
201 }
202
203 pub fn delete_all(&self, stdout: &mut impl Write) -> io::Result<()> {
205 queue!(stdout, Print("\x1b_Ga=d,d=A,q=2\x1b\\"))
206 }
207}
208
209#[inline]
217fn placement_eq_with_offset(
218 current: &KittyPlacement,
219 row_offset: u32,
220 prev: &KittyPlacement,
221) -> bool {
222 current.content_hash == prev.content_hash
223 && current.x == prev.x
224 && current.y.saturating_add(row_offset) == prev.y
225 && current.cols == prev.cols
226 && current.rows == prev.rows
227 && current.crop_y == prev.crop_y
228 && current.crop_h == prev.crop_h
229}
230
231fn compress_rgba(data: &[u8]) -> (Vec<u8>, &'static str) {
233 #[cfg(feature = "kitty-compress")]
234 {
235 use flate2::write::ZlibEncoder;
236 use flate2::Compression;
237 let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast());
238 if encoder.write_all(data).is_ok() {
239 if let Ok(compressed) = encoder.finish() {
240 if compressed.len() < data.len() {
242 return (compressed, "o=z,");
243 }
244 }
245 }
246 }
247 (data.to_vec(), "")
248}
249
250pub fn cell_pixel_size() -> (u32, u32) {
257 use std::sync::OnceLock;
258 static CACHED: OnceLock<(u32, u32)> = OnceLock::new();
259 *CACHED.get_or_init(|| detect_cell_pixel_size().unwrap_or((8, 16)))
260}
261
262fn detect_cell_pixel_size() -> Option<(u32, u32)> {
263 let mut stdout = io::stdout();
265 write!(stdout, "\x1b[16t").ok()?;
266 stdout.flush().ok()?;
267
268 let response = read_osc_response(Duration::from_millis(100))?;
269
270 let body = response.strip_prefix("\x1b[6;").or_else(|| {
272 let bytes = response.as_bytes();
274 if bytes.len() > 3 && bytes[0] == 0x9b && bytes[1] == b'6' && bytes[2] == b';' {
275 Some(&response[3..])
276 } else {
277 None
278 }
279 })?;
280 let body = body
281 .strip_suffix('t')
282 .or_else(|| body.strip_suffix("t\x1b"))?;
283 let mut parts = body.split(';');
284 let ch: u32 = parts.next()?.parse().ok()?;
285 let cw: u32 = parts.next()?.parse().ok()?;
286 if cw > 0 && ch > 0 {
287 Some((cw, ch))
288 } else {
289 None
290 }
291}
292
293fn split_base64(encoded: &str, chunk_size: usize) -> Vec<&str> {
294 let mut chunks = Vec::new();
295 let bytes = encoded.as_bytes();
296 let mut offset = 0;
297 while offset < bytes.len() {
298 let end = (offset + chunk_size).min(bytes.len());
299 chunks.push(&encoded[offset..end]);
300 offset = end;
301 }
302 if chunks.is_empty() {
303 chunks.push("");
304 }
305 chunks
306}
307
308pub(crate) struct Terminal {
309 stdout: BufWriter<Stdout>,
310 current: Buffer,
311 previous: Buffer,
312 cursor_visible: bool,
313 session: TerminalSessionGuard,
314 color_depth: ColorDepth,
315 pub(crate) theme_bg: Option<Color>,
316 kitty_mgr: KittyImageManager,
317}
318
319pub(crate) struct InlineTerminal {
320 stdout: BufWriter<Stdout>,
321 current: Buffer,
322 previous: Buffer,
323 cursor_visible: bool,
324 session: TerminalSessionGuard,
325 height: u32,
326 start_row: u16,
327 reserved: bool,
328 color_depth: ColorDepth,
329 pub(crate) theme_bg: Option<Color>,
330 kitty_mgr: KittyImageManager,
331}
332
333#[derive(Debug, Clone, Copy, PartialEq, Eq)]
334enum TerminalSessionMode {
335 Fullscreen,
336 Inline,
337}
338
339#[derive(Debug, Clone, Copy)]
340struct TerminalSessionGuard {
341 mode: TerminalSessionMode,
342 mouse_enabled: bool,
343 kitty_keyboard: bool,
344}
345
346impl TerminalSessionGuard {
347 fn enter(
348 mode: TerminalSessionMode,
349 stdout: &mut impl Write,
350 mouse_enabled: bool,
351 kitty_keyboard: bool,
352 ) -> io::Result<Self> {
353 let guard = Self {
354 mode,
355 mouse_enabled,
356 kitty_keyboard,
357 };
358
359 terminal::enable_raw_mode()?;
360 if let Err(err) = write_session_enter(stdout, &guard) {
361 guard.restore(stdout, false);
362 return Err(err);
363 }
364
365 Ok(guard)
366 }
367
368 fn restore(&self, stdout: &mut impl Write, inline_reserved: bool) {
369 if self.kitty_keyboard {
370 use crossterm::event::PopKeyboardEnhancementFlags;
371 let _ = execute!(stdout, PopKeyboardEnhancementFlags);
372 }
373 if self.mouse_enabled {
374 let _ = execute!(stdout, DisableMouseCapture);
375 }
376 let _ = execute!(stdout, DisableFocusChange);
377 let _ = write_session_cleanup(stdout, self.mode, inline_reserved);
378 let _ = terminal::disable_raw_mode();
379 }
380}
381
382impl Terminal {
383 pub fn new(mouse: bool, kitty_keyboard: bool, color_depth: ColorDepth) -> io::Result<Self> {
387 let (cols, rows) = terminal::size()?;
388 let area = Rect::new(0, 0, cols as u32, rows as u32);
389
390 let mut raw = io::stdout();
391 let session = TerminalSessionGuard::enter(
392 TerminalSessionMode::Fullscreen,
393 &mut raw,
394 mouse,
395 kitty_keyboard,
396 )?;
397
398 Ok(Self {
399 stdout: BufWriter::with_capacity(65536, raw),
400 current: Buffer::empty(area),
401 previous: Buffer::empty(area),
402 cursor_visible: false,
403 session,
404 color_depth,
405 theme_bg: None,
406 kitty_mgr: KittyImageManager::new(),
407 })
408 }
409
410 pub fn size(&self) -> (u32, u32) {
412 (self.current.area.width, self.current.area.height)
413 }
414
415 pub fn buffer_mut(&mut self) -> &mut Buffer {
417 &mut self.current
418 }
419
420 pub fn flush(&mut self) -> io::Result<()> {
424 if self.current.area.width < self.previous.area.width {
425 execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
426 }
427
428 queue!(self.stdout, BeginSynchronizedUpdate)?;
429 self.current.recompute_line_hashes();
434 self.previous.recompute_line_hashes();
435 flush_buffer_diff(
436 &mut self.stdout,
437 &self.current,
438 &self.previous,
439 self.color_depth,
440 0,
441 )?;
442
443 self.kitty_mgr
446 .flush(&mut self.stdout, &self.current.kitty_placements, 0)?;
447
448 flush_raw_sequences(&mut self.stdout, &self.current, &self.previous, 0)?;
450
451 queue!(self.stdout, EndSynchronizedUpdate)?;
452 flush_cursor(
453 &mut self.stdout,
454 &mut self.cursor_visible,
455 self.current.cursor_pos(),
456 0,
457 None,
458 )?;
459
460 self.stdout.flush()?;
461
462 std::mem::swap(&mut self.current, &mut self.previous);
463 if let Some(bg) = self.theme_bg {
464 self.current.reset_with_bg(bg);
465 } else {
466 self.current.reset();
467 }
468 Ok(())
469 }
470
471 pub fn handle_resize(&mut self) -> io::Result<()> {
474 let (cols, rows) = terminal::size()?;
475 let area = Rect::new(0, 0, cols as u32, rows as u32);
476 self.current.resize(area);
477 self.previous.resize(area);
478 execute!(
479 self.stdout,
480 terminal::Clear(terminal::ClearType::All),
481 cursor::MoveTo(0, 0)
482 )?;
483 Ok(())
484 }
485}
486
487impl crate::Backend for Terminal {
488 fn size(&self) -> (u32, u32) {
489 Terminal::size(self)
490 }
491
492 fn buffer_mut(&mut self) -> &mut Buffer {
493 Terminal::buffer_mut(self)
494 }
495
496 fn flush(&mut self) -> io::Result<()> {
497 Terminal::flush(self)
498 }
499}
500
501impl InlineTerminal {
502 pub fn new(
506 height: u32,
507 mouse: bool,
508 kitty_keyboard: bool,
509 color_depth: ColorDepth,
510 ) -> io::Result<Self> {
511 let (cols, _) = terminal::size()?;
512 let area = Rect::new(0, 0, cols as u32, height);
513
514 let mut raw = io::stdout();
515 let session = TerminalSessionGuard::enter(
516 TerminalSessionMode::Inline,
517 &mut raw,
518 mouse,
519 kitty_keyboard,
520 )?;
521
522 let (_, cursor_row) = match cursor::position() {
523 Ok(pos) => pos,
524 Err(err) => {
525 session.restore(&mut raw, false);
526 return Err(err);
527 }
528 };
529 Ok(Self {
530 stdout: BufWriter::with_capacity(65536, raw),
531 current: Buffer::empty(area),
532 previous: Buffer::empty(area),
533 cursor_visible: false,
534 session,
535 height,
536 start_row: cursor_row,
537 reserved: false,
538 color_depth,
539 theme_bg: None,
540 kitty_mgr: KittyImageManager::new(),
541 })
542 }
543
544 pub fn size(&self) -> (u32, u32) {
546 (self.current.area.width, self.current.area.height)
547 }
548
549 pub fn buffer_mut(&mut self) -> &mut Buffer {
551 &mut self.current
552 }
553
554 pub fn flush(&mut self) -> io::Result<()> {
558 if self.current.area.width < self.previous.area.width {
559 execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
560 }
561
562 queue!(self.stdout, BeginSynchronizedUpdate)?;
563
564 if !self.reserved {
565 queue!(self.stdout, cursor::MoveToColumn(0))?;
566 for _ in 0..self.height {
567 queue!(self.stdout, Print("\n"))?;
568 }
569 self.reserved = true;
570
571 let (_, rows) = terminal::size()?;
572 let bottom = self.start_row.saturating_add(sat_u16(self.height));
573 if bottom > rows {
574 self.start_row = rows.saturating_sub(sat_u16(self.height));
575 }
576 }
577 let row_offset = self.start_row as u32;
578 self.current.recompute_line_hashes();
581 self.previous.recompute_line_hashes();
582 flush_buffer_diff(
583 &mut self.stdout,
584 &self.current,
585 &self.previous,
586 self.color_depth,
587 row_offset,
588 )?;
589
590 self.kitty_mgr
596 .flush(&mut self.stdout, &self.current.kitty_placements, row_offset)?;
597
598 flush_raw_sequences(&mut self.stdout, &self.current, &self.previous, row_offset)?;
600
601 queue!(self.stdout, EndSynchronizedUpdate)?;
602 let fallback_row = row_offset + self.height.saturating_sub(1);
603 flush_cursor(
604 &mut self.stdout,
605 &mut self.cursor_visible,
606 self.current.cursor_pos(),
607 row_offset,
608 Some(fallback_row),
609 )?;
610
611 self.stdout.flush()?;
612
613 std::mem::swap(&mut self.current, &mut self.previous);
614 reset_current_buffer(&mut self.current, self.theme_bg);
615 Ok(())
616 }
617
618 pub fn handle_resize(&mut self) -> io::Result<()> {
621 let (cols, _) = terminal::size()?;
622 let area = Rect::new(0, 0, cols as u32, self.height);
623 self.current.resize(area);
624 self.previous.resize(area);
625 execute!(
626 self.stdout,
627 terminal::Clear(terminal::ClearType::All),
628 cursor::MoveTo(0, 0)
629 )?;
630 Ok(())
631 }
632}
633
634impl crate::Backend for InlineTerminal {
635 fn size(&self) -> (u32, u32) {
636 InlineTerminal::size(self)
637 }
638
639 fn buffer_mut(&mut self) -> &mut Buffer {
640 InlineTerminal::buffer_mut(self)
641 }
642
643 fn flush(&mut self) -> io::Result<()> {
644 InlineTerminal::flush(self)
645 }
646}
647
648impl Drop for Terminal {
649 fn drop(&mut self) {
650 let _ = self.kitty_mgr.delete_all(&mut self.stdout);
652 let _ = self.stdout.flush();
653 self.session.restore(&mut self.stdout, false);
654 }
655}
656
657impl Drop for InlineTerminal {
658 fn drop(&mut self) {
659 let _ = self.kitty_mgr.delete_all(&mut self.stdout);
660 let _ = self.stdout.flush();
661 self.session.restore(&mut self.stdout, self.reserved);
662 }
663}
664
665mod selection;
666pub(crate) use selection::{apply_selection_overlay, extract_selection_text, SelectionState};
667#[cfg(test)]
668pub(crate) use selection::{find_innermost_rect, normalize_selection};
669
670#[non_exhaustive]
672#[cfg(feature = "crossterm")]
673#[derive(Debug, Clone, Copy, PartialEq, Eq)]
674pub enum ColorScheme {
675 Dark,
677 Light,
679 Unknown,
681}
682
683#[cfg(feature = "crossterm")]
684fn read_osc_response(timeout: Duration) -> Option<String> {
685 let deadline = Instant::now() + timeout;
686 let mut stdin = io::stdin();
687 let mut bytes = Vec::new();
688 let mut buf = [0u8; 1];
689
690 while Instant::now() < deadline {
691 if !crossterm::event::poll(Duration::from_millis(10)).ok()? {
692 continue;
693 }
694
695 let read = stdin.read(&mut buf).ok()?;
696 if read == 0 {
697 continue;
698 }
699
700 bytes.push(buf[0]);
701
702 if buf[0] == b'\x07' {
703 break;
704 }
705 let len = bytes.len();
706 if len >= 2 && bytes[len - 2] == 0x1B && bytes[len - 1] == b'\\' {
707 break;
708 }
709
710 if bytes.len() >= 4096 {
711 break;
712 }
713 }
714
715 if bytes.is_empty() {
716 return None;
717 }
718
719 String::from_utf8(bytes).ok()
720}
721
722#[cfg(feature = "crossterm")]
724pub fn detect_color_scheme() -> ColorScheme {
725 let mut stdout = io::stdout();
726 if write!(stdout, "\x1b]11;?\x07").is_err() {
727 return ColorScheme::Unknown;
728 }
729 if stdout.flush().is_err() {
730 return ColorScheme::Unknown;
731 }
732
733 let Some(response) = read_osc_response(Duration::from_millis(100)) else {
734 return ColorScheme::Unknown;
735 };
736
737 parse_osc11_response(&response)
738}
739
740#[cfg(feature = "crossterm")]
741pub(crate) fn parse_osc11_response(response: &str) -> ColorScheme {
742 let Some(rgb_pos) = response.find("rgb:") else {
743 return ColorScheme::Unknown;
744 };
745
746 let payload = &response[rgb_pos + 4..];
747 let end = payload
748 .find(['\x07', '\x1b', '\r', '\n', ' ', '\t'])
749 .unwrap_or(payload.len());
750 let rgb = &payload[..end];
751
752 let mut channels = rgb.split('/');
753 let (Some(r), Some(g), Some(b), None) = (
754 channels.next(),
755 channels.next(),
756 channels.next(),
757 channels.next(),
758 ) else {
759 return ColorScheme::Unknown;
760 };
761
762 fn parse_channel(channel: &str) -> Option<f64> {
763 if channel.is_empty() || channel.len() > 4 {
764 return None;
765 }
766 let value = u16::from_str_radix(channel, 16).ok()? as f64;
767 let max = ((1u32 << (channel.len() * 4)) - 1) as f64;
768 if max <= 0.0 {
769 return None;
770 }
771 Some((value / max).clamp(0.0, 1.0))
772 }
773
774 let (Some(r), Some(g), Some(b)) = (parse_channel(r), parse_channel(g), parse_channel(b)) else {
775 return ColorScheme::Unknown;
776 };
777
778 let luminance = 0.299 * r + 0.587 * g + 0.114 * b;
779 if luminance < 0.5 {
780 ColorScheme::Dark
781 } else {
782 ColorScheme::Light
783 }
784}
785
786fn base64_encode(input: &[u8]) -> String {
787 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
788 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
789 for chunk in input.chunks(3) {
790 let b0 = chunk[0] as u32;
791 let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
792 let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
793 let triple = (b0 << 16) | (b1 << 8) | b2;
794 out.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
795 out.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
796 out.push(if chunk.len() > 1 {
797 CHARS[((triple >> 6) & 0x3F) as usize] as char
798 } else {
799 '='
800 });
801 out.push(if chunk.len() > 2 {
802 CHARS[(triple & 0x3F) as usize] as char
803 } else {
804 '='
805 });
806 }
807 out
808}
809
810pub(crate) fn copy_to_clipboard(w: &mut impl Write, text: &str) -> io::Result<()> {
811 let encoded = base64_encode(text.as_bytes());
812 write!(w, "\x1b]52;c;{encoded}\x1b\\")?;
813 w.flush()
814}
815
816#[cfg(feature = "crossterm")]
817fn parse_osc52_response(response: &str) -> Option<String> {
818 let osc_pos = response.find("]52;")?;
819 let body = &response[osc_pos + 4..];
820 let semicolon = body.find(';')?;
821 let payload = &body[semicolon + 1..];
822
823 let end = payload
824 .find("\x1b\\")
825 .or_else(|| payload.find('\x07'))
826 .unwrap_or(payload.len());
827 let encoded = payload[..end].trim();
828 if encoded.is_empty() || encoded == "?" {
829 return None;
830 }
831
832 base64_decode(encoded)
833}
834
835#[cfg(feature = "crossterm")]
837pub fn read_clipboard() -> Option<String> {
838 let mut stdout = io::stdout();
839 write!(stdout, "\x1b]52;c;?\x07").ok()?;
840 stdout.flush().ok()?;
841
842 let response = read_osc_response(Duration::from_millis(200))?;
843 parse_osc52_response(&response)
844}
845
846#[cfg(feature = "crossterm")]
847fn base64_decode(input: &str) -> Option<String> {
848 let mut filtered: Vec<u8> = input
849 .bytes()
850 .filter(|b| !matches!(b, b' ' | b'\n' | b'\r' | b'\t'))
851 .collect();
852
853 match filtered.len() % 4 {
854 0 => {}
855 2 => filtered.extend_from_slice(b"=="),
856 3 => filtered.push(b'='),
857 _ => return None,
858 }
859
860 fn decode_val(b: u8) -> Option<u8> {
861 match b {
862 b'A'..=b'Z' => Some(b - b'A'),
863 b'a'..=b'z' => Some(b - b'a' + 26),
864 b'0'..=b'9' => Some(b - b'0' + 52),
865 b'+' => Some(62),
866 b'/' => Some(63),
867 _ => None,
868 }
869 }
870
871 let mut out = Vec::with_capacity((filtered.len() / 4) * 3);
872 for chunk in filtered.chunks_exact(4) {
873 let p2 = chunk[2] == b'=';
874 let p3 = chunk[3] == b'=';
875 if p2 && !p3 {
876 return None;
877 }
878
879 let v0 = decode_val(chunk[0])? as u32;
880 let v1 = decode_val(chunk[1])? as u32;
881 let v2 = if p2 { 0 } else { decode_val(chunk[2])? as u32 };
882 let v3 = if p3 { 0 } else { decode_val(chunk[3])? as u32 };
883
884 let triple = (v0 << 18) | (v1 << 12) | (v2 << 6) | v3;
885 out.push(((triple >> 16) & 0xFF) as u8);
886 if !p2 {
887 out.push(((triple >> 8) & 0xFF) as u8);
888 }
889 if !p3 {
890 out.push((triple & 0xFF) as u8);
891 }
892 }
893
894 String::from_utf8(out).ok()
895}
896
897#[allow(clippy::too_many_arguments)]
898#[allow(unused_assignments)]
899fn flush_buffer_diff(
900 stdout: &mut impl Write,
901 current: &Buffer,
902 previous: &Buffer,
903 color_depth: ColorDepth,
904 row_offset: u32,
905) -> io::Result<()> {
906 let mut last_style = Style::new();
918 let mut first_style = true;
919 let mut active_link: Option<&str> = None;
920 let mut has_updates = false;
921 let mut last_cursor: Option<(u32, u32)> = None;
925
926 let mut run_buf = String::new();
929 let mut run_abs_y: u32 = 0;
930 let mut run_style: Style = Style::new();
931 let mut run_link: Option<&str> = None;
932 let mut run_next_col: u32 = 0;
933 let mut run_open = false;
934
935 macro_rules! flush_run {
940 ($stdout:expr) => {
941 if run_open {
942 queue!($stdout, Print(&run_buf))?;
943 last_cursor = Some((run_next_col, run_abs_y));
944 run_buf.clear();
945 run_open = false;
946 }
947 };
948 }
949
950 for y in current.area.y..current.area.bottom() {
951 if current.row_clean(y)
960 && current.row_hash(y).is_some()
961 && current.row_hash(y) == previous.row_hash(y)
962 {
963 continue;
964 }
965 for x in current.area.x..current.area.right() {
966 let cell = current.get(x, y);
967 let prev = previous.get(x, y);
968 if cell == prev || cell.symbol.is_empty() {
969 flush_run!(stdout);
971 continue;
972 }
973
974 let abs_y = row_offset + y;
975 let cell_link = cell
980 .hyperlink
981 .as_deref()
982 .filter(|u| crate::buffer::is_valid_osc8_url(u));
983
984 let extends = run_open
986 && run_abs_y == abs_y
987 && run_next_col == x
988 && run_style == cell.style
989 && run_link == cell_link;
990
991 if !extends {
992 flush_run!(stdout);
993
994 has_updates = true;
998
999 let need_move = last_cursor.map_or(true, |(lx, ly)| lx != x || ly != abs_y);
1000 if need_move {
1001 queue!(stdout, cursor::MoveTo(sat_u16(x), sat_u16(abs_y)))?;
1002 }
1003
1004 if cell.style != last_style {
1005 if first_style {
1006 queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
1007 apply_style(stdout, &cell.style, color_depth)?;
1008 first_style = false;
1009 } else {
1010 apply_style_delta(stdout, &last_style, &cell.style, color_depth)?;
1011 }
1012 last_style = cell.style;
1013 }
1014
1015 if cell_link != active_link {
1016 if let Some(url) = cell_link {
1017 queue!(stdout, Print(format!("\x1b]8;;{url}\x07")))?;
1018 } else {
1019 queue!(stdout, Print("\x1b]8;;\x07"))?;
1020 }
1021 active_link = cell_link;
1022 }
1023
1024 run_open = true;
1025 run_abs_y = abs_y;
1026 run_style = cell.style;
1027 run_link = cell_link;
1028 }
1029
1030 run_buf.push_str(&cell.symbol);
1034 let char_width = UnicodeWidthStr::width(cell.symbol.as_str()).max(1) as u32;
1035 if char_width > 1 && cell.symbol.chars().any(|c| c == '\u{FE0F}') {
1036 run_buf.push(' ');
1040 }
1041 run_next_col = x + char_width;
1042 }
1043
1044 flush_run!(stdout);
1046 }
1047
1048 if has_updates {
1049 if active_link.is_some() {
1050 queue!(stdout, Print("\x1b]8;;\x07"))?;
1051 }
1052 queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
1053 }
1054
1055 Ok(())
1056}
1057
1058#[doc(hidden)]
1068pub fn __bench_flush_buffer_diff<W: Write>(
1069 w: &mut W,
1070 current: &Buffer,
1071 previous: &Buffer,
1072 color_depth: ColorDepth,
1073) -> io::Result<()> {
1074 flush_buffer_diff(w, current, previous, color_depth, 0)
1075}
1076
1077#[doc(hidden)]
1086pub fn __bench_flush_buffer_diff_mut<W: Write>(
1087 w: &mut W,
1088 current: &mut Buffer,
1089 previous: &mut Buffer,
1090 color_depth: ColorDepth,
1091) -> io::Result<()> {
1092 current.recompute_line_hashes();
1093 previous.recompute_line_hashes();
1094 flush_buffer_diff(w, current, previous, color_depth, 0)
1095}
1096
1097#[doc(hidden)]
1102pub struct __BenchKittyFixture {
1103 mgr: KittyImageManager,
1104 placements: Vec<KittyPlacement>,
1105}
1106
1107#[doc(hidden)]
1110pub fn __bench_new_kitty_fixture(n: usize) -> __BenchKittyFixture {
1111 let mut placements = Vec::with_capacity(n);
1112 for i in 0..n {
1113 let mut rgba = vec![0u8; 256];
1115 rgba[0] = i as u8;
1117 let content_hash = crate::buffer::hash_rgba(&rgba);
1118 placements.push(KittyPlacement {
1119 content_hash,
1120 rgba: std::sync::Arc::new(rgba),
1121 src_width: 8,
1122 src_height: 8,
1123 x: (i as u32) * 4,
1124 y: (i as u32) * 2,
1125 cols: 4,
1126 rows: 2,
1127 crop_y: 0,
1128 crop_h: 0,
1129 });
1130 }
1131 __BenchKittyFixture {
1132 mgr: KittyImageManager::new(),
1133 placements,
1134 }
1135}
1136
1137impl __BenchKittyFixture {
1138 #[doc(hidden)]
1142 pub fn rgba_strong_counts(&self) -> Vec<usize> {
1143 self.placements
1144 .iter()
1145 .map(|p| std::sync::Arc::strong_count(&p.rgba))
1146 .collect()
1147 }
1148
1149 #[doc(hidden)]
1152 pub fn flush_inline<W: Write>(&mut self, sink: &mut W, row_offset: u32) -> io::Result<()> {
1153 self.mgr.flush(sink, &self.placements, row_offset)
1154 }
1155
1156 #[doc(hidden)]
1158 pub fn len(&self) -> usize {
1159 self.placements.len()
1160 }
1161
1162 #[doc(hidden)]
1164 pub fn is_empty(&self) -> bool {
1165 self.placements.is_empty()
1166 }
1167}
1168
1169fn flush_raw_sequences(
1170 stdout: &mut impl Write,
1171 current: &Buffer,
1172 previous: &Buffer,
1173 row_offset: u32,
1174) -> io::Result<()> {
1175 if current.raw_sequences == previous.raw_sequences {
1176 return Ok(());
1177 }
1178
1179 for (x, y, seq) in ¤t.raw_sequences {
1180 queue!(
1181 stdout,
1182 cursor::MoveTo(sat_u16(*x), sat_u16(row_offset + *y)),
1183 Print(seq)
1184 )?;
1185 }
1186
1187 Ok(())
1188}
1189
1190fn flush_cursor(
1191 stdout: &mut impl Write,
1192 cursor_visible: &mut bool,
1193 cursor_pos: Option<(u32, u32)>,
1194 row_offset: u32,
1195 fallback_row: Option<u32>,
1196) -> io::Result<()> {
1197 match cursor_pos {
1198 Some((cx, cy)) => {
1199 if !*cursor_visible {
1200 queue!(stdout, cursor::Show)?;
1201 *cursor_visible = true;
1202 }
1203 queue!(
1204 stdout,
1205 cursor::MoveTo(sat_u16(cx), sat_u16(row_offset + cy))
1206 )?;
1207 }
1208 None => {
1209 if *cursor_visible {
1210 queue!(stdout, cursor::Hide)?;
1211 *cursor_visible = false;
1212 }
1213 if let Some(row) = fallback_row {
1214 queue!(stdout, cursor::MoveTo(0, sat_u16(row)))?;
1215 }
1216 }
1217 }
1218
1219 Ok(())
1220}
1221
1222fn apply_style_delta(
1223 w: &mut impl Write,
1224 old: &Style,
1225 new: &Style,
1226 depth: ColorDepth,
1227) -> io::Result<()> {
1228 if old.fg != new.fg {
1229 match new.fg {
1230 Some(fg) => queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?,
1231 None => queue!(w, SetForegroundColor(CtColor::Reset))?,
1232 }
1233 }
1234 if old.bg != new.bg {
1235 match new.bg {
1236 Some(bg) => queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?,
1237 None => queue!(w, SetBackgroundColor(CtColor::Reset))?,
1238 }
1239 }
1240 let removed = Modifiers(old.modifiers.0 & !new.modifiers.0);
1241 let added = Modifiers(new.modifiers.0 & !old.modifiers.0);
1242 if removed.contains(Modifiers::BOLD) || removed.contains(Modifiers::DIM) {
1243 queue!(w, SetAttribute(Attribute::NormalIntensity))?;
1244 if new.modifiers.contains(Modifiers::BOLD) {
1245 queue!(w, SetAttribute(Attribute::Bold))?;
1246 }
1247 if new.modifiers.contains(Modifiers::DIM) {
1248 queue!(w, SetAttribute(Attribute::Dim))?;
1249 }
1250 } else {
1251 if added.contains(Modifiers::BOLD) {
1252 queue!(w, SetAttribute(Attribute::Bold))?;
1253 }
1254 if added.contains(Modifiers::DIM) {
1255 queue!(w, SetAttribute(Attribute::Dim))?;
1256 }
1257 }
1258 if removed.contains(Modifiers::ITALIC) {
1259 queue!(w, SetAttribute(Attribute::NoItalic))?;
1260 }
1261 if added.contains(Modifiers::ITALIC) {
1262 queue!(w, SetAttribute(Attribute::Italic))?;
1263 }
1264 if removed.contains(Modifiers::UNDERLINE) {
1265 queue!(w, SetAttribute(Attribute::NoUnderline))?;
1266 }
1267 if added.contains(Modifiers::UNDERLINE) {
1268 queue!(w, SetAttribute(Attribute::Underlined))?;
1269 }
1270 if removed.contains(Modifiers::REVERSED) {
1271 queue!(w, SetAttribute(Attribute::NoReverse))?;
1272 }
1273 if added.contains(Modifiers::REVERSED) {
1274 queue!(w, SetAttribute(Attribute::Reverse))?;
1275 }
1276 if removed.contains(Modifiers::STRIKETHROUGH) {
1277 queue!(w, SetAttribute(Attribute::NotCrossedOut))?;
1278 }
1279 if added.contains(Modifiers::STRIKETHROUGH) {
1280 queue!(w, SetAttribute(Attribute::CrossedOut))?;
1281 }
1282 Ok(())
1283}
1284
1285fn apply_style(w: &mut impl Write, style: &Style, depth: ColorDepth) -> io::Result<()> {
1286 if let Some(fg) = style.fg {
1287 queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?;
1288 }
1289 if let Some(bg) = style.bg {
1290 queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?;
1291 }
1292 let m = style.modifiers;
1293 if m.contains(Modifiers::BOLD) {
1294 queue!(w, SetAttribute(Attribute::Bold))?;
1295 }
1296 if m.contains(Modifiers::DIM) {
1297 queue!(w, SetAttribute(Attribute::Dim))?;
1298 }
1299 if m.contains(Modifiers::ITALIC) {
1300 queue!(w, SetAttribute(Attribute::Italic))?;
1301 }
1302 if m.contains(Modifiers::UNDERLINE) {
1303 queue!(w, SetAttribute(Attribute::Underlined))?;
1304 }
1305 if m.contains(Modifiers::REVERSED) {
1306 queue!(w, SetAttribute(Attribute::Reverse))?;
1307 }
1308 if m.contains(Modifiers::STRIKETHROUGH) {
1309 queue!(w, SetAttribute(Attribute::CrossedOut))?;
1310 }
1311 Ok(())
1312}
1313
1314fn to_crossterm_color(color: Color, depth: ColorDepth) -> CtColor {
1315 let color = color.downsampled(depth);
1316 match color {
1317 Color::Reset => CtColor::Reset,
1318 Color::Black => CtColor::Black,
1319 Color::Red => CtColor::DarkRed,
1320 Color::Green => CtColor::DarkGreen,
1321 Color::Yellow => CtColor::DarkYellow,
1322 Color::Blue => CtColor::DarkBlue,
1323 Color::Magenta => CtColor::DarkMagenta,
1324 Color::Cyan => CtColor::DarkCyan,
1325 Color::White => CtColor::White,
1326 Color::DarkGray => CtColor::DarkGrey,
1327 Color::LightRed => CtColor::Red,
1328 Color::LightGreen => CtColor::Green,
1329 Color::LightYellow => CtColor::Yellow,
1330 Color::LightBlue => CtColor::Blue,
1331 Color::LightMagenta => CtColor::Magenta,
1332 Color::LightCyan => CtColor::Cyan,
1333 Color::LightWhite => CtColor::White,
1334 Color::Rgb(r, g, b) => CtColor::Rgb { r, g, b },
1335 Color::Indexed(i) => CtColor::AnsiValue(i),
1336 }
1337}
1338
1339fn reset_current_buffer(buffer: &mut Buffer, theme_bg: Option<Color>) {
1340 if let Some(bg) = theme_bg {
1341 buffer.reset_with_bg(bg);
1342 } else {
1343 buffer.reset();
1344 }
1345}
1346
1347fn write_session_enter(stdout: &mut impl Write, session: &TerminalSessionGuard) -> io::Result<()> {
1348 match session.mode {
1349 TerminalSessionMode::Fullscreen => {
1350 execute!(
1351 stdout,
1352 terminal::EnterAlternateScreen,
1353 cursor::Hide,
1354 EnableBracketedPaste
1355 )?;
1356 }
1357 TerminalSessionMode::Inline => {
1358 execute!(stdout, cursor::Hide, EnableBracketedPaste)?;
1359 }
1360 }
1361
1362 execute!(stdout, EnableFocusChange)?;
1368 if session.mouse_enabled {
1369 execute!(stdout, EnableMouseCapture)?;
1370 }
1371 if session.kitty_keyboard {
1372 use crossterm::event::{KeyboardEnhancementFlags, PushKeyboardEnhancementFlags};
1373 let _ = execute!(
1374 stdout,
1375 PushKeyboardEnhancementFlags(
1376 KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
1377 | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
1378 )
1379 );
1380 }
1381
1382 Ok(())
1383}
1384
1385fn write_session_cleanup(
1386 stdout: &mut impl Write,
1387 mode: TerminalSessionMode,
1388 inline_reserved: bool,
1389) -> io::Result<()> {
1390 execute!(
1391 stdout,
1392 ResetColor,
1393 SetAttribute(Attribute::Reset),
1394 cursor::Show,
1395 DisableBracketedPaste
1396 )?;
1397
1398 match mode {
1399 TerminalSessionMode::Fullscreen => {
1400 execute!(stdout, terminal::LeaveAlternateScreen)?;
1401 }
1402 TerminalSessionMode::Inline => {
1403 if inline_reserved {
1404 execute!(
1405 stdout,
1406 cursor::MoveToColumn(0),
1407 cursor::MoveDown(1),
1408 cursor::MoveToColumn(0),
1409 Print("\n")
1410 )?;
1411 } else {
1412 execute!(stdout, Print("\n"))?;
1413 }
1414 }
1415 }
1416
1417 Ok(())
1418}
1419
1420#[cfg(test)]
1421mod tests {
1422 #![allow(clippy::unwrap_used)]
1423 use super::*;
1424
1425 #[test]
1426 fn reset_current_buffer_applies_theme_background() {
1427 let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 1));
1428
1429 reset_current_buffer(&mut buffer, Some(Color::Rgb(10, 20, 30)));
1430 assert_eq!(buffer.get(0, 0).style.bg, Some(Color::Rgb(10, 20, 30)));
1431
1432 reset_current_buffer(&mut buffer, None);
1433 assert_eq!(buffer.get(0, 0).style.bg, None);
1434 }
1435
1436 #[test]
1437 fn fullscreen_session_enter_writes_alt_screen_sequence() {
1438 let session = TerminalSessionGuard {
1439 mode: TerminalSessionMode::Fullscreen,
1440 mouse_enabled: false,
1441 kitty_keyboard: false,
1442 };
1443 let mut out = Vec::new();
1444 write_session_enter(&mut out, &session).unwrap();
1445 let output = String::from_utf8(out).unwrap();
1446 assert!(output.contains("\u{1b}[?1049h"));
1447 assert!(output.contains("\u{1b}[?25l"));
1448 assert!(output.contains("\u{1b}[?2004h"));
1449 }
1450
1451 #[test]
1452 fn inline_session_enter_skips_alt_screen_sequence() {
1453 let session = TerminalSessionGuard {
1454 mode: TerminalSessionMode::Inline,
1455 mouse_enabled: false,
1456 kitty_keyboard: false,
1457 };
1458 let mut out = Vec::new();
1459 write_session_enter(&mut out, &session).unwrap();
1460 let output = String::from_utf8(out).unwrap();
1461 assert!(!output.contains("\u{1b}[?1049h"));
1462 assert!(output.contains("\u{1b}[?25l"));
1463 assert!(output.contains("\u{1b}[?2004h"));
1464 }
1465
1466 #[test]
1467 fn fullscreen_session_cleanup_leaves_alt_screen() {
1468 let mut out = Vec::new();
1469 write_session_cleanup(&mut out, TerminalSessionMode::Fullscreen, false).unwrap();
1470 let output = String::from_utf8(out).unwrap();
1471 assert!(output.contains("\u{1b}[?1049l"));
1472 assert!(output.contains("\u{1b}[?25h"));
1473 assert!(output.contains("\u{1b}[?2004l"));
1474 }
1475
1476 #[test]
1477 fn inline_session_cleanup_keeps_normal_screen() {
1478 let mut out = Vec::new();
1479 write_session_cleanup(&mut out, TerminalSessionMode::Inline, false).unwrap();
1480 let output = String::from_utf8(out).unwrap();
1481 assert!(!output.contains("\u{1b}[?1049l"));
1482 assert!(output.ends_with('\n'));
1483 assert!(output.contains("\u{1b}[?25h"));
1484 assert!(output.contains("\u{1b}[?2004l"));
1485 }
1486
1487 #[test]
1488 fn base64_encode_empty() {
1489 assert_eq!(base64_encode(b""), "");
1490 }
1491
1492 #[test]
1493 fn base64_encode_hello() {
1494 assert_eq!(base64_encode(b"Hello"), "SGVsbG8=");
1495 }
1496
1497 #[test]
1498 fn base64_encode_padding() {
1499 assert_eq!(base64_encode(b"a"), "YQ==");
1500 assert_eq!(base64_encode(b"ab"), "YWI=");
1501 assert_eq!(base64_encode(b"abc"), "YWJj");
1502 }
1503
1504 #[test]
1505 fn base64_encode_unicode() {
1506 assert_eq!(base64_encode("한글".as_bytes()), "7ZWc6riA");
1507 }
1508
1509 #[cfg(feature = "crossterm")]
1510 #[test]
1511 fn parse_osc11_response_dark_and_light() {
1512 assert_eq!(
1513 parse_osc11_response("\x1b]11;rgb:0000/0000/0000\x1b\\"),
1514 ColorScheme::Dark
1515 );
1516 assert_eq!(
1517 parse_osc11_response("\x1b]11;rgb:ffff/ffff/ffff\x07"),
1518 ColorScheme::Light
1519 );
1520 }
1521
1522 #[cfg(feature = "crossterm")]
1523 #[test]
1524 fn base64_decode_round_trip_hello() {
1525 let encoded = base64_encode("hello".as_bytes());
1526 assert_eq!(base64_decode(&encoded), Some("hello".to_string()));
1527 }
1528
1529 #[cfg(feature = "crossterm")]
1530 #[test]
1531 fn color_scheme_equality() {
1532 assert_eq!(ColorScheme::Dark, ColorScheme::Dark);
1533 assert_ne!(ColorScheme::Dark, ColorScheme::Light);
1534 assert_eq!(ColorScheme::Unknown, ColorScheme::Unknown);
1535 }
1536
1537 fn pair(r: Rect) -> (Rect, Rect) {
1538 (r, r)
1539 }
1540
1541 #[test]
1542 fn find_innermost_rect_picks_smallest() {
1543 let rects = vec![
1544 pair(Rect::new(0, 0, 80, 24)),
1545 pair(Rect::new(5, 2, 30, 10)),
1546 pair(Rect::new(10, 4, 10, 5)),
1547 ];
1548 let result = find_innermost_rect(&rects, 12, 5);
1549 assert_eq!(result, Some(Rect::new(10, 4, 10, 5)));
1550 }
1551
1552 #[test]
1553 fn find_innermost_rect_no_match() {
1554 let rects = vec![pair(Rect::new(10, 10, 5, 5))];
1555 assert_eq!(find_innermost_rect(&rects, 0, 0), None);
1556 }
1557
1558 #[test]
1559 fn find_innermost_rect_empty() {
1560 assert_eq!(find_innermost_rect(&[], 5, 5), None);
1561 }
1562
1563 #[test]
1564 fn find_innermost_rect_returns_content_rect() {
1565 let rects = vec![
1566 (Rect::new(0, 0, 80, 24), Rect::new(1, 1, 78, 22)),
1567 (Rect::new(5, 2, 30, 10), Rect::new(6, 3, 28, 8)),
1568 ];
1569 let result = find_innermost_rect(&rects, 10, 5);
1570 assert_eq!(result, Some(Rect::new(6, 3, 28, 8)));
1571 }
1572
1573 #[test]
1574 fn normalize_selection_already_ordered() {
1575 let (s, e) = normalize_selection((2, 1), (5, 3));
1576 assert_eq!(s, (2, 1));
1577 assert_eq!(e, (5, 3));
1578 }
1579
1580 #[test]
1581 fn normalize_selection_reversed() {
1582 let (s, e) = normalize_selection((5, 3), (2, 1));
1583 assert_eq!(s, (2, 1));
1584 assert_eq!(e, (5, 3));
1585 }
1586
1587 #[test]
1588 fn normalize_selection_same_row() {
1589 let (s, e) = normalize_selection((10, 5), (3, 5));
1590 assert_eq!(s, (3, 5));
1591 assert_eq!(e, (10, 5));
1592 }
1593
1594 #[test]
1595 fn selection_state_mouse_down_finds_rect() {
1596 let hit_map = vec![pair(Rect::new(0, 0, 80, 24)), pair(Rect::new(5, 2, 20, 10))];
1597 let mut sel = SelectionState::default();
1598 sel.mouse_down(10, 5, &hit_map);
1599 assert_eq!(sel.anchor, Some((10, 5)));
1600 assert_eq!(sel.current, Some((10, 5)));
1601 assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 20, 10)));
1602 assert!(!sel.active);
1603 }
1604
1605 #[test]
1606 fn selection_state_drag_activates() {
1607 let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
1608 let mut sel = SelectionState {
1609 anchor: Some((10, 5)),
1610 current: Some((10, 5)),
1611 widget_rect: Some(Rect::new(0, 0, 80, 24)),
1612 ..Default::default()
1613 };
1614 sel.mouse_drag(10, 5, &hit_map);
1615 assert!(!sel.active, "no movement = not active");
1616 sel.mouse_drag(11, 5, &hit_map);
1617 assert!(!sel.active, "1 cell horizontal = not active yet");
1618 sel.mouse_drag(13, 5, &hit_map);
1619 assert!(sel.active, ">1 cell horizontal = active");
1620 }
1621
1622 #[test]
1623 fn selection_state_drag_vertical_activates() {
1624 let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
1625 let mut sel = SelectionState {
1626 anchor: Some((10, 5)),
1627 current: Some((10, 5)),
1628 widget_rect: Some(Rect::new(0, 0, 80, 24)),
1629 ..Default::default()
1630 };
1631 sel.mouse_drag(10, 6, &hit_map);
1632 assert!(sel.active, "any vertical movement = active");
1633 }
1634
1635 #[test]
1636 fn selection_state_drag_expands_widget_rect() {
1637 let hit_map = vec![
1638 pair(Rect::new(0, 0, 80, 24)),
1639 pair(Rect::new(5, 2, 30, 10)),
1640 pair(Rect::new(5, 2, 30, 3)),
1641 ];
1642 let mut sel = SelectionState {
1643 anchor: Some((10, 3)),
1644 current: Some((10, 3)),
1645 widget_rect: Some(Rect::new(5, 2, 30, 3)),
1646 ..Default::default()
1647 };
1648 sel.mouse_drag(10, 6, &hit_map);
1649 assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 30, 10)));
1650 }
1651
1652 #[test]
1653 fn selection_state_clear_resets() {
1654 let mut sel = SelectionState {
1655 anchor: Some((1, 2)),
1656 current: Some((3, 4)),
1657 widget_rect: Some(Rect::new(0, 0, 10, 10)),
1658 active: true,
1659 };
1660 sel.clear();
1661 assert_eq!(sel.anchor, None);
1662 assert_eq!(sel.current, None);
1663 assert_eq!(sel.widget_rect, None);
1664 assert!(!sel.active);
1665 }
1666
1667 #[test]
1668 fn extract_selection_text_single_line() {
1669 let area = Rect::new(0, 0, 20, 5);
1670 let mut buf = Buffer::empty(area);
1671 buf.set_string(0, 0, "Hello World", Style::default());
1672 let sel = SelectionState {
1673 anchor: Some((0, 0)),
1674 current: Some((4, 0)),
1675 widget_rect: Some(area),
1676 active: true,
1677 };
1678 let text = extract_selection_text(&buf, &sel, &[]);
1679 assert_eq!(text, "Hello");
1680 }
1681
1682 #[test]
1683 fn extract_selection_text_multi_line() {
1684 let area = Rect::new(0, 0, 20, 5);
1685 let mut buf = Buffer::empty(area);
1686 buf.set_string(0, 0, "Line one", Style::default());
1687 buf.set_string(0, 1, "Line two", Style::default());
1688 buf.set_string(0, 2, "Line three", Style::default());
1689 let sel = SelectionState {
1690 anchor: Some((5, 0)),
1691 current: Some((3, 2)),
1692 widget_rect: Some(area),
1693 active: true,
1694 };
1695 let text = extract_selection_text(&buf, &sel, &[]);
1696 assert_eq!(text, "one\nLine two\nLine");
1697 }
1698
1699 #[test]
1700 fn extract_selection_text_clamped_to_widget() {
1701 let area = Rect::new(0, 0, 40, 10);
1702 let widget = Rect::new(5, 2, 10, 3);
1703 let mut buf = Buffer::empty(area);
1704 buf.set_string(5, 2, "ABCDEFGHIJ", Style::default());
1705 buf.set_string(5, 3, "KLMNOPQRST", Style::default());
1706 let sel = SelectionState {
1707 anchor: Some((3, 1)),
1708 current: Some((20, 5)),
1709 widget_rect: Some(widget),
1710 active: true,
1711 };
1712 let text = extract_selection_text(&buf, &sel, &[]);
1713 assert_eq!(text, "ABCDEFGHIJ\nKLMNOPQRST");
1714 }
1715
1716 #[test]
1717 fn extract_selection_text_inactive_returns_empty() {
1718 let area = Rect::new(0, 0, 10, 5);
1719 let buf = Buffer::empty(area);
1720 let sel = SelectionState {
1721 anchor: Some((0, 0)),
1722 current: Some((5, 2)),
1723 widget_rect: Some(area),
1724 active: false,
1725 };
1726 assert_eq!(extract_selection_text(&buf, &sel, &[]), "");
1727 }
1728
1729 #[test]
1730 fn apply_selection_overlay_reverses_cells() {
1731 let area = Rect::new(0, 0, 10, 3);
1732 let mut buf = Buffer::empty(area);
1733 buf.set_string(0, 0, "ABCDE", Style::default());
1734 let sel = SelectionState {
1735 anchor: Some((1, 0)),
1736 current: Some((3, 0)),
1737 widget_rect: Some(area),
1738 active: true,
1739 };
1740 apply_selection_overlay(&mut buf, &sel, &[]);
1741 assert!(!buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED));
1742 assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
1743 assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
1744 assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
1745 assert!(!buf.get(4, 0).style.modifiers.contains(Modifiers::REVERSED));
1746 }
1747
1748 #[test]
1749 fn extract_selection_text_skips_border_cells() {
1750 let area = Rect::new(0, 0, 40, 5);
1755 let mut buf = Buffer::empty(area);
1756 buf.set_string(0, 0, "╭", Style::default());
1758 buf.set_string(0, 1, "│", Style::default());
1759 buf.set_string(0, 2, "│", Style::default());
1760 buf.set_string(0, 3, "│", Style::default());
1761 buf.set_string(0, 4, "╰", Style::default());
1762 buf.set_string(19, 0, "╮", Style::default());
1763 buf.set_string(19, 1, "│", Style::default());
1764 buf.set_string(19, 2, "│", Style::default());
1765 buf.set_string(19, 3, "│", Style::default());
1766 buf.set_string(19, 4, "╯", Style::default());
1767 buf.set_string(20, 0, "╭", Style::default());
1769 buf.set_string(20, 1, "│", Style::default());
1770 buf.set_string(20, 2, "│", Style::default());
1771 buf.set_string(20, 3, "│", Style::default());
1772 buf.set_string(20, 4, "╰", Style::default());
1773 buf.set_string(39, 0, "╮", Style::default());
1774 buf.set_string(39, 1, "│", Style::default());
1775 buf.set_string(39, 2, "│", Style::default());
1776 buf.set_string(39, 3, "│", Style::default());
1777 buf.set_string(39, 4, "╯", Style::default());
1778 buf.set_string(1, 1, "Hello Col1", Style::default());
1780 buf.set_string(1, 2, "Line2 Col1", Style::default());
1781 buf.set_string(21, 1, "Hello Col2", Style::default());
1783 buf.set_string(21, 2, "Line2 Col2", Style::default());
1784
1785 let content_map = vec![
1786 (Rect::new(0, 0, 20, 5), Rect::new(1, 1, 18, 3)),
1787 (Rect::new(20, 0, 20, 5), Rect::new(21, 1, 18, 3)),
1788 ];
1789
1790 let sel = SelectionState {
1792 anchor: Some((0, 1)),
1793 current: Some((39, 2)),
1794 widget_rect: Some(area),
1795 active: true,
1796 };
1797 let text = extract_selection_text(&buf, &sel, &content_map);
1798 assert!(!text.contains('│'), "Border char │ found in: {text}");
1800 assert!(!text.contains('╭'), "Border char ╭ found in: {text}");
1801 assert!(!text.contains('╮'), "Border char ╮ found in: {text}");
1802 assert!(
1804 text.contains("Hello Col1"),
1805 "Missing Col1 content in: {text}"
1806 );
1807 assert!(
1808 text.contains("Hello Col2"),
1809 "Missing Col2 content in: {text}"
1810 );
1811 assert!(text.contains("Line2 Col1"), "Missing Col1 line2 in: {text}");
1812 assert!(text.contains("Line2 Col2"), "Missing Col2 line2 in: {text}");
1813 }
1814
1815 #[test]
1816 fn apply_selection_overlay_skips_border_cells() {
1817 let area = Rect::new(0, 0, 20, 3);
1818 let mut buf = Buffer::empty(area);
1819 buf.set_string(0, 0, "│", Style::default());
1820 buf.set_string(1, 0, "ABC", Style::default());
1821 buf.set_string(19, 0, "│", Style::default());
1822
1823 let content_map = vec![(Rect::new(0, 0, 20, 3), Rect::new(1, 0, 18, 3))];
1824 let sel = SelectionState {
1825 anchor: Some((0, 0)),
1826 current: Some((19, 0)),
1827 widget_rect: Some(area),
1828 active: true,
1829 };
1830 apply_selection_overlay(&mut buf, &sel, &content_map);
1831 assert!(
1833 !buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED),
1834 "Left border cell should not be reversed"
1835 );
1836 assert!(
1837 !buf.get(19, 0).style.modifiers.contains(Modifiers::REVERSED),
1838 "Right border cell should not be reversed"
1839 );
1840 assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
1842 assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
1843 assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
1844 }
1845
1846 #[test]
1847 fn copy_to_clipboard_writes_osc52() {
1848 let mut output: Vec<u8> = Vec::new();
1849 copy_to_clipboard(&mut output, "test").unwrap();
1850 let s = String::from_utf8(output).unwrap();
1851 assert!(s.starts_with("\x1b]52;c;"));
1852 assert!(s.ends_with("\x1b\\"));
1853 assert!(s.contains(&base64_encode(b"test")));
1854 }
1855
1856 fn count_move_tos(s: &str) -> usize {
1858 let bytes = s.as_bytes();
1859 let mut count = 0;
1860 let mut i = 0;
1861 while i + 1 < bytes.len() {
1862 if bytes[i] == 0x1b && bytes[i + 1] == b'[' {
1863 let mut j = i + 2;
1865 while j < bytes.len() && !(0x40..=0x7e).contains(&bytes[j]) {
1866 j += 1;
1867 }
1868 if j < bytes.len() && bytes[j] == b'H' {
1869 count += 1;
1870 }
1871 i = j + 1;
1872 } else {
1873 i += 1;
1874 }
1875 }
1876 count
1877 }
1878
1879 #[test]
1880 fn flush_coalesces_consecutive_same_style_cells_into_one_run() {
1881 let area = Rect::new(0, 0, 20, 1);
1883 let mut current = Buffer::empty(area);
1884 let previous = Buffer::empty(area);
1885 let style = Style::new().fg(Color::Red);
1886 for x in 0..10u32 {
1887 let cell = current.get_mut(x, 0);
1888 cell.set_char('X');
1889 cell.set_style(style);
1890 }
1891
1892 let mut out: Vec<u8> = Vec::new();
1893 flush_buffer_diff(&mut out, ¤t, &previous, ColorDepth::TrueColor, 0).unwrap();
1894 let s = String::from_utf8(out).unwrap();
1895
1896 assert_eq!(
1898 count_move_tos(&s),
1899 1,
1900 "expected 1 MoveTo for a coalesced run, got {} in {:?}",
1901 count_move_tos(&s),
1902 s
1903 );
1904 assert!(
1906 s.contains("XXXXXXXXXX"),
1907 "expected contiguous run 'XXXXXXXXXX' in {:?}",
1908 s
1909 );
1910 }
1911
1912 #[test]
1913 fn flush_breaks_run_on_style_change() {
1914 let area = Rect::new(0, 0, 20, 1);
1916 let mut current = Buffer::empty(area);
1917 let previous = Buffer::empty(area);
1918 let red = Style::new().fg(Color::Red);
1919 let blue = Style::new().fg(Color::Blue);
1920 for x in 0..5u32 {
1921 let cell = current.get_mut(x, 0);
1922 cell.set_char('R');
1923 cell.set_style(red);
1924 }
1925 for x in 5..10u32 {
1926 let cell = current.get_mut(x, 0);
1927 cell.set_char('B');
1928 cell.set_style(blue);
1929 }
1930
1931 let mut out: Vec<u8> = Vec::new();
1932 flush_buffer_diff(&mut out, ¤t, &previous, ColorDepth::TrueColor, 0).unwrap();
1933 let s = String::from_utf8(out).unwrap();
1934
1935 let moves = count_move_tos(&s);
1939 assert!(
1940 moves <= 2,
1941 "expected at most 2 MoveTos across a style boundary, got {} in {:?}",
1942 moves,
1943 s
1944 );
1945 assert!(s.contains("RRRRR"), "missing 'RRRRR' run in {:?}", s);
1946 assert!(s.contains("BBBBB"), "missing 'BBBBB' run in {:?}", s);
1947 }
1948
1949 #[test]
1950 fn flush_breaks_run_on_column_gap() {
1951 let area = Rect::new(0, 0, 20, 1);
1953 let mut current = Buffer::empty(area);
1954 let previous = Buffer::empty(area);
1955 let style = Style::new().fg(Color::Green);
1956 for x in 0..3u32 {
1957 current.get_mut(x, 0).set_char('A').set_style(style);
1958 }
1959 for x in 6..9u32 {
1960 current.get_mut(x, 0).set_char('B').set_style(style);
1961 }
1962
1963 let mut out: Vec<u8> = Vec::new();
1964 flush_buffer_diff(&mut out, ¤t, &previous, ColorDepth::TrueColor, 0).unwrap();
1965 let s = String::from_utf8(out).unwrap();
1966
1967 assert_eq!(
1969 count_move_tos(&s),
1970 2,
1971 "expected 2 MoveTos across a column gap, got {} in {:?}",
1972 count_move_tos(&s),
1973 s
1974 );
1975 assert!(s.contains("AAA"), "missing 'AAA' run in {:?}", s);
1976 assert!(s.contains("BBB"), "missing 'BBB' run in {:?}", s);
1977 }
1978
1979 #[test]
1983 fn bufwriter_output_identical_to_direct_write() {
1984 let area = Rect::new(0, 0, 5, 1);
1985 let mut current = Buffer::empty(area);
1986 let previous = Buffer::empty(area);
1987 let style = Style::new().fg(Color::Rgb(255, 128, 0));
1988 for x in 0..5u32 {
1989 current.get_mut(x, 0).set_char('X').set_style(style);
1990 }
1991
1992 let mut direct: Vec<u8> = Vec::new();
1993 flush_buffer_diff(&mut direct, ¤t, &previous, ColorDepth::TrueColor, 0).unwrap();
1994
1995 let mut buffered: BufWriter<Vec<u8>> = BufWriter::with_capacity(65536, Vec::new());
1996 flush_buffer_diff(&mut buffered, ¤t, &previous, ColorDepth::TrueColor, 0).unwrap();
1997 buffered.flush().unwrap();
1998 let via_buf = buffered.into_inner().unwrap();
1999
2000 assert_eq!(
2001 direct, via_buf,
2002 "BufWriter output must be byte-for-byte identical to direct write"
2003 );
2004 }
2005
2006 #[test]
2010 fn bufwriter_coalesces_writes_into_single_flush() {
2011 #[derive(Debug)]
2012 struct CountingWriter {
2013 buf: Vec<u8>,
2014 write_call_count: usize,
2015 }
2016 impl Write for CountingWriter {
2017 fn write(&mut self, data: &[u8]) -> io::Result<usize> {
2018 self.write_call_count += 1;
2019 self.buf.extend_from_slice(data);
2020 Ok(data.len())
2021 }
2022 fn flush(&mut self) -> io::Result<()> {
2023 Ok(())
2024 }
2025 }
2026
2027 let area = Rect::new(0, 0, 10, 1);
2028 let mut current = Buffer::empty(area);
2029 let previous = Buffer::empty(area);
2030 for x in 0..10u32 {
2032 let color = if x % 2 == 0 {
2033 Color::Rgb(255, 0, 0)
2034 } else {
2035 Color::Rgb(0, 255, 0)
2036 };
2037 current
2038 .get_mut(x, 0)
2039 .set_char('Z')
2040 .set_style(Style::new().fg(color));
2041 }
2042
2043 let sink = CountingWriter {
2044 buf: Vec::new(),
2045 write_call_count: 0,
2046 };
2047 let mut bw = BufWriter::with_capacity(65536, sink);
2048 flush_buffer_diff(&mut bw, ¤t, &previous, ColorDepth::TrueColor, 0).unwrap();
2049 bw.flush().unwrap();
2050 let inner = bw.into_inner().unwrap();
2051
2052 assert_eq!(
2054 inner.write_call_count, 1,
2055 "expected 1 write syscall to sink, got {}",
2056 inner.write_call_count
2057 );
2058 }
2059
2060 #[test]
2066 fn flush_skips_unchanged_rows_when_hashes_match() {
2067 let area = Rect::new(0, 0, 20, 4);
2068 let mut current = Buffer::empty(area);
2069 let mut previous = Buffer::empty(area);
2070 for y in 0..4u32 {
2072 current.set_string(0, y, "identical-row-content", Style::new());
2073 previous.set_string(0, y, "identical-row-content", Style::new());
2074 }
2075 current.recompute_line_hashes();
2076 previous.recompute_line_hashes();
2077
2078 let mut out: Vec<u8> = Vec::new();
2079 flush_buffer_diff(&mut out, ¤t, &previous, ColorDepth::TrueColor, 0).unwrap();
2080 assert!(
2081 out.is_empty(),
2082 "identical buffers must emit zero flush bytes; got {} bytes: {:?}",
2083 out.len(),
2084 out
2085 );
2086 }
2087
2088 #[test]
2092 fn flush_skips_only_matching_rows_in_mixed_diff() {
2093 let area = Rect::new(0, 0, 6, 3);
2094 let mut current = Buffer::empty(area);
2095 let mut previous = Buffer::empty(area);
2096 current.set_string(0, 0, "abcdef", Style::new());
2097 previous.set_string(0, 0, "abcdef", Style::new());
2098 current.set_string(0, 1, "xxxxxx", Style::new());
2099 previous.set_string(0, 1, "yyyyyy", Style::new());
2100 current.set_string(0, 2, "zzzzzz", Style::new());
2101 previous.set_string(0, 2, "zzzzzz", Style::new());
2102 current.recompute_line_hashes();
2103 previous.recompute_line_hashes();
2104
2105 let mut out: Vec<u8> = Vec::new();
2106 flush_buffer_diff(&mut out, ¤t, &previous, ColorDepth::TrueColor, 0).unwrap();
2107 let s = String::from_utf8_lossy(&out);
2108 assert!(s.contains("xxxxxx"), "differing row must flush: {s:?}");
2111 assert!(
2112 !s.contains("abcdef"),
2113 "matching row 0 must not flush: {s:?}"
2114 );
2115 assert!(
2116 !s.contains("zzzzzz"),
2117 "matching row 2 must not flush: {s:?}"
2118 );
2119 }
2120}