1use std::borrow::Cow;
2use std::collections::HashMap;
3use std::io::{self, BufWriter, IsTerminal, Read, 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
26pub(crate) enum Sink {
37 Stdout(BufWriter<Stdout>),
39 #[cfg(any(test, feature = "pty-test"))]
41 Capture(Vec<u8>),
42}
43
44impl Write for Sink {
45 #[inline]
46 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
47 match self {
48 Sink::Stdout(w) => w.write(buf),
49 #[cfg(any(test, feature = "pty-test"))]
50 Sink::Capture(v) => v.write(buf),
51 }
52 }
53
54 #[inline]
55 fn flush(&mut self) -> io::Result<()> {
56 match self {
57 Sink::Stdout(w) => w.flush(),
58 #[cfg(any(test, feature = "pty-test"))]
59 Sink::Capture(v) => v.flush(),
60 }
61 }
62}
63
64pub(crate) struct KittyImageManager {
74 next_id: u32,
75 uploaded: HashMap<u64, u32>,
77 prev_placements: Vec<KittyPlacement>,
79 scratch_ids: smallvec::SmallVec<[u32; 8]>,
84 scratch_hashes: smallvec::SmallVec<[u64; 8]>,
87}
88
89impl KittyImageManager {
90 pub(crate) fn new() -> Self {
92 Self {
93 next_id: 1,
94 uploaded: HashMap::new(),
95 prev_placements: Vec::new(),
96 scratch_ids: smallvec::SmallVec::new(),
97 scratch_hashes: smallvec::SmallVec::new(),
98 }
99 }
100
101 pub(crate) fn flush(
108 &mut self,
109 stdout: &mut impl Write,
110 current: &[KittyPlacement],
111 row_offset: u32,
112 ) -> io::Result<()> {
113 if current.len() == self.prev_placements.len()
117 && current
118 .iter()
119 .zip(self.prev_placements.iter())
120 .all(|(c, p)| placement_eq_with_offset(c, row_offset, p))
121 {
122 return Ok(());
123 }
124
125 if !self.prev_placements.is_empty() {
131 self.scratch_ids.clear();
132 for p in &self.prev_placements {
133 if let Some(&img_id) = self.uploaded.get(&p.content_hash)
134 && !self.scratch_ids.contains(&img_id)
135 {
136 self.scratch_ids.push(img_id);
137 queue!(stdout, Print(format!("\x1b_Ga=d,d=i,i={img_id},q=2\x1b\\")))?;
139 }
140 }
141 }
142
143 for (idx, p) in current.iter().enumerate() {
145 let img_id = if let Some(&existing_id) = self.uploaded.get(&p.content_hash) {
146 existing_id
147 } else {
148 let id = self.next_id;
150 self.next_id += 1;
151 self.upload_image(stdout, id, p)?;
152 self.uploaded.insert(p.content_hash, id);
153 id
154 };
155
156 let pid = idx as u32 + 1;
158 self.place_image_offset(stdout, img_id, pid, p, row_offset)?;
159 }
160
161 self.scratch_hashes.clear();
167 self.scratch_hashes
168 .extend(current.iter().map(|p| p.content_hash));
169 self.scratch_hashes.sort_unstable();
170 let scratch_hashes = &self.scratch_hashes;
171 let stale: smallvec::SmallVec<[u64; 8]> = self
172 .uploaded
173 .keys()
174 .filter(|h| scratch_hashes.binary_search(h).is_err())
175 .copied()
176 .collect();
177 for hash in stale {
178 if let Some(id) = self.uploaded.remove(&hash) {
179 queue!(stdout, Print(format!("\x1b_Ga=d,d=I,i={id},q=2\x1b\\")))?;
181 }
182 }
183
184 self.prev_placements.clear();
191 self.prev_placements.reserve(current.len());
192 for p in current {
193 let mut copy = p.clone();
194 copy.y = copy.y.saturating_add(row_offset);
195 self.prev_placements.push(copy);
196 }
197 Ok(())
198 }
199
200 fn upload_image(&self, stdout: &mut impl Write, id: u32, p: &KittyPlacement) -> io::Result<()> {
202 let (payload, compression) = compress_rgba(&p.rgba);
203 let encoded = base64_encode(&payload);
204 let chunks = split_base64(&encoded, 4096);
205
206 for (i, chunk) in chunks.iter().enumerate() {
207 let more = if i < chunks.len() - 1 { 1 } else { 0 };
208 if i == 0 {
209 queue!(
210 stdout,
211 Print(format!(
212 "\x1b_Ga=t,i={id},f=32,{compression}s={},v={},q=2,m={more};{chunk}\x1b\\",
213 p.src_width, p.src_height
214 ))
215 )?;
216 } else {
217 queue!(stdout, Print(format!("\x1b_Gm={more};{chunk}\x1b\\")))?;
218 }
219 }
220 Ok(())
221 }
222
223 fn place_image_offset(
229 &self,
230 stdout: &mut impl Write,
231 img_id: u32,
232 placement_id: u32,
233 p: &KittyPlacement,
234 row_offset: u32,
235 ) -> io::Result<()> {
236 let display_y = p.y.saturating_add(row_offset);
237 queue!(stdout, cursor::MoveTo(sat_u16(p.x), sat_u16(display_y)))?;
238
239 let mut cmd = format!(
240 "\x1b_Ga=p,i={},p={},c={},r={},C=1,q=2",
241 img_id, placement_id, p.cols, p.rows
242 );
243
244 if p.crop_y > 0 || p.crop_h > 0 {
246 cmd.push_str(&format!(",y={}", p.crop_y));
247 if p.crop_h > 0 {
248 cmd.push_str(&format!(",h={}", p.crop_h));
249 }
250 }
251
252 cmd.push_str("\x1b\\");
253 queue!(stdout, Print(cmd))?;
254 Ok(())
255 }
256
257 pub(crate) fn delete_all(&self, stdout: &mut impl Write) -> io::Result<()> {
259 queue!(stdout, Print("\x1b_Ga=d,d=A,q=2\x1b\\"))
260 }
261}
262
263#[inline]
271fn placement_eq_with_offset(
272 current: &KittyPlacement,
273 row_offset: u32,
274 prev: &KittyPlacement,
275) -> bool {
276 current.content_hash == prev.content_hash
277 && current.x == prev.x
278 && current.y.saturating_add(row_offset) == prev.y
279 && current.cols == prev.cols
280 && current.rows == prev.rows
281 && current.crop_y == prev.crop_y
282 && current.crop_h == prev.crop_h
283}
284
285fn compress_rgba(data: &[u8]) -> (Cow<'_, [u8]>, &'static str) {
294 #[cfg(feature = "kitty-compress")]
295 {
296 use flate2::Compression;
297 use flate2::write::ZlibEncoder;
298 let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast());
299 if encoder.write_all(data).is_ok()
300 && let Ok(compressed) = encoder.finish()
301 {
302 if compressed.len() < data.len() {
304 return (Cow::Owned(compressed), "o=z,");
305 }
306 }
307 }
308 (Cow::Borrowed(data), "")
309}
310
311pub(crate) fn cell_pixel_size() -> (u32, u32) {
318 use std::sync::OnceLock;
319 static CACHED: OnceLock<(u32, u32)> = OnceLock::new();
320 if let Some(size) = CACHED.get() {
321 return *size;
322 }
323 let Some(size) = detect_cell_pixel_size() else {
324 return (8, 16);
325 };
326 let _ = CACHED.set(size);
327 size
328}
329
330fn detect_cell_pixel_size() -> Option<(u32, u32)> {
331 if !terminal_queries_allowed() {
332 return None;
333 }
334
335 let mut stdout = io::stdout();
337 write!(stdout, "\x1b[16t").ok()?;
338 stdout.flush().ok()?;
339
340 let response = read_osc_response(Duration::from_millis(100))?;
341
342 let bytes = response.as_bytes();
347 let start = bytes
348 .windows(4)
349 .position(|w| w == b"\x1b[6;")
350 .map(|pos| pos + 4)
351 .or_else(|| {
352 bytes
354 .windows(3)
355 .position(|w| w == [0x9b, b'6', b';'])
356 .map(|pos| pos + 3)
357 })?;
358 let tail = response.get(start..)?;
359 let body = &tail[..tail.find('t')?];
360 let mut parts = body.split(';');
361 let ch: u32 = parts.next()?.parse().ok()?;
362 let cw: u32 = parts.next()?.parse().ok()?;
363 if cw > 0 && ch > 0 {
364 Some((cw, ch))
365 } else {
366 None
367 }
368}
369
370#[derive(Debug, Clone, Copy, PartialEq, Eq)]
402pub struct BlitterSupport {
403 pub half: bool,
405 pub quad: bool,
407 pub sextant: bool,
411}
412
413impl Default for BlitterSupport {
414 fn default() -> Self {
415 Self {
416 half: true,
417 quad: true,
418 sextant: false,
419 }
420 }
421}
422
423#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
445pub struct Capabilities {
446 pub truecolor: bool,
448 pub sixel: bool,
450 pub iterm2: bool,
453 pub kitty_graphics: bool,
455 pub kitty_keyboard: bool,
457 pub sync_output: bool,
459 pub blitters: BlitterSupport,
461}
462
463#[derive(Debug, Clone, Copy, PartialEq, Eq)]
472pub enum Blitter {
473 Kitty,
475 Sixel,
477 Iterm2,
480 Sextant,
482 HalfBlock,
484}
485
486impl Capabilities {
487 pub fn best_blitter(&self) -> Blitter {
502 if self.kitty_graphics {
503 Blitter::Kitty
504 } else if self.sixel {
505 Blitter::Sixel
506 } else if self.iterm2 {
507 Blitter::Iterm2
508 } else if self.blitters.sextant {
509 Blitter::Sextant
510 } else {
511 Blitter::HalfBlock
512 }
513 }
514}
515
516#[cfg(feature = "crossterm")]
524#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
525pub fn capabilities() -> Capabilities {
526 use std::sync::OnceLock;
527 if !terminal_queries_allowed() {
528 return Capabilities::default();
529 }
530 static CACHED: OnceLock<Capabilities> = OnceLock::new();
531 *CACHED.get_or_init(probe_capabilities)
532}
533
534#[cfg(feature = "crossterm")]
540fn probe_capabilities() -> Capabilities {
541 let mut caps = Capabilities::default();
542 if !terminal_queries_allowed() {
543 return caps;
544 }
545
546 let mut out = io::stdout();
551 if write!(out, "\x1b[c\x1b[>c").is_ok()
554 && out.flush().is_ok()
555 && let Some(resp) = read_da_response(Duration::from_millis(90))
556 {
557 parse_da1(&resp, &mut caps);
558 parse_da2(&resp, &mut caps);
559 }
560
561 if write!(out, "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\").is_ok()
565 && out.flush().is_ok()
566 && let Some(resp) = read_osc_response(Duration::from_millis(30))
567 {
568 parse_kitty_graphics_ack(&resp, &mut caps);
569 }
570
571 if write!(out, "\x1bP+q5463\x1b\\").is_ok()
574 && out.flush().is_ok()
575 && let Some(resp) = read_osc_response(Duration::from_millis(30))
576 {
577 parse_xtgettcap_truecolor(&resp, &mut caps);
578 }
579
580 if write!(out, "\x1b[?2026$p").is_ok()
588 && out.flush().is_ok()
589 && let Some(resp) = read_decrpm_response(Duration::from_millis(30))
590 {
591 match parse_decrpm_sync_output(&resp) {
592 Some(true) => {
593 caps.sync_output = true;
594 let _ = SYNC_OUTPUT_RESOLUTION.set(SyncOutputResolution::Supported);
595 }
596 Some(false) => {
597 let _ = SYNC_OUTPUT_RESOLUTION.set(SyncOutputResolution::Unsupported);
598 }
599 None => {}
600 }
601 }
602
603 if matches!(ColorDepth::detect(), ColorDepth::TrueColor) {
606 caps.truecolor = true;
607 }
608
609 if !caps.sixel && term_is_sixel_host() {
610 caps.sixel = true;
611 }
612
613 if !caps.kitty_graphics && term_is_kitty_graphics_host() {
618 caps.kitty_graphics = true;
619 }
620
621 if term_is_iterm_host() {
625 caps.iterm2 = true;
626 }
627
628 caps
629}
630
631#[cfg(feature = "crossterm")]
636fn term_is_iterm_host() -> bool {
637 let term_program = std::env::var("TERM_PROGRAM")
638 .unwrap_or_default()
639 .to_ascii_lowercase();
640 term_is_iterm_host_env(
641 &term_program,
642 terminal_is_multiplexed(),
643 force_env_enabled("SLT_FORCE_ITERM"),
644 )
645}
646
647#[cfg(feature = "crossterm")]
648fn term_is_iterm_host_env(term_program: &str, multiplexed: bool, forced: bool) -> bool {
649 if forced {
650 return true;
651 }
652 if multiplexed {
653 return false;
654 }
655 matches!(term_program, "iterm.app" | "wezterm" | "tabby" | "mintty")
656}
657
658#[cfg(feature = "crossterm")]
659fn term_is_sixel_host() -> bool {
660 let term = std::env::var("TERM")
661 .unwrap_or_default()
662 .to_ascii_lowercase();
663 let term_program = std::env::var("TERM_PROGRAM")
664 .unwrap_or_default()
665 .to_ascii_lowercase();
666 term_is_sixel_host_env(
667 &term,
668 &term_program,
669 terminal_is_multiplexed(),
670 force_env_enabled("SLT_FORCE_SIXEL"),
671 )
672}
673
674#[cfg(feature = "crossterm")]
675fn term_is_sixel_host_env(term: &str, term_program: &str, multiplexed: bool, forced: bool) -> bool {
676 if forced {
677 return true;
678 }
679 if multiplexed {
680 return false;
681 }
682 const KNOWN_SIXEL_TERMS: &[&str] = &["mlterm", "foot", "yaft", "xterm-256color-sixel"];
683 const KNOWN_SIXEL_TERM_PROGRAMS: &[&str] = &["foot", "mlterm", "wezterm", "ghostty"];
684 KNOWN_SIXEL_TERMS.contains(&term)
685 || term.contains("sixel")
686 || KNOWN_SIXEL_TERM_PROGRAMS.contains(&term_program)
687}
688
689#[cfg(feature = "crossterm")]
694fn term_is_kitty_graphics_host() -> bool {
695 let term = std::env::var("TERM")
696 .unwrap_or_default()
697 .to_ascii_lowercase();
698 let term_program = std::env::var("TERM_PROGRAM")
699 .unwrap_or_default()
700 .to_ascii_lowercase();
701 term_is_kitty_graphics_host_env(
702 &term,
703 &term_program,
704 terminal_is_multiplexed(),
705 force_env_enabled("SLT_FORCE_KITTY"),
706 )
707}
708
709#[cfg(feature = "crossterm")]
710fn term_is_kitty_graphics_host_env(
711 term: &str,
712 term_program: &str,
713 multiplexed: bool,
714 forced: bool,
715) -> bool {
716 if forced {
717 return true;
718 }
719 if multiplexed {
720 return false;
721 }
722 term.contains("kitty") || matches!(term_program, "ghostty" | "wezterm" | "kitty")
724}
725
726#[cfg(feature = "crossterm")]
727fn terminal_is_multiplexed() -> bool {
728 if std::env::var_os("TMUX").is_some() || std::env::var_os("STY").is_some() {
729 return true;
730 }
731 let term = std::env::var("TERM")
732 .unwrap_or_default()
733 .to_ascii_lowercase();
734 terminal_is_multiplexed_env(&term, false, false)
735}
736
737#[cfg(feature = "crossterm")]
738fn terminal_is_multiplexed_env(term: &str, has_tmux: bool, has_sty: bool) -> bool {
739 let term = term.to_ascii_lowercase();
740 has_tmux || has_sty || term.starts_with("tmux") || term.starts_with("screen")
741}
742
743#[cfg(feature = "crossterm")]
744fn force_env_enabled(name: &str) -> bool {
745 std::env::var(name)
746 .ok()
747 .is_some_and(|value| truthy_env_value(&value))
748}
749
750#[cfg(feature = "crossterm")]
751fn truthy_env_value(value: &str) -> bool {
752 matches!(
753 value.to_ascii_lowercase().as_str(),
754 "1" | "true" | "yes" | "on"
755 )
756}
757
758#[cfg(feature = "crossterm")]
759fn terminal_queries_allowed() -> bool {
760 terminal_query_allowed(io::stdout().is_terminal(), io::stdin().is_terminal())
761}
762
763fn terminal_query_allowed(stdout_is_terminal: bool, stdin_is_terminal: bool) -> bool {
764 stdout_is_terminal && stdin_is_terminal
765}
766
767#[cfg(feature = "crossterm")]
770struct ReplyPump {
771 rx: std::sync::mpsc::Receiver<u8>,
772 serve: std::sync::Arc<std::sync::atomic::AtomicBool>,
775 exited: std::sync::Arc<std::sync::atomic::AtomicBool>,
778}
779
780#[cfg(feature = "crossterm")]
781static REPLY_PUMP: std::sync::Mutex<Option<ReplyPump>> = std::sync::Mutex::new(None);
782
783#[cfg(feature = "crossterm")]
812fn read_stdin_reply(
813 timeout: Duration,
814 mut is_complete: impl FnMut(&[u8]) -> bool,
815) -> Option<String> {
816 use std::sync::atomic::{AtomicBool, Ordering};
817 use std::sync::{Arc, mpsc};
818
819 let deadline = Instant::now() + timeout;
820
821 let Ok(mut slot) = REPLY_PUMP.lock() else {
822 return None;
826 };
827
828 let pump = match slot.take().filter(|p| !p.exited.load(Ordering::Acquire)) {
829 Some(pump) => {
830 pump.serve.store(true, Ordering::Release);
835 pump
836 }
837 None => {
838 let (tx, rx) = mpsc::channel::<u8>();
839 let serve = Arc::new(AtomicBool::new(true));
840 let exited = Arc::new(AtomicBool::new(false));
841 let thread_serve = Arc::clone(&serve);
842 let thread_exited = Arc::clone(&exited);
843 let spawned = std::thread::Builder::new()
844 .name("slt-reply-pump".into())
845 .spawn(move || {
846 let mut stdin = io::stdin();
847 let mut buf = [0u8; 1];
854 loop {
855 match stdin.read(&mut buf) {
856 Ok(0) | Err(_) => break,
857 Ok(_) => {
858 if tx.send(buf[0]).is_err() {
859 thread_exited.store(true, Ordering::Release);
860 return;
861 }
862 }
863 }
864 if !thread_serve.load(Ordering::Acquire) {
865 break;
866 }
867 }
868 thread_exited.store(true, Ordering::Release);
869 });
870 if spawned.is_err() {
871 return None;
872 }
873 ReplyPump { rx, serve, exited }
874 }
875 };
876
877 while pump.rx.try_recv().is_ok() {}
880
881 let bytes = collect_reply(&pump.rx, deadline, &mut is_complete);
882
883 pump.serve.store(false, Ordering::Release);
891 if crossterm::terminal::is_raw_mode_enabled().unwrap_or(false) {
892 let mut out = io::stdout();
893 let _ = write!(out, "\x1b[5n");
894 let _ = out.flush();
895 }
896 *slot = Some(pump);
897 drop(slot);
898
899 if bytes.is_empty() {
900 return None;
901 }
902 String::from_utf8(bytes).ok()
903}
904
905#[cfg(feature = "crossterm")]
911fn collect_reply(
912 rx: &std::sync::mpsc::Receiver<u8>,
913 deadline: Instant,
914 is_complete: &mut dyn FnMut(&[u8]) -> bool,
915) -> Vec<u8> {
916 let mut bytes = Vec::new();
917 loop {
918 let now = Instant::now();
919 if now >= deadline {
920 break;
921 }
922 match rx.recv_timeout(deadline - now) {
923 Ok(byte) => {
924 bytes.push(byte);
925 if is_complete(&bytes) || bytes.len() >= 4096 {
926 break;
927 }
928 }
929 Err(_) => break,
931 }
932 }
933 bytes
934}
935
936#[cfg(feature = "crossterm")]
939fn osc_reply_complete(bytes: &[u8]) -> bool {
940 let len = bytes.len();
941 bytes[len - 1] == b'\x07' || (len >= 2 && bytes[len - 2] == 0x1B && bytes[len - 1] == b'\\')
942}
943
944#[cfg(feature = "crossterm")]
948fn da_reply_complete() -> impl FnMut(&[u8]) -> bool {
949 let mut terminators = 0usize;
950 move |bytes: &[u8]| {
951 if bytes[bytes.len() - 1] == b'c' {
952 terminators += 1;
953 }
954 terminators >= 2
955 }
956}
957
958#[cfg(feature = "crossterm")]
960fn decrpm_reply_complete(bytes: &[u8]) -> bool {
961 bytes[bytes.len() - 1] == b'y'
962}
963
964#[cfg(feature = "crossterm")]
969fn read_da_response(timeout: Duration) -> Option<String> {
970 read_stdin_reply(timeout, da_reply_complete())
971}
972
973#[cfg(feature = "crossterm")]
977fn parse_da1(response: &str, caps: &mut Capabilities) {
978 let mut search = response;
980 while let Some(pos) = search.find("\x1b[?") {
981 let body = &search[pos + 3..];
982 let Some(end) = body.find('c') else { break };
983 let attrs = &body[..end];
984 for attr in attrs.split(';') {
985 if attr.trim() == "4" {
986 caps.sixel = true;
987 }
988 }
989 search = &body[end + 1..];
990 }
991}
992
993#[cfg(feature = "crossterm")]
1001fn parse_da2(response: &str, caps: &mut Capabilities) {
1002 let Some((id, _ver)) = parse_da2_identity(response) else {
1003 return;
1004 };
1005 const KITTY_GRAPHICS_DA2_ID: u32 = 41;
1010 if id == KITTY_GRAPHICS_DA2_ID {
1011 caps.kitty_graphics = true;
1012 }
1013}
1014
1015#[cfg(feature = "crossterm")]
1017fn parse_da2_identity(response: &str) -> Option<(u32, u32)> {
1018 let pos = response.find("\x1b[>")?;
1019 let body = &response[pos + 3..];
1020 let end = body.find('c')?;
1021 let mut parts = body[..end].split(';');
1022 let id = parts.next()?.trim().parse::<u32>().ok()?;
1023 let ver = parts.next().and_then(|s| s.trim().parse::<u32>().ok());
1024 Some((id, ver.unwrap_or(0)))
1025}
1026
1027#[cfg(feature = "crossterm")]
1031fn parse_kitty_graphics_ack(response: &str, caps: &mut Capabilities) {
1032 if let Some(pos) = response.find("\x1b_G") {
1035 let body = &response[pos + 3..];
1036 let end = body.find("\x1b\\").unwrap_or(body.len());
1037 let payload = &body[..end];
1038 if payload.contains("i=31") && payload.contains("OK") {
1039 caps.kitty_graphics = true;
1040 }
1041 }
1042}
1043
1044#[cfg(feature = "crossterm")]
1048fn parse_xtgettcap_truecolor(response: &str, caps: &mut Capabilities) {
1049 if let Some(pos) = response.find("\x1bP1+r") {
1051 let body = &response[pos + 5..];
1052 if body
1053 .to_ascii_lowercase()
1054 .split([';', '\x1b'])
1055 .any(|seg| seg.starts_with("5463"))
1056 {
1057 caps.truecolor = true;
1058 }
1059 }
1060}
1061
1062#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1072enum SyncOutputResolution {
1073 Supported,
1075 Unsupported,
1077}
1078
1079static SYNC_OUTPUT_RESOLUTION: std::sync::OnceLock<SyncOutputResolution> =
1082 std::sync::OnceLock::new();
1083
1084fn should_emit_synchronized_update() -> bool {
1094 !matches!(
1095 SYNC_OUTPUT_RESOLUTION.get(),
1096 Some(SyncOutputResolution::Unsupported)
1097 )
1098}
1099
1100#[cfg(feature = "crossterm")]
1104fn read_decrpm_response(timeout: Duration) -> Option<String> {
1105 read_stdin_reply(timeout, decrpm_reply_complete)
1106}
1107
1108#[cfg(feature = "crossterm")]
1117fn parse_decrpm_sync_output(response: &str) -> Option<bool> {
1118 let pos = response.find("\x1b[?2026;")?;
1120 let body = &response[pos + "\x1b[?2026;".len()..];
1121 let end = body.find("$y")?;
1122 let ps = body[..end].trim().parse::<u32>().ok()?;
1123 Some(ps != 0)
1125}
1126
1127fn split_base64(encoded: &str, chunk_size: usize) -> Vec<&str> {
1128 let mut chunks = Vec::new();
1129 let bytes = encoded.as_bytes();
1130 let mut offset = 0;
1131 while offset < bytes.len() {
1132 let end = (offset + chunk_size).min(bytes.len());
1133 chunks.push(&encoded[offset..end]);
1134 offset = end;
1135 }
1136 if chunks.is_empty() {
1137 chunks.push("");
1138 }
1139 chunks
1140}
1141
1142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1143struct GraphicsEmissionSupport {
1144 real_terminal: bool,
1145 capabilities: Capabilities,
1146 force_kitty: bool,
1147 force_sixel: bool,
1148 force_iterm: bool,
1149}
1150
1151impl GraphicsEmissionSupport {
1152 fn detect(capabilities: Capabilities) -> Self {
1153 Self {
1154 real_terminal: true,
1155 capabilities,
1156 force_kitty: force_env_enabled("SLT_FORCE_KITTY"),
1157 force_sixel: force_env_enabled("SLT_FORCE_SIXEL"),
1158 force_iterm: force_env_enabled("SLT_FORCE_ITERM"),
1159 }
1160 }
1161
1162 #[cfg(any(test, feature = "pty-test"))]
1163 fn capture() -> Self {
1164 Self {
1165 real_terminal: true,
1166 capabilities: Capabilities::default(),
1167 force_kitty: force_env_enabled("SLT_FORCE_KITTY"),
1168 force_sixel: force_env_enabled("SLT_FORCE_SIXEL"),
1169 force_iterm: force_env_enabled("SLT_FORCE_ITERM"),
1170 }
1171 }
1172
1173 fn should_emit_kitty(self) -> bool {
1174 self.real_terminal && (self.capabilities.kitty_graphics || self.force_kitty)
1175 }
1176
1177 fn should_emit_sprixel(self, protocol: SprixelProtocol) -> bool {
1178 if !self.real_terminal {
1179 return false;
1180 }
1181 match protocol {
1182 SprixelProtocol::Sixel => self.capabilities.sixel || self.force_sixel,
1183 SprixelProtocol::Iterm2 => self.capabilities.iterm2 || self.force_iterm,
1184 SprixelProtocol::Unknown => false,
1185 }
1186 }
1187}
1188
1189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1190enum SprixelProtocol {
1191 Sixel,
1192 Iterm2,
1193 Unknown,
1194}
1195
1196fn sprixel_protocol(seq: &str) -> SprixelProtocol {
1197 if seq.starts_with("\x1bPq") {
1198 SprixelProtocol::Sixel
1199 } else if seq.starts_with("\x1b]1337;File=") {
1200 SprixelProtocol::Iterm2
1201 } else {
1202 SprixelProtocol::Unknown
1203 }
1204}
1205
1206pub struct Terminal {
1214 stdout: Sink,
1215 current: Buffer,
1216 previous: Buffer,
1217 cursor_visible: bool,
1218 session: TerminalSessionGuard,
1219 color_depth: ColorDepth,
1220 pub(crate) theme_bg: Option<Color>,
1221 kitty_mgr: KittyImageManager,
1222 graphics_support: GraphicsEmissionSupport,
1223 run_buf: String,
1227}
1228
1229pub struct InlineTerminal {
1235 stdout: Sink,
1236 current: Buffer,
1237 previous: Buffer,
1238 cursor_visible: bool,
1239 session: TerminalSessionGuard,
1240 height: u32,
1241 start_row: u16,
1242 reserved: bool,
1243 color_depth: ColorDepth,
1244 pub(crate) theme_bg: Option<Color>,
1245 kitty_mgr: KittyImageManager,
1246 graphics_support: GraphicsEmissionSupport,
1247 run_buf: String,
1249}
1250
1251const RUN_BUF_INITIAL_CAPACITY: usize = 4096;
1255
1256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1257enum TerminalSessionMode {
1258 Fullscreen,
1259 Inline,
1260}
1261
1262#[derive(Debug, Clone, Copy)]
1263struct TerminalSessionGuard {
1264 mode: TerminalSessionMode,
1265 mouse_enabled: bool,
1266 kitty_keyboard: bool,
1267 report_all_keys: bool,
1268 harness: bool,
1274}
1275
1276impl TerminalSessionGuard {
1277 fn enter(
1278 mode: TerminalSessionMode,
1279 stdout: &mut impl Write,
1280 mouse_enabled: bool,
1281 kitty_keyboard: bool,
1282 report_all_keys: bool,
1283 ) -> io::Result<Self> {
1284 let guard = Self {
1285 mode,
1286 mouse_enabled,
1287 kitty_keyboard,
1288 report_all_keys,
1289 harness: false,
1290 };
1291
1292 terminal::enable_raw_mode()?;
1293 if let Err(err) = write_session_enter(stdout, &guard) {
1294 guard.restore(stdout, false);
1295 return Err(err);
1296 }
1297
1298 let _ = capabilities();
1305
1306 Ok(guard)
1307 }
1308
1309 fn restore(&self, stdout: &mut impl Write, inline_reserved: bool) {
1310 if self.harness {
1312 return;
1313 }
1314 let _ = write_session_exit(
1315 stdout,
1316 self.mode,
1317 inline_reserved,
1318 self.mouse_enabled,
1319 self.kitty_keyboard,
1320 );
1321 let _ = terminal::disable_raw_mode();
1322 }
1323}
1324
1325impl Terminal {
1326 pub fn new(
1331 mouse: bool,
1332 kitty_keyboard: bool,
1333 report_all_keys: bool,
1334 color_depth: ColorDepth,
1335 ) -> io::Result<Self> {
1336 let (cols, rows) = terminal::size()?;
1337 let area = Rect::new(0, 0, cols as u32, rows as u32);
1338
1339 let mut raw = io::stdout();
1340 let session = TerminalSessionGuard::enter(
1341 TerminalSessionMode::Fullscreen,
1342 &mut raw,
1343 mouse,
1344 kitty_keyboard,
1345 report_all_keys,
1346 )?;
1347 let graphics_support = GraphicsEmissionSupport::detect(capabilities());
1348
1349 Ok(Self {
1350 stdout: Sink::Stdout(BufWriter::with_capacity(65536, raw)),
1351 current: Buffer::empty(area),
1352 previous: Buffer::empty(area),
1353 cursor_visible: false,
1354 session,
1355 color_depth,
1356 theme_bg: None,
1357 kitty_mgr: KittyImageManager::new(),
1358 graphics_support,
1359 run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
1360 })
1361 }
1362
1363 pub fn size(&self) -> (u32, u32) {
1365 (self.current.area.width, self.current.area.height)
1366 }
1367
1368 pub fn buffer_mut(&mut self) -> &mut Buffer {
1370 &mut self.current
1371 }
1372
1373 pub fn flush(&mut self) -> io::Result<()> {
1377 if self.current.area.width < self.previous.area.width {
1378 execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
1379 }
1380
1381 let sync_guard = should_emit_synchronized_update();
1385 if sync_guard {
1386 queue!(self.stdout, BeginSynchronizedUpdate)?;
1387 }
1388 self.current.recompute_line_hashes();
1393 self.previous.recompute_line_hashes();
1394 flush_buffer_diff(
1395 &mut self.stdout,
1396 &self.current,
1397 &self.previous,
1398 self.color_depth,
1399 0,
1400 &mut self.run_buf,
1401 )?;
1402
1403 if self.graphics_support.should_emit_kitty() {
1406 self.kitty_mgr
1407 .flush(&mut self.stdout, &self.current.kitty_placements, 0)?;
1408 }
1409
1410 flush_raw_sequences(&mut self.stdout, &self.current, &self.previous, 0)?;
1412
1413 flush_sprixels_checked(
1415 &mut self.stdout,
1416 &self.current,
1417 &self.previous,
1418 0,
1419 self.graphics_support,
1420 )?;
1421
1422 if sync_guard {
1423 queue!(self.stdout, EndSynchronizedUpdate)?;
1424 }
1425 flush_cursor(
1426 &mut self.stdout,
1427 &mut self.cursor_visible,
1428 self.current.cursor_pos(),
1429 0,
1430 None,
1431 )?;
1432
1433 self.stdout.flush()?;
1434
1435 std::mem::swap(&mut self.current, &mut self.previous);
1436 if let Some(bg) = self.theme_bg {
1437 self.current.reset_with_bg(bg);
1438 } else {
1439 self.current.reset();
1440 }
1441 Ok(())
1442 }
1443
1444 pub fn handle_resize(&mut self) -> io::Result<()> {
1447 let (cols, rows) = terminal::size()?;
1448 let area = Rect::new(0, 0, cols as u32, rows as u32);
1449 self.current.resize(area);
1450 self.previous.resize(area);
1451 execute!(
1452 self.stdout,
1453 terminal::Clear(terminal::ClearType::All),
1454 cursor::MoveTo(0, 0)
1455 )?;
1456 Ok(())
1457 }
1458}
1459
1460#[cfg(any(test, feature = "pty-test"))]
1461impl Terminal {
1462 pub(crate) fn with_sink(width: u32, height: u32, color_depth: ColorDepth) -> Self {
1476 let area = Rect::new(0, 0, width, height);
1477 Self {
1478 stdout: Sink::Capture(Vec::new()),
1479 current: Buffer::empty(area),
1480 previous: Buffer::empty(area),
1481 cursor_visible: false,
1482 session: TerminalSessionGuard {
1483 mode: TerminalSessionMode::Fullscreen,
1484 mouse_enabled: false,
1485 kitty_keyboard: false,
1486 report_all_keys: false,
1487 harness: true,
1488 },
1489 color_depth,
1490 theme_bg: None,
1491 kitty_mgr: KittyImageManager::new(),
1492 graphics_support: GraphicsEmissionSupport::capture(),
1493 run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
1494 }
1495 }
1496
1497 pub(crate) fn take_sink_bytes(&mut self) -> Vec<u8> {
1502 match &mut self.stdout {
1503 Sink::Capture(v) => std::mem::take(v),
1504 Sink::Stdout(_) => panic!("take_sink_bytes called on a non-capture Terminal"),
1505 }
1506 }
1507}
1508
1509impl crate::Backend for Terminal {
1510 fn size(&self) -> (u32, u32) {
1511 Terminal::size(self)
1512 }
1513
1514 fn buffer_mut(&mut self) -> &mut Buffer {
1515 Terminal::buffer_mut(self)
1516 }
1517
1518 fn flush(&mut self) -> io::Result<()> {
1519 Terminal::flush(self)
1520 }
1521}
1522
1523impl InlineTerminal {
1524 pub fn new(
1530 height: u32,
1531 mouse: bool,
1532 kitty_keyboard: bool,
1533 report_all_keys: bool,
1534 color_depth: ColorDepth,
1535 ) -> io::Result<Self> {
1536 let (cols, _) = terminal::size()?;
1537 let area = Rect::new(0, 0, cols as u32, height);
1538
1539 let mut raw = io::stdout();
1540 let session = TerminalSessionGuard::enter(
1541 TerminalSessionMode::Inline,
1542 &mut raw,
1543 mouse,
1544 kitty_keyboard,
1545 report_all_keys,
1546 )?;
1547 let graphics_support = GraphicsEmissionSupport::detect(capabilities());
1548
1549 let (_, cursor_row) = match cursor::position() {
1550 Ok(pos) => pos,
1551 Err(err) => {
1552 session.restore(&mut raw, false);
1553 return Err(err);
1554 }
1555 };
1556 Ok(Self {
1557 stdout: Sink::Stdout(BufWriter::with_capacity(65536, raw)),
1558 current: Buffer::empty(area),
1559 previous: Buffer::empty(area),
1560 cursor_visible: false,
1561 session,
1562 height,
1563 start_row: cursor_row,
1564 reserved: false,
1565 color_depth,
1566 theme_bg: None,
1567 kitty_mgr: KittyImageManager::new(),
1568 graphics_support,
1569 run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
1570 })
1571 }
1572
1573 pub fn size(&self) -> (u32, u32) {
1575 (self.current.area.width, self.current.area.height)
1576 }
1577
1578 pub fn buffer_mut(&mut self) -> &mut Buffer {
1580 &mut self.current
1581 }
1582
1583 pub fn flush(&mut self) -> io::Result<()> {
1587 if self.current.area.width < self.previous.area.width {
1588 execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
1589 }
1590
1591 let sync_guard = should_emit_synchronized_update();
1594 if sync_guard {
1595 queue!(self.stdout, BeginSynchronizedUpdate)?;
1596 }
1597
1598 if !self.reserved {
1599 queue!(self.stdout, cursor::MoveToColumn(0))?;
1600 for _ in 0..self.height {
1601 queue!(self.stdout, Print("\n"))?;
1602 }
1603 self.reserved = true;
1604
1605 let (_, rows) = terminal::size()?;
1606 let bottom = self.start_row.saturating_add(sat_u16(self.height));
1607 if bottom > rows {
1608 self.start_row = rows.saturating_sub(sat_u16(self.height));
1609 }
1610 }
1611 let row_offset = self.start_row as u32;
1612 self.current.recompute_line_hashes();
1615 self.previous.recompute_line_hashes();
1616 flush_buffer_diff(
1617 &mut self.stdout,
1618 &self.current,
1619 &self.previous,
1620 self.color_depth,
1621 row_offset,
1622 &mut self.run_buf,
1623 )?;
1624
1625 if self.graphics_support.should_emit_kitty() {
1631 self.kitty_mgr
1632 .flush(&mut self.stdout, &self.current.kitty_placements, row_offset)?;
1633 }
1634
1635 flush_raw_sequences(&mut self.stdout, &self.current, &self.previous, row_offset)?;
1637
1638 flush_sprixels_checked(
1640 &mut self.stdout,
1641 &self.current,
1642 &self.previous,
1643 row_offset,
1644 self.graphics_support,
1645 )?;
1646
1647 if sync_guard {
1648 queue!(self.stdout, EndSynchronizedUpdate)?;
1649 }
1650 let fallback_row = row_offset + self.height.saturating_sub(1);
1651 flush_cursor(
1652 &mut self.stdout,
1653 &mut self.cursor_visible,
1654 self.current.cursor_pos(),
1655 row_offset,
1656 Some(fallback_row),
1657 )?;
1658
1659 self.stdout.flush()?;
1660
1661 std::mem::swap(&mut self.current, &mut self.previous);
1662 reset_current_buffer(&mut self.current, self.theme_bg);
1663 Ok(())
1664 }
1665
1666 pub fn handle_resize(&mut self) -> io::Result<()> {
1669 let (cols, _) = terminal::size()?;
1670 let area = Rect::new(0, 0, cols as u32, self.height);
1671 self.current.resize(area);
1672 self.previous.resize(area);
1673 execute!(
1674 self.stdout,
1675 terminal::Clear(terminal::ClearType::All),
1676 cursor::MoveTo(0, 0)
1677 )?;
1678 Ok(())
1679 }
1680}
1681
1682impl crate::Backend for InlineTerminal {
1683 fn size(&self) -> (u32, u32) {
1684 InlineTerminal::size(self)
1685 }
1686
1687 fn buffer_mut(&mut self) -> &mut Buffer {
1688 InlineTerminal::buffer_mut(self)
1689 }
1690
1691 fn flush(&mut self) -> io::Result<()> {
1692 InlineTerminal::flush(self)
1693 }
1694}
1695
1696impl Drop for Terminal {
1697 fn drop(&mut self) {
1698 if self.graphics_support.should_emit_kitty() {
1700 let _ = self.kitty_mgr.delete_all(&mut self.stdout);
1701 }
1702 let _ = self.stdout.flush();
1703 self.session.restore(&mut self.stdout, false);
1704 }
1705}
1706
1707impl Drop for InlineTerminal {
1708 fn drop(&mut self) {
1709 if self.graphics_support.should_emit_kitty() {
1710 let _ = self.kitty_mgr.delete_all(&mut self.stdout);
1711 }
1712 let _ = self.stdout.flush();
1713 self.session.restore(&mut self.stdout, self.reserved);
1714 }
1715}
1716
1717mod selection;
1718pub(crate) use selection::{SelectionState, apply_selection_overlay, extract_selection_text};
1719#[cfg(test)]
1720pub(crate) use selection::{find_innermost_rect, normalize_selection};
1721
1722#[non_exhaustive]
1724#[cfg(feature = "crossterm")]
1725#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
1726#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1727pub enum ColorScheme {
1728 Dark,
1730 Light,
1732 Unknown,
1734}
1735
1736#[cfg(feature = "crossterm")]
1738fn read_osc_response(timeout: Duration) -> Option<String> {
1739 read_stdin_reply(timeout, osc_reply_complete)
1740}
1741
1742#[cfg(feature = "crossterm")]
1744#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
1745pub fn detect_color_scheme() -> ColorScheme {
1746 if !terminal_queries_allowed() {
1747 return ColorScheme::Unknown;
1748 }
1749
1750 let mut stdout = io::stdout();
1751 if write!(stdout, "\x1b]11;?\x07").is_err() {
1752 return ColorScheme::Unknown;
1753 }
1754 if stdout.flush().is_err() {
1755 return ColorScheme::Unknown;
1756 }
1757
1758 let Some(response) = read_osc_response(Duration::from_millis(100)) else {
1759 return ColorScheme::Unknown;
1760 };
1761
1762 parse_osc11_response(&response)
1763}
1764
1765#[cfg(feature = "crossterm")]
1766pub(crate) fn parse_osc11_response(response: &str) -> ColorScheme {
1767 let Some(rgb_pos) = response.find("rgb:") else {
1768 return ColorScheme::Unknown;
1769 };
1770
1771 let payload = &response[rgb_pos + 4..];
1772 let end = payload
1773 .find(['\x07', '\x1b', '\r', '\n', ' ', '\t'])
1774 .unwrap_or(payload.len());
1775 let rgb = &payload[..end];
1776
1777 let mut channels = rgb.split('/');
1778 let (Some(r), Some(g), Some(b), None) = (
1779 channels.next(),
1780 channels.next(),
1781 channels.next(),
1782 channels.next(),
1783 ) else {
1784 return ColorScheme::Unknown;
1785 };
1786
1787 fn parse_channel(channel: &str) -> Option<f64> {
1788 if channel.is_empty() || channel.len() > 4 {
1789 return None;
1790 }
1791 let value = u16::from_str_radix(channel, 16).ok()? as f64;
1792 let max = ((1u32 << (channel.len() * 4)) - 1) as f64;
1793 if max <= 0.0 {
1794 return None;
1795 }
1796 Some((value / max).clamp(0.0, 1.0))
1797 }
1798
1799 let (Some(r), Some(g), Some(b)) = (parse_channel(r), parse_channel(g), parse_channel(b)) else {
1800 return ColorScheme::Unknown;
1801 };
1802
1803 let luminance = 0.299 * r + 0.587 * g + 0.114 * b;
1804 if luminance < 0.5 {
1805 ColorScheme::Dark
1806 } else {
1807 ColorScheme::Light
1808 }
1809}
1810
1811pub(crate) fn base64_encode(input: &[u8]) -> String {
1812 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1813 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
1814 for chunk in input.chunks(3) {
1815 let b0 = chunk[0] as u32;
1816 let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
1817 let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
1818 let triple = (b0 << 16) | (b1 << 8) | b2;
1819 out.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
1820 out.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
1821 out.push(if chunk.len() > 1 {
1822 CHARS[((triple >> 6) & 0x3F) as usize] as char
1823 } else {
1824 '='
1825 });
1826 out.push(if chunk.len() > 2 {
1827 CHARS[(triple & 0x3F) as usize] as char
1828 } else {
1829 '='
1830 });
1831 }
1832 out
1833}
1834
1835pub(crate) fn copy_to_clipboard(w: &mut impl Write, text: &str) -> io::Result<()> {
1836 let encoded = base64_encode(text.as_bytes());
1837 write!(w, "\x1b]52;c;{encoded}\x1b\\")?;
1838 w.flush()
1839}
1840
1841#[cfg(feature = "crossterm")]
1842fn parse_osc52_response(response: &str) -> Option<String> {
1843 let osc_pos = response.find("]52;")?;
1844 let body = &response[osc_pos + 4..];
1845 let semicolon = body.find(';')?;
1846 let payload = &body[semicolon + 1..];
1847
1848 let end = payload
1849 .find("\x1b\\")
1850 .or_else(|| payload.find('\x07'))
1851 .unwrap_or(payload.len());
1852 let encoded = payload[..end].trim();
1853 if encoded.is_empty() || encoded == "?" {
1854 return None;
1855 }
1856
1857 base64_decode(encoded)
1858}
1859
1860#[cfg(feature = "crossterm")]
1893#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
1894pub fn read_clipboard() -> Option<String> {
1895 if !terminal_queries_allowed() {
1896 return None;
1897 }
1898
1899 let mut stdout = io::stdout();
1900 write!(stdout, "\x1b]52;c;?\x07").ok()?;
1901 stdout.flush().ok()?;
1902
1903 let response = read_osc_response(Duration::from_millis(200))?;
1904 parse_osc52_response(&response)
1905}
1906
1907#[cfg(feature = "crossterm")]
1908fn base64_decode(input: &str) -> Option<String> {
1909 let mut filtered: Vec<u8> = input
1910 .bytes()
1911 .filter(|b| !matches!(b, b' ' | b'\n' | b'\r' | b'\t'))
1912 .collect();
1913
1914 match filtered.len() % 4 {
1915 0 => {}
1916 2 => filtered.extend_from_slice(b"=="),
1917 3 => filtered.push(b'='),
1918 _ => return None,
1919 }
1920
1921 fn decode_val(b: u8) -> Option<u8> {
1922 match b {
1923 b'A'..=b'Z' => Some(b - b'A'),
1924 b'a'..=b'z' => Some(b - b'a' + 26),
1925 b'0'..=b'9' => Some(b - b'0' + 52),
1926 b'+' => Some(62),
1927 b'/' => Some(63),
1928 _ => None,
1929 }
1930 }
1931
1932 let mut out = Vec::with_capacity((filtered.len() / 4) * 3);
1933 for chunk in filtered.chunks_exact(4) {
1934 let p2 = chunk[2] == b'=';
1935 let p3 = chunk[3] == b'=';
1936 if p2 && !p3 {
1937 return None;
1938 }
1939
1940 let v0 = decode_val(chunk[0])? as u32;
1941 let v1 = decode_val(chunk[1])? as u32;
1942 let v2 = if p2 { 0 } else { decode_val(chunk[2])? as u32 };
1943 let v3 = if p3 { 0 } else { decode_val(chunk[3])? as u32 };
1944
1945 let triple = (v0 << 18) | (v1 << 12) | (v2 << 6) | v3;
1946 out.push(((triple >> 16) & 0xFF) as u8);
1947 if !p2 {
1948 out.push(((triple >> 8) & 0xFF) as u8);
1949 }
1950 if !p3 {
1951 out.push((triple & 0xFF) as u8);
1952 }
1953 }
1954
1955 String::from_utf8(out).ok()
1956}
1957
1958#[allow(clippy::too_many_arguments)]
1959#[allow(unused_assignments)]
1960fn flush_buffer_diff(
1961 stdout: &mut impl Write,
1962 current: &Buffer,
1963 previous: &Buffer,
1964 color_depth: ColorDepth,
1965 row_offset: u32,
1966 run_buf: &mut String,
1967) -> io::Result<()> {
1968 let mut last_style = Style::new();
1980 let mut first_style = true;
1981 let mut active_link: Option<&str> = None;
1982 let mut has_updates = false;
1983 let mut last_cursor: Option<(u32, u32)> = None;
1987
1988 run_buf.clear();
1994 let mut run_abs_y: u32 = 0;
1995 let mut run_style: Style = Style::new();
1996 let mut run_link: Option<&str> = None;
1997 let mut run_next_col: u32 = 0;
1998 let mut run_open = false;
1999
2000 macro_rules! flush_run {
2005 ($stdout:expr) => {
2006 if run_open {
2007 queue!($stdout, Print(&run_buf))?;
2008 last_cursor = Some((run_next_col, run_abs_y));
2009 run_buf.clear();
2010 run_open = false;
2011 }
2012 };
2013 }
2014
2015 for y in current.area.y..current.area.bottom() {
2016 if current.row_clean(y)
2025 && current.row_hash(y).is_some()
2026 && current.row_hash(y) == previous.row_hash(y)
2027 {
2028 continue;
2029 }
2030 for x in current.area.x..current.area.right() {
2031 let cell = current.get(x, y);
2032 let prev = previous.get(x, y);
2033 if cell == prev || cell.symbol.is_empty() {
2034 flush_run!(stdout);
2036 continue;
2037 }
2038
2039 let abs_y = row_offset + y;
2040 let cell_link = cell
2045 .hyperlink
2046 .as_deref()
2047 .filter(|u| crate::buffer::is_valid_osc8_url(u));
2048
2049 let extends = run_open
2051 && run_abs_y == abs_y
2052 && run_next_col == x
2053 && run_style == cell.style
2054 && run_link == cell_link;
2055
2056 if !extends {
2057 flush_run!(stdout);
2058
2059 has_updates = true;
2063
2064 let need_move = last_cursor.is_none_or(|(lx, ly)| lx != x || ly != abs_y);
2065 if need_move {
2066 queue!(stdout, cursor::MoveTo(sat_u16(x), sat_u16(abs_y)))?;
2067 }
2068
2069 if cell.style != last_style {
2070 if first_style {
2071 queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
2072 apply_style(stdout, &cell.style, color_depth)?;
2073 first_style = false;
2074 } else {
2075 apply_style_delta(stdout, &last_style, &cell.style, color_depth)?;
2076 }
2077 last_style = cell.style;
2078 }
2079
2080 if cell_link != active_link {
2081 if let Some(url) = cell_link {
2082 queue!(stdout, Print("\x1b]8;;"))?;
2087 queue!(stdout, Print(url))?;
2088 queue!(stdout, Print("\x07"))?;
2089 } else {
2090 queue!(stdout, Print("\x1b]8;;\x07"))?;
2091 }
2092 active_link = cell_link;
2093 }
2094
2095 run_open = true;
2096 run_abs_y = abs_y;
2097 run_style = cell.style;
2098 run_link = cell_link;
2099 }
2100
2101 run_buf.push_str(&cell.symbol);
2105 let char_width = UnicodeWidthStr::width(cell.symbol.as_str()).max(1) as u32;
2106 if char_width > 1 && cell.symbol.chars().any(|c| c == '\u{FE0F}') {
2107 run_buf.push(' ');
2111 }
2112 run_next_col = x + char_width;
2113 }
2114
2115 flush_run!(stdout);
2117 }
2118
2119 if has_updates {
2120 if active_link.is_some() {
2121 queue!(stdout, Print("\x1b]8;;\x07"))?;
2122 }
2123 queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
2124 }
2125
2126 Ok(())
2127}
2128
2129#[doc(hidden)]
2139pub fn __bench_flush_buffer_diff<W: Write>(
2140 w: &mut W,
2141 current: &Buffer,
2142 previous: &Buffer,
2143 color_depth: ColorDepth,
2144) -> io::Result<()> {
2145 let mut run_buf = String::with_capacity(RUN_BUF_INITIAL_CAPACITY);
2148 flush_buffer_diff(w, current, previous, color_depth, 0, &mut run_buf)
2149}
2150
2151#[doc(hidden)]
2160pub fn __bench_flush_buffer_diff_mut<W: Write>(
2161 w: &mut W,
2162 current: &mut Buffer,
2163 previous: &mut Buffer,
2164 color_depth: ColorDepth,
2165) -> io::Result<()> {
2166 let mut run_buf = String::with_capacity(RUN_BUF_INITIAL_CAPACITY);
2170 __bench_flush_buffer_diff_mut_with_buf(w, current, previous, color_depth, &mut run_buf)
2171}
2172
2173#[doc(hidden)]
2198pub fn __bench_flush_buffer_diff_mut_with_buf<W: Write>(
2199 w: &mut W,
2200 current: &mut Buffer,
2201 previous: &mut Buffer,
2202 color_depth: ColorDepth,
2203 run_buf: &mut String,
2204) -> io::Result<()> {
2205 current.recompute_line_hashes();
2206 previous.recompute_line_hashes();
2207 flush_buffer_diff(w, current, previous, color_depth, 0, run_buf)
2208}
2209
2210#[doc(hidden)]
2215pub struct __BenchKittyFixture {
2216 mgr: KittyImageManager,
2217 placements: Vec<KittyPlacement>,
2218}
2219
2220#[doc(hidden)]
2223pub fn __bench_new_kitty_fixture(n: usize) -> __BenchKittyFixture {
2224 let mut placements = Vec::with_capacity(n);
2225 for i in 0..n {
2226 let mut rgba = vec![0u8; 256];
2228 rgba[0] = i as u8;
2230 let content_hash = crate::buffer::hash_rgba(&rgba);
2231 placements.push(KittyPlacement {
2232 content_hash,
2233 rgba: std::sync::Arc::new(rgba),
2234 src_width: 8,
2235 src_height: 8,
2236 x: (i as u32) * 4,
2237 y: (i as u32) * 2,
2238 cols: 4,
2239 rows: 2,
2240 crop_y: 0,
2241 crop_h: 0,
2242 });
2243 }
2244 __BenchKittyFixture {
2245 mgr: KittyImageManager::new(),
2246 placements,
2247 }
2248}
2249
2250impl __BenchKittyFixture {
2251 #[doc(hidden)]
2255 pub fn rgba_strong_counts(&self) -> Vec<usize> {
2256 self.placements
2257 .iter()
2258 .map(|p| std::sync::Arc::strong_count(&p.rgba))
2259 .collect()
2260 }
2261
2262 #[doc(hidden)]
2265 pub fn flush_inline<W: Write>(&mut self, sink: &mut W, row_offset: u32) -> io::Result<()> {
2266 self.mgr.flush(sink, &self.placements, row_offset)
2267 }
2268
2269 #[doc(hidden)]
2271 pub fn len(&self) -> usize {
2272 self.placements.len()
2273 }
2274
2275 #[doc(hidden)]
2277 pub fn is_empty(&self) -> bool {
2278 self.placements.is_empty()
2279 }
2280}
2281
2282#[doc(hidden)]
2292pub fn __bench_flush_kitty<W: Write>(sink: &mut W, n: usize, row_offset: u32) -> io::Result<()> {
2293 let mut fixture = __bench_new_kitty_fixture(n);
2294 fixture.flush_inline(sink, row_offset)
2295}
2296
2297#[doc(hidden)]
2304pub struct __BenchSprixelFixture {
2305 current: Buffer,
2306 previous: Buffer,
2307}
2308
2309#[doc(hidden)]
2318pub fn __bench_new_sprixel_fixture(n: usize) -> __BenchSprixelFixture {
2319 use crate::buffer::{SprixelCell, SprixelPlacement};
2320
2321 let height = (n as u32 * 3).max(1);
2323 let area = Rect::new(0, 0, 8, height);
2324 let mut current = Buffer::empty(area);
2325 let mut previous = Buffer::empty(area);
2326
2327 for i in 0..n {
2328 let placement = SprixelPlacement {
2329 content_hash: 0x5000 + i as u64,
2330 seq: "<SIXEL>".to_string(),
2331 x: 0,
2332 y: i as u32 * 3,
2333 cols: 4,
2334 rows: 2,
2335 cells: vec![SprixelCell::Opaque; 8],
2336 };
2337 current.sprixels.push(placement.clone());
2338 previous.sprixels.push(placement);
2339 }
2340
2341 current.recompute_line_hashes();
2344 previous.recompute_line_hashes();
2345
2346 __BenchSprixelFixture { current, previous }
2347}
2348
2349#[allow(dead_code)]
2358impl __BenchSprixelFixture {
2359 #[doc(hidden)]
2363 pub fn flush<W: Write>(&self, sink: &mut W, row_offset: u32) -> io::Result<()> {
2364 flush_sprixels(sink, &self.current, &self.previous, row_offset)
2365 }
2366
2367 #[doc(hidden)]
2369 pub fn len(&self) -> usize {
2370 self.current.sprixels.len()
2371 }
2372
2373 #[doc(hidden)]
2375 pub fn is_empty(&self) -> bool {
2376 self.current.sprixels.is_empty()
2377 }
2378}
2379
2380#[doc(hidden)]
2390pub fn __bench_flush_sprixels<W: Write>(sink: &mut W, n: usize, row_offset: u32) -> io::Result<()> {
2391 let fixture = __bench_new_sprixel_fixture(n);
2392 if fixture.is_empty() {
2393 return Ok(());
2394 }
2395 debug_assert_eq!(fixture.len(), n);
2396 fixture.flush(sink, row_offset)
2397}
2398
2399fn flush_raw_sequences(
2400 stdout: &mut impl Write,
2401 current: &Buffer,
2402 previous: &Buffer,
2403 row_offset: u32,
2404) -> io::Result<()> {
2405 if current.raw_sequences == previous.raw_sequences {
2406 return Ok(());
2407 }
2408
2409 for (x, y, seq) in ¤t.raw_sequences {
2410 queue!(
2411 stdout,
2412 cursor::MoveTo(sat_u16(*x), sat_u16(row_offset + *y)),
2413 Print(seq)
2414 )?;
2415 }
2416
2417 Ok(())
2418}
2419
2420type SprixelKey = (u64, u32, u32, u32, u32);
2425
2426#[inline]
2428fn sprixel_key(p: &crate::buffer::SprixelPlacement) -> SprixelKey {
2429 (p.content_hash, p.x, p.y, p.cols, p.rows)
2430}
2431
2432fn sprixel_needs_reblit(
2455 placement: &crate::buffer::SprixelPlacement,
2456 current: &Buffer,
2457 previous: &Buffer,
2458 prev_keys: &std::collections::HashSet<SprixelKey>,
2459) -> bool {
2460 use crate::buffer::SprixelCell;
2461
2462 if !prev_keys.contains(&sprixel_key(placement)) {
2467 return true;
2468 }
2469
2470 for row in 0..placement.rows {
2474 let y = placement.y + row;
2475 if current.row_clean(y) && current.row_hash(y) == previous.row_hash(y) {
2479 continue;
2480 }
2481 for col in 0..placement.cols {
2482 let idx = (row * placement.cols + col) as usize;
2483 match placement.cells.get(idx) {
2484 Some(SprixelCell::Opaque) | Some(SprixelCell::Mixed) => {}
2485 _ => continue,
2488 }
2489 let x = placement.x + col;
2490 let (Some(cell), Some(prev)) = (current.try_get(x, y), previous.try_get(x, y)) else {
2495 continue;
2496 };
2497 if cell != prev && !cell.symbol.is_empty() {
2503 return true;
2504 }
2505 }
2506 }
2507
2508 false
2509}
2510
2511fn flush_sprixels(
2523 stdout: &mut impl Write,
2524 current: &Buffer,
2525 previous: &Buffer,
2526 row_offset: u32,
2527) -> io::Result<()> {
2528 flush_sprixels_inner(stdout, current, previous, row_offset, |_| true)
2529}
2530
2531fn flush_sprixels_checked(
2532 stdout: &mut impl Write,
2533 current: &Buffer,
2534 previous: &Buffer,
2535 row_offset: u32,
2536 graphics_support: GraphicsEmissionSupport,
2537) -> io::Result<()> {
2538 flush_sprixels_inner(stdout, current, previous, row_offset, |placement| {
2539 graphics_support.should_emit_sprixel(sprixel_protocol(&placement.seq))
2540 })
2541}
2542
2543fn flush_sprixels_inner(
2544 stdout: &mut impl Write,
2545 current: &Buffer,
2546 previous: &Buffer,
2547 row_offset: u32,
2548 mut should_emit: impl FnMut(&crate::buffer::SprixelPlacement) -> bool,
2549) -> io::Result<()> {
2550 if current.sprixels.is_empty() {
2553 return Ok(());
2554 }
2555
2556 let prev_keys: std::collections::HashSet<SprixelKey> =
2557 previous.sprixels.iter().map(sprixel_key).collect();
2558
2559 for placement in ¤t.sprixels {
2560 if should_emit(placement) && sprixel_needs_reblit(placement, current, previous, &prev_keys)
2561 {
2562 queue!(
2563 stdout,
2564 cursor::MoveTo(sat_u16(placement.x), sat_u16(row_offset + placement.y)),
2565 Print(&placement.seq)
2566 )?;
2567 }
2568 }
2569 Ok(())
2570}
2571
2572fn flush_cursor(
2573 stdout: &mut impl Write,
2574 cursor_visible: &mut bool,
2575 cursor_pos: Option<(u32, u32)>,
2576 row_offset: u32,
2577 fallback_row: Option<u32>,
2578) -> io::Result<()> {
2579 match cursor_pos {
2580 Some((cx, cy)) => {
2581 if !*cursor_visible {
2582 queue!(stdout, cursor::Show)?;
2583 *cursor_visible = true;
2584 }
2585 queue!(
2586 stdout,
2587 cursor::MoveTo(sat_u16(cx), sat_u16(row_offset + cy))
2588 )?;
2589 }
2590 None => {
2591 if *cursor_visible {
2592 queue!(stdout, cursor::Hide)?;
2593 *cursor_visible = false;
2594 }
2595 if let Some(row) = fallback_row {
2596 queue!(stdout, cursor::MoveTo(0, sat_u16(row)))?;
2597 }
2598 }
2599 }
2600
2601 Ok(())
2602}
2603
2604fn apply_style_delta(
2605 w: &mut impl Write,
2606 old: &Style,
2607 new: &Style,
2608 depth: ColorDepth,
2609) -> io::Result<()> {
2610 if old.fg != new.fg {
2611 match new.fg {
2612 Some(fg) => emit_fg_color(w, fg, depth)?,
2613 None => write!(w, "\x1b[39m")?,
2614 }
2615 }
2616 if old.bg != new.bg {
2617 match new.bg {
2618 Some(bg) => emit_bg_color(w, bg, depth)?,
2619 None => write!(w, "\x1b[49m")?,
2620 }
2621 }
2622 let removed = Modifiers(old.modifiers.0 & !new.modifiers.0);
2623 let added = Modifiers(new.modifiers.0 & !old.modifiers.0);
2624 if removed.contains(Modifiers::BOLD) || removed.contains(Modifiers::DIM) {
2625 queue!(w, SetAttribute(Attribute::NormalIntensity))?;
2626 if new.modifiers.contains(Modifiers::BOLD) {
2627 queue!(w, SetAttribute(Attribute::Bold))?;
2628 }
2629 if new.modifiers.contains(Modifiers::DIM) {
2630 queue!(w, SetAttribute(Attribute::Dim))?;
2631 }
2632 } else {
2633 if added.contains(Modifiers::BOLD) {
2634 queue!(w, SetAttribute(Attribute::Bold))?;
2635 }
2636 if added.contains(Modifiers::DIM) {
2637 queue!(w, SetAttribute(Attribute::Dim))?;
2638 }
2639 }
2640 if removed.contains(Modifiers::ITALIC) {
2641 queue!(w, SetAttribute(Attribute::NoItalic))?;
2642 }
2643 if added.contains(Modifiers::ITALIC) {
2644 queue!(w, SetAttribute(Attribute::Italic))?;
2645 }
2646 if removed.contains(Modifiers::UNDERLINE) {
2647 queue!(w, SetAttribute(Attribute::NoUnderline))?;
2648 }
2649 if added.contains(Modifiers::UNDERLINE) {
2650 queue!(w, SetAttribute(Attribute::Underlined))?;
2651 }
2652 if removed.contains(Modifiers::REVERSED) {
2653 queue!(w, SetAttribute(Attribute::NoReverse))?;
2654 }
2655 if added.contains(Modifiers::REVERSED) {
2656 queue!(w, SetAttribute(Attribute::Reverse))?;
2657 }
2658 if removed.contains(Modifiers::STRIKETHROUGH) {
2659 queue!(w, SetAttribute(Attribute::NotCrossedOut))?;
2660 }
2661 if added.contains(Modifiers::STRIKETHROUGH) {
2662 queue!(w, SetAttribute(Attribute::CrossedOut))?;
2663 }
2664 if removed.contains(Modifiers::BLINK) {
2665 queue!(w, SetAttribute(Attribute::NoBlink))?;
2666 }
2667 if added.contains(Modifiers::BLINK) {
2668 queue!(w, SetAttribute(Attribute::SlowBlink))?;
2669 }
2670 if removed.contains(Modifiers::OVERLINE) {
2671 queue!(w, SetAttribute(Attribute::NotOverLined))?;
2672 }
2673 if added.contains(Modifiers::OVERLINE) {
2674 queue!(w, SetAttribute(Attribute::OverLined))?;
2675 }
2676 if old.underline_style != new.underline_style {
2680 write!(w, "\x1b[4:{}m", underline_style_param(new.underline_style))?;
2681 }
2682 if old.underline_color != new.underline_color {
2683 emit_underline_color(w, new.underline_color, depth)?;
2684 }
2685 Ok(())
2686}
2687
2688fn underline_style_param(style: UnderlineStyle) -> u8 {
2690 match style {
2691 UnderlineStyle::Straight => 1,
2692 UnderlineStyle::Double => 2,
2693 UnderlineStyle::Curly => 3,
2694 UnderlineStyle::Dotted => 4,
2695 UnderlineStyle::Dashed => 5,
2696 }
2697}
2698
2699fn emit_underline_color(
2705 w: &mut impl Write,
2706 color: Option<Color>,
2707 depth: ColorDepth,
2708) -> io::Result<()> {
2709 match color {
2710 None => write!(w, "\x1b[59m"),
2711 Some(c) => match c.downsampled(depth) {
2712 Color::Reset => write!(w, "\x1b[59m"),
2713 Color::Rgb(r, g, b) => write!(w, "\x1b[58:2::{r}:{g}:{b}m"),
2714 Color::Indexed(i) => write!(w, "\x1b[58:5:{i}m"),
2715 named => {
2718 let (r, g, b) = named.to_rgb();
2719 write!(w, "\x1b[58:2::{r}:{g}:{b}m")
2720 }
2721 },
2722 }
2723}
2724
2725fn apply_style(w: &mut impl Write, style: &Style, depth: ColorDepth) -> io::Result<()> {
2726 if let Some(fg) = style.fg {
2727 emit_fg_color(w, fg, depth)?;
2728 }
2729 if let Some(bg) = style.bg {
2730 emit_bg_color(w, bg, depth)?;
2731 }
2732 let m = style.modifiers;
2733 if m.contains(Modifiers::BOLD) {
2734 queue!(w, SetAttribute(Attribute::Bold))?;
2735 }
2736 if m.contains(Modifiers::DIM) {
2737 queue!(w, SetAttribute(Attribute::Dim))?;
2738 }
2739 if m.contains(Modifiers::ITALIC) {
2740 queue!(w, SetAttribute(Attribute::Italic))?;
2741 }
2742 if m.contains(Modifiers::UNDERLINE) {
2743 queue!(w, SetAttribute(Attribute::Underlined))?;
2744 }
2745 if m.contains(Modifiers::REVERSED) {
2746 queue!(w, SetAttribute(Attribute::Reverse))?;
2747 }
2748 if m.contains(Modifiers::STRIKETHROUGH) {
2749 queue!(w, SetAttribute(Attribute::CrossedOut))?;
2750 }
2751 if m.contains(Modifiers::BLINK) {
2752 queue!(w, SetAttribute(Attribute::SlowBlink))?;
2753 }
2754 if m.contains(Modifiers::OVERLINE) {
2755 queue!(w, SetAttribute(Attribute::OverLined))?;
2756 }
2757 if style.underline_style != UnderlineStyle::Straight {
2758 write!(
2759 w,
2760 "\x1b[4:{}m",
2761 underline_style_param(style.underline_style)
2762 )?;
2763 }
2764 if style.underline_color.is_some() {
2765 emit_underline_color(w, style.underline_color, depth)?;
2766 }
2767 Ok(())
2768}
2769
2770fn emit_fg_color(w: &mut impl Write, color: Color, depth: ColorDepth) -> io::Result<()> {
2771 emit_sgr_color(w, color, depth, true)
2772}
2773
2774fn emit_bg_color(w: &mut impl Write, color: Color, depth: ColorDepth) -> io::Result<()> {
2775 emit_sgr_color(w, color, depth, false)
2776}
2777
2778fn emit_sgr_color(
2779 w: &mut impl Write,
2780 color: Color,
2781 depth: ColorDepth,
2782 foreground: bool,
2783) -> io::Result<()> {
2784 match color.downsampled(depth) {
2785 Color::Reset => {
2786 let reset = if foreground { 39 } else { 49 };
2787 write!(w, "\x1b[{reset}m")
2788 }
2789 Color::Rgb(r, g, b) => {
2790 let channel = if foreground { 38 } else { 48 };
2791 write!(w, "\x1b[{channel};2;{r};{g};{b}m")
2792 }
2793 Color::Indexed(i) => {
2794 let channel = if foreground { 38 } else { 48 };
2795 write!(w, "\x1b[{channel};5;{i}m")
2796 }
2797 named => {
2798 let code = named_sgr_code(named, foreground);
2799 write!(w, "\x1b[{code}m")
2800 }
2801 }
2802}
2803
2804fn named_sgr_code(color: Color, foreground: bool) -> u8 {
2805 let dark_base = if foreground { 30 } else { 40 };
2806 let bright_base = if foreground { 90 } else { 100 };
2807 match color {
2808 Color::Black => dark_base,
2809 Color::Red => dark_base + 1,
2810 Color::Green => dark_base + 2,
2811 Color::Yellow => dark_base + 3,
2812 Color::Blue => dark_base + 4,
2813 Color::Magenta => dark_base + 5,
2814 Color::Cyan => dark_base + 6,
2815 Color::White => dark_base + 7,
2816 Color::DarkGray => bright_base,
2817 Color::LightRed => bright_base + 1,
2818 Color::LightGreen => bright_base + 2,
2819 Color::LightYellow => bright_base + 3,
2820 Color::LightBlue => bright_base + 4,
2821 Color::LightMagenta => bright_base + 5,
2822 Color::LightCyan => bright_base + 6,
2823 Color::LightWhite => bright_base + 7,
2824 Color::Reset | Color::Rgb(..) | Color::Indexed(_) => unreachable!(),
2825 }
2826}
2827
2828fn reset_current_buffer(buffer: &mut Buffer, theme_bg: Option<Color>) {
2829 if let Some(bg) = theme_bg {
2830 buffer.reset_with_bg(bg);
2831 } else {
2832 buffer.reset();
2833 }
2834}
2835
2836fn write_session_enter(stdout: &mut impl Write, session: &TerminalSessionGuard) -> io::Result<()> {
2837 match session.mode {
2838 TerminalSessionMode::Fullscreen => {
2839 execute!(
2840 stdout,
2841 terminal::EnterAlternateScreen,
2842 cursor::Hide,
2843 EnableBracketedPaste
2844 )?;
2845 }
2846 TerminalSessionMode::Inline => {
2847 execute!(stdout, cursor::Hide, EnableBracketedPaste)?;
2848 }
2849 }
2850
2851 execute!(stdout, EnableFocusChange)?;
2857 if session.mouse_enabled {
2858 execute!(stdout, EnableMouseCapture)?;
2859 }
2860 if session.kitty_keyboard {
2861 use crossterm::event::PushKeyboardEnhancementFlags;
2862 let _ = execute!(
2863 stdout,
2864 PushKeyboardEnhancementFlags(kitty_flags(session.report_all_keys))
2865 );
2866 }
2867
2868 Ok(())
2869}
2870
2871fn kitty_flags(report_all_keys: bool) -> crossterm::event::KeyboardEnhancementFlags {
2881 use crossterm::event::KeyboardEnhancementFlags;
2882 let mut flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
2883 | KeyboardEnhancementFlags::REPORT_EVENT_TYPES;
2884 if report_all_keys {
2885 flags |= KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES;
2886 }
2887 flags
2888}
2889
2890fn write_session_cleanup(
2891 stdout: &mut impl Write,
2892 mode: TerminalSessionMode,
2893 inline_reserved: bool,
2894) -> io::Result<()> {
2895 execute!(
2896 stdout,
2897 ResetColor,
2898 SetAttribute(Attribute::Reset),
2899 cursor::Show,
2900 DisableBracketedPaste
2901 )?;
2902
2903 match mode {
2904 TerminalSessionMode::Fullscreen => {
2905 execute!(stdout, terminal::LeaveAlternateScreen)?;
2906 }
2907 TerminalSessionMode::Inline => {
2908 if inline_reserved {
2909 execute!(
2910 stdout,
2911 cursor::MoveToColumn(0),
2912 cursor::MoveDown(1),
2913 cursor::MoveToColumn(0),
2914 Print("\n")
2915 )?;
2916 } else {
2917 execute!(stdout, Print("\n"))?;
2918 }
2919 }
2920 }
2921
2922 Ok(())
2923}
2924
2925fn write_session_exit(
2926 stdout: &mut impl Write,
2927 mode: TerminalSessionMode,
2928 inline_reserved: bool,
2929 mouse_enabled: bool,
2930 kitty_keyboard: bool,
2931) -> io::Result<()> {
2932 if kitty_keyboard {
2933 use crossterm::event::PopKeyboardEnhancementFlags;
2934 execute!(stdout, PopKeyboardEnhancementFlags)?;
2935 }
2936 if mouse_enabled {
2937 execute!(stdout, DisableMouseCapture)?;
2938 }
2939 execute!(stdout, DisableFocusChange)?;
2940 write_session_cleanup(stdout, mode, inline_reserved)
2941}
2942
2943#[cfg(feature = "crossterm")]
2950pub(crate) fn cleanup_after_panic() {
2951 let mut stdout = io::stdout();
2952 let _ = write_panic_cleanup(&mut stdout);
2953 let _ = terminal::disable_raw_mode();
2954 let _ = stdout.flush();
2955}
2956
2957fn write_panic_cleanup(stdout: &mut impl Write) -> io::Result<()> {
2958 write_session_exit(stdout, TerminalSessionMode::Fullscreen, false, true, true)
2959}
2960
2961#[cfg(unix)]
2978#[derive(Debug, Clone, Copy)]
2979pub(crate) struct SessionSnapshot {
2980 mode: TerminalSessionMode,
2981 mouse_enabled: bool,
2982 kitty_keyboard: bool,
2983 report_all_keys: bool,
2984}
2985
2986#[cfg(unix)]
2989pub(crate) static NEEDS_FULL_REDRAW: std::sync::atomic::AtomicBool =
2990 std::sync::atomic::AtomicBool::new(false);
2991
2992#[cfg(unix)]
2993impl Terminal {
2994 pub(crate) fn session_snapshot(&self) -> SessionSnapshot {
2997 SessionSnapshot {
2998 mode: self.session.mode,
2999 mouse_enabled: self.session.mouse_enabled,
3000 kitty_keyboard: self.session.kitty_keyboard,
3001 report_all_keys: self.session.report_all_keys,
3002 }
3003 }
3004}
3005
3006#[cfg(unix)]
3007impl InlineTerminal {
3008 pub(crate) fn session_snapshot(&self) -> SessionSnapshot {
3011 SessionSnapshot {
3012 mode: self.session.mode,
3013 mouse_enabled: self.session.mouse_enabled,
3014 kitty_keyboard: self.session.kitty_keyboard,
3015 report_all_keys: self.session.report_all_keys,
3016 }
3017 }
3018}
3019
3020#[cfg(unix)]
3028fn write_suspend_sequence(stdout: &mut impl Write, snapshot: &SessionSnapshot) -> io::Result<()> {
3029 write_session_exit(
3030 stdout,
3031 snapshot.mode,
3032 false,
3033 snapshot.mouse_enabled,
3034 snapshot.kitty_keyboard,
3035 )
3036}
3037
3038#[cfg(unix)]
3045pub(crate) fn suspend_to_shell(snapshot: &SessionSnapshot) {
3046 let mut out = io::stdout();
3047 let _ = write_suspend_sequence(&mut out, snapshot);
3048 let _ = terminal::disable_raw_mode();
3049 let _ = out.flush();
3050}
3051
3052#[cfg(unix)]
3059pub(crate) fn resume_from_shell(snapshot: &SessionSnapshot) {
3060 let mut out = io::stdout();
3061 let _ = terminal::enable_raw_mode();
3062 let _ = resume_from_shell_with_writer(&mut out, snapshot);
3063}
3064
3065#[cfg(unix)]
3066fn resume_from_shell_with_writer(
3067 out: &mut impl Write,
3068 snapshot: &SessionSnapshot,
3069) -> io::Result<()> {
3070 let guard = TerminalSessionGuard {
3071 mode: snapshot.mode,
3072 mouse_enabled: snapshot.mouse_enabled,
3073 kitty_keyboard: snapshot.kitty_keyboard,
3074 report_all_keys: snapshot.report_all_keys,
3075 harness: false,
3076 };
3077 write_session_enter(out, &guard)?;
3078 out.flush()?;
3079 NEEDS_FULL_REDRAW.store(true, std::sync::atomic::Ordering::SeqCst);
3080 Ok(())
3081}
3082
3083#[cfg(all(unix, test))]
3085fn test_snapshot(mode: TerminalSessionMode, mouse: bool, kitty: bool) -> SessionSnapshot {
3086 SessionSnapshot {
3087 mode,
3088 mouse_enabled: mouse,
3089 kitty_keyboard: kitty,
3090 report_all_keys: false,
3091 }
3092}
3093
3094#[cfg(all(unix, test))]
3097pub(crate) fn test_session_snapshot() -> SessionSnapshot {
3098 SessionSnapshot {
3099 mode: TerminalSessionMode::Fullscreen,
3100 mouse_enabled: false,
3101 kitty_keyboard: false,
3102 report_all_keys: false,
3103 }
3104}
3105
3106#[cfg(test)]
3107mod tests {
3108 #![allow(clippy::unwrap_used)]
3109 use super::*;
3110
3111 fn collect_with_feed(
3114 bytes: &'static [u8],
3115 delay: Duration,
3116 budget: Duration,
3117 is_complete: &mut dyn FnMut(&[u8]) -> bool,
3118 ) -> (Vec<u8>, Duration) {
3119 let (tx, rx) = std::sync::mpsc::channel::<u8>();
3120 std::thread::spawn(move || {
3121 std::thread::sleep(delay);
3122 for &b in bytes {
3123 if tx.send(b).is_err() {
3124 return;
3125 }
3126 }
3127 std::thread::sleep(Duration::from_secs(3));
3132 });
3133 let start = Instant::now();
3134 let out = collect_reply(&rx, start + budget, is_complete);
3135 (out, start.elapsed())
3136 }
3137
3138 #[test]
3139 fn collect_reply_osc_bel_terminator_completes_early() {
3140 let reply = b"\x1b]11;rgb:0000/0000/0000\x07";
3141 let (out, elapsed) = collect_with_feed(
3142 reply,
3143 Duration::ZERO,
3144 Duration::from_secs(2),
3145 &mut osc_reply_complete,
3146 );
3147 assert_eq!(out, reply);
3148 assert!(
3149 elapsed < Duration::from_secs(1),
3150 "should not wait out the budget"
3151 );
3152 }
3153
3154 #[test]
3155 fn collect_reply_osc_st_terminator_completes_early() {
3156 let reply = b"\x1bP>|tmux 3.5a\x1b\\";
3157 let (out, elapsed) = collect_with_feed(
3158 reply,
3159 Duration::ZERO,
3160 Duration::from_secs(2),
3161 &mut osc_reply_complete,
3162 );
3163 assert_eq!(out, reply);
3164 assert!(elapsed < Duration::from_secs(1));
3165 }
3166
3167 #[test]
3168 fn collect_reply_silence_returns_empty_at_deadline() {
3169 let budget = Duration::from_millis(150);
3172 let (out, elapsed) =
3173 collect_with_feed(b"", Duration::from_secs(5), budget, &mut osc_reply_complete);
3174 assert!(out.is_empty());
3175 assert!(elapsed >= budget);
3176 assert!(
3177 elapsed < Duration::from_secs(2),
3178 "must not block past the budget"
3179 );
3180 }
3181
3182 #[test]
3183 fn collect_reply_da_drains_two_replies() {
3184 let reply = b"\x1b[?62;4c\x1b[>1;10;0c";
3185 let (out, elapsed) = collect_with_feed(
3186 reply,
3187 Duration::ZERO,
3188 Duration::from_secs(2),
3189 &mut da_reply_complete(),
3190 );
3191 assert_eq!(out, reply);
3192 assert!(elapsed < Duration::from_secs(1));
3193 }
3194
3195 #[test]
3196 fn collect_reply_da_lone_reply_returns_partial_at_deadline() {
3197 let budget = Duration::from_millis(150);
3201 let (out, elapsed) = collect_with_feed(
3202 b"\x1b[?62;4c",
3203 Duration::ZERO,
3204 budget,
3205 &mut da_reply_complete(),
3206 );
3207 assert_eq!(out, b"\x1b[?62;4c");
3208 assert!(elapsed >= budget);
3209 }
3210
3211 #[test]
3212 fn collect_reply_unterminated_caps_at_4096_bytes() {
3213 static BIG: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
3214 let big = BIG.get_or_init(|| vec![b'x'; 5000]).as_slice();
3215 let (tx, rx) = std::sync::mpsc::channel::<u8>();
3216 for &b in big {
3217 tx.send(b).unwrap();
3218 }
3219 let out = collect_reply(
3220 &rx,
3221 Instant::now() + Duration::from_secs(2),
3222 &mut osc_reply_complete,
3223 );
3224 assert_eq!(out.len(), 4096);
3225 }
3226
3227 #[test]
3228 fn decrpm_predicate_terminates_on_y() {
3229 let reply = b"\x1b[?2026;1$y";
3230 let (out, _) = collect_with_feed(
3231 reply,
3232 Duration::ZERO,
3233 Duration::from_secs(2),
3234 &mut decrpm_reply_complete,
3235 );
3236 assert_eq!(out, reply);
3237 }
3238
3239 #[test]
3240 fn reset_current_buffer_applies_theme_background() {
3241 let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 1));
3242
3243 reset_current_buffer(&mut buffer, Some(Color::Rgb(10, 20, 30)));
3244 assert_eq!(buffer.get(0, 0).style.bg, Some(Color::Rgb(10, 20, 30)));
3245
3246 reset_current_buffer(&mut buffer, None);
3247 assert_eq!(buffer.get(0, 0).style.bg, None);
3248 }
3249
3250 #[test]
3251 fn fullscreen_session_enter_writes_alt_screen_sequence() {
3252 let session = TerminalSessionGuard {
3253 mode: TerminalSessionMode::Fullscreen,
3254 mouse_enabled: false,
3255 kitty_keyboard: false,
3256 report_all_keys: false,
3257 harness: false,
3258 };
3259 let mut out = Vec::new();
3260 write_session_enter(&mut out, &session).unwrap();
3261 let output = String::from_utf8(out).unwrap();
3262 assert!(output.contains("\u{1b}[?1049h"));
3263 assert!(output.contains("\u{1b}[?25l"));
3264 assert!(output.contains("\u{1b}[?2004h"));
3265 }
3266
3267 #[test]
3268 fn inline_session_enter_skips_alt_screen_sequence() {
3269 let session = TerminalSessionGuard {
3270 mode: TerminalSessionMode::Inline,
3271 mouse_enabled: false,
3272 kitty_keyboard: false,
3273 report_all_keys: false,
3274 harness: false,
3275 };
3276 let mut out = Vec::new();
3277 write_session_enter(&mut out, &session).unwrap();
3278 let output = String::from_utf8(out).unwrap();
3279 assert!(!output.contains("\u{1b}[?1049h"));
3280 assert!(output.contains("\u{1b}[?25l"));
3281 assert!(output.contains("\u{1b}[?2004h"));
3282 }
3283
3284 #[test]
3285 fn fullscreen_session_cleanup_leaves_alt_screen() {
3286 let mut out = Vec::new();
3287 write_session_cleanup(&mut out, TerminalSessionMode::Fullscreen, false).unwrap();
3288 let output = String::from_utf8(out).unwrap();
3289 assert!(output.contains("\u{1b}[?1049l"));
3290 assert!(output.contains("\u{1b}[?25h"));
3291 assert!(output.contains("\u{1b}[?2004l"));
3292 }
3293
3294 #[test]
3295 fn inline_session_cleanup_keeps_normal_screen() {
3296 let mut out = Vec::new();
3297 write_session_cleanup(&mut out, TerminalSessionMode::Inline, false).unwrap();
3298 let output = String::from_utf8(out).unwrap();
3299 assert!(!output.contains("\u{1b}[?1049l"));
3300 assert!(output.ends_with('\n'));
3301 assert!(output.contains("\u{1b}[?25h"));
3302 assert!(output.contains("\u{1b}[?2004l"));
3303 }
3304
3305 #[test]
3306 fn session_exit_disables_focus_mouse_and_kitty_keyboard() {
3307 let mut out = Vec::new();
3308 write_session_exit(&mut out, TerminalSessionMode::Fullscreen, false, true, true).unwrap();
3309 let output = String::from_utf8(out).unwrap();
3310 assert!(output.contains("\u{1b}[?1004l"), "disables focus reporting");
3311 assert!(output.contains("\u{1b}[?1006l"), "disables SGR mouse mode");
3312 assert!(output.contains("\u{1b}[<1u"), "pops Kitty keyboard flags");
3313 assert!(output.contains("\u{1b}[?1049l"), "leaves alt screen");
3314 }
3315
3316 #[test]
3317 fn panic_cleanup_uses_full_session_exit_path() {
3318 let mut out = Vec::new();
3319 write_panic_cleanup(&mut out).unwrap();
3320 let output = String::from_utf8(out).unwrap();
3321 assert!(output.contains("\u{1b}[?1004l"), "disables focus reporting");
3322 assert!(output.contains("\u{1b}[<1u"), "pops Kitty keyboard flags");
3323 assert!(output.contains("\u{1b}[?1049l"), "leaves alt screen");
3324 }
3325
3326 #[cfg(unix)]
3329 #[test]
3330 fn suspend_sequence_fullscreen_leaves_alt_screen() {
3331 let snapshot = test_snapshot(TerminalSessionMode::Fullscreen, false, false);
3332 let mut out = Vec::new();
3333 write_suspend_sequence(&mut out, &snapshot).unwrap();
3334 let output = String::from_utf8(out).unwrap();
3335 assert!(output.contains("\u{1b}[?1049l"), "leaves alt screen");
3336 assert!(output.contains("\u{1b}[?25h"), "shows cursor");
3337 assert!(output.contains("\u{1b}[?2004l"), "disables bracketed paste");
3338 }
3339
3340 #[cfg(unix)]
3341 #[test]
3342 fn suspend_sequence_inline_keeps_normal_screen() {
3343 let snapshot = test_snapshot(TerminalSessionMode::Inline, false, false);
3344 let mut out = Vec::new();
3345 write_suspend_sequence(&mut out, &snapshot).unwrap();
3346 let output = String::from_utf8(out).unwrap();
3347 assert!(
3348 !output.contains("\u{1b}[?1049l"),
3349 "inline must not leave alt screen"
3350 );
3351 assert!(output.contains("\u{1b}[?25h"), "shows cursor");
3352 assert!(output.contains("\u{1b}[?2004l"), "disables bracketed paste");
3353 }
3354
3355 #[cfg(unix)]
3356 #[test]
3357 fn suspend_sequence_disables_mouse_and_kitty_when_enabled() {
3358 let snapshot = test_snapshot(TerminalSessionMode::Fullscreen, true, true);
3359 let mut out = Vec::new();
3360 write_suspend_sequence(&mut out, &snapshot).unwrap();
3361 let output = String::from_utf8(out).unwrap();
3363 assert!(output.contains("\u{1b}[?1006l"), "disables SGR mouse mode");
3364 }
3365
3366 #[cfg(unix)]
3367 #[test]
3368 fn resume_sequence_fullscreen_round_trips_enter_and_flags_redraw() {
3369 let snapshot = test_snapshot(TerminalSessionMode::Fullscreen, false, false);
3370
3371 let guard = TerminalSessionGuard {
3373 mode: snapshot.mode,
3374 mouse_enabled: snapshot.mouse_enabled,
3375 kitty_keyboard: snapshot.kitty_keyboard,
3376 report_all_keys: snapshot.report_all_keys,
3377 harness: false,
3378 };
3379 let mut enter_bytes = Vec::new();
3380 write_session_enter(&mut enter_bytes, &guard).unwrap();
3381 let enter = String::from_utf8(enter_bytes).unwrap();
3382 assert!(enter.contains("\u{1b}[?1049h"));
3383 assert!(enter.contains("\u{1b}[?25l"));
3384 assert!(enter.contains("\u{1b}[?2004h"));
3385
3386 NEEDS_FULL_REDRAW.store(false, std::sync::atomic::Ordering::SeqCst);
3389 let mut resume_bytes = Vec::new();
3390 resume_from_shell_with_writer(&mut resume_bytes, &snapshot).unwrap();
3391 assert_eq!(String::from_utf8(resume_bytes).unwrap(), enter);
3392 assert!(
3393 NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst),
3394 "resume must request a full redraw exactly once"
3395 );
3396 assert!(
3397 !NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst),
3398 "the redraw flag is consumed by the first swap (idempotent)"
3399 );
3400 }
3401
3402 #[cfg(unix)]
3403 #[test]
3404 fn needs_full_redraw_swaps_true_once() {
3405 NEEDS_FULL_REDRAW.store(true, std::sync::atomic::Ordering::SeqCst);
3406 assert!(NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst));
3407 assert!(!NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst));
3408 }
3409
3410 #[test]
3411 fn kitty_flags_base_set_excludes_report_all_keys() {
3412 use crossterm::event::KeyboardEnhancementFlags;
3413 let flags = kitty_flags(false);
3414 assert!(flags.contains(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES));
3415 assert!(flags.contains(KeyboardEnhancementFlags::REPORT_EVENT_TYPES));
3416 assert!(!flags.contains(KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES));
3417 }
3418
3419 #[test]
3420 fn kitty_flags_report_all_keys_sets_flag() {
3421 use crossterm::event::KeyboardEnhancementFlags;
3422 let flags = kitty_flags(true);
3423 assert!(flags.contains(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES));
3424 assert!(flags.contains(KeyboardEnhancementFlags::REPORT_EVENT_TYPES));
3425 assert!(flags.contains(KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES));
3426 }
3427
3428 static ENV_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
3429
3430 #[allow(unsafe_code)]
3431 fn with_terminal_env<F: FnOnce()>(
3432 term: Option<&str>,
3433 term_program: Option<&str>,
3434 tmux: Option<&str>,
3435 sty: Option<&str>,
3436 f: F,
3437 ) {
3438 let _guard = ENV_GUARD.lock().unwrap_or_else(|err| err.into_inner());
3439 let prev_term = std::env::var("TERM").ok();
3440 let prev_program = std::env::var("TERM_PROGRAM").ok();
3441 let prev_tmux = std::env::var("TMUX").ok();
3442 let prev_sty = std::env::var("STY").ok();
3443
3444 unsafe {
3445 match term {
3446 Some(value) => std::env::set_var("TERM", value),
3447 None => std::env::remove_var("TERM"),
3448 }
3449 match term_program {
3450 Some(value) => std::env::set_var("TERM_PROGRAM", value),
3451 None => std::env::remove_var("TERM_PROGRAM"),
3452 }
3453 match tmux {
3454 Some(value) => std::env::set_var("TMUX", value),
3455 None => std::env::remove_var("TMUX"),
3456 }
3457 match sty {
3458 Some(value) => std::env::set_var("STY", value),
3459 None => std::env::remove_var("STY"),
3460 }
3461 }
3462
3463 f();
3464
3465 unsafe {
3466 match prev_term {
3467 Some(value) => std::env::set_var("TERM", value),
3468 None => std::env::remove_var("TERM"),
3469 }
3470 match prev_program {
3471 Some(value) => std::env::set_var("TERM_PROGRAM", value),
3472 None => std::env::remove_var("TERM_PROGRAM"),
3473 }
3474 match prev_tmux {
3475 Some(value) => std::env::set_var("TMUX", value),
3476 None => std::env::remove_var("TMUX"),
3477 }
3478 match prev_sty {
3479 Some(value) => std::env::set_var("STY", value),
3480 None => std::env::remove_var("STY"),
3481 }
3482 }
3483 }
3484
3485 #[test]
3486 fn multiplexers_disable_graphics_env_fallbacks() {
3487 with_terminal_env(
3488 Some("tmux-256color"),
3489 Some("WezTerm"),
3490 Some("/tmp/tmux"),
3491 None,
3492 || {
3493 assert!(terminal_is_multiplexed());
3494 assert!(!term_is_kitty_graphics_host());
3495 assert!(!term_is_sixel_host());
3496 assert!(!term_is_iterm_host());
3497 },
3498 );
3499 with_terminal_env(
3500 Some("screen-256color"),
3501 Some("iTerm.app"),
3502 None,
3503 Some("1234.pts"),
3504 || {
3505 assert!(terminal_is_multiplexed());
3506 assert!(!term_is_kitty_graphics_host());
3507 assert!(!term_is_sixel_host());
3508 assert!(!term_is_iterm_host());
3509 },
3510 );
3511 }
3512
3513 #[test]
3514 fn direct_hosts_keep_graphics_env_fallbacks() {
3515 with_terminal_env(Some("xterm-kitty"), None, None, None, || {
3516 assert!(!terminal_is_multiplexed());
3517 assert!(term_is_kitty_graphics_host());
3518 });
3519 with_terminal_env(Some("xterm-256color"), Some("WezTerm"), None, None, || {
3520 assert!(!terminal_is_multiplexed());
3521 assert!(term_is_sixel_host());
3522 });
3523 with_terminal_env(
3524 Some("xterm-256color"),
3525 Some("iTerm.app"),
3526 None,
3527 None,
3528 || {
3529 assert!(!terminal_is_multiplexed());
3530 assert!(term_is_iterm_host());
3531 },
3532 );
3533 }
3534
3535 #[test]
3536 fn graphics_emission_requires_protocol_support() {
3537 let unsupported = GraphicsEmissionSupport {
3538 real_terminal: true,
3539 capabilities: Capabilities::default(),
3540 force_kitty: false,
3541 force_sixel: false,
3542 force_iterm: false,
3543 };
3544 assert!(!unsupported.should_emit_kitty());
3545 assert!(!unsupported.should_emit_sprixel(SprixelProtocol::Sixel));
3546 assert!(!unsupported.should_emit_sprixel(SprixelProtocol::Iterm2));
3547 assert!(!unsupported.should_emit_sprixel(SprixelProtocol::Unknown));
3548
3549 let sixel = GraphicsEmissionSupport {
3550 capabilities: Capabilities {
3551 sixel: true,
3552 ..Capabilities::default()
3553 },
3554 ..unsupported
3555 };
3556 assert!(sixel.should_emit_sprixel(SprixelProtocol::Sixel));
3557
3558 let forced = GraphicsEmissionSupport {
3559 force_kitty: true,
3560 force_iterm: true,
3561 ..unsupported
3562 };
3563 assert!(forced.should_emit_kitty());
3564 assert!(forced.should_emit_sprixel(SprixelProtocol::Iterm2));
3565 }
3566
3567 #[test]
3568 fn base64_encode_empty() {
3569 assert_eq!(base64_encode(b""), "");
3570 }
3571
3572 #[test]
3573 fn base64_encode_hello() {
3574 assert_eq!(base64_encode(b"Hello"), "SGVsbG8=");
3575 }
3576
3577 #[test]
3578 fn base64_encode_padding() {
3579 assert_eq!(base64_encode(b"a"), "YQ==");
3580 assert_eq!(base64_encode(b"ab"), "YWI=");
3581 assert_eq!(base64_encode(b"abc"), "YWJj");
3582 }
3583
3584 #[test]
3585 fn base64_encode_unicode() {
3586 assert_eq!(base64_encode("한글".as_bytes()), "7ZWc6riA");
3587 }
3588
3589 #[cfg(feature = "crossterm")]
3590 #[test]
3591 fn parse_osc11_response_dark_and_light() {
3592 assert_eq!(
3593 parse_osc11_response("\x1b]11;rgb:0000/0000/0000\x1b\\"),
3594 ColorScheme::Dark
3595 );
3596 assert_eq!(
3597 parse_osc11_response("\x1b]11;rgb:ffff/ffff/ffff\x07"),
3598 ColorScheme::Light
3599 );
3600 }
3601
3602 #[test]
3605 fn blitter_support_default_is_conservative() {
3606 let b = BlitterSupport::default();
3607 assert!(b.half);
3608 assert!(b.quad);
3609 assert!(!b.sextant);
3610 }
3611
3612 #[test]
3613 fn capabilities_default_is_all_false_but_half_block() {
3614 let c = Capabilities::default();
3615 assert!(!c.truecolor);
3616 assert!(!c.sixel);
3617 assert!(!c.iterm2);
3618 assert!(!c.kitty_graphics);
3619 assert!(!c.kitty_keyboard);
3620 assert!(!c.sync_output);
3621 assert_eq!(c.best_blitter(), Blitter::HalfBlock);
3623 }
3624
3625 #[test]
3626 fn best_blitter_ladder_table() {
3627 let kitty = Capabilities {
3628 kitty_graphics: true,
3629 ..Default::default()
3630 };
3631 assert_eq!(kitty.best_blitter(), Blitter::Kitty);
3632
3633 let sixel = Capabilities {
3634 sixel: true,
3635 ..Default::default()
3636 };
3637 assert_eq!(sixel.best_blitter(), Blitter::Sixel);
3638
3639 let iterm2 = Capabilities {
3640 iterm2: true,
3641 ..Default::default()
3642 };
3643 assert_eq!(iterm2.best_blitter(), Blitter::Iterm2);
3644
3645 let sixel_and_iterm2 = Capabilities {
3647 sixel: true,
3648 iterm2: true,
3649 ..Default::default()
3650 };
3651 assert_eq!(sixel_and_iterm2.best_blitter(), Blitter::Sixel);
3652
3653 let sextant = Capabilities {
3654 blitters: BlitterSupport {
3655 sextant: true,
3656 ..Default::default()
3657 },
3658 ..Default::default()
3659 };
3660 assert_eq!(sextant.best_blitter(), Blitter::Sextant);
3661
3662 assert_eq!(Capabilities::default().best_blitter(), Blitter::HalfBlock);
3663 }
3664
3665 #[test]
3666 fn best_blitter_precedence_kitty_over_everything() {
3667 let all = Capabilities {
3668 kitty_graphics: true,
3669 sixel: true,
3670 blitters: BlitterSupport {
3671 sextant: true,
3672 ..Default::default()
3673 },
3674 ..Default::default()
3675 };
3676 assert_eq!(all.best_blitter(), Blitter::Kitty);
3677
3678 let sixel_and_sextant = Capabilities {
3679 sixel: true,
3680 blitters: BlitterSupport {
3681 sextant: true,
3682 ..Default::default()
3683 },
3684 ..Default::default()
3685 };
3686 assert_eq!(sixel_and_sextant.best_blitter(), Blitter::Sixel);
3687 }
3688
3689 #[test]
3690 fn best_blitter_never_picks_unsupported_protocol() {
3691 for kitty in [false, true] {
3694 for sixel in [false, true] {
3695 for iterm2 in [false, true] {
3696 for sextant in [false, true] {
3697 let caps = Capabilities {
3698 kitty_graphics: kitty,
3699 sixel,
3700 iterm2,
3701 blitters: BlitterSupport {
3702 sextant,
3703 ..Default::default()
3704 },
3705 ..Default::default()
3706 };
3707 match caps.best_blitter() {
3708 Blitter::Kitty => assert!(kitty),
3709 Blitter::Sixel => assert!(sixel && !kitty),
3710 Blitter::Iterm2 => assert!(iterm2 && !sixel && !kitty),
3711 Blitter::Sextant => {
3712 assert!(sextant && !iterm2 && !sixel && !kitty)
3713 }
3714 Blitter::HalfBlock => {
3715 assert!(!kitty && !sixel && !iterm2 && !sextant)
3716 }
3717 }
3718 }
3719 }
3720 }
3721 }
3722 }
3723
3724 #[cfg(feature = "crossterm")]
3725 #[test]
3726 fn parse_da1_attribute_4_sets_sixel() {
3727 let mut caps = Capabilities::default();
3728 parse_da1("\x1b[?62;4;6c", &mut caps);
3729 assert!(caps.sixel);
3730 }
3731
3732 #[cfg(feature = "crossterm")]
3733 #[test]
3734 fn parse_da1_without_4_leaves_sixel_false() {
3735 let mut caps = Capabilities::default();
3736 parse_da1("\x1b[?62;1;6c", &mut caps);
3737 assert!(!caps.sixel);
3738 }
3739
3740 #[cfg(feature = "crossterm")]
3741 #[test]
3742 fn parse_da1_ignores_da2_segment_in_same_string() {
3743 let mut caps = Capabilities::default();
3745 parse_da1("\x1b[?62;1c\x1b[>0;276;0c", &mut caps);
3746 assert!(!caps.sixel);
3747 }
3748
3749 #[cfg(feature = "crossterm")]
3750 #[test]
3751 fn parse_da2_no_panic_on_garbage() {
3752 let mut caps = Capabilities::default();
3753 parse_da2("\x1b[>99;1;0c", &mut caps);
3755 assert!(!caps.kitty_graphics);
3756 parse_da2("not a da2 reply", &mut caps);
3757 assert!(!caps.kitty_graphics);
3758 }
3759
3760 #[cfg(feature = "crossterm")]
3761 #[test]
3762 fn parse_da2_kitty_id_sets_kitty_graphics() {
3763 let mut caps = Capabilities::default();
3764 parse_da2("\x1b[>41;4000;0c", &mut caps);
3766 assert!(caps.kitty_graphics);
3767 }
3768
3769 #[cfg(feature = "crossterm")]
3770 #[test]
3771 fn parse_da2_identity_extracts_id_and_version() {
3772 assert_eq!(parse_da2_identity("\x1b[>0;276;0c"), Some((0, 276)));
3773 assert_eq!(parse_da2_identity("\x1b[>41;4000;0c"), Some((41, 4000)));
3774 assert_eq!(parse_da2_identity("no reply here"), None);
3775 }
3776
3777 #[cfg(feature = "crossterm")]
3778 #[test]
3779 fn parse_kitty_graphics_ack_ok_sets_flag() {
3780 let mut caps = Capabilities::default();
3781 parse_kitty_graphics_ack("\x1b_Gi=31;OK\x1b\\", &mut caps);
3782 assert!(caps.kitty_graphics);
3783 }
3784
3785 #[cfg(feature = "crossterm")]
3786 #[test]
3787 fn parse_kitty_graphics_ack_error_or_wrong_id_leaves_flag() {
3788 let mut caps = Capabilities::default();
3789 parse_kitty_graphics_ack("\x1b_Gi=31;ENOENT:bad\x1b\\", &mut caps);
3791 assert!(!caps.kitty_graphics);
3792 parse_kitty_graphics_ack("\x1b_Gi=99;OK\x1b\\", &mut caps);
3794 assert!(!caps.kitty_graphics);
3795 parse_kitty_graphics_ack("garbage", &mut caps);
3797 assert!(!caps.kitty_graphics);
3798 }
3799
3800 #[cfg(feature = "crossterm")]
3801 #[test]
3802 fn parse_decrpm_sync_output_recognized_states_are_supported() {
3803 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;1$y"), Some(true));
3806 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;2$y"), Some(true));
3807 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;3$y"), Some(true));
3808 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;4$y"), Some(true));
3809 }
3810
3811 #[cfg(feature = "crossterm")]
3812 #[test]
3813 fn parse_decrpm_sync_output_ps0_is_unsupported() {
3814 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;0$y"), Some(false));
3816 }
3817
3818 #[cfg(feature = "crossterm")]
3819 #[test]
3820 fn parse_decrpm_sync_output_garbage_is_none() {
3821 assert_eq!(parse_decrpm_sync_output("not a decrpm reply"), None);
3823 assert_eq!(parse_decrpm_sync_output("\x1b[?2004;1$y"), None);
3825 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;1"), None);
3827 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;x$y"), None);
3829 }
3830
3831 #[test]
3832 fn sync_output_gate_defaults_to_emit() {
3833 assert!(should_emit_synchronized_update());
3838 }
3839
3840 #[test]
3841 fn terminal_query_guard_requires_stdin_and_stdout_tty() {
3842 assert!(terminal_query_allowed(true, true));
3843 assert!(!terminal_query_allowed(true, false));
3844 assert!(!terminal_query_allowed(false, true));
3845 assert!(!terminal_query_allowed(false, false));
3846 }
3847
3848 #[test]
3849 fn terminal_multiplexer_detection_is_conservative() {
3850 assert!(terminal_is_multiplexed_env("tmux-256color", false, false));
3851 assert!(terminal_is_multiplexed_env("screen-256color", false, false));
3852 assert!(terminal_is_multiplexed_env("xterm-256color", true, false));
3853 assert!(terminal_is_multiplexed_env("xterm-256color", false, true));
3854 assert!(!terminal_is_multiplexed_env("xterm-kitty", false, false));
3855 }
3856
3857 #[test]
3858 fn kitty_env_fallback_is_blocked_inside_multiplexer_unless_forced() {
3859 assert!(term_is_kitty_graphics_host_env(
3860 "xterm-kitty",
3861 "",
3862 false,
3863 false
3864 ));
3865 assert!(term_is_kitty_graphics_host_env(
3866 "xterm-256color",
3867 "wezterm",
3868 false,
3869 false
3870 ));
3871 assert!(!term_is_kitty_graphics_host_env(
3872 "xterm-kitty",
3873 "wezterm",
3874 true,
3875 false
3876 ));
3877 assert!(term_is_kitty_graphics_host_env(
3878 "xterm-256color",
3879 "",
3880 true,
3881 true
3882 ));
3883 }
3884
3885 #[test]
3886 fn iterm_env_fallback_is_blocked_inside_multiplexer_unless_forced() {
3887 assert!(term_is_iterm_host_env("iterm.app", false, false));
3888 assert!(term_is_iterm_host_env("wezterm", false, false));
3889 assert!(!term_is_iterm_host_env("wezterm", true, false));
3890 assert!(term_is_iterm_host_env("xterm", true, true));
3891 }
3892
3893 #[test]
3894 fn graphics_support_blocks_kitty_without_ack_or_force() {
3895 let support = GraphicsEmissionSupport {
3896 real_terminal: true,
3897 capabilities: Capabilities::default(),
3898 force_kitty: false,
3899 force_sixel: false,
3900 force_iterm: false,
3901 };
3902 assert!(!support.should_emit_kitty());
3903
3904 let acked = GraphicsEmissionSupport {
3905 capabilities: Capabilities {
3906 kitty_graphics: true,
3907 ..Default::default()
3908 },
3909 ..support
3910 };
3911 assert!(acked.should_emit_kitty());
3912
3913 let forced = GraphicsEmissionSupport {
3914 force_kitty: true,
3915 ..support
3916 };
3917 assert!(forced.should_emit_kitty());
3918
3919 let captured = GraphicsEmissionSupport {
3920 real_terminal: false,
3921 force_kitty: true,
3922 ..support
3923 };
3924 assert!(!captured.should_emit_kitty());
3925 }
3926
3927 #[test]
3928 fn graphics_support_blocks_sprixels_without_ack_or_force() {
3929 let support = GraphicsEmissionSupport {
3930 real_terminal: true,
3931 capabilities: Capabilities::default(),
3932 force_kitty: false,
3933 force_sixel: false,
3934 force_iterm: false,
3935 };
3936 assert!(!support.should_emit_sprixel(SprixelProtocol::Sixel));
3937 assert!(!support.should_emit_sprixel(SprixelProtocol::Iterm2));
3938 assert!(!support.should_emit_sprixel(SprixelProtocol::Unknown));
3939
3940 let sixel_acked = GraphicsEmissionSupport {
3941 capabilities: Capabilities {
3942 sixel: true,
3943 ..Default::default()
3944 },
3945 ..support
3946 };
3947 assert!(sixel_acked.should_emit_sprixel(SprixelProtocol::Sixel));
3948
3949 let iterm_forced = GraphicsEmissionSupport {
3950 force_iterm: true,
3951 ..support
3952 };
3953 assert!(iterm_forced.should_emit_sprixel(SprixelProtocol::Iterm2));
3954 }
3955
3956 #[test]
3957 fn sprixel_protocol_detects_sixel_and_iterm() {
3958 assert_eq!(
3959 sprixel_protocol("\x1bPqpayload\x1b\\"),
3960 SprixelProtocol::Sixel
3961 );
3962 assert_eq!(
3963 sprixel_protocol("\x1b]1337;File=inline=1:AAAA\x07"),
3964 SprixelProtocol::Iterm2
3965 );
3966 assert_eq!(sprixel_protocol("plain"), SprixelProtocol::Unknown);
3967 }
3968
3969 #[cfg(feature = "crossterm")]
3970 #[test]
3971 fn parse_xtgettcap_tc_sets_truecolor() {
3972 let mut caps = Capabilities::default();
3973 parse_xtgettcap_truecolor("\x1bP1+r5463=\x1b\\", &mut caps);
3975 assert!(caps.truecolor);
3976 }
3977
3978 #[cfg(feature = "crossterm")]
3979 #[test]
3980 fn parse_xtgettcap_invalid_leaves_truecolor_false() {
3981 let mut caps = Capabilities::default();
3982 parse_xtgettcap_truecolor("\x1bP0+r5463\x1b\\", &mut caps);
3984 assert!(!caps.truecolor);
3985 parse_xtgettcap_truecolor("\x1bP1+r1234=\x1b\\", &mut caps);
3987 assert!(!caps.truecolor);
3988 }
3989
3990 #[cfg(feature = "crossterm")]
3991 #[test]
3992 fn base64_decode_round_trip_hello() {
3993 let encoded = base64_encode("hello".as_bytes());
3994 assert_eq!(base64_decode(&encoded), Some("hello".to_string()));
3995 }
3996
3997 #[cfg(feature = "crossterm")]
3998 #[test]
3999 fn color_scheme_equality() {
4000 assert_eq!(ColorScheme::Dark, ColorScheme::Dark);
4001 assert_ne!(ColorScheme::Dark, ColorScheme::Light);
4002 assert_eq!(ColorScheme::Unknown, ColorScheme::Unknown);
4003 }
4004
4005 fn pair(r: Rect) -> (Rect, Rect) {
4006 (r, r)
4007 }
4008
4009 #[test]
4010 fn find_innermost_rect_picks_smallest() {
4011 let rects = vec![
4012 pair(Rect::new(0, 0, 80, 24)),
4013 pair(Rect::new(5, 2, 30, 10)),
4014 pair(Rect::new(10, 4, 10, 5)),
4015 ];
4016 let result = find_innermost_rect(&rects, 12, 5);
4017 assert_eq!(result, Some(Rect::new(10, 4, 10, 5)));
4018 }
4019
4020 #[test]
4021 fn find_innermost_rect_no_match() {
4022 let rects = vec![pair(Rect::new(10, 10, 5, 5))];
4023 assert_eq!(find_innermost_rect(&rects, 0, 0), None);
4024 }
4025
4026 #[test]
4027 fn find_innermost_rect_empty() {
4028 assert_eq!(find_innermost_rect(&[], 5, 5), None);
4029 }
4030
4031 #[test]
4032 fn find_innermost_rect_returns_content_rect() {
4033 let rects = vec![
4034 (Rect::new(0, 0, 80, 24), Rect::new(1, 1, 78, 22)),
4035 (Rect::new(5, 2, 30, 10), Rect::new(6, 3, 28, 8)),
4036 ];
4037 let result = find_innermost_rect(&rects, 10, 5);
4038 assert_eq!(result, Some(Rect::new(6, 3, 28, 8)));
4039 }
4040
4041 #[test]
4042 fn normalize_selection_already_ordered() {
4043 let (s, e) = normalize_selection((2, 1), (5, 3));
4044 assert_eq!(s, (2, 1));
4045 assert_eq!(e, (5, 3));
4046 }
4047
4048 #[test]
4049 fn normalize_selection_reversed() {
4050 let (s, e) = normalize_selection((5, 3), (2, 1));
4051 assert_eq!(s, (2, 1));
4052 assert_eq!(e, (5, 3));
4053 }
4054
4055 #[test]
4056 fn normalize_selection_same_row() {
4057 let (s, e) = normalize_selection((10, 5), (3, 5));
4058 assert_eq!(s, (3, 5));
4059 assert_eq!(e, (10, 5));
4060 }
4061
4062 #[test]
4063 fn selection_state_mouse_down_finds_rect() {
4064 let hit_map = vec![pair(Rect::new(0, 0, 80, 24)), pair(Rect::new(5, 2, 20, 10))];
4065 let mut sel = SelectionState::default();
4066 sel.mouse_down(10, 5, &hit_map);
4067 assert_eq!(sel.anchor, Some((10, 5)));
4068 assert_eq!(sel.current, Some((10, 5)));
4069 assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 20, 10)));
4070 assert!(!sel.active);
4071 }
4072
4073 #[test]
4074 fn selection_state_drag_activates() {
4075 let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
4076 let mut sel = SelectionState {
4077 anchor: Some((10, 5)),
4078 current: Some((10, 5)),
4079 widget_rect: Some(Rect::new(0, 0, 80, 24)),
4080 ..Default::default()
4081 };
4082 sel.mouse_drag(10, 5, &hit_map);
4083 assert!(!sel.active, "no movement = not active");
4084 sel.mouse_drag(11, 5, &hit_map);
4085 assert!(!sel.active, "1 cell horizontal = not active yet");
4086 sel.mouse_drag(13, 5, &hit_map);
4087 assert!(sel.active, ">1 cell horizontal = active");
4088 }
4089
4090 #[test]
4091 fn selection_state_drag_vertical_activates() {
4092 let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
4093 let mut sel = SelectionState {
4094 anchor: Some((10, 5)),
4095 current: Some((10, 5)),
4096 widget_rect: Some(Rect::new(0, 0, 80, 24)),
4097 ..Default::default()
4098 };
4099 sel.mouse_drag(10, 6, &hit_map);
4100 assert!(sel.active, "any vertical movement = active");
4101 }
4102
4103 #[test]
4104 fn selection_state_drag_expands_widget_rect() {
4105 let hit_map = vec![
4106 pair(Rect::new(0, 0, 80, 24)),
4107 pair(Rect::new(5, 2, 30, 10)),
4108 pair(Rect::new(5, 2, 30, 3)),
4109 ];
4110 let mut sel = SelectionState {
4111 anchor: Some((10, 3)),
4112 current: Some((10, 3)),
4113 widget_rect: Some(Rect::new(5, 2, 30, 3)),
4114 ..Default::default()
4115 };
4116 sel.mouse_drag(10, 6, &hit_map);
4117 assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 30, 10)));
4118 }
4119
4120 #[test]
4121 fn selection_state_clear_resets() {
4122 let mut sel = SelectionState {
4123 anchor: Some((1, 2)),
4124 current: Some((3, 4)),
4125 widget_rect: Some(Rect::new(0, 0, 10, 10)),
4126 active: true,
4127 };
4128 sel.clear();
4129 assert_eq!(sel.anchor, None);
4130 assert_eq!(sel.current, None);
4131 assert_eq!(sel.widget_rect, None);
4132 assert!(!sel.active);
4133 }
4134
4135 #[test]
4136 fn extract_selection_text_single_line() {
4137 let area = Rect::new(0, 0, 20, 5);
4138 let mut buf = Buffer::empty(area);
4139 buf.set_string(0, 0, "Hello World", Style::default());
4140 let sel = SelectionState {
4141 anchor: Some((0, 0)),
4142 current: Some((4, 0)),
4143 widget_rect: Some(area),
4144 active: true,
4145 };
4146 let text = extract_selection_text(&buf, &sel, &[]);
4147 assert_eq!(text, "Hello");
4148 }
4149
4150 #[test]
4151 fn extract_selection_text_multi_line() {
4152 let area = Rect::new(0, 0, 20, 5);
4153 let mut buf = Buffer::empty(area);
4154 buf.set_string(0, 0, "Line one", Style::default());
4155 buf.set_string(0, 1, "Line two", Style::default());
4156 buf.set_string(0, 2, "Line three", Style::default());
4157 let sel = SelectionState {
4158 anchor: Some((5, 0)),
4159 current: Some((3, 2)),
4160 widget_rect: Some(area),
4161 active: true,
4162 };
4163 let text = extract_selection_text(&buf, &sel, &[]);
4164 assert_eq!(text, "one\nLine two\nLine");
4165 }
4166
4167 #[test]
4168 fn extract_selection_text_clamped_to_widget() {
4169 let area = Rect::new(0, 0, 40, 10);
4170 let widget = Rect::new(5, 2, 10, 3);
4171 let mut buf = Buffer::empty(area);
4172 buf.set_string(5, 2, "ABCDEFGHIJ", Style::default());
4173 buf.set_string(5, 3, "KLMNOPQRST", Style::default());
4174 let sel = SelectionState {
4175 anchor: Some((3, 1)),
4176 current: Some((20, 5)),
4177 widget_rect: Some(widget),
4178 active: true,
4179 };
4180 let text = extract_selection_text(&buf, &sel, &[]);
4181 assert_eq!(text, "ABCDEFGHIJ\nKLMNOPQRST");
4182 }
4183
4184 #[test]
4185 fn extract_selection_text_inactive_returns_empty() {
4186 let area = Rect::new(0, 0, 10, 5);
4187 let buf = Buffer::empty(area);
4188 let sel = SelectionState {
4189 anchor: Some((0, 0)),
4190 current: Some((5, 2)),
4191 widget_rect: Some(area),
4192 active: false,
4193 };
4194 assert_eq!(extract_selection_text(&buf, &sel, &[]), "");
4195 }
4196
4197 #[test]
4198 fn apply_selection_overlay_reverses_cells() {
4199 let area = Rect::new(0, 0, 10, 3);
4200 let mut buf = Buffer::empty(area);
4201 buf.set_string(0, 0, "ABCDE", Style::default());
4202 let sel = SelectionState {
4203 anchor: Some((1, 0)),
4204 current: Some((3, 0)),
4205 widget_rect: Some(area),
4206 active: true,
4207 };
4208 apply_selection_overlay(&mut buf, &sel, &[]);
4209 assert!(!buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED));
4210 assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
4211 assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
4212 assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
4213 assert!(!buf.get(4, 0).style.modifiers.contains(Modifiers::REVERSED));
4214 }
4215
4216 #[test]
4217 fn extract_selection_text_skips_border_cells() {
4218 let area = Rect::new(0, 0, 40, 5);
4223 let mut buf = Buffer::empty(area);
4224 buf.set_string(0, 0, "╭", Style::default());
4226 buf.set_string(0, 1, "│", Style::default());
4227 buf.set_string(0, 2, "│", Style::default());
4228 buf.set_string(0, 3, "│", Style::default());
4229 buf.set_string(0, 4, "╰", Style::default());
4230 buf.set_string(19, 0, "╮", Style::default());
4231 buf.set_string(19, 1, "│", Style::default());
4232 buf.set_string(19, 2, "│", Style::default());
4233 buf.set_string(19, 3, "│", Style::default());
4234 buf.set_string(19, 4, "╯", Style::default());
4235 buf.set_string(20, 0, "╭", Style::default());
4237 buf.set_string(20, 1, "│", Style::default());
4238 buf.set_string(20, 2, "│", Style::default());
4239 buf.set_string(20, 3, "│", Style::default());
4240 buf.set_string(20, 4, "╰", Style::default());
4241 buf.set_string(39, 0, "╮", Style::default());
4242 buf.set_string(39, 1, "│", Style::default());
4243 buf.set_string(39, 2, "│", Style::default());
4244 buf.set_string(39, 3, "│", Style::default());
4245 buf.set_string(39, 4, "╯", Style::default());
4246 buf.set_string(1, 1, "Hello Col1", Style::default());
4248 buf.set_string(1, 2, "Line2 Col1", Style::default());
4249 buf.set_string(21, 1, "Hello Col2", Style::default());
4251 buf.set_string(21, 2, "Line2 Col2", Style::default());
4252
4253 let content_map = vec![
4254 (Rect::new(0, 0, 20, 5), Rect::new(1, 1, 18, 3)),
4255 (Rect::new(20, 0, 20, 5), Rect::new(21, 1, 18, 3)),
4256 ];
4257
4258 let sel = SelectionState {
4260 anchor: Some((0, 1)),
4261 current: Some((39, 2)),
4262 widget_rect: Some(area),
4263 active: true,
4264 };
4265 let text = extract_selection_text(&buf, &sel, &content_map);
4266 assert!(!text.contains('│'), "Border char │ found in: {text}");
4268 assert!(!text.contains('╭'), "Border char ╭ found in: {text}");
4269 assert!(!text.contains('╮'), "Border char ╮ found in: {text}");
4270 assert!(
4272 text.contains("Hello Col1"),
4273 "Missing Col1 content in: {text}"
4274 );
4275 assert!(
4276 text.contains("Hello Col2"),
4277 "Missing Col2 content in: {text}"
4278 );
4279 assert!(text.contains("Line2 Col1"), "Missing Col1 line2 in: {text}");
4280 assert!(text.contains("Line2 Col2"), "Missing Col2 line2 in: {text}");
4281 }
4282
4283 #[test]
4284 fn apply_selection_overlay_skips_border_cells() {
4285 let area = Rect::new(0, 0, 20, 3);
4286 let mut buf = Buffer::empty(area);
4287 buf.set_string(0, 0, "│", Style::default());
4288 buf.set_string(1, 0, "ABC", Style::default());
4289 buf.set_string(19, 0, "│", Style::default());
4290
4291 let content_map = vec![(Rect::new(0, 0, 20, 3), Rect::new(1, 0, 18, 3))];
4292 let sel = SelectionState {
4293 anchor: Some((0, 0)),
4294 current: Some((19, 0)),
4295 widget_rect: Some(area),
4296 active: true,
4297 };
4298 apply_selection_overlay(&mut buf, &sel, &content_map);
4299 assert!(
4301 !buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED),
4302 "Left border cell should not be reversed"
4303 );
4304 assert!(
4305 !buf.get(19, 0).style.modifiers.contains(Modifiers::REVERSED),
4306 "Right border cell should not be reversed"
4307 );
4308 assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
4310 assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
4311 assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
4312 }
4313
4314 #[test]
4315 fn copy_to_clipboard_writes_osc52() {
4316 let mut output: Vec<u8> = Vec::new();
4317 copy_to_clipboard(&mut output, "test").unwrap();
4318 let s = String::from_utf8(output).unwrap();
4319 assert!(s.starts_with("\x1b]52;c;"));
4320 assert!(s.ends_with("\x1b\\"));
4321 assert!(s.contains(&base64_encode(b"test")));
4322 }
4323
4324 fn count_move_tos(s: &str) -> usize {
4326 let bytes = s.as_bytes();
4327 let mut count = 0;
4328 let mut i = 0;
4329 while i + 1 < bytes.len() {
4330 if bytes[i] == 0x1b && bytes[i + 1] == b'[' {
4331 let mut j = i + 2;
4333 while j < bytes.len() && !(0x40..=0x7e).contains(&bytes[j]) {
4334 j += 1;
4335 }
4336 if j < bytes.len() && bytes[j] == b'H' {
4337 count += 1;
4338 }
4339 i = j + 1;
4340 } else {
4341 i += 1;
4342 }
4343 }
4344 count
4345 }
4346
4347 #[test]
4348 fn flush_coalesces_consecutive_same_style_cells_into_one_run() {
4349 let area = Rect::new(0, 0, 20, 1);
4351 let mut current = Buffer::empty(area);
4352 let previous = Buffer::empty(area);
4353 let style = Style::new().fg(Color::Red);
4354 for x in 0..10u32 {
4355 let cell = current.get_mut(x, 0);
4356 cell.set_char('X');
4357 cell.set_style(style);
4358 }
4359
4360 let mut out: Vec<u8> = Vec::new();
4361 flush_buffer_diff(
4362 &mut out,
4363 ¤t,
4364 &previous,
4365 ColorDepth::TrueColor,
4366 0,
4367 &mut String::new(),
4368 )
4369 .unwrap();
4370 let s = String::from_utf8(out).unwrap();
4371
4372 assert_eq!(
4374 count_move_tos(&s),
4375 1,
4376 "expected 1 MoveTo for a coalesced run, got {} in {:?}",
4377 count_move_tos(&s),
4378 s
4379 );
4380 assert!(
4382 s.contains("XXXXXXXXXX"),
4383 "expected contiguous run 'XXXXXXXXXX' in {:?}",
4384 s
4385 );
4386 }
4387
4388 #[test]
4389 fn flush_breaks_run_on_style_change() {
4390 let area = Rect::new(0, 0, 20, 1);
4392 let mut current = Buffer::empty(area);
4393 let previous = Buffer::empty(area);
4394 let red = Style::new().fg(Color::Red);
4395 let blue = Style::new().fg(Color::Blue);
4396 for x in 0..5u32 {
4397 let cell = current.get_mut(x, 0);
4398 cell.set_char('R');
4399 cell.set_style(red);
4400 }
4401 for x in 5..10u32 {
4402 let cell = current.get_mut(x, 0);
4403 cell.set_char('B');
4404 cell.set_style(blue);
4405 }
4406
4407 let mut out: Vec<u8> = Vec::new();
4408 flush_buffer_diff(
4409 &mut out,
4410 ¤t,
4411 &previous,
4412 ColorDepth::TrueColor,
4413 0,
4414 &mut String::new(),
4415 )
4416 .unwrap();
4417 let s = String::from_utf8(out).unwrap();
4418
4419 let moves = count_move_tos(&s);
4423 assert!(
4424 moves <= 2,
4425 "expected at most 2 MoveTos across a style boundary, got {} in {:?}",
4426 moves,
4427 s
4428 );
4429 assert!(s.contains("RRRRR"), "missing 'RRRRR' run in {:?}", s);
4430 assert!(s.contains("BBBBB"), "missing 'BBBBB' run in {:?}", s);
4431 }
4432
4433 #[test]
4434 fn flush_breaks_run_on_column_gap() {
4435 let area = Rect::new(0, 0, 20, 1);
4437 let mut current = Buffer::empty(area);
4438 let previous = Buffer::empty(area);
4439 let style = Style::new().fg(Color::Green);
4440 for x in 0..3u32 {
4441 current.get_mut(x, 0).set_char('A').set_style(style);
4442 }
4443 for x in 6..9u32 {
4444 current.get_mut(x, 0).set_char('B').set_style(style);
4445 }
4446
4447 let mut out: Vec<u8> = Vec::new();
4448 flush_buffer_diff(
4449 &mut out,
4450 ¤t,
4451 &previous,
4452 ColorDepth::TrueColor,
4453 0,
4454 &mut String::new(),
4455 )
4456 .unwrap();
4457 let s = String::from_utf8(out).unwrap();
4458
4459 assert_eq!(
4461 count_move_tos(&s),
4462 2,
4463 "expected 2 MoveTos across a column gap, got {} in {:?}",
4464 count_move_tos(&s),
4465 s
4466 );
4467 assert!(s.contains("AAA"), "missing 'AAA' run in {:?}", s);
4468 assert!(s.contains("BBB"), "missing 'BBB' run in {:?}", s);
4469 }
4470
4471 #[test]
4475 fn bufwriter_output_identical_to_direct_write() {
4476 let area = Rect::new(0, 0, 5, 1);
4477 let mut current = Buffer::empty(area);
4478 let previous = Buffer::empty(area);
4479 let style = Style::new().fg(Color::Rgb(255, 128, 0));
4480 for x in 0..5u32 {
4481 current.get_mut(x, 0).set_char('X').set_style(style);
4482 }
4483
4484 let mut direct: Vec<u8> = Vec::new();
4485 flush_buffer_diff(
4486 &mut direct,
4487 ¤t,
4488 &previous,
4489 ColorDepth::TrueColor,
4490 0,
4491 &mut String::new(),
4492 )
4493 .unwrap();
4494
4495 let mut buffered: BufWriter<Vec<u8>> = BufWriter::with_capacity(65536, Vec::new());
4496 flush_buffer_diff(
4497 &mut buffered,
4498 ¤t,
4499 &previous,
4500 ColorDepth::TrueColor,
4501 0,
4502 &mut String::new(),
4503 )
4504 .unwrap();
4505 buffered.flush().unwrap();
4506 let via_buf = buffered.into_inner().unwrap();
4507
4508 assert_eq!(
4509 direct, via_buf,
4510 "BufWriter output must be byte-for-byte identical to direct write"
4511 );
4512 }
4513
4514 #[test]
4518 fn bufwriter_coalesces_writes_into_single_flush() {
4519 #[derive(Debug)]
4520 struct CountingWriter {
4521 buf: Vec<u8>,
4522 write_call_count: usize,
4523 }
4524 impl Write for CountingWriter {
4525 fn write(&mut self, data: &[u8]) -> io::Result<usize> {
4526 self.write_call_count += 1;
4527 self.buf.extend_from_slice(data);
4528 Ok(data.len())
4529 }
4530 fn flush(&mut self) -> io::Result<()> {
4531 Ok(())
4532 }
4533 }
4534
4535 let area = Rect::new(0, 0, 10, 1);
4536 let mut current = Buffer::empty(area);
4537 let previous = Buffer::empty(area);
4538 for x in 0..10u32 {
4540 let color = if x % 2 == 0 {
4541 Color::Rgb(255, 0, 0)
4542 } else {
4543 Color::Rgb(0, 255, 0)
4544 };
4545 current
4546 .get_mut(x, 0)
4547 .set_char('Z')
4548 .set_style(Style::new().fg(color));
4549 }
4550
4551 let sink = CountingWriter {
4552 buf: Vec::new(),
4553 write_call_count: 0,
4554 };
4555 let mut bw = BufWriter::with_capacity(65536, sink);
4556 flush_buffer_diff(
4557 &mut bw,
4558 ¤t,
4559 &previous,
4560 ColorDepth::TrueColor,
4561 0,
4562 &mut String::new(),
4563 )
4564 .unwrap();
4565 bw.flush().unwrap();
4566 let inner = bw.into_inner().unwrap();
4567
4568 assert_eq!(
4570 inner.write_call_count, 1,
4571 "expected 1 write syscall to sink, got {}",
4572 inner.write_call_count
4573 );
4574 }
4575
4576 #[test]
4582 fn flush_skips_unchanged_rows_when_hashes_match() {
4583 let area = Rect::new(0, 0, 20, 4);
4584 let mut current = Buffer::empty(area);
4585 let mut previous = Buffer::empty(area);
4586 for y in 0..4u32 {
4588 current.set_string(0, y, "identical-row-content", Style::new());
4589 previous.set_string(0, y, "identical-row-content", Style::new());
4590 }
4591 current.recompute_line_hashes();
4592 previous.recompute_line_hashes();
4593
4594 let mut out: Vec<u8> = Vec::new();
4595 flush_buffer_diff(
4596 &mut out,
4597 ¤t,
4598 &previous,
4599 ColorDepth::TrueColor,
4600 0,
4601 &mut String::new(),
4602 )
4603 .unwrap();
4604 assert!(
4605 out.is_empty(),
4606 "identical buffers must emit zero flush bytes; got {} bytes: {:?}",
4607 out.len(),
4608 out
4609 );
4610 }
4611
4612 #[test]
4616 fn flush_skips_only_matching_rows_in_mixed_diff() {
4617 let area = Rect::new(0, 0, 6, 3);
4618 let mut current = Buffer::empty(area);
4619 let mut previous = Buffer::empty(area);
4620 current.set_string(0, 0, "abcdef", Style::new());
4621 previous.set_string(0, 0, "abcdef", Style::new());
4622 current.set_string(0, 1, "xxxxxx", Style::new());
4623 previous.set_string(0, 1, "yyyyyy", Style::new());
4624 current.set_string(0, 2, "zzzzzz", Style::new());
4625 previous.set_string(0, 2, "zzzzzz", Style::new());
4626 current.recompute_line_hashes();
4627 previous.recompute_line_hashes();
4628
4629 let mut out: Vec<u8> = Vec::new();
4630 flush_buffer_diff(
4631 &mut out,
4632 ¤t,
4633 &previous,
4634 ColorDepth::TrueColor,
4635 0,
4636 &mut String::new(),
4637 )
4638 .unwrap();
4639 let s = String::from_utf8_lossy(&out);
4640 assert!(s.contains("xxxxxx"), "differing row must flush: {s:?}");
4643 assert!(
4644 !s.contains("abcdef"),
4645 "matching row 0 must not flush: {s:?}"
4646 );
4647 assert!(
4648 !s.contains("zzzzzz"),
4649 "matching row 2 must not flush: {s:?}"
4650 );
4651 }
4652
4653 fn delta_bytes(old: &Style, new: &Style) -> Vec<u8> {
4654 let mut out = Vec::new();
4655 apply_style_delta(&mut out, old, new, ColorDepth::TrueColor).unwrap();
4656 out
4657 }
4658
4659 fn contains_seq(haystack: &[u8], needle: &[u8]) -> bool {
4660 haystack.windows(needle.len()).any(|w| w == needle)
4661 }
4662
4663 #[test]
4664 fn apply_style_delta_emits_blink_set_and_reset() {
4665 let on = delta_bytes(&Style::new(), &Style::new().blink());
4666 assert!(contains_seq(&on, b"\x1b[5m"), "blink set: {on:?}");
4668 let off = delta_bytes(&Style::new().blink(), &Style::new());
4669 assert!(contains_seq(&off, b"\x1b[25m"), "blink reset: {off:?}");
4671 }
4672
4673 #[test]
4674 fn apply_style_delta_emits_overline_set_and_reset() {
4675 let on = delta_bytes(&Style::new(), &Style::new().overline());
4676 assert!(contains_seq(&on, b"\x1b[53m"), "overline set: {on:?}");
4678 let off = delta_bytes(&Style::new().overline(), &Style::new());
4679 assert!(contains_seq(&off, b"\x1b[55m"), "overline reset: {off:?}");
4681 }
4682
4683 #[test]
4684 fn apply_style_delta_emits_curly_underline_subparameter() {
4685 let out = delta_bytes(
4686 &Style::new(),
4687 &Style::new().underline_style(UnderlineStyle::Curly),
4688 );
4689 assert!(contains_seq(&out, b"\x1b[4:3m"), "curly underline: {out:?}");
4690 }
4691
4692 #[test]
4693 fn apply_style_delta_emits_underline_color_and_reset() {
4694 let set = delta_bytes(
4695 &Style::new(),
4696 &Style::new().underline_color(Color::Rgb(255, 0, 0)),
4697 );
4698 assert!(
4699 contains_seq(&set, b"\x1b[58:2::255:0:0m"),
4700 "underline color set: {set:?}"
4701 );
4702 let clear = delta_bytes(
4703 &Style::new().underline_color(Color::Rgb(255, 0, 0)),
4704 &Style::new(),
4705 );
4706 assert!(
4707 contains_seq(&clear, b"\x1b[59m"),
4708 "underline color reset: {clear:?}"
4709 );
4710 }
4711
4712 #[test]
4713 fn apply_style_delta_underline_color_indexed_uses_sgr_58_5() {
4714 let out = delta_bytes(
4715 &Style::new(),
4716 &Style::new().underline_color(Color::Indexed(42)),
4717 );
4718 assert!(
4719 contains_seq(&out, b"\x1b[58:5:42m"),
4720 "indexed underline: {out:?}"
4721 );
4722 }
4723
4724 #[test]
4725 fn apply_style_full_emits_blink_overline_and_underline() {
4726 let mut out = Vec::new();
4727 let style = Style::new()
4728 .blink()
4729 .overline()
4730 .underline_style(UnderlineStyle::Dotted)
4731 .underline_color(Color::Rgb(0, 0, 255));
4732 apply_style(&mut out, &style, ColorDepth::TrueColor).unwrap();
4733 assert!(contains_seq(&out, b"\x1b[5m"), "blink: {out:?}");
4734 assert!(contains_seq(&out, b"\x1b[53m"), "overline: {out:?}");
4735 assert!(
4736 contains_seq(&out, b"\x1b[4:4m"),
4737 "dotted underline: {out:?}"
4738 );
4739 assert!(
4740 contains_seq(&out, b"\x1b[58:2::0:0:255m"),
4741 "underline color: {out:?}"
4742 );
4743 }
4744 #[test]
4748 fn with_sink_captures_flush_bytes_and_drops_clean() {
4749 let mut term = Terminal::with_sink(10, 1, ColorDepth::TrueColor);
4750 term.buffer_mut()
4751 .set_string(0, 0, "Z", Style::new().fg(Color::Rgb(200, 50, 50)));
4752 term.flush().unwrap();
4753 let bytes = term.take_sink_bytes();
4754 let s = String::from_utf8_lossy(&bytes);
4755 assert!(s.contains("\u{1b}["), "missing CSI: {s:?}");
4757 assert!(s.contains('Z'), "missing glyph: {s:?}");
4758 assert!(term.take_sink_bytes().is_empty());
4760 drop(term);
4762 }
4763
4764 #[test]
4769 fn reused_run_buf_byte_identical_across_frames() {
4770 let area = Rect::new(0, 0, 12, 2);
4771 let make_frame = || {
4773 let mut current = Buffer::empty(area);
4774 let previous = Buffer::empty(area);
4775 current.set_string(0, 0, "hello world", Style::new().fg(Color::Rgb(1, 2, 3)));
4776 current.set_string(0, 1, "second line", Style::new().fg(Color::Rgb(4, 5, 6)));
4777 (current, previous)
4778 };
4779
4780 let mut baseline: Vec<u8> = Vec::new();
4782 {
4783 let (mut a, mut b) = make_frame();
4784 __bench_flush_buffer_diff_mut_with_buf(
4785 &mut baseline,
4786 &mut a,
4787 &mut b,
4788 ColorDepth::TrueColor,
4789 &mut String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
4790 )
4791 .unwrap();
4792 }
4793
4794 let mut shared = String::with_capacity(RUN_BUF_INITIAL_CAPACITY);
4797 {
4798 let mut warm: Vec<u8> = Vec::new();
4799 let (mut a, mut b) = make_frame();
4800 __bench_flush_buffer_diff_mut_with_buf(
4801 &mut warm,
4802 &mut a,
4803 &mut b,
4804 ColorDepth::TrueColor,
4805 &mut shared,
4806 )
4807 .unwrap();
4808 }
4809 let cap_after_warm = shared.capacity();
4810
4811 let mut reused: Vec<u8> = Vec::new();
4812 let (mut current, mut previous) = make_frame();
4813 __bench_flush_buffer_diff_mut_with_buf(
4814 &mut reused,
4815 &mut current,
4816 &mut previous,
4817 ColorDepth::TrueColor,
4818 &mut shared,
4819 )
4820 .unwrap();
4821
4822 assert_eq!(
4823 baseline, reused,
4824 "reused run_buf must emit byte-identical output"
4825 );
4826 assert!(
4829 shared.capacity() >= cap_after_warm,
4830 "run_buf capacity must persist across frames"
4831 );
4832 }
4833
4834 #[test]
4838 fn osc8_hyperlink_emitted_verbatim_after_write_rewrite() {
4839 let area = Rect::new(0, 0, 8, 1);
4840 let mut current = Buffer::empty(area);
4841 let previous = Buffer::empty(area);
4842 let url = "https://example.com/x";
4843 current.set_string_linked(0, 0, "link", Style::new(), url);
4845
4846 let mut out: Vec<u8> = Vec::new();
4847 flush_buffer_diff(
4848 &mut out,
4849 ¤t,
4850 &previous,
4851 ColorDepth::TrueColor,
4852 0,
4853 &mut String::new(),
4854 )
4855 .unwrap();
4856
4857 let open = format!("\x1b]8;;{url}\x07");
4858 assert!(
4859 contains_seq(&out, open.as_bytes()),
4860 "OSC 8 open must appear verbatim: {:?}",
4861 String::from_utf8_lossy(&out)
4862 );
4863 assert!(
4864 contains_seq(&out, b"\x1b]8;;\x07"),
4865 "OSC 8 close must appear: {:?}",
4866 String::from_utf8_lossy(&out)
4867 );
4868 }
4869
4870 fn kitty_placements(n: usize) -> Vec<KittyPlacement> {
4872 (0..n)
4873 .map(|i| {
4874 let mut rgba = vec![0u8; 256];
4875 rgba[0] = i as u8;
4876 let content_hash = crate::buffer::hash_rgba(&rgba);
4877 KittyPlacement {
4878 content_hash,
4879 rgba: std::sync::Arc::new(rgba),
4880 src_width: 8,
4881 src_height: 8,
4882 x: (i as u32) * 4,
4883 y: (i as u32) * 2,
4884 cols: 4,
4885 rows: 2,
4886 crop_y: 0,
4887 crop_h: 0,
4888 }
4889 })
4890 .collect()
4891 }
4892
4893 #[test]
4894 fn captured_sink_suppresses_kitty_graphics_bytes() {
4895 let mut term = Terminal::with_sink(8, 4, ColorDepth::TrueColor);
4896 term.graphics_support = GraphicsEmissionSupport {
4897 real_terminal: true,
4898 capabilities: Capabilities::default(),
4899 force_kitty: false,
4900 force_sixel: false,
4901 force_iterm: false,
4902 };
4903 for placement in kitty_placements(1) {
4904 term.buffer_mut().kitty_place(placement);
4905 }
4906 term.flush().unwrap();
4907 let bytes = term.take_sink_bytes();
4908 assert!(
4909 !contains_seq(&bytes, b"\x1b_G"),
4910 "captured sink must not emit Kitty APC bytes: {:?}",
4911 String::from_utf8_lossy(&bytes)
4912 );
4913 }
4914
4915 #[test]
4921 fn kitty_flush_smallvec_dedup_matches_for_small_n() {
4922 for n in [0usize, 1, 5] {
4923 let placements = kitty_placements(n);
4924 let mut mgr = KittyImageManager::new();
4925
4926 let mut frame1: Vec<u8> = Vec::new();
4928 mgr.flush(&mut frame1, &placements, 0).unwrap();
4929 let s1 = String::from_utf8_lossy(&frame1);
4930 assert_eq!(
4932 s1.matches("a=t,").count(),
4933 n,
4934 "n={n}: expected {n} uploads in frame 1: {s1:?}"
4935 );
4936 assert_eq!(
4937 s1.matches("a=p,").count(),
4938 n,
4939 "n={n}: expected {n} placements in frame 1: {s1:?}"
4940 );
4941
4942 let mut frame2: Vec<u8> = Vec::new();
4944 mgr.flush(&mut frame2, &placements, 0).unwrap();
4945 assert!(
4946 frame2.is_empty(),
4947 "n={n}: identical frame must hit the kitty fast path, got {} bytes",
4948 frame2.len()
4949 );
4950
4951 let mut frame3: Vec<u8> = Vec::new();
4955 mgr.flush(&mut frame3, &[], 0).unwrap();
4956 let s3 = String::from_utf8_lossy(&frame3);
4957 assert_eq!(
4958 s3.matches("a=d,d=i,").count(),
4959 n,
4960 "n={n}: expected {n} placement deletes in frame 3: {s3:?}"
4961 );
4962 assert_eq!(
4963 s3.matches("a=d,d=I,").count(),
4964 n,
4965 "n={n}: expected {n} image-data deletes in frame 3: {s3:?}"
4966 );
4967 }
4968 }
4969
4970 use crate::buffer::{SprixelCell, SprixelPlacement};
4973
4974 fn make_sprixel(cells: Vec<SprixelCell>) -> SprixelPlacement {
4976 SprixelPlacement {
4977 content_hash: 0xABCD,
4978 seq: "<SIXEL>".to_string(),
4979 x: 1,
4980 y: 1,
4981 cols: 2,
4982 rows: 2,
4983 cells,
4984 }
4985 }
4986
4987 #[test]
4988 fn checked_sprixel_flush_suppresses_mux_without_ack_or_force() {
4989 let area = Rect::new(0, 0, 10, 5);
4990 let mut placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
4991 placement.seq = "\x1bPqpayload\x1b\\".to_string();
4992
4993 let mut current = Buffer::empty(area);
4994 current.sprixels.push(placement);
4995 let previous = Buffer::empty(area);
4996 let support = GraphicsEmissionSupport {
4997 real_terminal: true,
4998 capabilities: Capabilities::default(),
4999 force_kitty: false,
5000 force_sixel: false,
5001 force_iterm: false,
5002 };
5003
5004 let mut out = Vec::new();
5005 flush_sprixels_checked(&mut out, ¤t, &previous, 0, support).unwrap();
5006 assert!(
5007 out.is_empty(),
5008 "tmux/screen without ack must not emit Sixel"
5009 );
5010 }
5011
5012 #[test]
5013 fn checked_sprixel_flush_allows_mux_with_probe_ack() {
5014 let area = Rect::new(0, 0, 10, 5);
5015 let mut placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
5016 placement.seq = "\x1bPqpayload\x1b\\".to_string();
5017
5018 let mut current = Buffer::empty(area);
5019 current.sprixels.push(placement);
5020 let previous = Buffer::empty(area);
5021 let support = GraphicsEmissionSupport {
5022 real_terminal: true,
5023 capabilities: Capabilities {
5024 sixel: true,
5025 ..Default::default()
5026 },
5027 force_kitty: false,
5028 force_sixel: false,
5029 force_iterm: false,
5030 };
5031
5032 let mut out = Vec::new();
5033 flush_sprixels_checked(&mut out, ¤t, &previous, 0, support).unwrap();
5034 assert!(
5035 contains_seq(&out, b"\x1bPqpayload\x1b\\"),
5036 "probe-acked Sixel should emit: {:?}",
5037 String::from_utf8_lossy(&out)
5038 );
5039 }
5040
5041 #[test]
5042 fn sprixel_no_text_change_emits_zero_bytes() {
5043 let area = Rect::new(0, 0, 10, 5);
5045 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
5046
5047 let mut current = Buffer::empty(area);
5048 current.sprixels.push(placement.clone());
5049 let mut previous = Buffer::empty(area);
5050 previous.sprixels.push(placement);
5051
5052 let mut out: Vec<u8> = Vec::new();
5053 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
5054 assert!(out.is_empty(), "stable frame should emit no sprixel bytes");
5055 }
5056
5057 #[test]
5058 fn sprixel_first_frame_blits_once() {
5059 let area = Rect::new(0, 0, 10, 5);
5061 let mut current = Buffer::empty(area);
5062 current
5063 .sprixels
5064 .push(make_sprixel(vec![SprixelCell::Opaque; 4]));
5065 let previous = Buffer::empty(area);
5066
5067 let mut out: Vec<u8> = Vec::new();
5068 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
5069 let s = String::from_utf8(out).unwrap();
5070 assert_eq!(s.matches("<SIXEL>").count(), 1);
5071 }
5072
5073 #[test]
5074 fn sprixel_text_in_opaque_cell_reblits_once() {
5075 let area = Rect::new(0, 0, 10, 5);
5077 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
5078
5079 let mut current = Buffer::empty(area);
5080 current.sprixels.push(placement.clone());
5081 current.set_char(1, 1, 'X', Style::new());
5083
5084 let mut previous = Buffer::empty(area);
5085 previous.sprixels.push(placement);
5086
5087 let mut out: Vec<u8> = Vec::new();
5088 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
5089 let s = String::from_utf8(out).unwrap();
5090 assert_eq!(
5091 s.matches("<SIXEL>").count(),
5092 1,
5093 "opaque-cell text write must re-blit the graphic exactly once"
5094 );
5095 }
5096
5097 #[test]
5098 fn sprixel_text_in_transparent_cell_does_not_reblit() {
5099 let area = Rect::new(0, 0, 10, 5);
5102 let cells = vec![
5103 SprixelCell::Transparent, SprixelCell::Opaque, SprixelCell::Opaque, SprixelCell::Opaque, ];
5108 let placement = make_sprixel(cells);
5109
5110 let mut current = Buffer::empty(area);
5111 current.sprixels.push(placement.clone());
5112 current.set_char(1, 1, 'X', Style::new());
5113
5114 let mut previous = Buffer::empty(area);
5115 previous.sprixels.push(placement);
5116
5117 let mut out: Vec<u8> = Vec::new();
5118 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
5119 assert!(
5120 out.is_empty(),
5121 "text in a transparent footprint cell must emit zero sprixel bytes"
5122 );
5123 }
5124
5125 #[test]
5126 fn sprixel_text_outside_footprint_does_not_reblit() {
5127 let area = Rect::new(0, 0, 10, 5);
5129 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
5130
5131 let mut current = Buffer::empty(area);
5132 current.sprixels.push(placement.clone());
5133 current.set_char(5, 0, 'Z', Style::new());
5135
5136 let mut previous = Buffer::empty(area);
5137 previous.sprixels.push(placement);
5138
5139 let mut out: Vec<u8> = Vec::new();
5140 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
5141 assert!(
5142 out.is_empty(),
5143 "text outside the footprint must not re-blit the graphic"
5144 );
5145 }
5146
5147 #[test]
5148 fn sprixel_position_change_reblits() {
5149 let area = Rect::new(0, 0, 10, 5);
5151 let mut moved = make_sprixel(vec![SprixelCell::Opaque; 4]);
5152 let original = moved.clone();
5153 moved.x = 4;
5154
5155 let mut current = Buffer::empty(area);
5156 current.sprixels.push(moved);
5157 let mut previous = Buffer::empty(area);
5158 previous.sprixels.push(original);
5159
5160 let mut out: Vec<u8> = Vec::new();
5161 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
5162 let s = String::from_utf8(out).unwrap();
5163 assert_eq!(s.matches("<SIXEL>").count(), 1);
5164 }
5165
5166 #[test]
5167 fn sprixel_content_change_reblits() {
5168 let area = Rect::new(0, 0, 10, 5);
5170 let mut recolored = make_sprixel(vec![SprixelCell::Opaque; 4]);
5171 let original = recolored.clone();
5172 recolored.content_hash = 0x1234;
5173 recolored.seq = "<SIXEL2>".to_string();
5174
5175 let mut current = Buffer::empty(area);
5176 current.sprixels.push(recolored);
5177 let mut previous = Buffer::empty(area);
5178 previous.sprixels.push(original);
5179
5180 let mut out: Vec<u8> = Vec::new();
5181 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
5182 let s = String::from_utf8(out).unwrap();
5183 assert_eq!(s.matches("<SIXEL2>").count(), 1);
5184 }
5185
5186 #[test]
5187 fn sprixel_reblit_count_invariant_over_single_cell_writes() {
5188 let area = Rect::new(0, 0, 10, 5);
5192 for (idx, (col, row)) in [(0u32, 0u32), (1, 0), (0, 1), (1, 1)]
5193 .into_iter()
5194 .enumerate()
5195 {
5196 for state in [
5197 SprixelCell::Opaque,
5198 SprixelCell::Mixed,
5199 SprixelCell::Transparent,
5200 ] {
5201 let mut cells = vec![SprixelCell::Opaque; 4];
5202 cells[idx] = state;
5203 let placement = make_sprixel(cells);
5204
5205 let mut current = Buffer::empty(area);
5206 current.sprixels.push(placement.clone());
5207 current.set_char(1 + col, 1 + row, 'A', Style::new());
5208
5209 let mut previous = Buffer::empty(area);
5210 previous.sprixels.push(placement);
5211
5212 let mut out: Vec<u8> = Vec::new();
5213 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
5214 let count = String::from_utf8(out).unwrap().matches("<SIXEL>").count();
5215 let expected = if matches!(state, SprixelCell::Transparent) {
5216 0
5217 } else {
5218 1
5219 };
5220 assert_eq!(
5221 count, expected,
5222 "cell ({col},{row}) state {state:?}: expected {expected} re-blits"
5223 );
5224 }
5225 }
5226 }
5227
5228 #[test]
5235 fn sprixel_unchanged_with_hashes_engaged_emits_zero_bytes() {
5236 let area = Rect::new(0, 0, 10, 5);
5241 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
5242
5243 let mut current = Buffer::empty(area);
5244 current.sprixels.push(placement.clone());
5245 let mut previous = Buffer::empty(area);
5246 previous.sprixels.push(placement);
5247
5248 current.recompute_line_hashes();
5250 previous.recompute_line_hashes();
5251 assert!(current.row_clean(1) && current.row_clean(2));
5254 assert_eq!(current.row_hash(1), previous.row_hash(1));
5255
5256 let mut out: Vec<u8> = Vec::new();
5257 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
5258 assert!(
5259 out.is_empty(),
5260 "unchanged sprixel must not be re-blitted (per-row shortcut)"
5261 );
5262 }
5263
5264 #[test]
5265 fn sprixel_changed_text_with_hashes_engaged_reblits_once() {
5266 let area = Rect::new(0, 0, 10, 5);
5271 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
5272
5273 let mut current = Buffer::empty(area);
5274 current.sprixels.push(placement.clone());
5275 current.set_char(1, 1, 'X', Style::new());
5276 let mut previous = Buffer::empty(area);
5277 previous.sprixels.push(placement);
5278
5279 current.recompute_line_hashes();
5280 previous.recompute_line_hashes();
5281 assert_ne!(current.row_hash(1), previous.row_hash(1));
5283
5284 let mut out: Vec<u8> = Vec::new();
5285 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
5286 let s = String::from_utf8(out).unwrap();
5287 assert_eq!(
5288 s.matches("<SIXEL>").count(),
5289 1,
5290 "annihilating text write must re-blit exactly once"
5291 );
5292 }
5293
5294 #[test]
5295 fn sprixel_changed_text_in_transparent_cell_with_hashes_does_not_reblit() {
5296 let area = Rect::new(0, 0, 10, 5);
5301 let cells = vec![
5302 SprixelCell::Transparent, SprixelCell::Opaque, SprixelCell::Opaque, SprixelCell::Opaque, ];
5307 let placement = make_sprixel(cells);
5308
5309 let mut current = Buffer::empty(area);
5310 current.sprixels.push(placement.clone());
5311 current.set_char(1, 1, 'X', Style::new());
5312 let mut previous = Buffer::empty(area);
5313 previous.sprixels.push(placement);
5314
5315 current.recompute_line_hashes();
5316 previous.recompute_line_hashes();
5317
5318 let mut out: Vec<u8> = Vec::new();
5319 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
5320 assert!(
5321 out.is_empty(),
5322 "transparent-cell text write must not re-blit even with hashes engaged"
5323 );
5324 }
5325
5326 #[test]
5327 fn sprixel_key_matches_partial_eq_contract() {
5328 let base = make_sprixel(vec![SprixelCell::Opaque; 4]);
5332 assert_eq!(sprixel_key(&base), sprixel_key(&base.clone()));
5333
5334 let mut moved = base.clone();
5335 moved.x = 7;
5336 assert_ne!(sprixel_key(&base), sprixel_key(&moved));
5337
5338 let mut recolored = base.clone();
5339 recolored.content_hash = 0x9999;
5340 assert_ne!(sprixel_key(&base), sprixel_key(&recolored));
5341
5342 let mut annihilated = base.clone();
5344 annihilated.cells = vec![SprixelCell::Annihilated; 4];
5345 assert_eq!(sprixel_key(&base), sprixel_key(&annihilated));
5346 assert_eq!(base, annihilated);
5347 }
5348
5349 #[test]
5350 fn sprixel_multi_placement_only_changed_one_reblits() {
5351 let area = Rect::new(0, 0, 10, 9);
5355 let mut current = Buffer::empty(area);
5356 let mut previous = Buffer::empty(area);
5357 for i in 0..3u32 {
5358 let p = SprixelPlacement {
5359 content_hash: 0x100 + i as u64,
5360 seq: format!("<S{i}>"),
5361 x: 0,
5362 y: i * 3,
5363 cols: 2,
5364 rows: 2,
5365 cells: vec![SprixelCell::Opaque; 4],
5366 };
5367 current.sprixels.push(p.clone());
5368 previous.sprixels.push(p);
5369 }
5370 current.sprixels[1].x = 5;
5372
5373 current.recompute_line_hashes();
5374 previous.recompute_line_hashes();
5375
5376 let mut out: Vec<u8> = Vec::new();
5377 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
5378 let s = String::from_utf8(out).unwrap();
5379 assert_eq!(s.matches("<S0>").count(), 0);
5380 assert_eq!(
5381 s.matches("<S1>").count(),
5382 1,
5383 "only the moved sprixel reblits"
5384 );
5385 assert_eq!(s.matches("<S2>").count(), 0);
5386 }
5387
5388 #[test]
5389 fn bench_sprixel_fixture_steady_state_emits_nothing() {
5390 let fixture = __bench_new_sprixel_fixture(4);
5394 assert_eq!(fixture.len(), 4);
5395 assert!(!fixture.is_empty());
5396 let mut out: Vec<u8> = Vec::new();
5397 fixture.flush(&mut out, 0).unwrap();
5398 assert!(
5399 out.is_empty(),
5400 "steady-state bench fixture re-blits nothing"
5401 );
5402 }
5403}