1use std::borrow::Cow;
2use std::collections::HashMap;
3use std::io::{self, BufWriter, IsTerminal, Stdout, Write};
4use std::time::{Duration, Instant};
5
6use crossterm::event::{
7 DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
8 EnableFocusChange, EnableMouseCapture,
9};
10use crossterm::style::{Attribute, Print, ResetColor, SetAttribute};
11use crossterm::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate};
12use crossterm::{cursor, execute, queue, terminal};
13
14use unicode_width::UnicodeWidthStr;
15
16use crate::buffer::{Buffer, KittyPlacement};
17use crate::rect::Rect;
18use crate::style::{Color, ColorDepth, Modifiers, Style, UnderlineStyle};
19
20#[inline]
22fn sat_u16(v: u32) -> u16 {
23 v.min(u16::MAX as u32) as u16
24}
25
26fn buffer_error(error: crate::buffer::BufferError) -> io::Error {
27 io::Error::new(io::ErrorKind::InvalidInput, error)
28}
29
30fn try_buffer_pair(area: Rect) -> io::Result<(Buffer, Buffer)> {
31 Buffer::validate_area(area).map_err(buffer_error)?;
32 let current = Buffer::try_empty(area).map_err(buffer_error)?;
33 let previous = Buffer::try_empty(area).map_err(buffer_error)?;
34 Ok((current, previous))
35}
36
37#[cfg(any(test, feature = "pty-test"))]
38fn buffer_pair(area: Rect) -> (Buffer, Buffer) {
39 try_buffer_pair(area)
40 .unwrap_or_else(|error| panic!("terminal buffer pair for {area:?} failed: {error}"))
41}
42
43pub(crate) enum Sink {
54 Stdout(BufWriter<Stdout>),
56 #[cfg(any(test, feature = "pty-test"))]
58 Capture(Vec<u8>),
59}
60
61impl Write for Sink {
62 #[inline]
63 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
64 match self {
65 Sink::Stdout(w) => w.write(buf),
66 #[cfg(any(test, feature = "pty-test"))]
67 Sink::Capture(v) => v.write(buf),
68 }
69 }
70
71 #[inline]
72 fn flush(&mut self) -> io::Result<()> {
73 match self {
74 Sink::Stdout(w) => w.flush(),
75 #[cfg(any(test, feature = "pty-test"))]
76 Sink::Capture(v) => v.flush(),
77 }
78 }
79}
80
81pub(crate) struct KittyImageManager {
91 next_id: u32,
92 uploaded: HashMap<u64, u32>,
94 prev_placements: Vec<KittyPlacement>,
96 scratch_ids: smallvec::SmallVec<[u32; 8]>,
101 scratch_hashes: smallvec::SmallVec<[u64; 8]>,
104}
105
106impl KittyImageManager {
107 pub(crate) fn new() -> Self {
109 Self {
110 next_id: 1,
111 uploaded: HashMap::new(),
112 prev_placements: Vec::new(),
113 scratch_ids: smallvec::SmallVec::new(),
114 scratch_hashes: smallvec::SmallVec::new(),
115 }
116 }
117
118 pub(crate) fn flush(
125 &mut self,
126 stdout: &mut impl Write,
127 current: &[KittyPlacement],
128 row_offset: u32,
129 ) -> io::Result<()> {
130 if current.len() == self.prev_placements.len()
134 && current
135 .iter()
136 .zip(self.prev_placements.iter())
137 .all(|(c, p)| placement_eq_with_offset(c, row_offset, p))
138 {
139 return Ok(());
140 }
141
142 if !self.prev_placements.is_empty() {
148 self.scratch_ids.clear();
149 for p in &self.prev_placements {
150 if let Some(&img_id) = self.uploaded.get(&p.content_hash)
151 && !self.scratch_ids.contains(&img_id)
152 {
153 self.scratch_ids.push(img_id);
154 queue!(stdout, Print(format!("\x1b_Ga=d,d=i,i={img_id},q=2\x1b\\")))?;
156 }
157 }
158 }
159
160 for (idx, p) in current.iter().enumerate() {
162 let img_id = if let Some(&existing_id) = self.uploaded.get(&p.content_hash) {
163 existing_id
164 } else {
165 let id = self.next_id;
167 self.next_id += 1;
168 self.upload_image(stdout, id, p)?;
169 self.uploaded.insert(p.content_hash, id);
170 id
171 };
172
173 let pid = idx as u32 + 1;
175 self.place_image_offset(stdout, img_id, pid, p, row_offset)?;
176 }
177
178 self.scratch_hashes.clear();
184 self.scratch_hashes
185 .extend(current.iter().map(|p| p.content_hash));
186 self.scratch_hashes.sort_unstable();
187 let scratch_hashes = &self.scratch_hashes;
188 let stale: smallvec::SmallVec<[u64; 8]> = self
189 .uploaded
190 .keys()
191 .filter(|h| scratch_hashes.binary_search(h).is_err())
192 .copied()
193 .collect();
194 for hash in stale {
195 if let Some(id) = self.uploaded.remove(&hash) {
196 queue!(stdout, Print(format!("\x1b_Ga=d,d=I,i={id},q=2\x1b\\")))?;
198 }
199 }
200
201 self.prev_placements.clear();
208 self.prev_placements.reserve(current.len());
209 for p in current {
210 let mut copy = p.clone();
211 copy.y = copy.y.saturating_add(row_offset);
212 self.prev_placements.push(copy);
213 }
214 Ok(())
215 }
216
217 fn upload_image(&self, stdout: &mut impl Write, id: u32, p: &KittyPlacement) -> io::Result<()> {
219 let (payload, compression) = compress_rgba(&p.rgba);
220 let encoded = base64_encode(&payload);
221 let chunks = split_base64(&encoded, 4096);
222
223 for (i, chunk) in chunks.iter().enumerate() {
224 let more = if i < chunks.len() - 1 { 1 } else { 0 };
225 if i == 0 {
226 queue!(
227 stdout,
228 Print(format!(
229 "\x1b_Ga=t,i={id},f=32,{compression}s={},v={},q=2,m={more};{chunk}\x1b\\",
230 p.src_width, p.src_height
231 ))
232 )?;
233 } else {
234 queue!(stdout, Print(format!("\x1b_Gm={more};{chunk}\x1b\\")))?;
235 }
236 }
237 Ok(())
238 }
239
240 fn place_image_offset(
246 &self,
247 stdout: &mut impl Write,
248 img_id: u32,
249 placement_id: u32,
250 p: &KittyPlacement,
251 row_offset: u32,
252 ) -> io::Result<()> {
253 let display_y = p.y.saturating_add(row_offset);
254 queue!(stdout, cursor::MoveTo(sat_u16(p.x), sat_u16(display_y)))?;
255
256 let mut cmd = format!(
257 "\x1b_Ga=p,i={},p={},c={},r={},C=1,q=2",
258 img_id, placement_id, p.cols, p.rows
259 );
260
261 if p.crop_y > 0 || p.crop_h > 0 {
263 cmd.push_str(&format!(",y={}", p.crop_y));
264 if p.crop_h > 0 {
265 cmd.push_str(&format!(",h={}", p.crop_h));
266 }
267 }
268
269 cmd.push_str("\x1b\\");
270 queue!(stdout, Print(cmd))?;
271 Ok(())
272 }
273
274 pub(crate) fn delete_all(&self, stdout: &mut impl Write) -> io::Result<()> {
276 queue!(stdout, Print("\x1b_Ga=d,d=A,q=2\x1b\\"))
277 }
278}
279
280#[inline]
288fn placement_eq_with_offset(
289 current: &KittyPlacement,
290 row_offset: u32,
291 prev: &KittyPlacement,
292) -> bool {
293 current.content_hash == prev.content_hash
294 && current.x == prev.x
295 && current.y.saturating_add(row_offset) == prev.y
296 && current.cols == prev.cols
297 && current.rows == prev.rows
298 && current.crop_y == prev.crop_y
299 && current.crop_h == prev.crop_h
300}
301
302fn compress_rgba(data: &[u8]) -> (Cow<'_, [u8]>, &'static str) {
311 #[cfg(feature = "kitty-compress")]
312 {
313 use flate2::Compression;
314 use flate2::write::ZlibEncoder;
315 let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast());
316 if encoder.write_all(data).is_ok()
317 && let Ok(compressed) = encoder.finish()
318 {
319 if compressed.len() < data.len() {
321 return (Cow::Owned(compressed), "o=z,");
322 }
323 }
324 }
325 (Cow::Borrowed(data), "")
326}
327
328pub(crate) fn cell_pixel_size() -> (u32, u32) {
335 use std::sync::OnceLock;
336 static CACHED: OnceLock<(u32, u32)> = OnceLock::new();
337 if let Some(size) = CACHED.get() {
338 return *size;
339 }
340 let Some(size) = detect_cell_pixel_size() else {
341 return (8, 16);
342 };
343 let _ = CACHED.set(size);
344 size
345}
346
347fn detect_cell_pixel_size() -> Option<(u32, u32)> {
348 if !automatic_terminal_queries_allowed() {
349 return None;
350 }
351
352 let mut stdout = io::stdout();
354 write!(stdout, "\x1b[16t").ok()?;
355 stdout.flush().ok()?;
356
357 let response = read_osc_response(Duration::from_millis(100))?;
358
359 let bytes = response.as_bytes();
364 let start = bytes
365 .windows(4)
366 .position(|w| w == b"\x1b[6;")
367 .map(|pos| pos + 4)
368 .or_else(|| {
369 bytes
371 .windows(3)
372 .position(|w| w == [0x9b, b'6', b';'])
373 .map(|pos| pos + 3)
374 })?;
375 let tail = response.get(start..)?;
376 let body = &tail[..tail.find('t')?];
377 let mut parts = body.split(';');
378 let ch: u32 = parts.next()?.parse().ok()?;
379 let cw: u32 = parts.next()?.parse().ok()?;
380 if cw > 0 && ch > 0 {
381 Some((cw, ch))
382 } else {
383 None
384 }
385}
386
387#[derive(Debug, Clone, Copy, PartialEq, Eq)]
419pub struct BlitterSupport {
420 pub half: bool,
422 pub quad: bool,
424 pub sextant: bool,
428}
429
430impl Default for BlitterSupport {
431 fn default() -> Self {
432 Self {
433 half: true,
434 quad: true,
435 sextant: false,
436 }
437 }
438}
439
440#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
462pub struct Capabilities {
463 pub truecolor: bool,
465 pub sixel: bool,
467 pub iterm2: bool,
470 pub kitty_graphics: bool,
472 pub kitty_keyboard: bool,
474 pub sync_output: bool,
476 pub blitters: BlitterSupport,
478}
479
480#[derive(Debug, Clone, Copy, PartialEq, Eq)]
489pub enum Blitter {
490 Kitty,
492 Sixel,
494 Iterm2,
497 Sextant,
499 HalfBlock,
501}
502
503impl Capabilities {
504 pub fn best_blitter(&self) -> Blitter {
519 if self.kitty_graphics {
520 Blitter::Kitty
521 } else if self.sixel {
522 Blitter::Sixel
523 } else if self.iterm2 {
524 Blitter::Iterm2
525 } else if self.blitters.sextant {
526 Blitter::Sextant
527 } else {
528 Blitter::HalfBlock
529 }
530 }
531}
532
533#[cfg(feature = "crossterm")]
546#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
547pub fn capabilities() -> Capabilities {
548 use std::sync::OnceLock;
549 if !io::stdout().is_terminal() {
550 return Capabilities::default();
551 }
552 static CACHED: OnceLock<Capabilities> = OnceLock::new();
553 *CACHED.get_or_init(probe_capabilities)
554}
555
556#[cfg(feature = "crossterm")]
562fn probe_capabilities() -> Capabilities {
563 let mut caps = Capabilities::default();
564 if automatic_terminal_queries_allowed() {
565 let mut out = io::stdout();
570 if write!(out, "\x1b[c\x1b[>c").is_ok()
573 && out.flush().is_ok()
574 && let Some(resp) = read_da_response(Duration::from_millis(90))
575 {
576 parse_da1(&resp, &mut caps);
577 parse_da2(&resp, &mut caps);
578 }
579
580 if write!(out, "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\").is_ok()
583 && out.flush().is_ok()
584 && let Some(resp) = read_osc_response(Duration::from_millis(30))
585 {
586 parse_kitty_graphics_ack(&resp, &mut caps);
587 }
588
589 if write!(out, "\x1bP+q5463\x1b\\").is_ok()
591 && out.flush().is_ok()
592 && let Some(resp) = read_osc_response(Duration::from_millis(30))
593 {
594 parse_xtgettcap_truecolor(&resp, &mut caps);
595 }
596
597 if write!(out, "\x1b[?2026$p").is_ok()
599 && out.flush().is_ok()
600 && let Some(resp) = read_decrpm_response(Duration::from_millis(30))
601 {
602 match parse_decrpm_sync_output(&resp) {
603 Some(true) => {
604 caps.sync_output = true;
605 let _ = SYNC_OUTPUT_RESOLUTION.set(SyncOutputResolution::Supported);
606 }
607 Some(false) => {
608 let _ = SYNC_OUTPUT_RESOLUTION.set(SyncOutputResolution::Unsupported);
609 }
610 None => {}
611 }
612 }
613 }
614
615 if matches!(ColorDepth::detect(), ColorDepth::TrueColor) {
618 caps.truecolor = true;
619 }
620
621 if !caps.sixel && term_is_sixel_host() {
622 caps.sixel = true;
623 }
624
625 if !caps.kitty_graphics && term_is_kitty_graphics_host() {
630 caps.kitty_graphics = true;
631 }
632
633 if term_is_iterm_host() {
637 caps.iterm2 = true;
638 }
639
640 if force_env_enabled("SLT_DISABLE_KITTY") {
643 caps.kitty_graphics = false;
644 }
645 if force_env_enabled("SLT_DISABLE_SIXEL") {
646 caps.sixel = false;
647 }
648 if force_env_enabled("SLT_DISABLE_ITERM") {
649 caps.iterm2 = false;
650 }
651
652 caps
653}
654
655#[cfg(feature = "crossterm")]
660fn term_is_iterm_host() -> bool {
661 if force_env_enabled("SLT_DISABLE_ITERM") {
662 return false;
663 }
664 let term_program = std::env::var("TERM_PROGRAM")
665 .unwrap_or_default()
666 .to_ascii_lowercase();
667 term_is_iterm_host_env(
668 &term_program,
669 terminal_multiplexer(),
670 terminal_is_remote(),
671 force_env_enabled("SLT_FORCE_ITERM"),
672 )
673}
674
675#[cfg(feature = "crossterm")]
676fn term_is_iterm_host_env(
677 term_program: &str,
678 multiplexer: Option<MultiplexerKind>,
679 remote: bool,
680 forced: bool,
681) -> bool {
682 if !terminal_protocol_allowed(multiplexer, TerminalProtocol::Iterm2, forced, false) {
683 return false;
684 }
685 forced || (!remote && matches!(term_program, "iterm.app" | "wezterm" | "tabby" | "mintty"))
686}
687
688#[cfg(feature = "crossterm")]
689fn term_is_sixel_host() -> bool {
690 if force_env_enabled("SLT_DISABLE_SIXEL") {
691 return false;
692 }
693 let term = std::env::var("TERM")
694 .unwrap_or_default()
695 .to_ascii_lowercase();
696 let term_program = std::env::var("TERM_PROGRAM")
697 .unwrap_or_default()
698 .to_ascii_lowercase();
699 term_is_sixel_host_env(
700 &term,
701 &term_program,
702 terminal_multiplexer(),
703 terminal_is_remote(),
704 force_env_enabled("SLT_FORCE_SIXEL"),
705 )
706}
707
708#[cfg(feature = "crossterm")]
709fn term_is_sixel_host_env(
710 term: &str,
711 term_program: &str,
712 multiplexer: Option<MultiplexerKind>,
713 remote: bool,
714 forced: bool,
715) -> bool {
716 if !terminal_protocol_allowed(multiplexer, TerminalProtocol::Sixel, forced, false) {
717 return false;
718 }
719 if forced || matches!(multiplexer, Some(MultiplexerKind::Zellij)) {
720 return true;
721 }
722 const KNOWN_SIXEL_TERMS: &[&str] = &["mlterm", "foot", "yaft", "xterm-256color-sixel"];
723 const KNOWN_SIXEL_TERM_PROGRAMS: &[&str] = &["foot", "mlterm", "wezterm", "ghostty"];
724 KNOWN_SIXEL_TERMS.contains(&term)
725 || term.contains("sixel")
726 || (!remote && KNOWN_SIXEL_TERM_PROGRAMS.contains(&term_program))
727}
728
729#[cfg(feature = "crossterm")]
734fn term_is_kitty_graphics_host() -> bool {
735 if force_env_enabled("SLT_DISABLE_KITTY") {
736 return false;
737 }
738 let term = std::env::var("TERM")
739 .unwrap_or_default()
740 .to_ascii_lowercase();
741 let term_program = std::env::var("TERM_PROGRAM")
742 .unwrap_or_default()
743 .to_ascii_lowercase();
744 term_is_kitty_graphics_host_env(
745 &term,
746 &term_program,
747 terminal_multiplexer(),
748 terminal_is_remote(),
749 force_env_enabled("SLT_FORCE_KITTY"),
750 )
751}
752
753#[cfg(feature = "crossterm")]
754fn term_is_kitty_graphics_host_env(
755 term: &str,
756 term_program: &str,
757 multiplexer: Option<MultiplexerKind>,
758 remote: bool,
759 forced: bool,
760) -> bool {
761 if !terminal_protocol_allowed(multiplexer, TerminalProtocol::KittyGraphics, forced, false) {
762 return false;
763 }
764 forced
766 || term.contains("kitty")
767 || (!remote && matches!(term_program, "ghostty" | "wezterm" | "kitty"))
768}
769
770#[derive(Debug, Clone, Copy, PartialEq, Eq)]
771enum MultiplexerKind {
772 Tmux,
773 Screen,
774 Zellij,
775}
776
777#[derive(Debug, Clone, Copy, PartialEq, Eq)]
778enum TerminalProtocol {
779 Queries,
780 SynchronizedOutput,
781 KittyGraphics,
782 Sixel,
783 Iterm2,
784 KittyKeyboard,
785}
786
787fn terminal_protocol_allowed(
788 multiplexer: Option<MultiplexerKind>,
789 protocol: TerminalProtocol,
790 forced: bool,
791 disabled: bool,
792) -> bool {
793 if disabled {
794 return false;
795 }
796 if forced {
797 return true;
798 }
799 match multiplexer {
800 None => true,
801 Some(MultiplexerKind::Zellij) => {
802 matches!(
803 protocol,
804 TerminalProtocol::Sixel | TerminalProtocol::KittyKeyboard
805 )
806 }
807 Some(MultiplexerKind::Tmux | MultiplexerKind::Screen) => false,
808 }
809}
810
811#[cfg(feature = "crossterm")]
812fn terminal_multiplexer() -> Option<MultiplexerKind> {
813 let term = std::env::var("TERM")
814 .unwrap_or_default()
815 .to_ascii_lowercase();
816 terminal_multiplexer_env(
817 &term,
818 std::env::var_os("TMUX").is_some(),
819 std::env::var_os("STY").is_some(),
820 std::env::var_os("ZELLIJ").is_some(),
821 std::env::var_os("ZELLIJ_SESSION_NAME").is_some(),
822 )
823}
824
825fn terminal_multiplexer_env(
826 term: &str,
827 has_tmux: bool,
828 has_sty: bool,
829 has_zellij: bool,
830 has_zellij_session: bool,
831) -> Option<MultiplexerKind> {
832 let term = term.to_ascii_lowercase();
833 if term.starts_with("tmux") {
834 Some(MultiplexerKind::Tmux)
835 } else if term.starts_with("screen") {
836 Some(MultiplexerKind::Screen)
837 } else if has_zellij || has_zellij_session {
838 Some(MultiplexerKind::Zellij)
839 } else if has_tmux {
840 Some(MultiplexerKind::Tmux)
841 } else if has_sty {
842 Some(MultiplexerKind::Screen)
843 } else {
844 None
845 }
846}
847
848#[cfg(feature = "crossterm")]
849fn terminal_is_remote() -> bool {
850 terminal_is_remote_env(
851 std::env::var_os("SSH_CONNECTION").is_some(),
852 std::env::var_os("SSH_TTY").is_some(),
853 std::env::var_os("MOSH_IP").is_some(),
854 )
855}
856
857fn terminal_is_remote_env(has_ssh_connection: bool, has_ssh_tty: bool, has_mosh_ip: bool) -> bool {
858 has_ssh_connection || has_ssh_tty || has_mosh_ip
859}
860
861#[cfg(feature = "crossterm")]
862fn terminal_kitty_keyboard_allowed() -> bool {
863 kitty_keyboard_allowed_env(
864 terminal_multiplexer(),
865 force_env_enabled("SLT_FORCE_KITTY_KEYBOARD"),
866 force_env_enabled("SLT_DISABLE_KITTY_KEYBOARD"),
867 )
868}
869
870fn kitty_keyboard_allowed_env(
871 multiplexer: Option<MultiplexerKind>,
872 forced: bool,
873 disabled: bool,
874) -> bool {
875 terminal_protocol_allowed(
876 multiplexer,
877 TerminalProtocol::KittyKeyboard,
878 forced,
879 disabled,
880 )
881}
882
883#[cfg(feature = "crossterm")]
884fn force_env_enabled(name: &str) -> bool {
885 std::env::var(name)
886 .ok()
887 .is_some_and(|value| truthy_env_value(&value))
888}
889
890#[cfg(feature = "crossterm")]
891fn truthy_env_value(value: &str) -> bool {
892 matches!(
893 value.to_ascii_lowercase().as_str(),
894 "1" | "true" | "yes" | "on"
895 )
896}
897
898#[cfg(feature = "crossterm")]
899fn terminal_queries_allowed() -> bool {
900 let term = std::env::var("TERM").unwrap_or_default();
901 terminal_query_allowed(
902 io::stdout().is_terminal(),
903 io::stdin().is_terminal(),
904 &term,
905 terminal_multiplexer(),
906 force_env_enabled("SLT_FORCE_TERMINAL_QUERIES"),
907 force_env_enabled("SLT_DISABLE_TERMINAL_QUERIES"),
908 )
909}
910
911#[cfg(feature = "crossterm")]
912fn automatic_terminal_queries_allowed() -> bool {
913 if !terminal_queries_allowed() {
914 return false;
915 }
916 force_env_enabled("SLT_FORCE_TERMINAL_QUERIES") || terminal_query_host_is_identified()
917}
918
919#[cfg(feature = "crossterm")]
920fn terminal_query_host_is_identified() -> bool {
921 const IDENTITY_VARS: &[&str] = &[
922 "TERM_PROGRAM",
923 "WT_SESSION",
924 "VTE_VERSION",
925 "KONSOLE_VERSION",
926 "KITTY_WINDOW_ID",
927 ];
928 let remote = terminal_is_remote();
929 if !remote
930 && IDENTITY_VARS
931 .iter()
932 .any(|name| std::env::var_os(name).is_some_and(|value| !value.is_empty()))
933 {
934 return true;
935 }
936
937 let term = std::env::var("TERM")
938 .unwrap_or_default()
939 .to_ascii_lowercase();
940 terminal_query_host_is_identified_env(&term, false, remote)
941}
942
943fn terminal_query_host_is_identified_env(term: &str, has_identity_var: bool, remote: bool) -> bool {
944 (has_identity_var && !remote)
945 || matches!(
946 term.to_ascii_lowercase().as_str(),
947 "alacritty" | "foot" | "foot-extra" | "mlterm" | "wezterm" | "xterm-kitty"
948 )
949}
950
951fn terminal_query_allowed(
952 stdout_is_terminal: bool,
953 stdin_is_terminal: bool,
954 term: &str,
955 multiplexer: Option<MultiplexerKind>,
956 forced: bool,
957 disabled: bool,
958) -> bool {
959 if !stdout_is_terminal || !stdin_is_terminal {
960 return false;
961 }
962 terminal_protocol_allowed(multiplexer, TerminalProtocol::Queries, forced, disabled)
963 && (forced || (!term.is_empty() && !term.eq_ignore_ascii_case("dumb")))
964}
965
966#[cfg(feature = "crossterm")]
967static REPLY_READ_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
968
969#[cfg(all(feature = "crossterm", unix))]
970struct NonblockingFdGuard<'a, Fd: rustix::fd::AsFd> {
971 fd: &'a Fd,
972 original: rustix::fs::OFlags,
973 changed: bool,
974}
975
976#[cfg(all(feature = "crossterm", unix))]
977impl<'a, Fd: rustix::fd::AsFd> NonblockingFdGuard<'a, Fd> {
978 fn new(fd: &'a Fd) -> rustix::io::Result<Self> {
979 let original = rustix::fs::fcntl_getfl(fd)?;
980 let changed = !original.contains(rustix::fs::OFlags::NONBLOCK);
981 if changed {
982 rustix::fs::fcntl_setfl(fd, original | rustix::fs::OFlags::NONBLOCK)?;
983 }
984 Ok(Self {
985 fd,
986 original,
987 changed,
988 })
989 }
990}
991
992#[cfg(all(feature = "crossterm", unix))]
993impl<Fd: rustix::fd::AsFd> Drop for NonblockingFdGuard<'_, Fd> {
994 fn drop(&mut self) {
995 if self.changed {
996 let _ = rustix::fs::fcntl_setfl(self.fd, self.original);
997 }
998 }
999}
1000
1001#[cfg(feature = "crossterm")]
1009fn read_stdin_reply(
1010 timeout: Duration,
1011 mut is_complete: impl FnMut(&[u8]) -> bool,
1012) -> Option<String> {
1013 let deadline = Instant::now() + timeout;
1014 let _guard = REPLY_READ_LOCK
1015 .lock()
1016 .unwrap_or_else(std::sync::PoisonError::into_inner);
1017
1018 #[cfg(unix)]
1019 let bytes = read_reply_from_fd(&io::stdin(), deadline, &mut is_complete);
1020 #[cfg(windows)]
1021 let bytes = read_reply_from_windows_console(deadline, &mut is_complete);
1022 #[cfg(not(any(unix, windows)))]
1023 let bytes = Vec::new();
1024
1025 if bytes.is_empty() {
1026 return None;
1027 }
1028 String::from_utf8(bytes).ok()
1029}
1030
1031#[cfg(all(feature = "crossterm", unix))]
1032fn read_reply_from_fd<Fd: rustix::fd::AsFd>(
1033 fd: &Fd,
1034 deadline: Instant,
1035 is_complete: &mut dyn FnMut(&[u8]) -> bool,
1036) -> Vec<u8> {
1037 let Ok(_nonblocking) = NonblockingFdGuard::new(fd) else {
1038 return Vec::new();
1039 };
1040 let mut bytes = Vec::new();
1041 let mut byte = [0u8; 1];
1042 while Instant::now() < deadline && bytes.len() < 4096 {
1043 match rustix::io::read(fd, &mut byte) {
1044 Ok(0) => break,
1045 Ok(_) => {
1046 bytes.push(byte[0]);
1047 if is_complete(&bytes) {
1048 break;
1049 }
1050 }
1051 Err(rustix::io::Errno::AGAIN) => {
1052 let remaining = deadline.saturating_duration_since(Instant::now());
1053 std::thread::sleep(remaining.min(Duration::from_millis(1)));
1054 }
1055 Err(rustix::io::Errno::INTR) => {}
1056 Err(_) => break,
1057 }
1058 }
1059 bytes
1060}
1061
1062#[cfg(all(feature = "crossterm", windows))]
1063fn read_reply_from_windows_console(
1064 deadline: Instant,
1065 is_complete: &mut dyn FnMut(&[u8]) -> bool,
1066) -> Vec<u8> {
1067 use crossterm_winapi::{Console, Handle, HandleType, InputRecord};
1068
1069 let Ok(handle) = Handle::new(HandleType::InputHandle) else {
1070 return Vec::new();
1071 };
1072 let console = Console::from(handle);
1073 let mut bytes = Vec::new();
1074 while Instant::now() < deadline && bytes.len() < 4096 {
1075 match console.number_of_console_input_events() {
1076 Ok(0) => {
1077 let remaining = deadline.saturating_duration_since(Instant::now());
1078 std::thread::sleep(remaining.min(Duration::from_millis(1)));
1079 }
1080 Ok(_) => match console.read_single_input_event() {
1081 Ok(InputRecord::KeyEvent(record)) if record.key_down && record.u_char != 0 => {
1082 let Some(ch) = char::from_u32(u32::from(record.u_char)) else {
1083 continue;
1084 };
1085 let mut encoded = [0u8; 4];
1086 let encoded = ch.encode_utf8(&mut encoded).as_bytes();
1087 for _ in 0..record.repeat_count.max(1) {
1088 bytes.extend_from_slice(encoded);
1089 if is_complete(&bytes) || bytes.len() >= 4096 {
1090 break;
1091 }
1092 }
1093 if is_complete(&bytes) {
1094 break;
1095 }
1096 }
1097 Ok(_) => {}
1098 Err(_) => break,
1099 },
1100 Err(_) => break,
1101 }
1102 }
1103 bytes.truncate(4096);
1104 bytes
1105}
1106
1107#[cfg(all(feature = "crossterm", test))]
1109fn collect_reply(
1110 rx: &std::sync::mpsc::Receiver<u8>,
1111 deadline: Instant,
1112 is_complete: &mut dyn FnMut(&[u8]) -> bool,
1113) -> Vec<u8> {
1114 let mut bytes = Vec::new();
1115 loop {
1116 let now = Instant::now();
1117 if now >= deadline {
1118 break;
1119 }
1120 match rx.recv_timeout(deadline - now) {
1121 Ok(byte) => {
1122 bytes.push(byte);
1123 if is_complete(&bytes) || bytes.len() >= 4096 {
1124 break;
1125 }
1126 }
1127 Err(_) => break,
1129 }
1130 }
1131 bytes
1132}
1133
1134#[cfg(feature = "crossterm")]
1137fn osc_reply_complete(bytes: &[u8]) -> bool {
1138 let len = bytes.len();
1139 bytes[len - 1] == b'\x07' || (len >= 2 && bytes[len - 2] == 0x1B && bytes[len - 1] == b'\\')
1140}
1141
1142#[cfg(feature = "crossterm")]
1146fn da_reply_complete() -> impl FnMut(&[u8]) -> bool {
1147 let mut terminators = 0usize;
1148 move |bytes: &[u8]| {
1149 if bytes[bytes.len() - 1] == b'c' {
1150 terminators += 1;
1151 }
1152 terminators >= 2
1153 }
1154}
1155
1156#[cfg(feature = "crossterm")]
1158fn decrpm_reply_complete(bytes: &[u8]) -> bool {
1159 bytes[bytes.len() - 1] == b'y'
1160}
1161
1162#[cfg(feature = "crossterm")]
1167fn read_da_response(timeout: Duration) -> Option<String> {
1168 read_stdin_reply(timeout, da_reply_complete())
1169}
1170
1171#[cfg(feature = "crossterm")]
1175fn parse_da1(response: &str, caps: &mut Capabilities) {
1176 let mut search = response;
1178 while let Some(pos) = search.find("\x1b[?") {
1179 let body = &search[pos + 3..];
1180 let Some(end) = body.find('c') else { break };
1181 let attrs = &body[..end];
1182 for attr in attrs.split(';') {
1183 if attr.trim() == "4" {
1184 caps.sixel = true;
1185 }
1186 }
1187 search = &body[end + 1..];
1188 }
1189}
1190
1191#[cfg(feature = "crossterm")]
1199fn parse_da2(response: &str, caps: &mut Capabilities) {
1200 let Some((id, _ver)) = parse_da2_identity(response) else {
1201 return;
1202 };
1203 const KITTY_GRAPHICS_DA2_ID: u32 = 41;
1208 if id == KITTY_GRAPHICS_DA2_ID {
1209 caps.kitty_graphics = true;
1210 }
1211}
1212
1213#[cfg(feature = "crossterm")]
1215fn parse_da2_identity(response: &str) -> Option<(u32, u32)> {
1216 let pos = response.find("\x1b[>")?;
1217 let body = &response[pos + 3..];
1218 let end = body.find('c')?;
1219 let mut parts = body[..end].split(';');
1220 let id = parts.next()?.trim().parse::<u32>().ok()?;
1221 let ver = parts.next().and_then(|s| s.trim().parse::<u32>().ok());
1222 Some((id, ver.unwrap_or(0)))
1223}
1224
1225#[cfg(feature = "crossterm")]
1229fn parse_kitty_graphics_ack(response: &str, caps: &mut Capabilities) {
1230 if let Some(pos) = response.find("\x1b_G") {
1233 let body = &response[pos + 3..];
1234 let end = body.find("\x1b\\").unwrap_or(body.len());
1235 let payload = &body[..end];
1236 if payload.contains("i=31") && payload.contains("OK") {
1237 caps.kitty_graphics = true;
1238 }
1239 }
1240}
1241
1242#[cfg(feature = "crossterm")]
1246fn parse_xtgettcap_truecolor(response: &str, caps: &mut Capabilities) {
1247 if let Some(pos) = response.find("\x1bP1+r") {
1249 let body = &response[pos + 5..];
1250 if body
1251 .to_ascii_lowercase()
1252 .split([';', '\x1b'])
1253 .any(|seg| seg.starts_with("5463"))
1254 {
1255 caps.truecolor = true;
1256 }
1257 }
1258}
1259
1260#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1270enum SyncOutputResolution {
1271 Supported,
1273 Unsupported,
1275}
1276
1277static SYNC_OUTPUT_RESOLUTION: std::sync::OnceLock<SyncOutputResolution> =
1280 std::sync::OnceLock::new();
1281
1282fn should_emit_synchronized_update() -> bool {
1292 synchronized_update_allowed(
1293 terminal_multiplexer(),
1294 force_env_enabled("SLT_FORCE_SYNC_OUTPUT"),
1295 force_env_enabled("SLT_DISABLE_SYNC_OUTPUT"),
1296 matches!(
1297 SYNC_OUTPUT_RESOLUTION.get(),
1298 Some(SyncOutputResolution::Unsupported)
1299 ),
1300 )
1301}
1302
1303fn synchronized_update_allowed(
1304 multiplexer: Option<MultiplexerKind>,
1305 forced: bool,
1306 disabled: bool,
1307 probe_unsupported: bool,
1308) -> bool {
1309 !disabled
1310 && (forced
1311 || (!probe_unsupported
1312 && terminal_protocol_allowed(
1313 multiplexer,
1314 TerminalProtocol::SynchronizedOutput,
1315 false,
1316 false,
1317 )))
1318}
1319
1320#[cfg(feature = "crossterm")]
1324fn read_decrpm_response(timeout: Duration) -> Option<String> {
1325 read_stdin_reply(timeout, decrpm_reply_complete)
1326}
1327
1328#[cfg(feature = "crossterm")]
1337fn parse_decrpm_sync_output(response: &str) -> Option<bool> {
1338 let pos = response.find("\x1b[?2026;")?;
1340 let body = &response[pos + "\x1b[?2026;".len()..];
1341 let end = body.find("$y")?;
1342 let ps = body[..end].trim().parse::<u32>().ok()?;
1343 Some(ps != 0)
1345}
1346
1347fn split_base64(encoded: &str, chunk_size: usize) -> Vec<&str> {
1348 let mut chunks = Vec::new();
1349 let bytes = encoded.as_bytes();
1350 let mut offset = 0;
1351 while offset < bytes.len() {
1352 let end = (offset + chunk_size).min(bytes.len());
1353 chunks.push(&encoded[offset..end]);
1354 offset = end;
1355 }
1356 if chunks.is_empty() {
1357 chunks.push("");
1358 }
1359 chunks
1360}
1361
1362#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1363struct GraphicsEmissionSupport {
1364 real_terminal: bool,
1365 capabilities: Capabilities,
1366 force_kitty: bool,
1367 force_sixel: bool,
1368 force_iterm: bool,
1369}
1370
1371impl GraphicsEmissionSupport {
1372 fn detect(capabilities: Capabilities) -> Self {
1373 let disable_kitty = force_env_enabled("SLT_DISABLE_KITTY");
1374 let disable_sixel = force_env_enabled("SLT_DISABLE_SIXEL");
1375 let disable_iterm = force_env_enabled("SLT_DISABLE_ITERM");
1376 Self {
1377 real_terminal: true,
1378 capabilities: Capabilities {
1379 kitty_graphics: capabilities.kitty_graphics && !disable_kitty,
1380 sixel: capabilities.sixel && !disable_sixel,
1381 iterm2: capabilities.iterm2 && !disable_iterm,
1382 ..capabilities
1383 },
1384 force_kitty: force_env_enabled("SLT_FORCE_KITTY") && !disable_kitty,
1385 force_sixel: force_env_enabled("SLT_FORCE_SIXEL") && !disable_sixel,
1386 force_iterm: force_env_enabled("SLT_FORCE_ITERM") && !disable_iterm,
1387 }
1388 }
1389
1390 #[cfg(any(test, feature = "pty-test"))]
1391 fn capture() -> Self {
1392 Self::detect(probe_capabilities())
1393 }
1394
1395 fn should_emit_kitty(self) -> bool {
1396 self.real_terminal && (self.capabilities.kitty_graphics || self.force_kitty)
1397 }
1398
1399 fn should_emit_sprixel(self, protocol: SprixelProtocol) -> bool {
1400 if !self.real_terminal {
1401 return false;
1402 }
1403 match protocol {
1404 SprixelProtocol::Sixel => self.capabilities.sixel || self.force_sixel,
1405 SprixelProtocol::Iterm2 => self.capabilities.iterm2 || self.force_iterm,
1406 SprixelProtocol::Unknown => false,
1407 }
1408 }
1409}
1410
1411#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1412enum SprixelProtocol {
1413 Sixel,
1414 Iterm2,
1415 Unknown,
1416}
1417
1418fn sprixel_protocol(seq: &str) -> SprixelProtocol {
1419 if seq.starts_with("\x1bPq") {
1420 SprixelProtocol::Sixel
1421 } else if seq.starts_with("\x1b]1337;File=") {
1422 SprixelProtocol::Iterm2
1423 } else {
1424 SprixelProtocol::Unknown
1425 }
1426}
1427
1428pub struct Terminal {
1436 stdout: Sink,
1437 current: Buffer,
1438 previous: Buffer,
1439 cursor_visible: bool,
1440 session: TerminalSessionGuard,
1441 color_depth: ColorDepth,
1442 synchronized_output: bool,
1443 pub(crate) theme_bg: Option<Color>,
1444 kitty_mgr: KittyImageManager,
1445 graphics_support: GraphicsEmissionSupport,
1446 run_buf: String,
1450}
1451
1452pub struct InlineTerminal {
1458 stdout: Sink,
1459 current: Buffer,
1460 previous: Buffer,
1461 cursor_visible: bool,
1462 session: TerminalSessionGuard,
1463 height: u32,
1464 anchor_row: u16,
1465 start_row: u16,
1466 viewport_rows: u16,
1467 reserved: bool,
1468 color_depth: ColorDepth,
1469 synchronized_output: bool,
1470 pub(crate) theme_bg: Option<Color>,
1471 kitty_mgr: KittyImageManager,
1472 graphics_support: GraphicsEmissionSupport,
1473 run_buf: String,
1475}
1476
1477const RUN_BUF_INITIAL_CAPACITY: usize = 4096;
1481
1482#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1483enum TerminalSessionMode {
1484 Fullscreen,
1485 Inline,
1486}
1487
1488#[derive(Debug, Clone, Copy)]
1490pub(crate) struct SessionSnapshot {
1491 mode: TerminalSessionMode,
1492 mouse_enabled: bool,
1493 kitty_keyboard: bool,
1494 report_all_keys: bool,
1495 raw_mode_owned: bool,
1496}
1497
1498#[derive(Debug, Clone, Copy)]
1499struct ActiveSession {
1500 id: u64,
1501 snapshot: SessionSnapshot,
1502}
1503
1504static ACTIVE_SESSIONS: std::sync::Mutex<Vec<ActiveSession>> = std::sync::Mutex::new(Vec::new());
1505static NEXT_SESSION_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
1506
1507fn register_active_session(snapshot: SessionSnapshot) -> u64 {
1508 use std::sync::atomic::Ordering;
1509 let id = NEXT_SESSION_ID.fetch_add(1, Ordering::Relaxed);
1510 ACTIVE_SESSIONS
1511 .lock()
1512 .unwrap_or_else(std::sync::PoisonError::into_inner)
1513 .push(ActiveSession { id, snapshot });
1514 id
1515}
1516
1517fn unregister_active_session(id: u64) {
1518 let mut sessions = ACTIVE_SESSIONS
1519 .lock()
1520 .unwrap_or_else(std::sync::PoisonError::into_inner);
1521 if let Some(index) = sessions.iter().rposition(|session| session.id == id) {
1522 sessions.remove(index);
1523 }
1524}
1525
1526fn active_session_snapshot() -> Option<SessionSnapshot> {
1527 ACTIVE_SESSIONS
1528 .lock()
1529 .unwrap_or_else(std::sync::PoisonError::into_inner)
1530 .last()
1531 .map(|session| session.snapshot)
1532}
1533
1534#[derive(Debug)]
1535struct TerminalSessionGuard {
1536 mode: TerminalSessionMode,
1537 mouse_enabled: bool,
1538 kitty_keyboard: bool,
1539 report_all_keys: bool,
1540 raw_mode_owned: bool,
1541 registry_id: Option<u64>,
1542 restored: std::sync::atomic::AtomicBool,
1543 harness: bool,
1549}
1550
1551impl TerminalSessionGuard {
1552 fn enter(
1553 mode: TerminalSessionMode,
1554 stdout: &mut impl Write,
1555 mouse_enabled: bool,
1556 kitty_keyboard: bool,
1557 report_all_keys: bool,
1558 ) -> io::Result<Self> {
1559 let kitty_keyboard = kitty_keyboard && terminal_kitty_keyboard_allowed();
1560 let raw_mode_owned = raw_mode_is_acquired(terminal::is_raw_mode_enabled()?);
1561 if raw_mode_owned {
1562 terminal::enable_raw_mode()?;
1563 }
1564
1565 let mut guard = Self {
1566 mode,
1567 mouse_enabled,
1568 kitty_keyboard,
1569 report_all_keys,
1570 raw_mode_owned,
1571 registry_id: None,
1572 restored: std::sync::atomic::AtomicBool::new(false),
1573 harness: false,
1574 };
1575 guard.registry_id = Some(register_active_session(guard.snapshot()));
1576 if let Err(err) = write_session_enter(stdout, &guard) {
1577 guard.restore(stdout, false);
1578 return Err(err);
1579 }
1580
1581 let _ = capabilities();
1588
1589 Ok(guard)
1590 }
1591
1592 fn restore(&self, stdout: &mut impl Write, inline_reserved: bool) {
1593 if self
1594 .restored
1595 .swap(true, std::sync::atomic::Ordering::AcqRel)
1596 {
1597 return;
1598 }
1599 if self.harness {
1601 return;
1602 }
1603 let _ = write_session_exit(
1604 stdout,
1605 self.mode,
1606 inline_reserved,
1607 self.mouse_enabled,
1608 self.kitty_keyboard,
1609 );
1610 if self.raw_mode_owned {
1611 let _ = terminal::disable_raw_mode();
1612 }
1613 if let Some(id) = self.registry_id {
1614 unregister_active_session(id);
1615 }
1616 }
1617
1618 fn snapshot(&self) -> SessionSnapshot {
1619 SessionSnapshot {
1620 mode: self.mode,
1621 mouse_enabled: self.mouse_enabled,
1622 kitty_keyboard: self.kitty_keyboard,
1623 report_all_keys: self.report_all_keys,
1624 raw_mode_owned: self.raw_mode_owned,
1625 }
1626 }
1627}
1628
1629impl Drop for TerminalSessionGuard {
1630 fn drop(&mut self) {
1631 let mut stdout = io::stdout();
1632 self.restore(&mut stdout, false);
1633 }
1634}
1635
1636fn raw_mode_is_acquired(raw_mode_was_enabled: bool) -> bool {
1637 !raw_mode_was_enabled
1638}
1639
1640impl Terminal {
1641 pub fn new(
1646 mouse: bool,
1647 kitty_keyboard: bool,
1648 report_all_keys: bool,
1649 color_depth: ColorDepth,
1650 ) -> io::Result<Self> {
1651 let (cols, rows) = terminal::size()?;
1652 let area = Rect::new(0, 0, cols as u32, rows as u32);
1653 let (current, previous) = try_buffer_pair(area)?;
1654
1655 let mut raw = io::stdout();
1656 let session = TerminalSessionGuard::enter(
1657 TerminalSessionMode::Fullscreen,
1658 &mut raw,
1659 mouse,
1660 kitty_keyboard,
1661 report_all_keys,
1662 )?;
1663 let graphics_support = GraphicsEmissionSupport::detect(capabilities());
1664 let synchronized_output = should_emit_synchronized_update();
1665
1666 Ok(Self {
1667 stdout: Sink::Stdout(BufWriter::with_capacity(65536, raw)),
1668 current,
1669 previous,
1670 cursor_visible: false,
1671 session,
1672 color_depth,
1673 synchronized_output,
1674 theme_bg: None,
1675 kitty_mgr: KittyImageManager::new(),
1676 graphics_support,
1677 run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
1678 })
1679 }
1680
1681 pub fn size(&self) -> (u32, u32) {
1683 (self.current.area.width, self.current.area.height)
1684 }
1685
1686 pub fn buffer_mut(&mut self) -> &mut Buffer {
1688 &mut self.current
1689 }
1690
1691 pub fn flush(&mut self) -> io::Result<()> {
1695 if self.current.area.width < self.previous.area.width {
1696 execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
1697 }
1698
1699 let sync_guard = self.synchronized_output;
1703 if sync_guard {
1704 queue!(self.stdout, BeginSynchronizedUpdate)?;
1705 }
1706 self.current.recompute_line_hashes();
1711 self.previous.recompute_line_hashes();
1712 let redrawn_sprixel_rows =
1713 previous_only_sprixel_rows(&self.current, &self.previous, |placement| {
1714 self.graphics_support
1715 .should_emit_sprixel(sprixel_protocol(&placement.seq))
1716 });
1717 flush_buffer_diff_rows(
1718 &mut self.stdout,
1719 &self.current,
1720 &self.previous,
1721 self.color_depth,
1722 0,
1723 &mut self.run_buf,
1724 &redrawn_sprixel_rows,
1725 )?;
1726
1727 if self.graphics_support.should_emit_kitty() {
1730 self.kitty_mgr
1731 .flush(&mut self.stdout, &self.current.kitty_placements, 0)?;
1732 }
1733
1734 flush_raw_sequences(&mut self.stdout, &self.current, &self.previous, 0)?;
1736
1737 flush_sprixels_checked_with_rows(
1739 &mut self.stdout,
1740 &self.current,
1741 &self.previous,
1742 0,
1743 self.graphics_support,
1744 &redrawn_sprixel_rows,
1745 )?;
1746
1747 if sync_guard {
1748 queue!(self.stdout, EndSynchronizedUpdate)?;
1749 }
1750 flush_cursor(
1751 &mut self.stdout,
1752 &mut self.cursor_visible,
1753 self.current.cursor_pos(),
1754 0,
1755 None,
1756 )?;
1757
1758 self.stdout.flush()?;
1759
1760 std::mem::swap(&mut self.current, &mut self.previous);
1761 if let Some(bg) = self.theme_bg {
1762 self.current.reset_with_bg(bg);
1763 } else {
1764 self.current.reset();
1765 }
1766 Ok(())
1767 }
1768
1769 pub fn handle_resize(&mut self) -> io::Result<()> {
1772 let (cols, rows) = terminal::size()?;
1773 let area = Rect::new(0, 0, cols as u32, rows as u32);
1774 let (current, previous) = try_buffer_pair(area)?;
1775 self.current = current;
1776 self.previous = previous;
1777 execute!(
1778 self.stdout,
1779 terminal::Clear(terminal::ClearType::All),
1780 cursor::MoveTo(0, 0)
1781 )?;
1782 Ok(())
1783 }
1784}
1785
1786#[cfg(any(test, feature = "pty-test"))]
1787impl Terminal {
1788 pub(crate) fn with_sink(width: u32, height: u32, color_depth: ColorDepth) -> Self {
1802 let area = Rect::new(0, 0, width, height);
1803 let (current, previous) = buffer_pair(area);
1804 Self {
1805 stdout: Sink::Capture(Vec::new()),
1806 current,
1807 previous,
1808 cursor_visible: false,
1809 session: TerminalSessionGuard {
1810 mode: TerminalSessionMode::Fullscreen,
1811 mouse_enabled: false,
1812 kitty_keyboard: false,
1813 report_all_keys: false,
1814 raw_mode_owned: false,
1815 registry_id: None,
1816 restored: std::sync::atomic::AtomicBool::new(false),
1817 harness: true,
1818 },
1819 color_depth,
1820 synchronized_output: true,
1821 theme_bg: None,
1822 kitty_mgr: KittyImageManager::new(),
1823 graphics_support: GraphicsEmissionSupport::capture(),
1824 run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
1825 }
1826 }
1827
1828 pub(crate) fn take_sink_bytes(&mut self) -> Vec<u8> {
1833 match &mut self.stdout {
1834 Sink::Capture(v) => std::mem::take(v),
1835 Sink::Stdout(_) => panic!("take_sink_bytes called on a non-capture Terminal"),
1836 }
1837 }
1838}
1839
1840impl crate::Backend for Terminal {
1841 fn size(&self) -> (u32, u32) {
1842 Terminal::size(self)
1843 }
1844
1845 fn buffer_mut(&mut self) -> &mut Buffer {
1846 Terminal::buffer_mut(self)
1847 }
1848
1849 fn flush(&mut self) -> io::Result<()> {
1850 Terminal::flush(self)
1851 }
1852
1853 fn owns_terminal_session(&self) -> bool {
1854 true
1855 }
1856}
1857
1858impl InlineTerminal {
1859 pub fn new(
1865 height: u32,
1866 mouse: bool,
1867 kitty_keyboard: bool,
1868 report_all_keys: bool,
1869 color_depth: ColorDepth,
1870 ) -> io::Result<Self> {
1871 let (cols, rows) = terminal::size()?;
1872 let area = Rect::new(0, 0, cols as u32, height);
1873 let (current, previous) = try_buffer_pair(area)?;
1874
1875 let mut raw = io::stdout();
1876 let session = TerminalSessionGuard::enter(
1877 TerminalSessionMode::Inline,
1878 &mut raw,
1879 mouse,
1880 kitty_keyboard,
1881 report_all_keys,
1882 )?;
1883 let graphics_support = GraphicsEmissionSupport::detect(capabilities());
1884 let synchronized_output = should_emit_synchronized_update();
1885
1886 let (_, cursor_row) = match cursor::position() {
1887 Ok(pos) => pos,
1888 Err(err) => {
1889 session.restore(&mut raw, false);
1890 return Err(err);
1891 }
1892 };
1893 Ok(Self {
1894 stdout: Sink::Stdout(BufWriter::with_capacity(65536, raw)),
1895 current,
1896 previous,
1897 cursor_visible: false,
1898 session,
1899 height,
1900 anchor_row: cursor_row,
1901 start_row: cursor_row,
1902 viewport_rows: rows,
1903 reserved: false,
1904 color_depth,
1905 synchronized_output,
1906 theme_bg: None,
1907 kitty_mgr: KittyImageManager::new(),
1908 graphics_support,
1909 run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
1910 })
1911 }
1912
1913 pub fn size(&self) -> (u32, u32) {
1915 (self.current.area.width, self.current.area.height)
1916 }
1917
1918 pub fn buffer_mut(&mut self) -> &mut Buffer {
1920 &mut self.current
1921 }
1922
1923 pub fn flush(&mut self) -> io::Result<()> {
1927 if self.current.area.width < self.previous.area.width {
1928 execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
1929 }
1930
1931 let sync_guard = self.synchronized_output;
1934 if sync_guard {
1935 queue!(self.stdout, BeginSynchronizedUpdate)?;
1936 }
1937
1938 if !self.reserved {
1939 queue!(self.stdout, cursor::MoveToColumn(0))?;
1940 for _ in 0..self.height {
1941 queue!(self.stdout, Print("\n"))?;
1942 }
1943 self.reserved = true;
1944
1945 let (_, rows) = terminal::size()?;
1946 self.viewport_rows = rows;
1947 self.start_row = self
1948 .anchor_row
1949 .min(rows.saturating_sub(sat_u16(self.height)));
1950 }
1951 let row_offset = self.start_row as u32;
1952 self.current.recompute_line_hashes();
1955 self.previous.recompute_line_hashes();
1956 let redrawn_sprixel_rows =
1957 previous_only_sprixel_rows(&self.current, &self.previous, |placement| {
1958 self.graphics_support
1959 .should_emit_sprixel(sprixel_protocol(&placement.seq))
1960 });
1961 flush_buffer_diff_rows(
1962 &mut self.stdout,
1963 &self.current,
1964 &self.previous,
1965 self.color_depth,
1966 row_offset,
1967 &mut self.run_buf,
1968 &redrawn_sprixel_rows,
1969 )?;
1970
1971 if self.graphics_support.should_emit_kitty() {
1977 self.kitty_mgr
1978 .flush(&mut self.stdout, &self.current.kitty_placements, row_offset)?;
1979 }
1980
1981 flush_raw_sequences(&mut self.stdout, &self.current, &self.previous, row_offset)?;
1983
1984 flush_sprixels_checked_with_rows(
1986 &mut self.stdout,
1987 &self.current,
1988 &self.previous,
1989 row_offset,
1990 self.graphics_support,
1991 &redrawn_sprixel_rows,
1992 )?;
1993
1994 if sync_guard {
1995 queue!(self.stdout, EndSynchronizedUpdate)?;
1996 }
1997 let fallback_row = row_offset + self.height.saturating_sub(1);
1998 flush_cursor(
1999 &mut self.stdout,
2000 &mut self.cursor_visible,
2001 self.current.cursor_pos(),
2002 row_offset,
2003 Some(fallback_row),
2004 )?;
2005
2006 self.stdout.flush()?;
2007
2008 std::mem::swap(&mut self.current, &mut self.previous);
2009 reset_current_buffer(&mut self.current, self.theme_bg);
2010 Ok(())
2011 }
2012
2013 pub(crate) fn write_scrollback(&mut self, lines: &[String]) -> io::Result<()> {
2015 if lines.is_empty() {
2016 return Ok(());
2017 }
2018
2019 if self.graphics_support.should_emit_kitty() {
2020 self.kitty_mgr.delete_all(&mut self.stdout)?;
2021 }
2022 queue!(
2023 self.stdout,
2024 cursor::MoveTo(0, self.start_row),
2025 terminal::Clear(terminal::ClearType::FromCursorDown)
2026 )?;
2027 for line in lines {
2028 let safe = crate::sanitize_terminal_text(line);
2029 queue!(self.stdout, Print(safe), Print("\r\n"))?;
2030 }
2031 self.stdout.flush()?;
2032
2033 let line_count = lines.len().min(u32::MAX as usize) as u32;
2034 self.anchor_row = self.anchor_row.saturating_add(sat_u16(line_count));
2035 self.start_row = self
2036 .anchor_row
2037 .min(self.viewport_rows.saturating_sub(sat_u16(self.height)));
2038 self.previous = Buffer::try_empty(self.current.area).map_err(buffer_error)?;
2039 self.cursor_visible = false;
2040 Ok(())
2041 }
2042
2043 pub fn handle_resize(&mut self) -> io::Result<()> {
2045 let (cols, rows) = terminal::size()?;
2046 self.handle_resize_to(cols, rows)
2047 }
2048
2049 fn handle_resize_to(&mut self, cols: u16, rows: u16) -> io::Result<()> {
2050 let start_row = self
2051 .anchor_row
2052 .min(rows.saturating_sub(sat_u16(self.height)));
2053 let area = Rect::new(0, 0, cols as u32, self.height);
2054 let (current, previous) = try_buffer_pair(area)?;
2055 self.viewport_rows = rows;
2056 self.start_row = start_row;
2057 self.current = current;
2058 self.previous = previous;
2059 if self.graphics_support.should_emit_kitty() {
2060 self.kitty_mgr.delete_all(&mut self.stdout)?;
2061 }
2062 self.cursor_visible = false;
2063 execute!(
2064 self.stdout,
2065 terminal::Clear(terminal::ClearType::All),
2066 cursor::MoveTo(0, 0)
2067 )?;
2068 Ok(())
2069 }
2070}
2071
2072#[cfg(test)]
2073impl InlineTerminal {
2074 fn with_sink(width: u16, viewport_rows: u16, height: u32, anchor_row: u16) -> Self {
2075 let area = Rect::new(0, 0, width as u32, height);
2076 let (current, previous) = buffer_pair(area);
2077 let start_row = anchor_row.min(viewport_rows.saturating_sub(sat_u16(height)));
2078 Self {
2079 stdout: Sink::Capture(Vec::new()),
2080 current,
2081 previous,
2082 cursor_visible: false,
2083 session: TerminalSessionGuard {
2084 mode: TerminalSessionMode::Inline,
2085 mouse_enabled: false,
2086 kitty_keyboard: false,
2087 report_all_keys: false,
2088 raw_mode_owned: false,
2089 registry_id: None,
2090 restored: std::sync::atomic::AtomicBool::new(false),
2091 harness: true,
2092 },
2093 height,
2094 anchor_row,
2095 start_row,
2096 viewport_rows,
2097 reserved: false,
2098 color_depth: ColorDepth::TrueColor,
2099 synchronized_output: true,
2100 theme_bg: None,
2101 kitty_mgr: KittyImageManager::new(),
2102 graphics_support: GraphicsEmissionSupport {
2103 real_terminal: false,
2104 capabilities: Capabilities::default(),
2105 force_kitty: false,
2106 force_sixel: false,
2107 force_iterm: false,
2108 },
2109 run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
2110 }
2111 }
2112
2113 fn take_sink_bytes(&mut self) -> Vec<u8> {
2114 match &mut self.stdout {
2115 Sink::Capture(bytes) => std::mem::take(bytes),
2116 Sink::Stdout(_) => unreachable!("test inline terminal always captures"),
2117 }
2118 }
2119}
2120
2121impl crate::Backend for InlineTerminal {
2122 fn size(&self) -> (u32, u32) {
2123 InlineTerminal::size(self)
2124 }
2125
2126 fn buffer_mut(&mut self) -> &mut Buffer {
2127 InlineTerminal::buffer_mut(self)
2128 }
2129
2130 fn flush(&mut self) -> io::Result<()> {
2131 InlineTerminal::flush(self)
2132 }
2133
2134 fn owns_terminal_session(&self) -> bool {
2135 true
2136 }
2137}
2138
2139impl Drop for Terminal {
2140 fn drop(&mut self) {
2141 if self.graphics_support.should_emit_kitty() {
2143 let _ = self.kitty_mgr.delete_all(&mut self.stdout);
2144 }
2145 let _ = self.stdout.flush();
2146 self.session.restore(&mut self.stdout, false);
2147 }
2148}
2149
2150impl Drop for InlineTerminal {
2151 fn drop(&mut self) {
2152 if self.graphics_support.should_emit_kitty() {
2153 let _ = self.kitty_mgr.delete_all(&mut self.stdout);
2154 }
2155 let _ = self.stdout.flush();
2156 self.session.restore(&mut self.stdout, self.reserved);
2157 }
2158}
2159
2160mod selection;
2161pub(crate) use selection::{SelectionState, apply_selection_overlay, extract_selection_text};
2162#[cfg(test)]
2163pub(crate) use selection::{find_innermost_rect, normalize_selection};
2164
2165#[non_exhaustive]
2167#[cfg(feature = "crossterm")]
2168#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
2169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2170pub enum ColorScheme {
2171 Dark,
2173 Light,
2175 Unknown,
2177}
2178
2179#[cfg(feature = "crossterm")]
2181fn read_osc_response(timeout: Duration) -> Option<String> {
2182 read_stdin_reply(timeout, osc_reply_complete)
2183}
2184
2185#[cfg(feature = "crossterm")]
2187#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
2188pub fn detect_color_scheme() -> ColorScheme {
2189 if !terminal_queries_allowed() {
2190 return ColorScheme::Unknown;
2191 }
2192
2193 let mut stdout = io::stdout();
2194 if write!(stdout, "\x1b]11;?\x07").is_err() {
2195 return ColorScheme::Unknown;
2196 }
2197 if stdout.flush().is_err() {
2198 return ColorScheme::Unknown;
2199 }
2200
2201 let Some(response) = read_osc_response(Duration::from_millis(100)) else {
2202 return ColorScheme::Unknown;
2203 };
2204
2205 parse_osc11_response(&response)
2206}
2207
2208#[cfg(feature = "crossterm")]
2209pub(crate) fn parse_osc11_response(response: &str) -> ColorScheme {
2210 let Some(rgb_pos) = response.find("rgb:") else {
2211 return ColorScheme::Unknown;
2212 };
2213
2214 let payload = &response[rgb_pos + 4..];
2215 let end = payload
2216 .find(['\x07', '\x1b', '\r', '\n', ' ', '\t'])
2217 .unwrap_or(payload.len());
2218 let rgb = &payload[..end];
2219
2220 let mut channels = rgb.split('/');
2221 let (Some(r), Some(g), Some(b), None) = (
2222 channels.next(),
2223 channels.next(),
2224 channels.next(),
2225 channels.next(),
2226 ) else {
2227 return ColorScheme::Unknown;
2228 };
2229
2230 fn parse_channel(channel: &str) -> Option<f64> {
2231 if channel.is_empty() || channel.len() > 4 {
2232 return None;
2233 }
2234 let value = u16::from_str_radix(channel, 16).ok()? as f64;
2235 let max = ((1u32 << (channel.len() * 4)) - 1) as f64;
2236 if max <= 0.0 {
2237 return None;
2238 }
2239 Some((value / max).clamp(0.0, 1.0))
2240 }
2241
2242 let (Some(r), Some(g), Some(b)) = (parse_channel(r), parse_channel(g), parse_channel(b)) else {
2243 return ColorScheme::Unknown;
2244 };
2245
2246 let luminance = 0.299 * r + 0.587 * g + 0.114 * b;
2247 if luminance < 0.5 {
2248 ColorScheme::Dark
2249 } else {
2250 ColorScheme::Light
2251 }
2252}
2253
2254pub(crate) fn base64_encode(input: &[u8]) -> String {
2255 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2256 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
2257 for chunk in input.chunks(3) {
2258 let b0 = chunk[0] as u32;
2259 let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
2260 let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
2261 let triple = (b0 << 16) | (b1 << 8) | b2;
2262 out.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
2263 out.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
2264 out.push(if chunk.len() > 1 {
2265 CHARS[((triple >> 6) & 0x3F) as usize] as char
2266 } else {
2267 '='
2268 });
2269 out.push(if chunk.len() > 2 {
2270 CHARS[(triple & 0x3F) as usize] as char
2271 } else {
2272 '='
2273 });
2274 }
2275 out
2276}
2277
2278pub(crate) fn copy_to_clipboard(w: &mut impl Write, text: &str) -> io::Result<()> {
2279 let encoded = base64_encode(text.as_bytes());
2280 write!(w, "\x1b]52;c;{encoded}\x1b\\")?;
2281 w.flush()
2282}
2283
2284#[cfg(feature = "crossterm")]
2285fn parse_osc52_response(response: &str) -> Option<String> {
2286 let osc_pos = response.find("]52;")?;
2287 let body = &response[osc_pos + 4..];
2288 let semicolon = body.find(';')?;
2289 let payload = &body[semicolon + 1..];
2290
2291 let end = payload
2292 .find("\x1b\\")
2293 .or_else(|| payload.find('\x07'))
2294 .unwrap_or(payload.len());
2295 let encoded = payload[..end].trim();
2296 if encoded.is_empty() || encoded == "?" {
2297 return None;
2298 }
2299
2300 base64_decode(encoded)
2301}
2302
2303#[cfg(feature = "crossterm")]
2336#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
2337pub fn read_clipboard() -> Option<String> {
2338 if !terminal_queries_allowed() {
2339 return None;
2340 }
2341
2342 let mut stdout = io::stdout();
2343 write!(stdout, "\x1b]52;c;?\x07").ok()?;
2344 stdout.flush().ok()?;
2345
2346 let response = read_osc_response(Duration::from_millis(200))?;
2347 parse_osc52_response(&response)
2348}
2349
2350#[cfg(feature = "crossterm")]
2351fn base64_decode(input: &str) -> Option<String> {
2352 let mut filtered: Vec<u8> = input
2353 .bytes()
2354 .filter(|b| !matches!(b, b' ' | b'\n' | b'\r' | b'\t'))
2355 .collect();
2356
2357 match filtered.len() % 4 {
2358 0 => {}
2359 2 => filtered.extend_from_slice(b"=="),
2360 3 => filtered.push(b'='),
2361 _ => return None,
2362 }
2363
2364 fn decode_val(b: u8) -> Option<u8> {
2365 match b {
2366 b'A'..=b'Z' => Some(b - b'A'),
2367 b'a'..=b'z' => Some(b - b'a' + 26),
2368 b'0'..=b'9' => Some(b - b'0' + 52),
2369 b'+' => Some(62),
2370 b'/' => Some(63),
2371 _ => None,
2372 }
2373 }
2374
2375 let mut out = Vec::with_capacity((filtered.len() / 4) * 3);
2376 for chunk in filtered.as_chunks::<4>().0 {
2377 let p2 = chunk[2] == b'=';
2378 let p3 = chunk[3] == b'=';
2379 if p2 && !p3 {
2380 return None;
2381 }
2382
2383 let v0 = decode_val(chunk[0])? as u32;
2384 let v1 = decode_val(chunk[1])? as u32;
2385 let v2 = if p2 { 0 } else { decode_val(chunk[2])? as u32 };
2386 let v3 = if p3 { 0 } else { decode_val(chunk[3])? as u32 };
2387
2388 let triple = (v0 << 18) | (v1 << 12) | (v2 << 6) | v3;
2389 out.push(((triple >> 16) & 0xFF) as u8);
2390 if !p2 {
2391 out.push(((triple >> 8) & 0xFF) as u8);
2392 }
2393 if !p3 {
2394 out.push((triple & 0xFF) as u8);
2395 }
2396 }
2397
2398 String::from_utf8(out).ok()
2399}
2400
2401#[allow(clippy::too_many_arguments)]
2402#[allow(unused_assignments)]
2403fn flush_buffer_diff(
2404 stdout: &mut impl Write,
2405 current: &Buffer,
2406 previous: &Buffer,
2407 color_depth: ColorDepth,
2408 row_offset: u32,
2409 run_buf: &mut String,
2410) -> io::Result<()> {
2411 flush_buffer_diff_rows(
2412 stdout,
2413 current,
2414 previous,
2415 color_depth,
2416 row_offset,
2417 run_buf,
2418 &[],
2419 )
2420}
2421
2422#[allow(clippy::too_many_arguments)]
2423#[allow(unused_assignments)]
2424fn flush_buffer_diff_rows(
2425 stdout: &mut impl Write,
2426 current: &Buffer,
2427 previous: &Buffer,
2428 color_depth: ColorDepth,
2429 row_offset: u32,
2430 run_buf: &mut String,
2431 forced_rows: &[u32],
2432) -> io::Result<()> {
2433 let mut last_style = Style::new();
2445 let mut first_style = true;
2446 let mut active_link: Option<&str> = None;
2447 let mut has_updates = false;
2448 let mut last_cursor: Option<(u32, u32)> = None;
2452
2453 run_buf.clear();
2459 let mut run_abs_y: u32 = 0;
2460 let mut run_style: Style = Style::new();
2461 let mut run_link: Option<&str> = None;
2462 let mut run_next_col: u32 = 0;
2463 let mut run_open = false;
2464
2465 macro_rules! flush_run {
2470 ($stdout:expr) => {
2471 if run_open {
2472 queue!($stdout, Print(&run_buf))?;
2473 last_cursor = Some((run_next_col, run_abs_y));
2474 run_buf.clear();
2475 run_open = false;
2476 }
2477 };
2478 }
2479
2480 for y in current.area.y..current.area.bottom() {
2481 let force_row = forced_rows.binary_search(&y).is_ok();
2482 if !force_row
2491 && current.row_clean(y)
2492 && current.row_hash(y).is_some()
2493 && current.row_hash(y) == previous.row_hash(y)
2494 {
2495 continue;
2496 }
2497 for x in current.area.x..current.area.right() {
2498 let cell = current.get(x, y);
2499 let prev = previous.get(x, y);
2500 let symbol = cell.normalized_symbol();
2501 if (!force_row && cell == prev) || symbol.is_empty() {
2502 flush_run!(stdout);
2504 continue;
2505 }
2506
2507 let abs_y = row_offset + y;
2508 let cell_link = cell
2513 .hyperlink
2514 .as_deref()
2515 .filter(|u| crate::buffer::is_valid_osc8_url(u));
2516
2517 let extends = run_open
2519 && run_abs_y == abs_y
2520 && run_next_col == x
2521 && run_style == cell.style
2522 && run_link == cell_link;
2523
2524 if !extends {
2525 flush_run!(stdout);
2526
2527 has_updates = true;
2531
2532 let need_move = last_cursor.is_none_or(|(lx, ly)| lx != x || ly != abs_y);
2533 if need_move {
2534 queue!(stdout, cursor::MoveTo(sat_u16(x), sat_u16(abs_y)))?;
2535 }
2536
2537 if cell.style != last_style {
2538 if first_style {
2539 queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
2540 apply_style(stdout, &cell.style, color_depth)?;
2541 first_style = false;
2542 } else {
2543 apply_style_delta(stdout, &last_style, &cell.style, color_depth)?;
2544 }
2545 last_style = cell.style;
2546 }
2547
2548 if cell_link != active_link {
2549 if let Some(url) = cell_link {
2550 queue!(stdout, Print("\x1b]8;;"))?;
2555 queue!(stdout, Print(url))?;
2556 queue!(stdout, Print("\x07"))?;
2557 } else {
2558 queue!(stdout, Print("\x1b]8;;\x07"))?;
2559 }
2560 active_link = cell_link;
2561 }
2562
2563 run_open = true;
2564 run_abs_y = abs_y;
2565 run_style = cell.style;
2566 run_link = cell_link;
2567 }
2568
2569 run_buf.push_str(&symbol);
2573 let char_width = UnicodeWidthStr::width(symbol.as_str()).max(1) as u32;
2574 if char_width > 1 && symbol.chars().any(|c| c == '\u{FE0F}') {
2575 run_buf.push(' ');
2579 }
2580 run_next_col = x + char_width;
2581 }
2582
2583 flush_run!(stdout);
2585 }
2586
2587 if has_updates {
2588 if active_link.is_some() {
2589 queue!(stdout, Print("\x1b]8;;\x07"))?;
2590 }
2591 queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
2592 }
2593
2594 Ok(())
2595}
2596
2597#[doc(hidden)]
2607pub fn __bench_flush_buffer_diff<W: Write>(
2608 w: &mut W,
2609 current: &Buffer,
2610 previous: &Buffer,
2611 color_depth: ColorDepth,
2612) -> io::Result<()> {
2613 let mut run_buf = String::with_capacity(RUN_BUF_INITIAL_CAPACITY);
2616 flush_buffer_diff(w, current, previous, color_depth, 0, &mut run_buf)
2617}
2618
2619#[doc(hidden)]
2628pub fn __bench_flush_buffer_diff_mut<W: Write>(
2629 w: &mut W,
2630 current: &mut Buffer,
2631 previous: &mut Buffer,
2632 color_depth: ColorDepth,
2633) -> io::Result<()> {
2634 let mut run_buf = String::with_capacity(RUN_BUF_INITIAL_CAPACITY);
2638 __bench_flush_buffer_diff_mut_with_buf(w, current, previous, color_depth, &mut run_buf)
2639}
2640
2641#[doc(hidden)]
2666pub fn __bench_flush_buffer_diff_mut_with_buf<W: Write>(
2667 w: &mut W,
2668 current: &mut Buffer,
2669 previous: &mut Buffer,
2670 color_depth: ColorDepth,
2671 run_buf: &mut String,
2672) -> io::Result<()> {
2673 current.recompute_line_hashes();
2674 previous.recompute_line_hashes();
2675 flush_buffer_diff(w, current, previous, color_depth, 0, run_buf)
2676}
2677
2678#[doc(hidden)]
2683pub struct __BenchKittyFixture {
2684 mgr: KittyImageManager,
2685 placements: Vec<KittyPlacement>,
2686}
2687
2688#[doc(hidden)]
2691pub fn __bench_new_kitty_fixture(n: usize) -> __BenchKittyFixture {
2692 let mut placements = Vec::with_capacity(n);
2693 for i in 0..n {
2694 let mut rgba = vec![0u8; 256];
2696 rgba[0] = i as u8;
2698 let content_hash = crate::buffer::hash_rgba(&rgba);
2699 placements.push(KittyPlacement {
2700 content_hash,
2701 rgba: std::sync::Arc::new(rgba),
2702 src_width: 8,
2703 src_height: 8,
2704 x: (i as u32) * 4,
2705 y: (i as u32) * 2,
2706 cols: 4,
2707 rows: 2,
2708 crop_y: 0,
2709 crop_h: 0,
2710 });
2711 }
2712 __BenchKittyFixture {
2713 mgr: KittyImageManager::new(),
2714 placements,
2715 }
2716}
2717
2718impl __BenchKittyFixture {
2719 #[doc(hidden)]
2723 pub fn rgba_strong_counts(&self) -> Vec<usize> {
2724 self.placements
2725 .iter()
2726 .map(|p| std::sync::Arc::strong_count(&p.rgba))
2727 .collect()
2728 }
2729
2730 #[doc(hidden)]
2733 pub fn flush_inline<W: Write>(&mut self, sink: &mut W, row_offset: u32) -> io::Result<()> {
2734 self.mgr.flush(sink, &self.placements, row_offset)
2735 }
2736
2737 #[doc(hidden)]
2739 pub fn len(&self) -> usize {
2740 self.placements.len()
2741 }
2742
2743 #[doc(hidden)]
2745 pub fn is_empty(&self) -> bool {
2746 self.placements.is_empty()
2747 }
2748}
2749
2750#[doc(hidden)]
2760pub fn __bench_flush_kitty<W: Write>(sink: &mut W, n: usize, row_offset: u32) -> io::Result<()> {
2761 let mut fixture = __bench_new_kitty_fixture(n);
2762 fixture.flush_inline(sink, row_offset)
2763}
2764
2765#[doc(hidden)]
2772pub struct __BenchSprixelFixture {
2773 current: Buffer,
2774 previous: Buffer,
2775}
2776
2777#[doc(hidden)]
2786pub fn __bench_new_sprixel_fixture(n: usize) -> __BenchSprixelFixture {
2787 use crate::buffer::{SprixelCell, SprixelPlacement};
2788
2789 let height = (n as u32 * 3).max(1);
2791 let area = Rect::new(0, 0, 8, height);
2792 let mut current = Buffer::empty(area);
2793 let mut previous = Buffer::empty(area);
2794
2795 for i in 0..n {
2796 let placement = SprixelPlacement {
2797 content_hash: 0x5000 + i as u64,
2798 seq: "<SIXEL>".to_string(),
2799 x: 0,
2800 y: i as u32 * 3,
2801 cols: 4,
2802 rows: 2,
2803 cells: vec![SprixelCell::Opaque; 8],
2804 };
2805 current.sprixels.push(placement.clone());
2806 previous.sprixels.push(placement);
2807 }
2808
2809 current.recompute_line_hashes();
2812 previous.recompute_line_hashes();
2813
2814 __BenchSprixelFixture { current, previous }
2815}
2816
2817#[allow(dead_code)]
2826impl __BenchSprixelFixture {
2827 #[doc(hidden)]
2831 pub fn flush<W: Write>(&self, sink: &mut W, row_offset: u32) -> io::Result<()> {
2832 flush_sprixels(sink, &self.current, &self.previous, row_offset)
2833 }
2834
2835 #[doc(hidden)]
2837 pub fn len(&self) -> usize {
2838 self.current.sprixels.len()
2839 }
2840
2841 #[doc(hidden)]
2843 pub fn is_empty(&self) -> bool {
2844 self.current.sprixels.is_empty()
2845 }
2846}
2847
2848#[doc(hidden)]
2858pub fn __bench_flush_sprixels<W: Write>(sink: &mut W, n: usize, row_offset: u32) -> io::Result<()> {
2859 let fixture = __bench_new_sprixel_fixture(n);
2860 if fixture.is_empty() {
2861 return Ok(());
2862 }
2863 debug_assert_eq!(fixture.len(), n);
2864 fixture.flush(sink, row_offset)
2865}
2866
2867fn flush_raw_sequences(
2868 stdout: &mut impl Write,
2869 current: &Buffer,
2870 previous: &Buffer,
2871 row_offset: u32,
2872) -> io::Result<()> {
2873 if current.raw_sequences == previous.raw_sequences {
2874 return Ok(());
2875 }
2876
2877 for (x, y, seq) in ¤t.raw_sequences {
2878 queue!(
2879 stdout,
2880 cursor::MoveTo(sat_u16(*x), sat_u16(row_offset + *y)),
2881 Print(seq)
2882 )?;
2883 }
2884
2885 Ok(())
2886}
2887
2888type SprixelKey = (u64, u32, u32, u32, u32);
2893
2894#[inline]
2896fn sprixel_key(p: &crate::buffer::SprixelPlacement) -> SprixelKey {
2897 (p.content_hash, p.x, p.y, p.cols, p.rows)
2898}
2899
2900fn previous_only_sprixel_rows(
2901 current: &Buffer,
2902 previous: &Buffer,
2903 mut should_emit: impl FnMut(&crate::buffer::SprixelPlacement) -> bool,
2904) -> smallvec::SmallVec<[u32; 8]> {
2905 use crate::buffer::SprixelCell;
2906
2907 if previous.sprixels.is_empty() {
2908 return smallvec::SmallVec::new();
2909 }
2910 let current_keys: std::collections::HashSet<SprixelKey> =
2911 current.sprixels.iter().map(sprixel_key).collect();
2912 let mut rows = smallvec::SmallVec::<[u32; 8]>::new();
2913 for placement in &previous.sprixels {
2914 if !should_emit(placement) || current_keys.contains(&sprixel_key(placement)) {
2915 continue;
2916 }
2917 let Ok(cols) = usize::try_from(placement.cols) else {
2918 continue;
2919 };
2920 for row in 0..placement.rows {
2921 let Ok(row_index) = usize::try_from(row) else {
2922 continue;
2923 };
2924 let Some(start) = row_index.checked_mul(cols) else {
2925 continue;
2926 };
2927 let end = start.saturating_add(cols).min(placement.cells.len());
2928 if placement.cells.get(start..end).is_some_and(|cells| {
2929 cells
2930 .iter()
2931 .any(|cell| matches!(cell, SprixelCell::Opaque | SprixelCell::Mixed))
2932 }) {
2933 let y = placement.y.saturating_add(row);
2934 if current.area.contains(current.area.x, y) {
2935 rows.push(y);
2936 }
2937 }
2938 }
2939 }
2940 rows.sort_unstable();
2941 rows.dedup();
2942 rows
2943}
2944
2945fn sprixel_intersects_rows(placement: &crate::buffer::SprixelPlacement, rows: &[u32]) -> bool {
2946 let end = placement.y.saturating_add(placement.rows);
2947 rows.iter().any(|row| *row >= placement.y && *row < end)
2948}
2949
2950fn sprixel_needs_reblit(
2973 placement: &crate::buffer::SprixelPlacement,
2974 current: &Buffer,
2975 previous: &Buffer,
2976 prev_keys: &std::collections::HashSet<SprixelKey>,
2977 redrawn_rows: &[u32],
2978) -> bool {
2979 use crate::buffer::SprixelCell;
2980
2981 if !prev_keys.contains(&sprixel_key(placement)) {
2986 return true;
2987 }
2988
2989 if sprixel_intersects_rows(placement, redrawn_rows) {
2992 return true;
2993 }
2994
2995 for row in 0..placement.rows {
2999 let y = placement.y + row;
3000 if current.row_clean(y) && current.row_hash(y) == previous.row_hash(y) {
3004 continue;
3005 }
3006 for col in 0..placement.cols {
3007 let idx = (row * placement.cols + col) as usize;
3008 match placement.cells.get(idx) {
3009 Some(SprixelCell::Opaque) | Some(SprixelCell::Mixed) => {}
3010 _ => continue,
3013 }
3014 let x = placement.x + col;
3015 let (Some(cell), Some(prev)) = (current.try_get(x, y), previous.try_get(x, y)) else {
3020 continue;
3021 };
3022 if cell != prev && !cell.symbol.is_empty() {
3028 return true;
3029 }
3030 }
3031 }
3032
3033 false
3034}
3035
3036fn flush_sprixels(
3048 stdout: &mut impl Write,
3049 current: &Buffer,
3050 previous: &Buffer,
3051 row_offset: u32,
3052) -> io::Result<()> {
3053 flush_sprixels_inner(stdout, current, previous, row_offset, |_| true, &[])
3054}
3055
3056#[cfg(test)]
3057fn flush_sprixels_checked(
3058 stdout: &mut impl Write,
3059 current: &Buffer,
3060 previous: &Buffer,
3061 row_offset: u32,
3062 graphics_support: GraphicsEmissionSupport,
3063) -> io::Result<()> {
3064 flush_sprixels_checked_with_rows(stdout, current, previous, row_offset, graphics_support, &[])
3065}
3066
3067fn flush_sprixels_checked_with_rows(
3068 stdout: &mut impl Write,
3069 current: &Buffer,
3070 previous: &Buffer,
3071 row_offset: u32,
3072 graphics_support: GraphicsEmissionSupport,
3073 redrawn_rows: &[u32],
3074) -> io::Result<()> {
3075 flush_sprixels_inner(
3076 stdout,
3077 current,
3078 previous,
3079 row_offset,
3080 |placement| graphics_support.should_emit_sprixel(sprixel_protocol(&placement.seq)),
3081 redrawn_rows,
3082 )
3083}
3084
3085fn flush_sprixels_inner(
3086 stdout: &mut impl Write,
3087 current: &Buffer,
3088 previous: &Buffer,
3089 row_offset: u32,
3090 mut should_emit: impl FnMut(&crate::buffer::SprixelPlacement) -> bool,
3091 redrawn_rows: &[u32],
3092) -> io::Result<()> {
3093 if current.sprixels.is_empty() {
3096 return Ok(());
3097 }
3098
3099 let prev_keys: std::collections::HashSet<SprixelKey> =
3100 previous.sprixels.iter().map(sprixel_key).collect();
3101
3102 for placement in ¤t.sprixels {
3103 if should_emit(placement)
3104 && sprixel_needs_reblit(placement, current, previous, &prev_keys, redrawn_rows)
3105 {
3106 queue!(
3107 stdout,
3108 cursor::MoveTo(sat_u16(placement.x), sat_u16(row_offset + placement.y)),
3109 Print(&placement.seq)
3110 )?;
3111 }
3112 }
3113 Ok(())
3114}
3115
3116fn flush_cursor(
3117 stdout: &mut impl Write,
3118 cursor_visible: &mut bool,
3119 cursor_pos: Option<(u32, u32)>,
3120 row_offset: u32,
3121 fallback_row: Option<u32>,
3122) -> io::Result<()> {
3123 match cursor_pos {
3124 Some((cx, cy)) => {
3125 if !*cursor_visible {
3126 queue!(stdout, cursor::Show)?;
3127 *cursor_visible = true;
3128 }
3129 queue!(
3130 stdout,
3131 cursor::MoveTo(sat_u16(cx), sat_u16(row_offset + cy))
3132 )?;
3133 }
3134 None => {
3135 if *cursor_visible {
3136 queue!(stdout, cursor::Hide)?;
3137 *cursor_visible = false;
3138 }
3139 if let Some(row) = fallback_row {
3140 queue!(stdout, cursor::MoveTo(0, sat_u16(row)))?;
3141 }
3142 }
3143 }
3144
3145 Ok(())
3146}
3147
3148fn apply_style_delta(
3149 w: &mut impl Write,
3150 old: &Style,
3151 new: &Style,
3152 depth: ColorDepth,
3153) -> io::Result<()> {
3154 if old.fg != new.fg {
3155 match new.fg {
3156 Some(fg) => emit_fg_color(w, fg, depth)?,
3157 None => write!(w, "\x1b[39m")?,
3158 }
3159 }
3160 if old.bg != new.bg {
3161 match new.bg {
3162 Some(bg) => emit_bg_color(w, bg, depth)?,
3163 None => write!(w, "\x1b[49m")?,
3164 }
3165 }
3166 let removed = Modifiers(old.modifiers.0 & !new.modifiers.0);
3167 let added = Modifiers(new.modifiers.0 & !old.modifiers.0);
3168 if removed.contains(Modifiers::BOLD) || removed.contains(Modifiers::DIM) {
3169 queue!(w, SetAttribute(Attribute::NormalIntensity))?;
3170 if new.modifiers.contains(Modifiers::BOLD) {
3171 queue!(w, SetAttribute(Attribute::Bold))?;
3172 }
3173 if new.modifiers.contains(Modifiers::DIM) {
3174 queue!(w, SetAttribute(Attribute::Dim))?;
3175 }
3176 } else {
3177 if added.contains(Modifiers::BOLD) {
3178 queue!(w, SetAttribute(Attribute::Bold))?;
3179 }
3180 if added.contains(Modifiers::DIM) {
3181 queue!(w, SetAttribute(Attribute::Dim))?;
3182 }
3183 }
3184 if removed.contains(Modifiers::ITALIC) {
3185 queue!(w, SetAttribute(Attribute::NoItalic))?;
3186 }
3187 if added.contains(Modifiers::ITALIC) {
3188 queue!(w, SetAttribute(Attribute::Italic))?;
3189 }
3190 if removed.contains(Modifiers::UNDERLINE) {
3191 queue!(w, SetAttribute(Attribute::NoUnderline))?;
3192 }
3193 if added.contains(Modifiers::UNDERLINE) {
3194 queue!(w, SetAttribute(Attribute::Underlined))?;
3195 }
3196 if removed.contains(Modifiers::REVERSED) {
3197 queue!(w, SetAttribute(Attribute::NoReverse))?;
3198 }
3199 if added.contains(Modifiers::REVERSED) {
3200 queue!(w, SetAttribute(Attribute::Reverse))?;
3201 }
3202 if removed.contains(Modifiers::STRIKETHROUGH) {
3203 queue!(w, SetAttribute(Attribute::NotCrossedOut))?;
3204 }
3205 if added.contains(Modifiers::STRIKETHROUGH) {
3206 queue!(w, SetAttribute(Attribute::CrossedOut))?;
3207 }
3208 if removed.contains(Modifiers::BLINK) {
3209 queue!(w, SetAttribute(Attribute::NoBlink))?;
3210 }
3211 if added.contains(Modifiers::BLINK) {
3212 queue!(w, SetAttribute(Attribute::SlowBlink))?;
3213 }
3214 if removed.contains(Modifiers::OVERLINE) {
3215 queue!(w, SetAttribute(Attribute::NotOverLined))?;
3216 }
3217 if added.contains(Modifiers::OVERLINE) {
3218 queue!(w, SetAttribute(Attribute::OverLined))?;
3219 }
3220 if old.underline_style != new.underline_style {
3224 write!(w, "\x1b[4:{}m", underline_style_param(new.underline_style))?;
3225 }
3226 if old.underline_color != new.underline_color {
3227 emit_underline_color(w, new.underline_color, depth)?;
3228 }
3229 Ok(())
3230}
3231
3232fn underline_style_param(style: UnderlineStyle) -> u8 {
3234 match style {
3235 UnderlineStyle::Straight => 1,
3236 UnderlineStyle::Double => 2,
3237 UnderlineStyle::Curly => 3,
3238 UnderlineStyle::Dotted => 4,
3239 UnderlineStyle::Dashed => 5,
3240 }
3241}
3242
3243fn emit_underline_color(
3249 w: &mut impl Write,
3250 color: Option<Color>,
3251 depth: ColorDepth,
3252) -> io::Result<()> {
3253 match color {
3254 None => write!(w, "\x1b[59m"),
3255 Some(c) => match c.downsampled(depth) {
3256 Color::Reset => write!(w, "\x1b[59m"),
3257 Color::Rgb(r, g, b) => write!(w, "\x1b[58:2::{r}:{g}:{b}m"),
3258 Color::Indexed(i) => write!(w, "\x1b[58:5:{i}m"),
3259 named => {
3262 let (r, g, b) = named.to_rgb();
3263 write!(w, "\x1b[58:2::{r}:{g}:{b}m")
3264 }
3265 },
3266 }
3267}
3268
3269fn apply_style(w: &mut impl Write, style: &Style, depth: ColorDepth) -> io::Result<()> {
3270 if let Some(fg) = style.fg {
3271 emit_fg_color(w, fg, depth)?;
3272 }
3273 if let Some(bg) = style.bg {
3274 emit_bg_color(w, bg, depth)?;
3275 }
3276 let m = style.modifiers;
3277 if m.contains(Modifiers::BOLD) {
3278 queue!(w, SetAttribute(Attribute::Bold))?;
3279 }
3280 if m.contains(Modifiers::DIM) {
3281 queue!(w, SetAttribute(Attribute::Dim))?;
3282 }
3283 if m.contains(Modifiers::ITALIC) {
3284 queue!(w, SetAttribute(Attribute::Italic))?;
3285 }
3286 if m.contains(Modifiers::UNDERLINE) {
3287 queue!(w, SetAttribute(Attribute::Underlined))?;
3288 }
3289 if m.contains(Modifiers::REVERSED) {
3290 queue!(w, SetAttribute(Attribute::Reverse))?;
3291 }
3292 if m.contains(Modifiers::STRIKETHROUGH) {
3293 queue!(w, SetAttribute(Attribute::CrossedOut))?;
3294 }
3295 if m.contains(Modifiers::BLINK) {
3296 queue!(w, SetAttribute(Attribute::SlowBlink))?;
3297 }
3298 if m.contains(Modifiers::OVERLINE) {
3299 queue!(w, SetAttribute(Attribute::OverLined))?;
3300 }
3301 if style.underline_style != UnderlineStyle::Straight {
3302 write!(
3303 w,
3304 "\x1b[4:{}m",
3305 underline_style_param(style.underline_style)
3306 )?;
3307 }
3308 if style.underline_color.is_some() {
3309 emit_underline_color(w, style.underline_color, depth)?;
3310 }
3311 Ok(())
3312}
3313
3314fn emit_fg_color(w: &mut impl Write, color: Color, depth: ColorDepth) -> io::Result<()> {
3315 emit_sgr_color(w, color, depth, true)
3316}
3317
3318fn emit_bg_color(w: &mut impl Write, color: Color, depth: ColorDepth) -> io::Result<()> {
3319 emit_sgr_color(w, color, depth, false)
3320}
3321
3322fn emit_sgr_color(
3323 w: &mut impl Write,
3324 color: Color,
3325 depth: ColorDepth,
3326 foreground: bool,
3327) -> io::Result<()> {
3328 match color.downsampled(depth) {
3329 Color::Reset => {
3330 let reset = if foreground { 39 } else { 49 };
3331 write!(w, "\x1b[{reset}m")
3332 }
3333 Color::Rgb(r, g, b) => {
3334 let channel = if foreground { 38 } else { 48 };
3335 write!(w, "\x1b[{channel};2;{r};{g};{b}m")
3336 }
3337 Color::Indexed(i) => {
3338 let channel = if foreground { 38 } else { 48 };
3339 write!(w, "\x1b[{channel};5;{i}m")
3340 }
3341 named => {
3342 let code = named_sgr_code(named, foreground);
3343 write!(w, "\x1b[{code}m")
3344 }
3345 }
3346}
3347
3348fn named_sgr_code(color: Color, foreground: bool) -> u8 {
3349 let dark_base = if foreground { 30 } else { 40 };
3350 let bright_base = if foreground { 90 } else { 100 };
3351 match color {
3352 Color::Black => dark_base,
3353 Color::Red => dark_base + 1,
3354 Color::Green => dark_base + 2,
3355 Color::Yellow => dark_base + 3,
3356 Color::Blue => dark_base + 4,
3357 Color::Magenta => dark_base + 5,
3358 Color::Cyan => dark_base + 6,
3359 Color::White => dark_base + 7,
3360 Color::DarkGray => bright_base,
3361 Color::LightRed => bright_base + 1,
3362 Color::LightGreen => bright_base + 2,
3363 Color::LightYellow => bright_base + 3,
3364 Color::LightBlue => bright_base + 4,
3365 Color::LightMagenta => bright_base + 5,
3366 Color::LightCyan => bright_base + 6,
3367 Color::LightWhite => bright_base + 7,
3368 Color::Reset | Color::Rgb(..) | Color::Indexed(_) => unreachable!(),
3369 }
3370}
3371
3372fn reset_current_buffer(buffer: &mut Buffer, theme_bg: Option<Color>) {
3373 if let Some(bg) = theme_bg {
3374 buffer.reset_with_bg(bg);
3375 } else {
3376 buffer.reset();
3377 }
3378}
3379
3380fn write_session_enter(stdout: &mut impl Write, session: &TerminalSessionGuard) -> io::Result<()> {
3381 match session.mode {
3382 TerminalSessionMode::Fullscreen => {
3383 execute!(
3384 stdout,
3385 terminal::EnterAlternateScreen,
3386 cursor::Hide,
3387 EnableBracketedPaste
3388 )?;
3389 }
3390 TerminalSessionMode::Inline => {
3391 execute!(stdout, cursor::Hide, EnableBracketedPaste)?;
3392 }
3393 }
3394
3395 execute!(stdout, EnableFocusChange)?;
3401 if session.mouse_enabled {
3402 execute!(stdout, EnableMouseCapture)?;
3403 }
3404 if session.kitty_keyboard {
3405 let _ = write_kitty_keyboard_push(stdout, session.report_all_keys);
3406 }
3407
3408 Ok(())
3409}
3410
3411fn kitty_flags(report_all_keys: bool) -> crossterm::event::KeyboardEnhancementFlags {
3421 use crossterm::event::KeyboardEnhancementFlags;
3422 let mut flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
3423 | KeyboardEnhancementFlags::REPORT_EVENT_TYPES;
3424 if report_all_keys {
3425 flags |= KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES;
3426 }
3427 flags
3428}
3429
3430fn write_kitty_keyboard_push(stdout: &mut impl Write, report_all_keys: bool) -> io::Result<()> {
3434 write!(stdout, "\x1b[>{}u", kitty_flags(report_all_keys).bits())
3435}
3436
3437fn write_kitty_keyboard_pop(stdout: &mut impl Write) -> io::Result<()> {
3438 stdout.write_all(b"\x1b[<1u")
3439}
3440
3441fn write_session_cleanup(
3442 stdout: &mut impl Write,
3443 mode: TerminalSessionMode,
3444 inline_reserved: bool,
3445) -> io::Result<()> {
3446 execute!(
3447 stdout,
3448 ResetColor,
3449 SetAttribute(Attribute::Reset),
3450 cursor::Show,
3451 DisableBracketedPaste
3452 )?;
3453
3454 match mode {
3455 TerminalSessionMode::Fullscreen => {
3456 execute!(stdout, terminal::LeaveAlternateScreen)?;
3457 }
3458 TerminalSessionMode::Inline => {
3459 if inline_reserved {
3460 execute!(
3461 stdout,
3462 cursor::MoveToColumn(0),
3463 cursor::MoveDown(1),
3464 cursor::MoveToColumn(0),
3465 Print("\n")
3466 )?;
3467 } else {
3468 execute!(stdout, Print("\n"))?;
3469 }
3470 }
3471 }
3472
3473 Ok(())
3474}
3475
3476fn write_session_exit(
3477 stdout: &mut impl Write,
3478 mode: TerminalSessionMode,
3479 inline_reserved: bool,
3480 mouse_enabled: bool,
3481 kitty_keyboard: bool,
3482) -> io::Result<()> {
3483 if kitty_keyboard {
3484 write_kitty_keyboard_pop(stdout)?;
3485 }
3486 if mouse_enabled {
3487 let _ = execute!(stdout, DisableMouseCapture);
3492 }
3493 let _ = execute!(stdout, DisableFocusChange);
3494 write_session_cleanup(stdout, mode, inline_reserved)
3495}
3496
3497#[cfg(feature = "crossterm")]
3499pub(crate) fn cleanup_after_panic() -> bool {
3500 let Some(snapshot) = active_session_snapshot() else {
3501 return false;
3502 };
3503 let mut stdout = io::stdout();
3504 let _ = write_session_exit(
3505 &mut stdout,
3506 snapshot.mode,
3507 false,
3508 snapshot.mouse_enabled,
3509 snapshot.kitty_keyboard,
3510 );
3511 if snapshot.raw_mode_owned {
3512 let _ = terminal::disable_raw_mode();
3513 }
3514 let _ = stdout.flush();
3515 true
3516}
3517
3518#[cfg(test)]
3519fn write_panic_cleanup(stdout: &mut impl Write) -> io::Result<()> {
3520 write_session_exit(stdout, TerminalSessionMode::Fullscreen, false, true, true)
3521}
3522
3523#[cfg(unix)]
3539pub(crate) static NEEDS_FULL_REDRAW: std::sync::atomic::AtomicBool =
3540 std::sync::atomic::AtomicBool::new(false);
3541
3542#[cfg(unix)]
3543impl Terminal {
3544 pub(crate) fn session_snapshot(&self) -> SessionSnapshot {
3547 self.session.snapshot()
3548 }
3549}
3550
3551#[cfg(unix)]
3552impl InlineTerminal {
3553 pub(crate) fn session_snapshot(&self) -> SessionSnapshot {
3556 self.session.snapshot()
3557 }
3558}
3559
3560#[cfg(unix)]
3568fn write_suspend_sequence(stdout: &mut impl Write, snapshot: &SessionSnapshot) -> io::Result<()> {
3569 write_session_exit(
3570 stdout,
3571 snapshot.mode,
3572 false,
3573 snapshot.mouse_enabled,
3574 snapshot.kitty_keyboard,
3575 )
3576}
3577
3578#[cfg(unix)]
3585pub(crate) fn suspend_to_shell(snapshot: &SessionSnapshot) {
3586 let mut out = io::stdout();
3587 let _ = write_suspend_sequence(&mut out, snapshot);
3588 if snapshot.raw_mode_owned {
3589 let _ = terminal::disable_raw_mode();
3590 }
3591 let _ = out.flush();
3592}
3593
3594#[cfg(unix)]
3601pub(crate) fn resume_from_shell(snapshot: &SessionSnapshot) {
3602 let mut out = io::stdout();
3603 if snapshot.raw_mode_owned {
3604 let _ = terminal::enable_raw_mode();
3605 }
3606 let _ = resume_from_shell_with_writer(&mut out, snapshot);
3607}
3608
3609#[cfg(unix)]
3610fn resume_from_shell_with_writer(
3611 out: &mut impl Write,
3612 snapshot: &SessionSnapshot,
3613) -> io::Result<()> {
3614 let guard = TerminalSessionGuard {
3615 mode: snapshot.mode,
3616 mouse_enabled: snapshot.mouse_enabled,
3617 kitty_keyboard: snapshot.kitty_keyboard,
3618 report_all_keys: snapshot.report_all_keys,
3619 raw_mode_owned: snapshot.raw_mode_owned,
3620 registry_id: None,
3621 restored: std::sync::atomic::AtomicBool::new(false),
3622 harness: true,
3623 };
3624 write_session_enter(out, &guard)?;
3625 out.flush()?;
3626 NEEDS_FULL_REDRAW.store(true, std::sync::atomic::Ordering::SeqCst);
3627 Ok(())
3628}
3629
3630#[cfg(all(unix, test))]
3632fn test_snapshot(mode: TerminalSessionMode, mouse: bool, kitty: bool) -> SessionSnapshot {
3633 SessionSnapshot {
3634 mode,
3635 mouse_enabled: mouse,
3636 kitty_keyboard: kitty,
3637 report_all_keys: false,
3638 raw_mode_owned: true,
3639 }
3640}
3641
3642#[cfg(all(unix, test))]
3645pub(crate) fn test_session_snapshot() -> SessionSnapshot {
3646 SessionSnapshot {
3647 mode: TerminalSessionMode::Fullscreen,
3648 mouse_enabled: false,
3649 kitty_keyboard: false,
3650 report_all_keys: false,
3651 raw_mode_owned: true,
3652 }
3653}
3654
3655#[cfg(test)]
3656mod tests {
3657 #![allow(clippy::unwrap_used)]
3658 use super::*;
3659
3660 fn collect_with_feed(
3663 bytes: &'static [u8],
3664 delay: Duration,
3665 budget: Duration,
3666 is_complete: &mut dyn FnMut(&[u8]) -> bool,
3667 ) -> (Vec<u8>, Duration) {
3668 let (tx, rx) = std::sync::mpsc::channel::<u8>();
3669 std::thread::spawn(move || {
3670 std::thread::sleep(delay);
3671 for &b in bytes {
3672 if tx.send(b).is_err() {
3673 return;
3674 }
3675 }
3676 std::thread::sleep(Duration::from_secs(3));
3681 });
3682 let start = Instant::now();
3683 let out = collect_reply(&rx, start + budget, is_complete);
3684 (out, start.elapsed())
3685 }
3686
3687 #[cfg(unix)]
3688 fn open_raw_pty() -> (rustix::fd::OwnedFd, rustix::fd::OwnedFd) {
3689 use rustix::fs::{Mode, OFlags};
3690 use rustix::pty::OpenptFlags;
3691 use rustix::termios::OptionalActions;
3692
3693 let controller = rustix::pty::openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY).unwrap();
3694 rustix::pty::grantpt(&controller).unwrap();
3695 rustix::pty::unlockpt(&controller).unwrap();
3696 let path = rustix::pty::ptsname(&controller, Vec::new()).unwrap();
3697 let user = rustix::fs::open(&path, OFlags::RDWR | OFlags::NOCTTY, Mode::empty()).unwrap();
3698 let mut attrs = rustix::termios::tcgetattr(&user).unwrap();
3699 attrs.make_raw();
3700 rustix::termios::tcsetattr(&user, OptionalActions::Now, &attrs).unwrap();
3701 (controller, user)
3702 }
3703
3704 #[cfg(unix)]
3705 fn pty_write_all(fd: &impl rustix::fd::AsFd, mut bytes: &[u8]) {
3706 while !bytes.is_empty() {
3707 let written = rustix::io::write(fd, bytes).unwrap();
3708 bytes = &bytes[written..];
3709 }
3710 }
3711
3712 #[test]
3713 fn collect_reply_osc_bel_terminator_completes_early() {
3714 let reply = b"\x1b]11;rgb:0000/0000/0000\x07";
3715 let (out, elapsed) = collect_with_feed(
3716 reply,
3717 Duration::ZERO,
3718 Duration::from_secs(2),
3719 &mut osc_reply_complete,
3720 );
3721 assert_eq!(out, reply);
3722 assert!(
3723 elapsed < Duration::from_secs(1),
3724 "should not wait out the budget"
3725 );
3726 }
3727
3728 #[test]
3729 fn collect_reply_osc_st_terminator_completes_early() {
3730 let reply = b"\x1bP>|tmux 3.5a\x1b\\";
3731 let (out, elapsed) = collect_with_feed(
3732 reply,
3733 Duration::ZERO,
3734 Duration::from_secs(2),
3735 &mut osc_reply_complete,
3736 );
3737 assert_eq!(out, reply);
3738 assert!(elapsed < Duration::from_secs(1));
3739 }
3740
3741 #[test]
3742 fn collect_reply_silence_returns_empty_at_deadline() {
3743 let budget = Duration::from_millis(150);
3746 let (out, elapsed) =
3747 collect_with_feed(b"", Duration::from_secs(5), budget, &mut osc_reply_complete);
3748 assert!(out.is_empty());
3749 assert!(elapsed >= budget);
3750 assert!(
3751 elapsed < Duration::from_secs(2),
3752 "must not block past the budget"
3753 );
3754 }
3755
3756 #[test]
3757 fn collect_reply_da_drains_two_replies() {
3758 let reply = b"\x1b[?62;4c\x1b[>1;10;0c";
3759 let (out, elapsed) = collect_with_feed(
3760 reply,
3761 Duration::ZERO,
3762 Duration::from_secs(2),
3763 &mut da_reply_complete(),
3764 );
3765 assert_eq!(out, reply);
3766 assert!(elapsed < Duration::from_secs(1));
3767 }
3768
3769 #[test]
3770 fn collect_reply_da_lone_reply_returns_partial_at_deadline() {
3771 let budget = Duration::from_millis(150);
3775 let (out, elapsed) = collect_with_feed(
3776 b"\x1b[?62;4c",
3777 Duration::ZERO,
3778 budget,
3779 &mut da_reply_complete(),
3780 );
3781 assert_eq!(out, b"\x1b[?62;4c");
3782 assert!(elapsed >= budget);
3783 }
3784
3785 #[test]
3786 fn collect_reply_unterminated_caps_at_4096_bytes() {
3787 static BIG: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
3788 let big = BIG.get_or_init(|| vec![b'x'; 5000]).as_slice();
3789 let (tx, rx) = std::sync::mpsc::channel::<u8>();
3790 for &b in big {
3791 tx.send(b).unwrap();
3792 }
3793 let out = collect_reply(
3794 &rx,
3795 Instant::now() + Duration::from_secs(2),
3796 &mut osc_reply_complete,
3797 );
3798 assert_eq!(out.len(), 4096);
3799 }
3800
3801 #[test]
3802 fn decrpm_predicate_terminates_on_y() {
3803 let reply = b"\x1b[?2026;1$y";
3804 let (out, _) = collect_with_feed(
3805 reply,
3806 Duration::ZERO,
3807 Duration::from_secs(2),
3808 &mut decrpm_reply_complete,
3809 );
3810 assert_eq!(out, reply);
3811 }
3812
3813 #[cfg(unix)]
3814 #[test]
3815 fn pty_probe_deadline_preserves_first_tab_printable_and_escape_bytes() {
3816 for payload in [b"\t".as_slice(), b"A".as_slice(), b"\x1b[D".as_slice()] {
3817 let (controller, user) = open_raw_pty();
3818 let silent = read_reply_from_fd(
3819 &user,
3820 Instant::now() + Duration::from_millis(15),
3821 &mut |_| false,
3822 );
3823 assert!(silent.is_empty());
3824
3825 pty_write_all(&controller, payload);
3826 let received = read_reply_from_fd(
3827 &user,
3828 Instant::now() + Duration::from_millis(100),
3829 &mut |bytes| bytes.len() == payload.len(),
3830 );
3831 assert_eq!(received, payload, "first post-deadline input changed");
3832 }
3833 }
3834
3835 #[cfg(unix)]
3836 #[test]
3837 fn pty_responsive_and_partial_replies_keep_existing_semantics() {
3838 let (controller, user) = open_raw_pty();
3839 let full = b"\x1b[?62;4c\x1b[>1;10;0c";
3840 pty_write_all(&controller, full);
3841 let start = Instant::now();
3842 let received = read_reply_from_fd(
3843 &user,
3844 start + Duration::from_secs(1),
3845 &mut da_reply_complete(),
3846 );
3847 assert_eq!(received, full);
3848 assert!(start.elapsed() < Duration::from_millis(250));
3849
3850 let (controller, user) = open_raw_pty();
3851 let partial = b"\x1b[?62;4c";
3852 pty_write_all(&controller, partial);
3853 let received = read_reply_from_fd(
3854 &user,
3855 Instant::now() + Duration::from_millis(20),
3856 &mut da_reply_complete(),
3857 );
3858 assert_eq!(received, partial);
3859 }
3860
3861 #[test]
3862 fn reset_current_buffer_applies_theme_background() {
3863 let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 1));
3864
3865 reset_current_buffer(&mut buffer, Some(Color::Rgb(10, 20, 30)));
3866 assert_eq!(buffer.get(0, 0).style.bg, Some(Color::Rgb(10, 20, 30)));
3867
3868 reset_current_buffer(&mut buffer, None);
3869 assert_eq!(buffer.get(0, 0).style.bg, None);
3870 }
3871
3872 #[test]
3873 fn inline_vertical_resize_clamps_and_restores_anchor_row() {
3874 let mut term = InlineTerminal::with_sink(80, 24, 4, 18);
3875 assert_eq!(term.start_row, 18);
3876
3877 term.handle_resize_to(72, 10).unwrap();
3878 assert_eq!(term.size(), (72, 4));
3879 assert_eq!(term.start_row, 6);
3880
3881 term.handle_resize_to(100, 30).unwrap();
3882 assert_eq!(term.size(), (100, 4));
3883 assert_eq!(term.start_row, 18);
3884 }
3885
3886 #[test]
3887 fn raw_mode_ownership_only_tracks_state_acquired_by_slt() {
3888 assert!(raw_mode_is_acquired(false));
3889 assert!(!raw_mode_is_acquired(true));
3890
3891 let inherited = SessionSnapshot {
3892 mode: TerminalSessionMode::Fullscreen,
3893 mouse_enabled: false,
3894 kitty_keyboard: false,
3895 report_all_keys: false,
3896 raw_mode_owned: false,
3897 };
3898 assert!(!inherited.raw_mode_owned);
3899 }
3900
3901 #[test]
3902 fn terminal_buffer_pair_rejects_invalid_geometry_before_allocation() {
3903 let oversized = Rect::new(0, 0, crate::buffer::MAX_BUFFER_CELLS as u32 + 1, 1);
3904 assert!(try_buffer_pair(oversized).is_err());
3905
3906 let area = Rect::new(0, 0, 80, 24);
3907 let (current, previous) = try_buffer_pair(area).unwrap();
3908 assert_eq!(current.area, area);
3909 assert_eq!(previous.area, area);
3910 }
3911
3912 #[test]
3913 fn failed_inline_resize_preserves_both_buffers_and_viewport_state() {
3914 let mut term = InlineTerminal::with_sink(4, 24, 20, 4);
3915 term.current.set_string(0, 0, "safe", Style::new());
3916 let current_area = term.current.area;
3917 let previous_area = term.previous.area;
3918 let viewport_rows = term.viewport_rows;
3919 let start_row = term.start_row;
3920
3921 assert!(term.handle_resize_to(u16::MAX, 3).is_err());
3922 assert_eq!(term.current.area, current_area);
3923 assert_eq!(term.previous.area, previous_area);
3924 assert_eq!(term.viewport_rows, viewport_rows);
3925 assert_eq!(term.start_row, start_row);
3926 assert_eq!(term.current.get(0, 0).symbol, "s");
3927 }
3928
3929 #[test]
3930 fn inline_scrollback_moves_region_and_invalidates_previous_frame() {
3931 let mut term = InlineTerminal::with_sink(40, 24, 3, 10);
3932 term.reserved = true;
3933 term.previous
3934 .set_string(0, 0, "stale dynamic frame", Style::default());
3935
3936 term.write_scrollback(&["first".into(), "second\x1b[31m".into()])
3937 .unwrap();
3938
3939 assert_eq!(term.start_row, 12);
3940 assert_eq!(term.previous.get(0, 0).symbol, " ");
3941 let bytes = String::from_utf8(term.take_sink_bytes()).unwrap();
3942 assert!(bytes.contains("first\r\n"));
3943 assert!(bytes.contains("second?[31m\r\n"));
3944 assert!(bytes.contains("\u{1b}[11;1H"), "moves to the owned row");
3945 }
3946
3947 #[test]
3948 fn fullscreen_session_enter_writes_alt_screen_sequence() {
3949 let session = TerminalSessionGuard {
3950 mode: TerminalSessionMode::Fullscreen,
3951 mouse_enabled: false,
3952 kitty_keyboard: false,
3953 report_all_keys: false,
3954 raw_mode_owned: false,
3955 registry_id: None,
3956 restored: std::sync::atomic::AtomicBool::new(false),
3957 harness: true,
3958 };
3959 let mut out = Vec::new();
3960 write_session_enter(&mut out, &session).unwrap();
3961 let output = String::from_utf8(out).unwrap();
3962 assert!(output.contains("\u{1b}[?1049h"));
3963 assert!(output.contains("\u{1b}[?25l"));
3964 assert!(output.contains("\u{1b}[?2004h"));
3965 }
3966
3967 #[test]
3968 fn inline_session_enter_skips_alt_screen_sequence() {
3969 let session = TerminalSessionGuard {
3970 mode: TerminalSessionMode::Inline,
3971 mouse_enabled: false,
3972 kitty_keyboard: false,
3973 report_all_keys: false,
3974 raw_mode_owned: false,
3975 registry_id: None,
3976 restored: std::sync::atomic::AtomicBool::new(false),
3977 harness: true,
3978 };
3979 let mut out = Vec::new();
3980 write_session_enter(&mut out, &session).unwrap();
3981 let output = String::from_utf8(out).unwrap();
3982 assert!(!output.contains("\u{1b}[?1049h"));
3983 assert!(output.contains("\u{1b}[?25l"));
3984 assert!(output.contains("\u{1b}[?2004h"));
3985 }
3986
3987 #[test]
3988 fn session_enter_writes_kitty_keyboard_flags_portably() {
3989 let session = TerminalSessionGuard {
3990 mode: TerminalSessionMode::Fullscreen,
3991 mouse_enabled: false,
3992 kitty_keyboard: true,
3993 report_all_keys: true,
3994 raw_mode_owned: false,
3995 registry_id: None,
3996 restored: std::sync::atomic::AtomicBool::new(false),
3997 harness: true,
3998 };
3999 let mut out = Vec::new();
4000 write_session_enter(&mut out, &session).unwrap();
4001 let output = String::from_utf8(out).unwrap();
4002 let expected = format!("\u{1b}[>{}u", kitty_flags(true).bits());
4003 assert!(output.contains(&expected));
4004 }
4005
4006 #[test]
4007 fn fullscreen_session_cleanup_leaves_alt_screen() {
4008 let mut out = Vec::new();
4009 write_session_cleanup(&mut out, TerminalSessionMode::Fullscreen, false).unwrap();
4010 let output = String::from_utf8(out).unwrap();
4011 assert!(output.contains("\u{1b}[?1049l"));
4012 assert!(output.contains("\u{1b}[?25h"));
4013 assert!(output.contains("\u{1b}[?2004l"));
4014 }
4015
4016 #[test]
4017 fn inline_session_cleanup_keeps_normal_screen() {
4018 let mut out = Vec::new();
4019 write_session_cleanup(&mut out, TerminalSessionMode::Inline, false).unwrap();
4020 let output = String::from_utf8(out).unwrap();
4021 assert!(!output.contains("\u{1b}[?1049l"));
4022 assert!(output.ends_with('\n'));
4023 assert!(output.contains("\u{1b}[?25h"));
4024 assert!(output.contains("\u{1b}[?2004l"));
4025 }
4026
4027 #[test]
4028 fn session_exit_disables_focus_mouse_and_kitty_keyboard() {
4029 let mut out = Vec::new();
4030 write_session_exit(&mut out, TerminalSessionMode::Fullscreen, false, true, true).unwrap();
4031 let output = String::from_utf8(out).unwrap();
4032 #[cfg(not(windows))]
4035 assert!(output.contains("\u{1b}[?1004l"), "disables focus reporting");
4036 #[cfg(not(windows))]
4037 assert!(output.contains("\u{1b}[?1006l"), "disables SGR mouse mode");
4038 assert!(output.contains("\u{1b}[<1u"), "pops Kitty keyboard flags");
4039 assert!(output.contains("\u{1b}[?1049l"), "leaves alt screen");
4040 }
4041
4042 #[test]
4043 fn panic_cleanup_uses_full_session_exit_path() {
4044 let mut out = Vec::new();
4045 write_panic_cleanup(&mut out).unwrap();
4046 let output = String::from_utf8(out).unwrap();
4047 #[cfg(not(windows))]
4048 assert!(output.contains("\u{1b}[?1004l"), "disables focus reporting");
4049 assert!(output.contains("\u{1b}[<1u"), "pops Kitty keyboard flags");
4050 assert!(output.contains("\u{1b}[?1049l"), "leaves alt screen");
4051 }
4052
4053 #[cfg(unix)]
4056 #[test]
4057 fn suspend_sequence_fullscreen_leaves_alt_screen() {
4058 let snapshot = test_snapshot(TerminalSessionMode::Fullscreen, false, false);
4059 let mut out = Vec::new();
4060 write_suspend_sequence(&mut out, &snapshot).unwrap();
4061 let output = String::from_utf8(out).unwrap();
4062 assert!(output.contains("\u{1b}[?1049l"), "leaves alt screen");
4063 assert!(output.contains("\u{1b}[?25h"), "shows cursor");
4064 assert!(output.contains("\u{1b}[?2004l"), "disables bracketed paste");
4065 }
4066
4067 #[cfg(unix)]
4068 #[test]
4069 fn suspend_sequence_inline_keeps_normal_screen() {
4070 let snapshot = test_snapshot(TerminalSessionMode::Inline, false, false);
4071 let mut out = Vec::new();
4072 write_suspend_sequence(&mut out, &snapshot).unwrap();
4073 let output = String::from_utf8(out).unwrap();
4074 assert!(
4075 !output.contains("\u{1b}[?1049l"),
4076 "inline must not leave alt screen"
4077 );
4078 assert!(output.contains("\u{1b}[?25h"), "shows cursor");
4079 assert!(output.contains("\u{1b}[?2004l"), "disables bracketed paste");
4080 }
4081
4082 #[cfg(unix)]
4083 #[test]
4084 fn suspend_sequence_disables_mouse_and_kitty_when_enabled() {
4085 let snapshot = test_snapshot(TerminalSessionMode::Fullscreen, true, true);
4086 let mut out = Vec::new();
4087 write_suspend_sequence(&mut out, &snapshot).unwrap();
4088 let output = String::from_utf8(out).unwrap();
4090 assert!(output.contains("\u{1b}[?1006l"), "disables SGR mouse mode");
4091 }
4092
4093 #[cfg(unix)]
4094 #[test]
4095 fn resume_sequence_fullscreen_round_trips_enter_and_flags_redraw() {
4096 let snapshot = test_snapshot(TerminalSessionMode::Fullscreen, false, false);
4097
4098 let guard = TerminalSessionGuard {
4100 mode: snapshot.mode,
4101 mouse_enabled: snapshot.mouse_enabled,
4102 kitty_keyboard: snapshot.kitty_keyboard,
4103 report_all_keys: snapshot.report_all_keys,
4104 raw_mode_owned: snapshot.raw_mode_owned,
4105 registry_id: None,
4106 restored: std::sync::atomic::AtomicBool::new(false),
4107 harness: true,
4108 };
4109 let mut enter_bytes = Vec::new();
4110 write_session_enter(&mut enter_bytes, &guard).unwrap();
4111 let enter = String::from_utf8(enter_bytes).unwrap();
4112 assert!(enter.contains("\u{1b}[?1049h"));
4113 assert!(enter.contains("\u{1b}[?25l"));
4114 assert!(enter.contains("\u{1b}[?2004h"));
4115
4116 NEEDS_FULL_REDRAW.store(false, std::sync::atomic::Ordering::SeqCst);
4119 let mut resume_bytes = Vec::new();
4120 resume_from_shell_with_writer(&mut resume_bytes, &snapshot).unwrap();
4121 assert_eq!(String::from_utf8(resume_bytes).unwrap(), enter);
4122 assert!(
4123 NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst),
4124 "resume must request a full redraw exactly once"
4125 );
4126 assert!(
4127 !NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst),
4128 "the redraw flag is consumed by the first swap (idempotent)"
4129 );
4130 }
4131
4132 #[cfg(unix)]
4133 #[test]
4134 fn needs_full_redraw_swaps_true_once() {
4135 NEEDS_FULL_REDRAW.store(true, std::sync::atomic::Ordering::SeqCst);
4136 assert!(NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst));
4137 assert!(!NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst));
4138 }
4139
4140 #[test]
4141 fn kitty_flags_base_set_excludes_report_all_keys() {
4142 use crossterm::event::KeyboardEnhancementFlags;
4143 let flags = kitty_flags(false);
4144 assert!(flags.contains(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES));
4145 assert!(flags.contains(KeyboardEnhancementFlags::REPORT_EVENT_TYPES));
4146 assert!(!flags.contains(KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES));
4147 }
4148
4149 #[test]
4150 fn kitty_flags_report_all_keys_sets_flag() {
4151 use crossterm::event::KeyboardEnhancementFlags;
4152 let flags = kitty_flags(true);
4153 assert!(flags.contains(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES));
4154 assert!(flags.contains(KeyboardEnhancementFlags::REPORT_EVENT_TYPES));
4155 assert!(flags.contains(KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES));
4156 }
4157
4158 #[test]
4159 fn graphics_emission_requires_protocol_support() {
4160 let unsupported = GraphicsEmissionSupport {
4161 real_terminal: true,
4162 capabilities: Capabilities::default(),
4163 force_kitty: false,
4164 force_sixel: false,
4165 force_iterm: false,
4166 };
4167 assert!(!unsupported.should_emit_kitty());
4168 assert!(!unsupported.should_emit_sprixel(SprixelProtocol::Sixel));
4169 assert!(!unsupported.should_emit_sprixel(SprixelProtocol::Iterm2));
4170 assert!(!unsupported.should_emit_sprixel(SprixelProtocol::Unknown));
4171
4172 let sixel = GraphicsEmissionSupport {
4173 capabilities: Capabilities {
4174 sixel: true,
4175 ..Capabilities::default()
4176 },
4177 ..unsupported
4178 };
4179 assert!(sixel.should_emit_sprixel(SprixelProtocol::Sixel));
4180
4181 let forced = GraphicsEmissionSupport {
4182 force_kitty: true,
4183 force_iterm: true,
4184 ..unsupported
4185 };
4186 assert!(forced.should_emit_kitty());
4187 assert!(forced.should_emit_sprixel(SprixelProtocol::Iterm2));
4188 }
4189
4190 #[test]
4191 fn base64_encode_empty() {
4192 assert_eq!(base64_encode(b""), "");
4193 }
4194
4195 #[test]
4196 fn base64_encode_hello() {
4197 assert_eq!(base64_encode(b"Hello"), "SGVsbG8=");
4198 }
4199
4200 #[test]
4201 fn base64_encode_padding() {
4202 assert_eq!(base64_encode(b"a"), "YQ==");
4203 assert_eq!(base64_encode(b"ab"), "YWI=");
4204 assert_eq!(base64_encode(b"abc"), "YWJj");
4205 }
4206
4207 #[test]
4208 fn base64_encode_unicode() {
4209 assert_eq!(base64_encode("한글".as_bytes()), "7ZWc6riA");
4210 }
4211
4212 #[cfg(feature = "crossterm")]
4213 #[test]
4214 fn parse_osc11_response_dark_and_light() {
4215 assert_eq!(
4216 parse_osc11_response("\x1b]11;rgb:0000/0000/0000\x1b\\"),
4217 ColorScheme::Dark
4218 );
4219 assert_eq!(
4220 parse_osc11_response("\x1b]11;rgb:ffff/ffff/ffff\x07"),
4221 ColorScheme::Light
4222 );
4223 }
4224
4225 #[test]
4228 fn blitter_support_default_is_conservative() {
4229 let b = BlitterSupport::default();
4230 assert!(b.half);
4231 assert!(b.quad);
4232 assert!(!b.sextant);
4233 }
4234
4235 #[test]
4236 fn capabilities_default_is_all_false_but_half_block() {
4237 let c = Capabilities::default();
4238 assert!(!c.truecolor);
4239 assert!(!c.sixel);
4240 assert!(!c.iterm2);
4241 assert!(!c.kitty_graphics);
4242 assert!(!c.kitty_keyboard);
4243 assert!(!c.sync_output);
4244 assert_eq!(c.best_blitter(), Blitter::HalfBlock);
4246 }
4247
4248 #[test]
4249 fn best_blitter_ladder_table() {
4250 let kitty = Capabilities {
4251 kitty_graphics: true,
4252 ..Default::default()
4253 };
4254 assert_eq!(kitty.best_blitter(), Blitter::Kitty);
4255
4256 let sixel = Capabilities {
4257 sixel: true,
4258 ..Default::default()
4259 };
4260 assert_eq!(sixel.best_blitter(), Blitter::Sixel);
4261
4262 let iterm2 = Capabilities {
4263 iterm2: true,
4264 ..Default::default()
4265 };
4266 assert_eq!(iterm2.best_blitter(), Blitter::Iterm2);
4267
4268 let sixel_and_iterm2 = Capabilities {
4270 sixel: true,
4271 iterm2: true,
4272 ..Default::default()
4273 };
4274 assert_eq!(sixel_and_iterm2.best_blitter(), Blitter::Sixel);
4275
4276 let sextant = Capabilities {
4277 blitters: BlitterSupport {
4278 sextant: true,
4279 ..Default::default()
4280 },
4281 ..Default::default()
4282 };
4283 assert_eq!(sextant.best_blitter(), Blitter::Sextant);
4284
4285 assert_eq!(Capabilities::default().best_blitter(), Blitter::HalfBlock);
4286 }
4287
4288 #[test]
4289 fn best_blitter_precedence_kitty_over_everything() {
4290 let all = Capabilities {
4291 kitty_graphics: true,
4292 sixel: true,
4293 blitters: BlitterSupport {
4294 sextant: true,
4295 ..Default::default()
4296 },
4297 ..Default::default()
4298 };
4299 assert_eq!(all.best_blitter(), Blitter::Kitty);
4300
4301 let sixel_and_sextant = Capabilities {
4302 sixel: true,
4303 blitters: BlitterSupport {
4304 sextant: true,
4305 ..Default::default()
4306 },
4307 ..Default::default()
4308 };
4309 assert_eq!(sixel_and_sextant.best_blitter(), Blitter::Sixel);
4310 }
4311
4312 #[test]
4313 fn best_blitter_never_picks_unsupported_protocol() {
4314 for kitty in [false, true] {
4317 for sixel in [false, true] {
4318 for iterm2 in [false, true] {
4319 for sextant in [false, true] {
4320 let caps = Capabilities {
4321 kitty_graphics: kitty,
4322 sixel,
4323 iterm2,
4324 blitters: BlitterSupport {
4325 sextant,
4326 ..Default::default()
4327 },
4328 ..Default::default()
4329 };
4330 match caps.best_blitter() {
4331 Blitter::Kitty => assert!(kitty),
4332 Blitter::Sixel => assert!(sixel && !kitty),
4333 Blitter::Iterm2 => assert!(iterm2 && !sixel && !kitty),
4334 Blitter::Sextant => {
4335 assert!(sextant && !iterm2 && !sixel && !kitty)
4336 }
4337 Blitter::HalfBlock => {
4338 assert!(!kitty && !sixel && !iterm2 && !sextant)
4339 }
4340 }
4341 }
4342 }
4343 }
4344 }
4345 }
4346
4347 #[cfg(feature = "crossterm")]
4348 #[test]
4349 fn parse_da1_attribute_4_sets_sixel() {
4350 let mut caps = Capabilities::default();
4351 parse_da1("\x1b[?62;4;6c", &mut caps);
4352 assert!(caps.sixel);
4353 }
4354
4355 #[cfg(feature = "crossterm")]
4356 #[test]
4357 fn parse_da1_without_4_leaves_sixel_false() {
4358 let mut caps = Capabilities::default();
4359 parse_da1("\x1b[?62;1;6c", &mut caps);
4360 assert!(!caps.sixel);
4361 }
4362
4363 #[cfg(feature = "crossterm")]
4364 #[test]
4365 fn parse_da1_ignores_da2_segment_in_same_string() {
4366 let mut caps = Capabilities::default();
4368 parse_da1("\x1b[?62;1c\x1b[>0;276;0c", &mut caps);
4369 assert!(!caps.sixel);
4370 }
4371
4372 #[cfg(feature = "crossterm")]
4373 #[test]
4374 fn parse_da2_no_panic_on_garbage() {
4375 let mut caps = Capabilities::default();
4376 parse_da2("\x1b[>99;1;0c", &mut caps);
4378 assert!(!caps.kitty_graphics);
4379 parse_da2("not a da2 reply", &mut caps);
4380 assert!(!caps.kitty_graphics);
4381 }
4382
4383 #[cfg(feature = "crossterm")]
4384 #[test]
4385 fn parse_da2_kitty_id_sets_kitty_graphics() {
4386 let mut caps = Capabilities::default();
4387 parse_da2("\x1b[>41;4000;0c", &mut caps);
4389 assert!(caps.kitty_graphics);
4390 }
4391
4392 #[cfg(feature = "crossterm")]
4393 #[test]
4394 fn parse_da2_identity_extracts_id_and_version() {
4395 assert_eq!(parse_da2_identity("\x1b[>0;276;0c"), Some((0, 276)));
4396 assert_eq!(parse_da2_identity("\x1b[>41;4000;0c"), Some((41, 4000)));
4397 assert_eq!(parse_da2_identity("no reply here"), None);
4398 }
4399
4400 #[cfg(feature = "crossterm")]
4401 #[test]
4402 fn parse_kitty_graphics_ack_ok_sets_flag() {
4403 let mut caps = Capabilities::default();
4404 parse_kitty_graphics_ack("\x1b_Gi=31;OK\x1b\\", &mut caps);
4405 assert!(caps.kitty_graphics);
4406 }
4407
4408 #[cfg(feature = "crossterm")]
4409 #[test]
4410 fn parse_kitty_graphics_ack_error_or_wrong_id_leaves_flag() {
4411 let mut caps = Capabilities::default();
4412 parse_kitty_graphics_ack("\x1b_Gi=31;ENOENT:bad\x1b\\", &mut caps);
4414 assert!(!caps.kitty_graphics);
4415 parse_kitty_graphics_ack("\x1b_Gi=99;OK\x1b\\", &mut caps);
4417 assert!(!caps.kitty_graphics);
4418 parse_kitty_graphics_ack("garbage", &mut caps);
4420 assert!(!caps.kitty_graphics);
4421 }
4422
4423 #[cfg(feature = "crossterm")]
4424 #[test]
4425 fn parse_decrpm_sync_output_recognized_states_are_supported() {
4426 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;1$y"), Some(true));
4429 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;2$y"), Some(true));
4430 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;3$y"), Some(true));
4431 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;4$y"), Some(true));
4432 }
4433
4434 #[cfg(feature = "crossterm")]
4435 #[test]
4436 fn parse_decrpm_sync_output_ps0_is_unsupported() {
4437 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;0$y"), Some(false));
4439 }
4440
4441 #[cfg(feature = "crossterm")]
4442 #[test]
4443 fn parse_decrpm_sync_output_garbage_is_none() {
4444 assert_eq!(parse_decrpm_sync_output("not a decrpm reply"), None);
4446 assert_eq!(parse_decrpm_sync_output("\x1b[?2004;1$y"), None);
4448 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;1"), None);
4450 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;x$y"), None);
4452 }
4453
4454 #[test]
4455 fn sync_output_gate_defaults_to_emit() {
4456 assert!(should_emit_synchronized_update());
4461 }
4462
4463 #[test]
4464 fn terminal_query_guard_rejects_unsafe_hosts() {
4465 assert!(terminal_query_allowed(
4466 true,
4467 true,
4468 "xterm-256color",
4469 None,
4470 false,
4471 false
4472 ));
4473 assert!(!terminal_query_allowed(
4474 true,
4475 false,
4476 "xterm-256color",
4477 None,
4478 false,
4479 false
4480 ));
4481 assert!(!terminal_query_allowed(
4482 false,
4483 true,
4484 "xterm-256color",
4485 None,
4486 false,
4487 false
4488 ));
4489 assert!(!terminal_query_allowed(
4490 true, true, "dumb", None, false, false
4491 ));
4492 assert!(!terminal_query_allowed(
4493 true,
4494 true,
4495 "screen-256color",
4496 Some(MultiplexerKind::Screen),
4497 false,
4498 false
4499 ));
4500 assert!(!terminal_query_allowed(true, true, "", None, false, false));
4501 }
4502
4503 #[test]
4504 fn terminal_query_guard_honors_force_and_disable_precedence() {
4505 assert!(terminal_query_allowed(
4506 true,
4507 true,
4508 "dumb",
4509 Some(MultiplexerKind::Tmux),
4510 true,
4511 false
4512 ));
4513 assert!(!terminal_query_allowed(
4514 true,
4515 true,
4516 "xterm-kitty",
4517 None,
4518 true,
4519 true
4520 ));
4521 }
4522
4523 #[test]
4524 fn automatic_query_hosts_require_a_real_terminal_identity() {
4525 assert!(!terminal_query_host_is_identified_env(
4526 "xterm-256color",
4527 false,
4528 false
4529 ));
4530 assert!(terminal_query_host_is_identified_env(
4531 "xterm-256color",
4532 true,
4533 false
4534 ));
4535 assert!(terminal_query_host_is_identified_env(
4536 "xterm-kitty",
4537 false,
4538 false
4539 ));
4540 assert!(terminal_query_host_is_identified_env("foot", false, false));
4541 assert!(!terminal_query_host_is_identified_env(
4542 "xterm-256color",
4543 true,
4544 true
4545 ));
4546 }
4547
4548 #[test]
4549 fn remote_env_does_not_treat_inherited_outer_identity_as_endpoint_proof() {
4550 assert!(terminal_is_remote_env(true, false, false));
4551 assert!(terminal_is_remote_env(false, true, false));
4552 assert!(terminal_is_remote_env(false, false, true));
4553 assert!(!terminal_is_remote_env(false, false, false));
4554
4555 assert!(!term_is_kitty_graphics_host_env(
4556 "xterm-256color",
4557 "wezterm",
4558 None,
4559 true,
4560 false
4561 ));
4562 assert!(term_is_kitty_graphics_host_env(
4563 "xterm-kitty",
4564 "wezterm",
4565 None,
4566 true,
4567 false
4568 ));
4569 assert!(!term_is_sixel_host_env(
4570 "xterm-256color",
4571 "ghostty",
4572 None,
4573 true,
4574 false
4575 ));
4576 assert!(!term_is_iterm_host_env("iterm.app", None, true, false));
4577 assert!(term_is_iterm_host_env("iterm.app", None, true, true));
4578 }
4579
4580 #[test]
4581 fn terminal_multiplexer_detection_is_conservative() {
4582 assert_eq!(
4583 terminal_multiplexer_env("tmux-256color", false, false, false, false),
4584 Some(MultiplexerKind::Tmux)
4585 );
4586 assert_eq!(
4587 terminal_multiplexer_env("screen-256color", false, false, false, false),
4588 Some(MultiplexerKind::Screen)
4589 );
4590 assert_eq!(
4591 terminal_multiplexer_env("xterm-256color", false, false, true, false),
4592 Some(MultiplexerKind::Zellij)
4593 );
4594 assert_eq!(
4595 terminal_multiplexer_env("xterm-256color", false, false, false, true),
4596 Some(MultiplexerKind::Zellij)
4597 );
4598 assert_eq!(
4599 terminal_multiplexer_env("xterm-kitty", false, false, false, false),
4600 None
4601 );
4602 assert_eq!(
4603 terminal_multiplexer_env("tmux-256color", true, false, true, true),
4604 Some(MultiplexerKind::Tmux),
4605 "the innermost TERM identity wins over inherited variables"
4606 );
4607 }
4608
4609 #[test]
4610 fn multiplexer_protocol_policy_is_explicit_and_disable_wins_force() {
4611 for multiplexer in [MultiplexerKind::Tmux, MultiplexerKind::Screen] {
4612 for protocol in [
4613 TerminalProtocol::Queries,
4614 TerminalProtocol::SynchronizedOutput,
4615 TerminalProtocol::KittyGraphics,
4616 TerminalProtocol::Sixel,
4617 TerminalProtocol::Iterm2,
4618 TerminalProtocol::KittyKeyboard,
4619 ] {
4620 assert!(!terminal_protocol_allowed(
4621 Some(multiplexer),
4622 protocol,
4623 false,
4624 false
4625 ));
4626 assert!(terminal_protocol_allowed(
4627 Some(multiplexer),
4628 protocol,
4629 true,
4630 false
4631 ));
4632 assert!(!terminal_protocol_allowed(
4633 Some(multiplexer),
4634 protocol,
4635 true,
4636 true
4637 ));
4638 }
4639 }
4640
4641 let zellij = Some(MultiplexerKind::Zellij);
4642 assert!(terminal_protocol_allowed(
4643 zellij,
4644 TerminalProtocol::Sixel,
4645 false,
4646 false
4647 ));
4648 assert!(terminal_protocol_allowed(
4649 zellij,
4650 TerminalProtocol::KittyKeyboard,
4651 false,
4652 false
4653 ));
4654 for protocol in [
4655 TerminalProtocol::Queries,
4656 TerminalProtocol::SynchronizedOutput,
4657 TerminalProtocol::KittyGraphics,
4658 TerminalProtocol::Iterm2,
4659 ] {
4660 assert!(!terminal_protocol_allowed(zellij, protocol, false, false));
4661 }
4662 }
4663
4664 #[test]
4665 fn kitty_env_fallback_is_blocked_inside_multiplexer_unless_forced() {
4666 assert!(term_is_kitty_graphics_host_env(
4667 "xterm-kitty",
4668 "",
4669 None,
4670 false,
4671 false
4672 ));
4673 assert!(term_is_kitty_graphics_host_env(
4674 "xterm-256color",
4675 "wezterm",
4676 None,
4677 false,
4678 false
4679 ));
4680 assert!(!term_is_kitty_graphics_host_env(
4681 "xterm-kitty",
4682 "wezterm",
4683 Some(MultiplexerKind::Tmux),
4684 false,
4685 false
4686 ));
4687 assert!(term_is_kitty_graphics_host_env(
4688 "xterm-256color",
4689 "",
4690 Some(MultiplexerKind::Tmux),
4691 false,
4692 true
4693 ));
4694 }
4695
4696 #[test]
4697 fn iterm_env_fallback_is_blocked_inside_multiplexer_unless_forced() {
4698 assert!(term_is_iterm_host_env("iterm.app", None, false, false));
4699 assert!(term_is_iterm_host_env("wezterm", None, false, false));
4700 assert!(!term_is_iterm_host_env(
4701 "wezterm",
4702 Some(MultiplexerKind::Tmux),
4703 false,
4704 false
4705 ));
4706 assert!(term_is_iterm_host_env(
4707 "xterm",
4708 Some(MultiplexerKind::Tmux),
4709 false,
4710 true
4711 ));
4712 }
4713
4714 #[test]
4715 fn zellij_uses_protocol_specific_graphics_policy() {
4716 let zellij = Some(MultiplexerKind::Zellij);
4717 assert!(term_is_sixel_host_env(
4718 "xterm-256color",
4719 "wezterm",
4720 zellij,
4721 false,
4722 false
4723 ));
4724 assert!(!term_is_kitty_graphics_host_env(
4725 "xterm-kitty",
4726 "wezterm",
4727 zellij,
4728 false,
4729 false
4730 ));
4731 assert!(!term_is_iterm_host_env("iterm.app", zellij, false, false));
4732 assert!(kitty_keyboard_allowed_env(zellij, false, false));
4733 assert!(!synchronized_update_allowed(zellij, false, false, false));
4734 assert!(synchronized_update_allowed(zellij, true, false, false));
4735
4736 for multiplexer in [MultiplexerKind::Tmux, MultiplexerKind::Screen] {
4737 assert!(!kitty_keyboard_allowed_env(Some(multiplexer), false, false));
4738 assert!(kitty_keyboard_allowed_env(Some(multiplexer), true, false));
4739 assert!(!synchronized_update_allowed(
4740 Some(multiplexer),
4741 false,
4742 false,
4743 false
4744 ));
4745 }
4746 }
4747
4748 #[test]
4749 fn graphics_support_blocks_kitty_without_ack_or_force() {
4750 let support = GraphicsEmissionSupport {
4751 real_terminal: true,
4752 capabilities: Capabilities::default(),
4753 force_kitty: false,
4754 force_sixel: false,
4755 force_iterm: false,
4756 };
4757 assert!(!support.should_emit_kitty());
4758
4759 let acked = GraphicsEmissionSupport {
4760 capabilities: Capabilities {
4761 kitty_graphics: true,
4762 ..Default::default()
4763 },
4764 ..support
4765 };
4766 assert!(acked.should_emit_kitty());
4767
4768 let forced = GraphicsEmissionSupport {
4769 force_kitty: true,
4770 ..support
4771 };
4772 assert!(forced.should_emit_kitty());
4773
4774 let captured = GraphicsEmissionSupport {
4775 real_terminal: false,
4776 force_kitty: true,
4777 ..support
4778 };
4779 assert!(!captured.should_emit_kitty());
4780 }
4781
4782 #[test]
4783 fn graphics_support_blocks_sprixels_without_ack_or_force() {
4784 let support = GraphicsEmissionSupport {
4785 real_terminal: true,
4786 capabilities: Capabilities::default(),
4787 force_kitty: false,
4788 force_sixel: false,
4789 force_iterm: false,
4790 };
4791 assert!(!support.should_emit_sprixel(SprixelProtocol::Sixel));
4792 assert!(!support.should_emit_sprixel(SprixelProtocol::Iterm2));
4793 assert!(!support.should_emit_sprixel(SprixelProtocol::Unknown));
4794
4795 let sixel_acked = GraphicsEmissionSupport {
4796 capabilities: Capabilities {
4797 sixel: true,
4798 ..Default::default()
4799 },
4800 ..support
4801 };
4802 assert!(sixel_acked.should_emit_sprixel(SprixelProtocol::Sixel));
4803
4804 let iterm_forced = GraphicsEmissionSupport {
4805 force_iterm: true,
4806 ..support
4807 };
4808 assert!(iterm_forced.should_emit_sprixel(SprixelProtocol::Iterm2));
4809 }
4810
4811 #[test]
4812 fn sprixel_protocol_detects_sixel_and_iterm() {
4813 assert_eq!(
4814 sprixel_protocol("\x1bPqpayload\x1b\\"),
4815 SprixelProtocol::Sixel
4816 );
4817 assert_eq!(
4818 sprixel_protocol("\x1b]1337;File=inline=1:AAAA\x07"),
4819 SprixelProtocol::Iterm2
4820 );
4821 assert_eq!(sprixel_protocol("plain"), SprixelProtocol::Unknown);
4822 }
4823
4824 #[cfg(feature = "crossterm")]
4825 #[test]
4826 fn parse_xtgettcap_tc_sets_truecolor() {
4827 let mut caps = Capabilities::default();
4828 parse_xtgettcap_truecolor("\x1bP1+r5463=\x1b\\", &mut caps);
4830 assert!(caps.truecolor);
4831 }
4832
4833 #[cfg(feature = "crossterm")]
4834 #[test]
4835 fn parse_xtgettcap_invalid_leaves_truecolor_false() {
4836 let mut caps = Capabilities::default();
4837 parse_xtgettcap_truecolor("\x1bP0+r5463\x1b\\", &mut caps);
4839 assert!(!caps.truecolor);
4840 parse_xtgettcap_truecolor("\x1bP1+r1234=\x1b\\", &mut caps);
4842 assert!(!caps.truecolor);
4843 }
4844
4845 #[cfg(feature = "crossterm")]
4846 #[test]
4847 fn base64_decode_round_trip_hello() {
4848 let encoded = base64_encode("hello".as_bytes());
4849 assert_eq!(base64_decode(&encoded), Some("hello".to_string()));
4850 }
4851
4852 #[cfg(feature = "crossterm")]
4853 #[test]
4854 fn color_scheme_equality() {
4855 assert_eq!(ColorScheme::Dark, ColorScheme::Dark);
4856 assert_ne!(ColorScheme::Dark, ColorScheme::Light);
4857 assert_eq!(ColorScheme::Unknown, ColorScheme::Unknown);
4858 }
4859
4860 fn pair(r: Rect) -> (Rect, Rect) {
4861 (r, r)
4862 }
4863
4864 #[test]
4865 fn find_innermost_rect_picks_smallest() {
4866 let rects = vec![
4867 pair(Rect::new(0, 0, 80, 24)),
4868 pair(Rect::new(5, 2, 30, 10)),
4869 pair(Rect::new(10, 4, 10, 5)),
4870 ];
4871 let result = find_innermost_rect(&rects, 12, 5);
4872 assert_eq!(result, Some(Rect::new(10, 4, 10, 5)));
4873 }
4874
4875 #[test]
4876 fn find_innermost_rect_no_match() {
4877 let rects = vec![pair(Rect::new(10, 10, 5, 5))];
4878 assert_eq!(find_innermost_rect(&rects, 0, 0), None);
4879 }
4880
4881 #[test]
4882 fn find_innermost_rect_empty() {
4883 assert_eq!(find_innermost_rect(&[], 5, 5), None);
4884 }
4885
4886 #[test]
4887 fn find_innermost_rect_returns_content_rect() {
4888 let rects = vec![
4889 (Rect::new(0, 0, 80, 24), Rect::new(1, 1, 78, 22)),
4890 (Rect::new(5, 2, 30, 10), Rect::new(6, 3, 28, 8)),
4891 ];
4892 let result = find_innermost_rect(&rects, 10, 5);
4893 assert_eq!(result, Some(Rect::new(6, 3, 28, 8)));
4894 }
4895
4896 #[test]
4897 fn normalize_selection_already_ordered() {
4898 let (s, e) = normalize_selection((2, 1), (5, 3));
4899 assert_eq!(s, (2, 1));
4900 assert_eq!(e, (5, 3));
4901 }
4902
4903 #[test]
4904 fn normalize_selection_reversed() {
4905 let (s, e) = normalize_selection((5, 3), (2, 1));
4906 assert_eq!(s, (2, 1));
4907 assert_eq!(e, (5, 3));
4908 }
4909
4910 #[test]
4911 fn normalize_selection_same_row() {
4912 let (s, e) = normalize_selection((10, 5), (3, 5));
4913 assert_eq!(s, (3, 5));
4914 assert_eq!(e, (10, 5));
4915 }
4916
4917 #[test]
4918 fn selection_state_mouse_down_finds_rect() {
4919 let hit_map = vec![pair(Rect::new(0, 0, 80, 24)), pair(Rect::new(5, 2, 20, 10))];
4920 let mut sel = SelectionState::default();
4921 sel.mouse_down(10, 5, &hit_map);
4922 assert_eq!(sel.anchor, Some((10, 5)));
4923 assert_eq!(sel.current, Some((10, 5)));
4924 assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 20, 10)));
4925 assert!(!sel.active);
4926 }
4927
4928 #[test]
4929 fn selection_state_drag_activates() {
4930 let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
4931 let mut sel = SelectionState {
4932 anchor: Some((10, 5)),
4933 current: Some((10, 5)),
4934 widget_rect: Some(Rect::new(0, 0, 80, 24)),
4935 ..Default::default()
4936 };
4937 sel.mouse_drag(10, 5, &hit_map);
4938 assert!(!sel.active, "no movement = not active");
4939 sel.mouse_drag(11, 5, &hit_map);
4940 assert!(!sel.active, "1 cell horizontal = not active yet");
4941 sel.mouse_drag(13, 5, &hit_map);
4942 assert!(sel.active, ">1 cell horizontal = active");
4943 }
4944
4945 #[test]
4946 fn selection_state_drag_vertical_activates() {
4947 let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
4948 let mut sel = SelectionState {
4949 anchor: Some((10, 5)),
4950 current: Some((10, 5)),
4951 widget_rect: Some(Rect::new(0, 0, 80, 24)),
4952 ..Default::default()
4953 };
4954 sel.mouse_drag(10, 6, &hit_map);
4955 assert!(sel.active, "any vertical movement = active");
4956 }
4957
4958 #[test]
4959 fn selection_state_drag_expands_widget_rect() {
4960 let hit_map = vec![
4961 pair(Rect::new(0, 0, 80, 24)),
4962 pair(Rect::new(5, 2, 30, 10)),
4963 pair(Rect::new(5, 2, 30, 3)),
4964 ];
4965 let mut sel = SelectionState {
4966 anchor: Some((10, 3)),
4967 current: Some((10, 3)),
4968 widget_rect: Some(Rect::new(5, 2, 30, 3)),
4969 ..Default::default()
4970 };
4971 sel.mouse_drag(10, 6, &hit_map);
4972 assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 30, 10)));
4973 }
4974
4975 #[test]
4976 fn selection_state_clear_resets() {
4977 let mut sel = SelectionState {
4978 anchor: Some((1, 2)),
4979 current: Some((3, 4)),
4980 widget_rect: Some(Rect::new(0, 0, 10, 10)),
4981 active: true,
4982 };
4983 sel.clear();
4984 assert_eq!(sel.anchor, None);
4985 assert_eq!(sel.current, None);
4986 assert_eq!(sel.widget_rect, None);
4987 assert!(!sel.active);
4988 }
4989
4990 #[test]
4991 fn extract_selection_text_single_line() {
4992 let area = Rect::new(0, 0, 20, 5);
4993 let mut buf = Buffer::empty(area);
4994 buf.set_string(0, 0, "Hello World", Style::default());
4995 let sel = SelectionState {
4996 anchor: Some((0, 0)),
4997 current: Some((4, 0)),
4998 widget_rect: Some(area),
4999 active: true,
5000 };
5001 let text = extract_selection_text(&buf, &sel, &[]);
5002 assert_eq!(text, "Hello");
5003 }
5004
5005 #[test]
5006 fn extract_selection_text_multi_line() {
5007 let area = Rect::new(0, 0, 20, 5);
5008 let mut buf = Buffer::empty(area);
5009 buf.set_string(0, 0, "Line one", Style::default());
5010 buf.set_string(0, 1, "Line two", Style::default());
5011 buf.set_string(0, 2, "Line three", Style::default());
5012 let sel = SelectionState {
5013 anchor: Some((5, 0)),
5014 current: Some((3, 2)),
5015 widget_rect: Some(area),
5016 active: true,
5017 };
5018 let text = extract_selection_text(&buf, &sel, &[]);
5019 assert_eq!(text, "one\nLine two\nLine");
5020 }
5021
5022 #[test]
5023 fn extract_selection_text_clamped_to_widget() {
5024 let area = Rect::new(0, 0, 40, 10);
5025 let widget = Rect::new(5, 2, 10, 3);
5026 let mut buf = Buffer::empty(area);
5027 buf.set_string(5, 2, "ABCDEFGHIJ", Style::default());
5028 buf.set_string(5, 3, "KLMNOPQRST", Style::default());
5029 let sel = SelectionState {
5030 anchor: Some((3, 1)),
5031 current: Some((20, 5)),
5032 widget_rect: Some(widget),
5033 active: true,
5034 };
5035 let text = extract_selection_text(&buf, &sel, &[]);
5036 assert_eq!(text, "ABCDEFGHIJ\nKLMNOPQRST");
5037 }
5038
5039 #[test]
5040 fn extract_selection_text_inactive_returns_empty() {
5041 let area = Rect::new(0, 0, 10, 5);
5042 let buf = Buffer::empty(area);
5043 let sel = SelectionState {
5044 anchor: Some((0, 0)),
5045 current: Some((5, 2)),
5046 widget_rect: Some(area),
5047 active: false,
5048 };
5049 assert_eq!(extract_selection_text(&buf, &sel, &[]), "");
5050 }
5051
5052 #[test]
5053 fn apply_selection_overlay_reverses_cells() {
5054 let area = Rect::new(0, 0, 10, 3);
5055 let mut buf = Buffer::empty(area);
5056 buf.set_string(0, 0, "ABCDE", Style::default());
5057 let sel = SelectionState {
5058 anchor: Some((1, 0)),
5059 current: Some((3, 0)),
5060 widget_rect: Some(area),
5061 active: true,
5062 };
5063 apply_selection_overlay(&mut buf, &sel, &[]);
5064 assert!(!buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED));
5065 assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
5066 assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
5067 assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
5068 assert!(!buf.get(4, 0).style.modifiers.contains(Modifiers::REVERSED));
5069 }
5070
5071 #[test]
5072 fn extract_selection_text_skips_border_cells() {
5073 let area = Rect::new(0, 0, 40, 5);
5078 let mut buf = Buffer::empty(area);
5079 buf.set_string(0, 0, "╭", Style::default());
5081 buf.set_string(0, 1, "│", Style::default());
5082 buf.set_string(0, 2, "│", Style::default());
5083 buf.set_string(0, 3, "│", Style::default());
5084 buf.set_string(0, 4, "╰", Style::default());
5085 buf.set_string(19, 0, "╮", Style::default());
5086 buf.set_string(19, 1, "│", Style::default());
5087 buf.set_string(19, 2, "│", Style::default());
5088 buf.set_string(19, 3, "│", Style::default());
5089 buf.set_string(19, 4, "╯", Style::default());
5090 buf.set_string(20, 0, "╭", Style::default());
5092 buf.set_string(20, 1, "│", Style::default());
5093 buf.set_string(20, 2, "│", Style::default());
5094 buf.set_string(20, 3, "│", Style::default());
5095 buf.set_string(20, 4, "╰", Style::default());
5096 buf.set_string(39, 0, "╮", Style::default());
5097 buf.set_string(39, 1, "│", Style::default());
5098 buf.set_string(39, 2, "│", Style::default());
5099 buf.set_string(39, 3, "│", Style::default());
5100 buf.set_string(39, 4, "╯", Style::default());
5101 buf.set_string(1, 1, "Hello Col1", Style::default());
5103 buf.set_string(1, 2, "Line2 Col1", Style::default());
5104 buf.set_string(21, 1, "Hello Col2", Style::default());
5106 buf.set_string(21, 2, "Line2 Col2", Style::default());
5107
5108 let content_map = vec![
5109 (Rect::new(0, 0, 20, 5), Rect::new(1, 1, 18, 3)),
5110 (Rect::new(20, 0, 20, 5), Rect::new(21, 1, 18, 3)),
5111 ];
5112
5113 let sel = SelectionState {
5115 anchor: Some((0, 1)),
5116 current: Some((39, 2)),
5117 widget_rect: Some(area),
5118 active: true,
5119 };
5120 let text = extract_selection_text(&buf, &sel, &content_map);
5121 assert!(!text.contains('│'), "Border char │ found in: {text}");
5123 assert!(!text.contains('╭'), "Border char ╭ found in: {text}");
5124 assert!(!text.contains('╮'), "Border char ╮ found in: {text}");
5125 assert!(
5127 text.contains("Hello Col1"),
5128 "Missing Col1 content in: {text}"
5129 );
5130 assert!(
5131 text.contains("Hello Col2"),
5132 "Missing Col2 content in: {text}"
5133 );
5134 assert!(text.contains("Line2 Col1"), "Missing Col1 line2 in: {text}");
5135 assert!(text.contains("Line2 Col2"), "Missing Col2 line2 in: {text}");
5136 }
5137
5138 #[test]
5139 fn apply_selection_overlay_skips_border_cells() {
5140 let area = Rect::new(0, 0, 20, 3);
5141 let mut buf = Buffer::empty(area);
5142 buf.set_string(0, 0, "│", Style::default());
5143 buf.set_string(1, 0, "ABC", Style::default());
5144 buf.set_string(19, 0, "│", Style::default());
5145
5146 let content_map = vec![(Rect::new(0, 0, 20, 3), Rect::new(1, 0, 18, 3))];
5147 let sel = SelectionState {
5148 anchor: Some((0, 0)),
5149 current: Some((19, 0)),
5150 widget_rect: Some(area),
5151 active: true,
5152 };
5153 apply_selection_overlay(&mut buf, &sel, &content_map);
5154 assert!(
5156 !buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED),
5157 "Left border cell should not be reversed"
5158 );
5159 assert!(
5160 !buf.get(19, 0).style.modifiers.contains(Modifiers::REVERSED),
5161 "Right border cell should not be reversed"
5162 );
5163 assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
5165 assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
5166 assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
5167 }
5168
5169 #[test]
5170 fn copy_to_clipboard_writes_osc52() {
5171 let mut output: Vec<u8> = Vec::new();
5172 copy_to_clipboard(&mut output, "test").unwrap();
5173 let s = String::from_utf8(output).unwrap();
5174 assert!(s.starts_with("\x1b]52;c;"));
5175 assert!(s.ends_with("\x1b\\"));
5176 assert!(s.contains(&base64_encode(b"test")));
5177 }
5178
5179 fn count_move_tos(s: &str) -> usize {
5181 let bytes = s.as_bytes();
5182 let mut count = 0;
5183 let mut i = 0;
5184 while i + 1 < bytes.len() {
5185 if bytes[i] == 0x1b && bytes[i + 1] == b'[' {
5186 let mut j = i + 2;
5188 while j < bytes.len() && !(0x40..=0x7e).contains(&bytes[j]) {
5189 j += 1;
5190 }
5191 if j < bytes.len() && bytes[j] == b'H' {
5192 count += 1;
5193 }
5194 i = j + 1;
5195 } else {
5196 i += 1;
5197 }
5198 }
5199 count
5200 }
5201
5202 #[test]
5203 fn flush_coalesces_consecutive_same_style_cells_into_one_run() {
5204 let area = Rect::new(0, 0, 20, 1);
5206 let mut current = Buffer::empty(area);
5207 let previous = Buffer::empty(area);
5208 let style = Style::new().fg(Color::Red);
5209 for x in 0..10u32 {
5210 let cell = current.get_mut(x, 0);
5211 cell.set_char('X');
5212 cell.set_style(style);
5213 }
5214
5215 let mut out: Vec<u8> = Vec::new();
5216 flush_buffer_diff(
5217 &mut out,
5218 ¤t,
5219 &previous,
5220 ColorDepth::TrueColor,
5221 0,
5222 &mut String::new(),
5223 )
5224 .unwrap();
5225 let s = String::from_utf8(out).unwrap();
5226
5227 assert_eq!(
5229 count_move_tos(&s),
5230 1,
5231 "expected 1 MoveTo for a coalesced run, got {} in {:?}",
5232 count_move_tos(&s),
5233 s
5234 );
5235 assert!(
5237 s.contains("XXXXXXXXXX"),
5238 "expected contiguous run 'XXXXXXXXXX' in {:?}",
5239 s
5240 );
5241 }
5242
5243 #[test]
5244 fn flush_normalizes_directly_mutated_cell_symbols() {
5245 let area = Rect::new(0, 0, 2, 1);
5246 let mut current = Buffer::empty(area);
5247 let previous = Buffer::empty(area);
5248 current.get_mut(0, 0).symbol = compact_str::CompactString::new("\x1b]52;c;payload");
5249
5250 let mut out = Vec::new();
5251 flush_buffer_diff(
5252 &mut out,
5253 ¤t,
5254 &previous,
5255 ColorDepth::TrueColor,
5256 0,
5257 &mut String::new(),
5258 )
5259 .unwrap();
5260
5261 let output = String::from_utf8(out).unwrap();
5262 assert!(output.contains('\u{FFFD}'));
5263 assert!(!output.contains("]52;c;payload"));
5264 }
5265
5266 #[test]
5267 fn flush_breaks_run_on_style_change() {
5268 let area = Rect::new(0, 0, 20, 1);
5270 let mut current = Buffer::empty(area);
5271 let previous = Buffer::empty(area);
5272 let red = Style::new().fg(Color::Red);
5273 let blue = Style::new().fg(Color::Blue);
5274 for x in 0..5u32 {
5275 let cell = current.get_mut(x, 0);
5276 cell.set_char('R');
5277 cell.set_style(red);
5278 }
5279 for x in 5..10u32 {
5280 let cell = current.get_mut(x, 0);
5281 cell.set_char('B');
5282 cell.set_style(blue);
5283 }
5284
5285 let mut out: Vec<u8> = Vec::new();
5286 flush_buffer_diff(
5287 &mut out,
5288 ¤t,
5289 &previous,
5290 ColorDepth::TrueColor,
5291 0,
5292 &mut String::new(),
5293 )
5294 .unwrap();
5295 let s = String::from_utf8(out).unwrap();
5296
5297 let moves = count_move_tos(&s);
5301 assert!(
5302 moves <= 2,
5303 "expected at most 2 MoveTos across a style boundary, got {} in {:?}",
5304 moves,
5305 s
5306 );
5307 assert!(s.contains("RRRRR"), "missing 'RRRRR' run in {:?}", s);
5308 assert!(s.contains("BBBBB"), "missing 'BBBBB' run in {:?}", s);
5309 }
5310
5311 #[test]
5312 fn flush_breaks_run_on_column_gap() {
5313 let area = Rect::new(0, 0, 20, 1);
5315 let mut current = Buffer::empty(area);
5316 let previous = Buffer::empty(area);
5317 let style = Style::new().fg(Color::Green);
5318 for x in 0..3u32 {
5319 current.get_mut(x, 0).set_char('A').set_style(style);
5320 }
5321 for x in 6..9u32 {
5322 current.get_mut(x, 0).set_char('B').set_style(style);
5323 }
5324
5325 let mut out: Vec<u8> = Vec::new();
5326 flush_buffer_diff(
5327 &mut out,
5328 ¤t,
5329 &previous,
5330 ColorDepth::TrueColor,
5331 0,
5332 &mut String::new(),
5333 )
5334 .unwrap();
5335 let s = String::from_utf8(out).unwrap();
5336
5337 assert_eq!(
5339 count_move_tos(&s),
5340 2,
5341 "expected 2 MoveTos across a column gap, got {} in {:?}",
5342 count_move_tos(&s),
5343 s
5344 );
5345 assert!(s.contains("AAA"), "missing 'AAA' run in {:?}", s);
5346 assert!(s.contains("BBB"), "missing 'BBB' run in {:?}", s);
5347 }
5348
5349 #[test]
5353 fn bufwriter_output_identical_to_direct_write() {
5354 let area = Rect::new(0, 0, 5, 1);
5355 let mut current = Buffer::empty(area);
5356 let previous = Buffer::empty(area);
5357 let style = Style::new().fg(Color::Rgb(255, 128, 0));
5358 for x in 0..5u32 {
5359 current.get_mut(x, 0).set_char('X').set_style(style);
5360 }
5361
5362 let mut direct: Vec<u8> = Vec::new();
5363 flush_buffer_diff(
5364 &mut direct,
5365 ¤t,
5366 &previous,
5367 ColorDepth::TrueColor,
5368 0,
5369 &mut String::new(),
5370 )
5371 .unwrap();
5372
5373 let mut buffered: BufWriter<Vec<u8>> = BufWriter::with_capacity(65536, Vec::new());
5374 flush_buffer_diff(
5375 &mut buffered,
5376 ¤t,
5377 &previous,
5378 ColorDepth::TrueColor,
5379 0,
5380 &mut String::new(),
5381 )
5382 .unwrap();
5383 buffered.flush().unwrap();
5384 let via_buf = buffered.into_inner().unwrap();
5385
5386 assert_eq!(
5387 direct, via_buf,
5388 "BufWriter output must be byte-for-byte identical to direct write"
5389 );
5390 }
5391
5392 #[test]
5396 fn bufwriter_coalesces_writes_into_single_flush() {
5397 #[derive(Debug)]
5398 struct CountingWriter {
5399 buf: Vec<u8>,
5400 write_call_count: usize,
5401 }
5402 impl Write for CountingWriter {
5403 fn write(&mut self, data: &[u8]) -> io::Result<usize> {
5404 self.write_call_count += 1;
5405 self.buf.extend_from_slice(data);
5406 Ok(data.len())
5407 }
5408 fn flush(&mut self) -> io::Result<()> {
5409 Ok(())
5410 }
5411 }
5412
5413 let area = Rect::new(0, 0, 10, 1);
5414 let mut current = Buffer::empty(area);
5415 let previous = Buffer::empty(area);
5416 for x in 0..10u32 {
5418 let color = if x % 2 == 0 {
5419 Color::Rgb(255, 0, 0)
5420 } else {
5421 Color::Rgb(0, 255, 0)
5422 };
5423 current
5424 .get_mut(x, 0)
5425 .set_char('Z')
5426 .set_style(Style::new().fg(color));
5427 }
5428
5429 let sink = CountingWriter {
5430 buf: Vec::new(),
5431 write_call_count: 0,
5432 };
5433 let mut bw = BufWriter::with_capacity(65536, sink);
5434 flush_buffer_diff(
5435 &mut bw,
5436 ¤t,
5437 &previous,
5438 ColorDepth::TrueColor,
5439 0,
5440 &mut String::new(),
5441 )
5442 .unwrap();
5443 bw.flush().unwrap();
5444 let inner = bw.into_inner().unwrap();
5445
5446 assert_eq!(
5448 inner.write_call_count, 1,
5449 "expected 1 write syscall to sink, got {}",
5450 inner.write_call_count
5451 );
5452 }
5453
5454 #[test]
5460 fn flush_skips_unchanged_rows_when_hashes_match() {
5461 let area = Rect::new(0, 0, 20, 4);
5462 let mut current = Buffer::empty(area);
5463 let mut previous = Buffer::empty(area);
5464 for y in 0..4u32 {
5466 current.set_string(0, y, "identical-row-content", Style::new());
5467 previous.set_string(0, y, "identical-row-content", Style::new());
5468 }
5469 current.recompute_line_hashes();
5470 previous.recompute_line_hashes();
5471
5472 let mut out: Vec<u8> = Vec::new();
5473 flush_buffer_diff(
5474 &mut out,
5475 ¤t,
5476 &previous,
5477 ColorDepth::TrueColor,
5478 0,
5479 &mut String::new(),
5480 )
5481 .unwrap();
5482 assert!(
5483 out.is_empty(),
5484 "identical buffers must emit zero flush bytes; got {} bytes: {:?}",
5485 out.len(),
5486 out
5487 );
5488 }
5489
5490 #[test]
5494 fn flush_skips_only_matching_rows_in_mixed_diff() {
5495 let area = Rect::new(0, 0, 6, 3);
5496 let mut current = Buffer::empty(area);
5497 let mut previous = Buffer::empty(area);
5498 current.set_string(0, 0, "abcdef", Style::new());
5499 previous.set_string(0, 0, "abcdef", Style::new());
5500 current.set_string(0, 1, "xxxxxx", Style::new());
5501 previous.set_string(0, 1, "yyyyyy", Style::new());
5502 current.set_string(0, 2, "zzzzzz", Style::new());
5503 previous.set_string(0, 2, "zzzzzz", Style::new());
5504 current.recompute_line_hashes();
5505 previous.recompute_line_hashes();
5506
5507 let mut out: Vec<u8> = Vec::new();
5508 flush_buffer_diff(
5509 &mut out,
5510 ¤t,
5511 &previous,
5512 ColorDepth::TrueColor,
5513 0,
5514 &mut String::new(),
5515 )
5516 .unwrap();
5517 let s = String::from_utf8_lossy(&out);
5518 assert!(s.contains("xxxxxx"), "differing row must flush: {s:?}");
5521 assert!(
5522 !s.contains("abcdef"),
5523 "matching row 0 must not flush: {s:?}"
5524 );
5525 assert!(
5526 !s.contains("zzzzzz"),
5527 "matching row 2 must not flush: {s:?}"
5528 );
5529 }
5530
5531 fn delta_bytes(old: &Style, new: &Style) -> Vec<u8> {
5532 let mut out = Vec::new();
5533 apply_style_delta(&mut out, old, new, ColorDepth::TrueColor).unwrap();
5534 out
5535 }
5536
5537 fn contains_seq(haystack: &[u8], needle: &[u8]) -> bool {
5538 haystack.windows(needle.len()).any(|w| w == needle)
5539 }
5540
5541 #[test]
5542 fn apply_style_delta_emits_blink_set_and_reset() {
5543 let on = delta_bytes(&Style::new(), &Style::new().blink());
5544 assert!(contains_seq(&on, b"\x1b[5m"), "blink set: {on:?}");
5546 let off = delta_bytes(&Style::new().blink(), &Style::new());
5547 assert!(contains_seq(&off, b"\x1b[25m"), "blink reset: {off:?}");
5549 }
5550
5551 #[test]
5552 fn apply_style_delta_emits_overline_set_and_reset() {
5553 let on = delta_bytes(&Style::new(), &Style::new().overline());
5554 assert!(contains_seq(&on, b"\x1b[53m"), "overline set: {on:?}");
5556 let off = delta_bytes(&Style::new().overline(), &Style::new());
5557 assert!(contains_seq(&off, b"\x1b[55m"), "overline reset: {off:?}");
5559 }
5560
5561 #[test]
5562 fn apply_style_delta_emits_curly_underline_subparameter() {
5563 let out = delta_bytes(
5564 &Style::new(),
5565 &Style::new().underline_style(UnderlineStyle::Curly),
5566 );
5567 assert!(contains_seq(&out, b"\x1b[4:3m"), "curly underline: {out:?}");
5568 }
5569
5570 #[test]
5571 fn apply_style_delta_emits_underline_color_and_reset() {
5572 let set = delta_bytes(
5573 &Style::new(),
5574 &Style::new().underline_color(Color::Rgb(255, 0, 0)),
5575 );
5576 assert!(
5577 contains_seq(&set, b"\x1b[58:2::255:0:0m"),
5578 "underline color set: {set:?}"
5579 );
5580 let clear = delta_bytes(
5581 &Style::new().underline_color(Color::Rgb(255, 0, 0)),
5582 &Style::new(),
5583 );
5584 assert!(
5585 contains_seq(&clear, b"\x1b[59m"),
5586 "underline color reset: {clear:?}"
5587 );
5588 }
5589
5590 #[test]
5591 fn apply_style_delta_underline_color_indexed_uses_sgr_58_5() {
5592 let out = delta_bytes(
5593 &Style::new(),
5594 &Style::new().underline_color(Color::Indexed(42)),
5595 );
5596 assert!(
5597 contains_seq(&out, b"\x1b[58:5:42m"),
5598 "indexed underline: {out:?}"
5599 );
5600 }
5601
5602 #[test]
5603 fn apply_style_full_emits_blink_overline_and_underline() {
5604 let mut out = Vec::new();
5605 let style = Style::new()
5606 .blink()
5607 .overline()
5608 .underline_style(UnderlineStyle::Dotted)
5609 .underline_color(Color::Rgb(0, 0, 255));
5610 apply_style(&mut out, &style, ColorDepth::TrueColor).unwrap();
5611 assert!(contains_seq(&out, b"\x1b[5m"), "blink: {out:?}");
5612 assert!(contains_seq(&out, b"\x1b[53m"), "overline: {out:?}");
5613 assert!(
5614 contains_seq(&out, b"\x1b[4:4m"),
5615 "dotted underline: {out:?}"
5616 );
5617 assert!(
5618 contains_seq(&out, b"\x1b[58:2::0:0:255m"),
5619 "underline color: {out:?}"
5620 );
5621 }
5622 #[test]
5626 fn with_sink_captures_flush_bytes_and_drops_clean() {
5627 let mut term = Terminal::with_sink(10, 1, ColorDepth::TrueColor);
5628 term.buffer_mut()
5629 .set_string(0, 0, "Z", Style::new().fg(Color::Rgb(200, 50, 50)));
5630 term.flush().unwrap();
5631 let bytes = term.take_sink_bytes();
5632 let s = String::from_utf8_lossy(&bytes);
5633 assert!(s.contains("\u{1b}["), "missing CSI: {s:?}");
5635 assert!(s.contains('Z'), "missing glyph: {s:?}");
5636 assert!(term.take_sink_bytes().is_empty());
5638 drop(term);
5640 }
5641
5642 #[test]
5647 fn reused_run_buf_byte_identical_across_frames() {
5648 let area = Rect::new(0, 0, 12, 2);
5649 let make_frame = || {
5651 let mut current = Buffer::empty(area);
5652 let previous = Buffer::empty(area);
5653 current.set_string(0, 0, "hello world", Style::new().fg(Color::Rgb(1, 2, 3)));
5654 current.set_string(0, 1, "second line", Style::new().fg(Color::Rgb(4, 5, 6)));
5655 (current, previous)
5656 };
5657
5658 let mut baseline: Vec<u8> = Vec::new();
5660 {
5661 let (mut a, mut b) = make_frame();
5662 __bench_flush_buffer_diff_mut_with_buf(
5663 &mut baseline,
5664 &mut a,
5665 &mut b,
5666 ColorDepth::TrueColor,
5667 &mut String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
5668 )
5669 .unwrap();
5670 }
5671
5672 let mut shared = String::with_capacity(RUN_BUF_INITIAL_CAPACITY);
5675 {
5676 let mut warm: Vec<u8> = Vec::new();
5677 let (mut a, mut b) = make_frame();
5678 __bench_flush_buffer_diff_mut_with_buf(
5679 &mut warm,
5680 &mut a,
5681 &mut b,
5682 ColorDepth::TrueColor,
5683 &mut shared,
5684 )
5685 .unwrap();
5686 }
5687 let cap_after_warm = shared.capacity();
5688
5689 let mut reused: Vec<u8> = Vec::new();
5690 let (mut current, mut previous) = make_frame();
5691 __bench_flush_buffer_diff_mut_with_buf(
5692 &mut reused,
5693 &mut current,
5694 &mut previous,
5695 ColorDepth::TrueColor,
5696 &mut shared,
5697 )
5698 .unwrap();
5699
5700 assert_eq!(
5701 baseline, reused,
5702 "reused run_buf must emit byte-identical output"
5703 );
5704 assert!(
5707 shared.capacity() >= cap_after_warm,
5708 "run_buf capacity must persist across frames"
5709 );
5710 }
5711
5712 #[test]
5716 fn osc8_hyperlink_emitted_verbatim_after_write_rewrite() {
5717 let area = Rect::new(0, 0, 8, 1);
5718 let mut current = Buffer::empty(area);
5719 let previous = Buffer::empty(area);
5720 let url = "https://example.com/x";
5721 current.set_string_linked(0, 0, "link", Style::new(), url);
5723
5724 let mut out: Vec<u8> = Vec::new();
5725 flush_buffer_diff(
5726 &mut out,
5727 ¤t,
5728 &previous,
5729 ColorDepth::TrueColor,
5730 0,
5731 &mut String::new(),
5732 )
5733 .unwrap();
5734
5735 let open = format!("\x1b]8;;{url}\x07");
5736 assert!(
5737 contains_seq(&out, open.as_bytes()),
5738 "OSC 8 open must appear verbatim: {:?}",
5739 String::from_utf8_lossy(&out)
5740 );
5741 assert!(
5742 contains_seq(&out, b"\x1b]8;;\x07"),
5743 "OSC 8 close must appear: {:?}",
5744 String::from_utf8_lossy(&out)
5745 );
5746 }
5747
5748 fn kitty_placements(n: usize) -> Vec<KittyPlacement> {
5750 (0..n)
5751 .map(|i| {
5752 let mut rgba = vec![0u8; 256];
5753 rgba[0] = i as u8;
5754 let content_hash = crate::buffer::hash_rgba(&rgba);
5755 KittyPlacement {
5756 content_hash,
5757 rgba: std::sync::Arc::new(rgba),
5758 src_width: 8,
5759 src_height: 8,
5760 x: (i as u32) * 4,
5761 y: (i as u32) * 2,
5762 cols: 4,
5763 rows: 2,
5764 crop_y: 0,
5765 crop_h: 0,
5766 }
5767 })
5768 .collect()
5769 }
5770
5771 #[test]
5772 fn captured_sink_suppresses_kitty_graphics_bytes() {
5773 let mut term = Terminal::with_sink(8, 4, ColorDepth::TrueColor);
5774 term.graphics_support = GraphicsEmissionSupport {
5775 real_terminal: true,
5776 capabilities: Capabilities::default(),
5777 force_kitty: false,
5778 force_sixel: false,
5779 force_iterm: false,
5780 };
5781 for placement in kitty_placements(1) {
5782 term.buffer_mut().kitty_place(placement);
5783 }
5784 term.flush().unwrap();
5785 let bytes = term.take_sink_bytes();
5786 assert!(
5787 !contains_seq(&bytes, b"\x1b_G"),
5788 "captured sink must not emit Kitty APC bytes: {:?}",
5789 String::from_utf8_lossy(&bytes)
5790 );
5791 }
5792
5793 #[test]
5799 fn kitty_flush_smallvec_dedup_matches_for_small_n() {
5800 for n in [0usize, 1, 5] {
5801 let placements = kitty_placements(n);
5802 let mut mgr = KittyImageManager::new();
5803
5804 let mut frame1: Vec<u8> = Vec::new();
5806 mgr.flush(&mut frame1, &placements, 0).unwrap();
5807 let s1 = String::from_utf8_lossy(&frame1);
5808 assert_eq!(
5810 s1.matches("a=t,").count(),
5811 n,
5812 "n={n}: expected {n} uploads in frame 1: {s1:?}"
5813 );
5814 assert_eq!(
5815 s1.matches("a=p,").count(),
5816 n,
5817 "n={n}: expected {n} placements in frame 1: {s1:?}"
5818 );
5819
5820 let mut frame2: Vec<u8> = Vec::new();
5822 mgr.flush(&mut frame2, &placements, 0).unwrap();
5823 assert!(
5824 frame2.is_empty(),
5825 "n={n}: identical frame must hit the kitty fast path, got {} bytes",
5826 frame2.len()
5827 );
5828
5829 let mut frame3: Vec<u8> = Vec::new();
5833 mgr.flush(&mut frame3, &[], 0).unwrap();
5834 let s3 = String::from_utf8_lossy(&frame3);
5835 assert_eq!(
5836 s3.matches("a=d,d=i,").count(),
5837 n,
5838 "n={n}: expected {n} placement deletes in frame 3: {s3:?}"
5839 );
5840 assert_eq!(
5841 s3.matches("a=d,d=I,").count(),
5842 n,
5843 "n={n}: expected {n} image-data deletes in frame 3: {s3:?}"
5844 );
5845 }
5846 }
5847
5848 use crate::buffer::{SprixelCell, SprixelPlacement};
5851
5852 fn make_sprixel(cells: Vec<SprixelCell>) -> SprixelPlacement {
5854 SprixelPlacement {
5855 content_hash: 0xABCD,
5856 seq: "<SIXEL>".to_string(),
5857 x: 1,
5858 y: 1,
5859 cols: 2,
5860 rows: 2,
5861 cells,
5862 }
5863 }
5864
5865 #[test]
5866 fn previous_only_sprixel_repaints_footprint_and_restores_overlap() {
5867 let area = Rect::new(0, 0, 10, 5);
5868 let removed = make_sprixel(vec![SprixelCell::Opaque; 4]);
5869 let mut stable = make_sprixel(vec![SprixelCell::Opaque; 4]);
5870 stable.x = 5;
5871 stable.content_hash = 0xBEEF;
5872 stable.seq = "<STABLE>".to_string();
5873
5874 let mut current = Buffer::empty(area);
5875 current.sprixels.push(stable.clone());
5876 let mut previous = Buffer::empty(area);
5877 previous.sprixels.push(removed);
5878 previous.sprixels.push(stable);
5879
5880 let rows = previous_only_sprixel_rows(¤t, &previous, |_| true);
5881 assert_eq!(rows.as_slice(), &[1, 2]);
5882
5883 let mut text = Vec::new();
5884 flush_buffer_diff_rows(
5885 &mut text,
5886 ¤t,
5887 &previous,
5888 ColorDepth::TrueColor,
5889 0,
5890 &mut String::new(),
5891 &rows,
5892 )
5893 .unwrap();
5894 assert!(!text.is_empty(), "removed footprint rows must be repainted");
5895
5896 let mut graphics = Vec::new();
5897 flush_sprixels_inner(&mut graphics, ¤t, &previous, 0, |_| true, &rows).unwrap();
5898 assert_eq!(
5899 String::from_utf8(graphics)
5900 .unwrap()
5901 .matches("<STABLE>")
5902 .count(),
5903 1,
5904 "row repaint must restore a surviving overlapping graphic"
5905 );
5906 }
5907
5908 #[test]
5909 fn terminal_flush_erases_removed_sprixel_with_text_redraw() {
5910 let mut term = Terminal::with_sink(10, 5, ColorDepth::TrueColor);
5911 term.graphics_support = GraphicsEmissionSupport {
5912 real_terminal: true,
5913 capabilities: Capabilities {
5914 sixel: true,
5915 ..Default::default()
5916 },
5917 force_kitty: false,
5918 force_sixel: false,
5919 force_iterm: false,
5920 };
5921 let mut placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
5922 placement.seq = "\x1bPqpayload\x1b\\".to_string();
5923 term.current.sprixels.push(placement);
5924 term.flush().unwrap();
5925 assert!(String::from_utf8_lossy(&term.take_sink_bytes()).contains("payload"));
5926
5927 term.flush().unwrap();
5928 let erase = term.take_sink_bytes();
5929 assert!(!erase.is_empty());
5930 assert!(!String::from_utf8_lossy(&erase).contains("payload"));
5931 }
5932
5933 #[test]
5934 fn checked_sprixel_flush_suppresses_mux_without_ack_or_force() {
5935 let area = Rect::new(0, 0, 10, 5);
5936 let mut placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
5937 placement.seq = "\x1bPqpayload\x1b\\".to_string();
5938
5939 let mut current = Buffer::empty(area);
5940 current.sprixels.push(placement);
5941 let previous = Buffer::empty(area);
5942 let support = GraphicsEmissionSupport {
5943 real_terminal: true,
5944 capabilities: Capabilities::default(),
5945 force_kitty: false,
5946 force_sixel: false,
5947 force_iterm: false,
5948 };
5949
5950 let mut out = Vec::new();
5951 flush_sprixels_checked(&mut out, ¤t, &previous, 0, support).unwrap();
5952 assert!(
5953 out.is_empty(),
5954 "tmux/screen without ack must not emit Sixel"
5955 );
5956 }
5957
5958 #[test]
5959 fn checked_sprixel_flush_allows_mux_with_probe_ack() {
5960 let area = Rect::new(0, 0, 10, 5);
5961 let mut placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
5962 placement.seq = "\x1bPqpayload\x1b\\".to_string();
5963
5964 let mut current = Buffer::empty(area);
5965 current.sprixels.push(placement);
5966 let previous = Buffer::empty(area);
5967 let support = GraphicsEmissionSupport {
5968 real_terminal: true,
5969 capabilities: Capabilities {
5970 sixel: true,
5971 ..Default::default()
5972 },
5973 force_kitty: false,
5974 force_sixel: false,
5975 force_iterm: false,
5976 };
5977
5978 let mut out = Vec::new();
5979 flush_sprixels_checked(&mut out, ¤t, &previous, 0, support).unwrap();
5980 assert!(
5981 contains_seq(&out, b"\x1bPqpayload\x1b\\"),
5982 "probe-acked Sixel should emit: {:?}",
5983 String::from_utf8_lossy(&out)
5984 );
5985 }
5986
5987 #[test]
5988 fn sprixel_no_text_change_emits_zero_bytes() {
5989 let area = Rect::new(0, 0, 10, 5);
5991 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
5992
5993 let mut current = Buffer::empty(area);
5994 current.sprixels.push(placement.clone());
5995 let mut previous = Buffer::empty(area);
5996 previous.sprixels.push(placement);
5997
5998 let mut out: Vec<u8> = Vec::new();
5999 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
6000 assert!(out.is_empty(), "stable frame should emit no sprixel bytes");
6001 }
6002
6003 #[test]
6004 fn sprixel_first_frame_blits_once() {
6005 let area = Rect::new(0, 0, 10, 5);
6007 let mut current = Buffer::empty(area);
6008 current
6009 .sprixels
6010 .push(make_sprixel(vec![SprixelCell::Opaque; 4]));
6011 let previous = Buffer::empty(area);
6012
6013 let mut out: Vec<u8> = Vec::new();
6014 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
6015 let s = String::from_utf8(out).unwrap();
6016 assert_eq!(s.matches("<SIXEL>").count(), 1);
6017 }
6018
6019 #[test]
6020 fn sprixel_text_in_opaque_cell_reblits_once() {
6021 let area = Rect::new(0, 0, 10, 5);
6023 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
6024
6025 let mut current = Buffer::empty(area);
6026 current.sprixels.push(placement.clone());
6027 current.set_char(1, 1, 'X', Style::new());
6029
6030 let mut previous = Buffer::empty(area);
6031 previous.sprixels.push(placement);
6032
6033 let mut out: Vec<u8> = Vec::new();
6034 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
6035 let s = String::from_utf8(out).unwrap();
6036 assert_eq!(
6037 s.matches("<SIXEL>").count(),
6038 1,
6039 "opaque-cell text write must re-blit the graphic exactly once"
6040 );
6041 }
6042
6043 #[test]
6044 fn sprixel_text_in_transparent_cell_does_not_reblit() {
6045 let area = Rect::new(0, 0, 10, 5);
6048 let cells = vec![
6049 SprixelCell::Transparent, SprixelCell::Opaque, SprixelCell::Opaque, SprixelCell::Opaque, ];
6054 let placement = make_sprixel(cells);
6055
6056 let mut current = Buffer::empty(area);
6057 current.sprixels.push(placement.clone());
6058 current.set_char(1, 1, 'X', Style::new());
6059
6060 let mut previous = Buffer::empty(area);
6061 previous.sprixels.push(placement);
6062
6063 let mut out: Vec<u8> = Vec::new();
6064 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
6065 assert!(
6066 out.is_empty(),
6067 "text in a transparent footprint cell must emit zero sprixel bytes"
6068 );
6069 }
6070
6071 #[test]
6072 fn sprixel_text_outside_footprint_does_not_reblit() {
6073 let area = Rect::new(0, 0, 10, 5);
6075 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
6076
6077 let mut current = Buffer::empty(area);
6078 current.sprixels.push(placement.clone());
6079 current.set_char(5, 0, 'Z', Style::new());
6081
6082 let mut previous = Buffer::empty(area);
6083 previous.sprixels.push(placement);
6084
6085 let mut out: Vec<u8> = Vec::new();
6086 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
6087 assert!(
6088 out.is_empty(),
6089 "text outside the footprint must not re-blit the graphic"
6090 );
6091 }
6092
6093 #[test]
6094 fn sprixel_position_change_reblits() {
6095 let area = Rect::new(0, 0, 10, 5);
6097 let mut moved = make_sprixel(vec![SprixelCell::Opaque; 4]);
6098 let original = moved.clone();
6099 moved.x = 4;
6100
6101 let mut current = Buffer::empty(area);
6102 current.sprixels.push(moved);
6103 let mut previous = Buffer::empty(area);
6104 previous.sprixels.push(original);
6105
6106 let mut out: Vec<u8> = Vec::new();
6107 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
6108 let s = String::from_utf8(out).unwrap();
6109 assert_eq!(s.matches("<SIXEL>").count(), 1);
6110 }
6111
6112 #[test]
6113 fn sprixel_content_change_reblits() {
6114 let area = Rect::new(0, 0, 10, 5);
6116 let mut recolored = make_sprixel(vec![SprixelCell::Opaque; 4]);
6117 let original = recolored.clone();
6118 recolored.content_hash = 0x1234;
6119 recolored.seq = "<SIXEL2>".to_string();
6120
6121 let mut current = Buffer::empty(area);
6122 current.sprixels.push(recolored);
6123 let mut previous = Buffer::empty(area);
6124 previous.sprixels.push(original);
6125
6126 let mut out: Vec<u8> = Vec::new();
6127 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
6128 let s = String::from_utf8(out).unwrap();
6129 assert_eq!(s.matches("<SIXEL2>").count(), 1);
6130 }
6131
6132 #[test]
6133 fn sprixel_reblit_count_invariant_over_single_cell_writes() {
6134 let area = Rect::new(0, 0, 10, 5);
6138 for (idx, (col, row)) in [(0u32, 0u32), (1, 0), (0, 1), (1, 1)]
6139 .into_iter()
6140 .enumerate()
6141 {
6142 for state in [
6143 SprixelCell::Opaque,
6144 SprixelCell::Mixed,
6145 SprixelCell::Transparent,
6146 ] {
6147 let mut cells = vec![SprixelCell::Opaque; 4];
6148 cells[idx] = state;
6149 let placement = make_sprixel(cells);
6150
6151 let mut current = Buffer::empty(area);
6152 current.sprixels.push(placement.clone());
6153 current.set_char(1 + col, 1 + row, 'A', Style::new());
6154
6155 let mut previous = Buffer::empty(area);
6156 previous.sprixels.push(placement);
6157
6158 let mut out: Vec<u8> = Vec::new();
6159 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
6160 let count = String::from_utf8(out).unwrap().matches("<SIXEL>").count();
6161 let expected = if matches!(state, SprixelCell::Transparent) {
6162 0
6163 } else {
6164 1
6165 };
6166 assert_eq!(
6167 count, expected,
6168 "cell ({col},{row}) state {state:?}: expected {expected} re-blits"
6169 );
6170 }
6171 }
6172 }
6173
6174 #[test]
6181 fn sprixel_unchanged_with_hashes_engaged_emits_zero_bytes() {
6182 let area = Rect::new(0, 0, 10, 5);
6187 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
6188
6189 let mut current = Buffer::empty(area);
6190 current.sprixels.push(placement.clone());
6191 let mut previous = Buffer::empty(area);
6192 previous.sprixels.push(placement);
6193
6194 current.recompute_line_hashes();
6196 previous.recompute_line_hashes();
6197 assert!(current.row_clean(1) && current.row_clean(2));
6200 assert_eq!(current.row_hash(1), previous.row_hash(1));
6201
6202 let mut out: Vec<u8> = Vec::new();
6203 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
6204 assert!(
6205 out.is_empty(),
6206 "unchanged sprixel must not be re-blitted (per-row shortcut)"
6207 );
6208 }
6209
6210 #[test]
6211 fn sprixel_changed_text_with_hashes_engaged_reblits_once() {
6212 let area = Rect::new(0, 0, 10, 5);
6217 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
6218
6219 let mut current = Buffer::empty(area);
6220 current.sprixels.push(placement.clone());
6221 current.set_char(1, 1, 'X', Style::new());
6222 let mut previous = Buffer::empty(area);
6223 previous.sprixels.push(placement);
6224
6225 current.recompute_line_hashes();
6226 previous.recompute_line_hashes();
6227 assert_ne!(current.row_hash(1), previous.row_hash(1));
6229
6230 let mut out: Vec<u8> = Vec::new();
6231 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
6232 let s = String::from_utf8(out).unwrap();
6233 assert_eq!(
6234 s.matches("<SIXEL>").count(),
6235 1,
6236 "annihilating text write must re-blit exactly once"
6237 );
6238 }
6239
6240 #[test]
6241 fn sprixel_changed_text_in_transparent_cell_with_hashes_does_not_reblit() {
6242 let area = Rect::new(0, 0, 10, 5);
6247 let cells = vec![
6248 SprixelCell::Transparent, SprixelCell::Opaque, SprixelCell::Opaque, SprixelCell::Opaque, ];
6253 let placement = make_sprixel(cells);
6254
6255 let mut current = Buffer::empty(area);
6256 current.sprixels.push(placement.clone());
6257 current.set_char(1, 1, 'X', Style::new());
6258 let mut previous = Buffer::empty(area);
6259 previous.sprixels.push(placement);
6260
6261 current.recompute_line_hashes();
6262 previous.recompute_line_hashes();
6263
6264 let mut out: Vec<u8> = Vec::new();
6265 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
6266 assert!(
6267 out.is_empty(),
6268 "transparent-cell text write must not re-blit even with hashes engaged"
6269 );
6270 }
6271
6272 #[test]
6273 fn sprixel_key_matches_partial_eq_contract() {
6274 let base = make_sprixel(vec![SprixelCell::Opaque; 4]);
6278 assert_eq!(sprixel_key(&base), sprixel_key(&base.clone()));
6279
6280 let mut moved = base.clone();
6281 moved.x = 7;
6282 assert_ne!(sprixel_key(&base), sprixel_key(&moved));
6283
6284 let mut recolored = base.clone();
6285 recolored.content_hash = 0x9999;
6286 assert_ne!(sprixel_key(&base), sprixel_key(&recolored));
6287
6288 let mut annihilated = base.clone();
6290 annihilated.cells = vec![SprixelCell::Annihilated; 4];
6291 assert_eq!(sprixel_key(&base), sprixel_key(&annihilated));
6292 assert_eq!(base, annihilated);
6293 }
6294
6295 #[test]
6296 fn sprixel_multi_placement_only_changed_one_reblits() {
6297 let area = Rect::new(0, 0, 10, 9);
6301 let mut current = Buffer::empty(area);
6302 let mut previous = Buffer::empty(area);
6303 for i in 0..3u32 {
6304 let p = SprixelPlacement {
6305 content_hash: 0x100 + i as u64,
6306 seq: format!("<S{i}>"),
6307 x: 0,
6308 y: i * 3,
6309 cols: 2,
6310 rows: 2,
6311 cells: vec![SprixelCell::Opaque; 4],
6312 };
6313 current.sprixels.push(p.clone());
6314 previous.sprixels.push(p);
6315 }
6316 current.sprixels[1].x = 5;
6318
6319 current.recompute_line_hashes();
6320 previous.recompute_line_hashes();
6321
6322 let mut out: Vec<u8> = Vec::new();
6323 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
6324 let s = String::from_utf8(out).unwrap();
6325 assert_eq!(s.matches("<S0>").count(), 0);
6326 assert_eq!(
6327 s.matches("<S1>").count(),
6328 1,
6329 "only the moved sprixel reblits"
6330 );
6331 assert_eq!(s.matches("<S2>").count(), 0);
6332 }
6333
6334 #[test]
6335 fn bench_sprixel_fixture_steady_state_emits_nothing() {
6336 let fixture = __bench_new_sprixel_fixture(4);
6340 assert_eq!(fixture.len(), 4);
6341 assert!(!fixture.is_empty());
6342 let mut out: Vec<u8> = Vec::new();
6343 fixture.flush(&mut out, 0).unwrap();
6344 assert!(
6345 out.is_empty(),
6346 "steady-state bench fixture re-blits nothing"
6347 );
6348 }
6349}