1use std::borrow::Cow;
2use std::collections::HashMap;
3use std::io::{self, BufWriter, Read, Stdout, Write};
4use std::time::{Duration, Instant};
5
6use crossterm::event::{
7 DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
8 EnableFocusChange, EnableMouseCapture,
9};
10use crossterm::style::{
11 Attribute, Color as CtColor, Print, ResetColor, SetAttribute, SetBackgroundColor,
12 SetForegroundColor,
13};
14use crossterm::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate};
15use crossterm::{cursor, execute, queue, terminal};
16
17use unicode_width::UnicodeWidthStr;
18
19use crate::buffer::{Buffer, KittyPlacement};
20use crate::rect::Rect;
21use crate::style::{Color, ColorDepth, Modifiers, Style, UnderlineStyle};
22
23#[inline]
25fn sat_u16(v: u32) -> u16 {
26 v.min(u16::MAX as u32) as u16
27}
28
29pub(crate) enum Sink {
40 Stdout(BufWriter<Stdout>),
42 #[cfg(any(test, feature = "pty-test"))]
44 Capture(Vec<u8>),
45}
46
47impl Write for Sink {
48 #[inline]
49 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
50 match self {
51 Sink::Stdout(w) => w.write(buf),
52 #[cfg(any(test, feature = "pty-test"))]
53 Sink::Capture(v) => v.write(buf),
54 }
55 }
56
57 #[inline]
58 fn flush(&mut self) -> io::Result<()> {
59 match self {
60 Sink::Stdout(w) => w.flush(),
61 #[cfg(any(test, feature = "pty-test"))]
62 Sink::Capture(v) => v.flush(),
63 }
64 }
65}
66
67pub(crate) struct KittyImageManager {
77 next_id: u32,
78 uploaded: HashMap<u64, u32>,
80 prev_placements: Vec<KittyPlacement>,
82 scratch_ids: smallvec::SmallVec<[u32; 8]>,
87 scratch_hashes: smallvec::SmallVec<[u64; 8]>,
90}
91
92impl KittyImageManager {
93 pub(crate) fn new() -> Self {
95 Self {
96 next_id: 1,
97 uploaded: HashMap::new(),
98 prev_placements: Vec::new(),
99 scratch_ids: smallvec::SmallVec::new(),
100 scratch_hashes: smallvec::SmallVec::new(),
101 }
102 }
103
104 pub(crate) fn flush(
111 &mut self,
112 stdout: &mut impl Write,
113 current: &[KittyPlacement],
114 row_offset: u32,
115 ) -> io::Result<()> {
116 if current.len() == self.prev_placements.len()
120 && current
121 .iter()
122 .zip(self.prev_placements.iter())
123 .all(|(c, p)| placement_eq_with_offset(c, row_offset, p))
124 {
125 return Ok(());
126 }
127
128 if !self.prev_placements.is_empty() {
134 self.scratch_ids.clear();
135 for p in &self.prev_placements {
136 if let Some(&img_id) = self.uploaded.get(&p.content_hash)
137 && !self.scratch_ids.contains(&img_id)
138 {
139 self.scratch_ids.push(img_id);
140 queue!(
142 stdout,
143 Print(format!("\x1b_Ga=d,d=i,i={},q=2\x1b\\", img_id))
144 )?;
145 }
146 }
147 }
148
149 for (idx, p) in current.iter().enumerate() {
151 let img_id = if let Some(&existing_id) = self.uploaded.get(&p.content_hash) {
152 existing_id
153 } else {
154 let id = self.next_id;
156 self.next_id += 1;
157 self.upload_image(stdout, id, p)?;
158 self.uploaded.insert(p.content_hash, id);
159 id
160 };
161
162 let pid = idx as u32 + 1;
164 self.place_image_offset(stdout, img_id, pid, p, row_offset)?;
165 }
166
167 self.scratch_hashes.clear();
173 self.scratch_hashes
174 .extend(current.iter().map(|p| p.content_hash));
175 self.scratch_hashes.sort_unstable();
176 let scratch_hashes = &self.scratch_hashes;
177 let stale: smallvec::SmallVec<[u64; 8]> = self
178 .uploaded
179 .keys()
180 .filter(|h| scratch_hashes.binary_search(h).is_err())
181 .copied()
182 .collect();
183 for hash in stale {
184 if let Some(id) = self.uploaded.remove(&hash) {
185 queue!(stdout, Print(format!("\x1b_Ga=d,d=I,i={},q=2\x1b\\", id)))?;
187 }
188 }
189
190 self.prev_placements.clear();
197 self.prev_placements.reserve(current.len());
198 for p in current {
199 let mut copy = p.clone();
200 copy.y = copy.y.saturating_add(row_offset);
201 self.prev_placements.push(copy);
202 }
203 Ok(())
204 }
205
206 fn upload_image(&self, stdout: &mut impl Write, id: u32, p: &KittyPlacement) -> io::Result<()> {
208 let (payload, compression) = compress_rgba(&p.rgba);
209 let encoded = base64_encode(&payload);
210 let chunks = split_base64(&encoded, 4096);
211
212 for (i, chunk) in chunks.iter().enumerate() {
213 let more = if i < chunks.len() - 1 { 1 } else { 0 };
214 if i == 0 {
215 queue!(
216 stdout,
217 Print(format!(
218 "\x1b_Ga=t,i={},f=32,{}s={},v={},q=2,m={};{}\x1b\\",
219 id, compression, p.src_width, p.src_height, more, chunk
220 ))
221 )?;
222 } else {
223 queue!(stdout, Print(format!("\x1b_Gm={};{}\x1b\\", more, chunk)))?;
224 }
225 }
226 Ok(())
227 }
228
229 fn place_image_offset(
235 &self,
236 stdout: &mut impl Write,
237 img_id: u32,
238 placement_id: u32,
239 p: &KittyPlacement,
240 row_offset: u32,
241 ) -> io::Result<()> {
242 let display_y = p.y.saturating_add(row_offset);
243 queue!(stdout, cursor::MoveTo(sat_u16(p.x), sat_u16(display_y)))?;
244
245 let mut cmd = format!(
246 "\x1b_Ga=p,i={},p={},c={},r={},C=1,q=2",
247 img_id, placement_id, p.cols, p.rows
248 );
249
250 if p.crop_y > 0 || p.crop_h > 0 {
252 cmd.push_str(&format!(",y={}", p.crop_y));
253 if p.crop_h > 0 {
254 cmd.push_str(&format!(",h={}", p.crop_h));
255 }
256 }
257
258 cmd.push_str("\x1b\\");
259 queue!(stdout, Print(cmd))?;
260 Ok(())
261 }
262
263 pub(crate) fn delete_all(&self, stdout: &mut impl Write) -> io::Result<()> {
265 queue!(stdout, Print("\x1b_Ga=d,d=A,q=2\x1b\\"))
266 }
267}
268
269#[inline]
277fn placement_eq_with_offset(
278 current: &KittyPlacement,
279 row_offset: u32,
280 prev: &KittyPlacement,
281) -> bool {
282 current.content_hash == prev.content_hash
283 && current.x == prev.x
284 && current.y.saturating_add(row_offset) == prev.y
285 && current.cols == prev.cols
286 && current.rows == prev.rows
287 && current.crop_y == prev.crop_y
288 && current.crop_h == prev.crop_h
289}
290
291fn compress_rgba(data: &[u8]) -> (Cow<'_, [u8]>, &'static str) {
300 #[cfg(feature = "kitty-compress")]
301 {
302 use flate2::Compression;
303 use flate2::write::ZlibEncoder;
304 let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast());
305 if encoder.write_all(data).is_ok()
306 && let Ok(compressed) = encoder.finish()
307 {
308 if compressed.len() < data.len() {
310 return (Cow::Owned(compressed), "o=z,");
311 }
312 }
313 }
314 (Cow::Borrowed(data), "")
315}
316
317pub(crate) fn cell_pixel_size() -> (u32, u32) {
324 use std::sync::OnceLock;
325 static CACHED: OnceLock<(u32, u32)> = OnceLock::new();
326 *CACHED.get_or_init(|| detect_cell_pixel_size().unwrap_or((8, 16)))
327}
328
329fn detect_cell_pixel_size() -> Option<(u32, u32)> {
330 let mut stdout = io::stdout();
332 write!(stdout, "\x1b[16t").ok()?;
333 stdout.flush().ok()?;
334
335 let response = read_osc_response(Duration::from_millis(100))?;
336
337 let bytes = response.as_bytes();
342 let start = bytes
343 .windows(4)
344 .position(|w| w == b"\x1b[6;")
345 .map(|pos| pos + 4)
346 .or_else(|| {
347 bytes
349 .windows(3)
350 .position(|w| w == [0x9b, b'6', b';'])
351 .map(|pos| pos + 3)
352 })?;
353 let tail = response.get(start..)?;
354 let body = &tail[..tail.find('t')?];
355 let mut parts = body.split(';');
356 let ch: u32 = parts.next()?.parse().ok()?;
357 let cw: u32 = parts.next()?.parse().ok()?;
358 if cw > 0 && ch > 0 {
359 Some((cw, ch))
360 } else {
361 None
362 }
363}
364
365#[derive(Debug, Clone, Copy, PartialEq, Eq)]
397pub struct BlitterSupport {
398 pub half: bool,
400 pub quad: bool,
402 pub sextant: bool,
406}
407
408impl Default for BlitterSupport {
409 fn default() -> Self {
410 Self {
411 half: true,
412 quad: true,
413 sextant: false,
414 }
415 }
416}
417
418#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
440pub struct Capabilities {
441 pub truecolor: bool,
443 pub sixel: bool,
445 pub iterm2: bool,
448 pub kitty_graphics: bool,
450 pub kitty_keyboard: bool,
452 pub sync_output: bool,
454 pub blitters: BlitterSupport,
456}
457
458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
467pub enum Blitter {
468 Kitty,
470 Sixel,
472 Iterm2,
475 Sextant,
477 HalfBlock,
479}
480
481impl Capabilities {
482 pub fn best_blitter(&self) -> Blitter {
497 if self.kitty_graphics {
498 Blitter::Kitty
499 } else if self.sixel {
500 Blitter::Sixel
501 } else if self.iterm2 {
502 Blitter::Iterm2
503 } else if self.blitters.sextant {
504 Blitter::Sextant
505 } else {
506 Blitter::HalfBlock
507 }
508 }
509}
510
511#[cfg(feature = "crossterm")]
519#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
520pub fn capabilities() -> Capabilities {
521 use std::sync::OnceLock;
522 static CACHED: OnceLock<Capabilities> = OnceLock::new();
523 *CACHED.get_or_init(probe_capabilities)
524}
525
526#[cfg(feature = "crossterm")]
532fn probe_capabilities() -> Capabilities {
533 let mut caps = Capabilities::default();
534
535 let mut out = io::stdout();
540 if write!(out, "\x1b[c\x1b[>c").is_ok()
543 && out.flush().is_ok()
544 && let Some(resp) = read_da_response(Duration::from_millis(90))
545 {
546 parse_da1(&resp, &mut caps);
547 parse_da2(&resp, &mut caps);
548 }
549
550 if write!(out, "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\").is_ok()
554 && out.flush().is_ok()
555 && let Some(resp) = read_osc_response(Duration::from_millis(30))
556 {
557 parse_kitty_graphics_ack(&resp, &mut caps);
558 }
559
560 if write!(out, "\x1bP+q5463\x1b\\").is_ok()
563 && out.flush().is_ok()
564 && let Some(resp) = read_osc_response(Duration::from_millis(30))
565 {
566 parse_xtgettcap_truecolor(&resp, &mut caps);
567 }
568
569 if write!(out, "\x1b[?2026$p").is_ok()
577 && out.flush().is_ok()
578 && let Some(resp) = read_decrpm_response(Duration::from_millis(30))
579 {
580 match parse_decrpm_sync_output(&resp) {
581 Some(true) => {
582 caps.sync_output = true;
583 let _ = SYNC_OUTPUT_RESOLUTION.set(SyncOutputResolution::Supported);
584 }
585 Some(false) => {
586 let _ = SYNC_OUTPUT_RESOLUTION.set(SyncOutputResolution::Unsupported);
587 }
588 None => {}
589 }
590 }
591
592 if matches!(ColorDepth::detect(), ColorDepth::TrueColor) {
595 caps.truecolor = true;
596 }
597
598 if !caps.kitty_graphics && term_is_kitty_graphics_host() {
603 caps.kitty_graphics = true;
604 }
605
606 if term_is_iterm_host() {
610 caps.iterm2 = true;
611 }
612
613 caps
614}
615
616#[cfg(feature = "crossterm")]
621fn term_is_iterm_host() -> bool {
622 let term_program = std::env::var("TERM_PROGRAM")
623 .unwrap_or_default()
624 .to_ascii_lowercase();
625 matches!(
626 term_program.as_str(),
627 "iterm.app" | "wezterm" | "tabby" | "mintty"
628 )
629}
630
631#[cfg(feature = "crossterm")]
636fn term_is_kitty_graphics_host() -> bool {
637 let term = std::env::var("TERM")
638 .unwrap_or_default()
639 .to_ascii_lowercase();
640 let term_program = std::env::var("TERM_PROGRAM")
641 .unwrap_or_default()
642 .to_ascii_lowercase();
643 term.contains("kitty") || matches!(term_program.as_str(), "ghostty" | "wezterm" | "kitty")
645}
646
647#[cfg(feature = "crossterm")]
650struct ReplyPump {
651 rx: std::sync::mpsc::Receiver<u8>,
652 serve: std::sync::Arc<std::sync::atomic::AtomicBool>,
655 exited: std::sync::Arc<std::sync::atomic::AtomicBool>,
658}
659
660#[cfg(feature = "crossterm")]
661static REPLY_PUMP: std::sync::Mutex<Option<ReplyPump>> = std::sync::Mutex::new(None);
662
663#[cfg(feature = "crossterm")]
692fn read_stdin_reply(
693 timeout: Duration,
694 mut is_complete: impl FnMut(&[u8]) -> bool,
695) -> Option<String> {
696 use std::sync::atomic::{AtomicBool, Ordering};
697 use std::sync::{Arc, mpsc};
698
699 let deadline = Instant::now() + timeout;
700
701 let Ok(mut slot) = REPLY_PUMP.lock() else {
702 return None;
706 };
707
708 let pump = match slot.take().filter(|p| !p.exited.load(Ordering::Acquire)) {
709 Some(pump) => {
710 pump.serve.store(true, Ordering::Release);
715 pump
716 }
717 None => {
718 let (tx, rx) = mpsc::channel::<u8>();
719 let serve = Arc::new(AtomicBool::new(true));
720 let exited = Arc::new(AtomicBool::new(false));
721 let thread_serve = Arc::clone(&serve);
722 let thread_exited = Arc::clone(&exited);
723 let spawned = std::thread::Builder::new()
724 .name("slt-reply-pump".into())
725 .spawn(move || {
726 let mut stdin = io::stdin();
727 let mut buf = [0u8; 1];
734 loop {
735 match stdin.read(&mut buf) {
736 Ok(0) | Err(_) => break,
737 Ok(_) => {
738 if tx.send(buf[0]).is_err() {
739 thread_exited.store(true, Ordering::Release);
740 return;
741 }
742 }
743 }
744 if !thread_serve.load(Ordering::Acquire) {
745 break;
746 }
747 }
748 thread_exited.store(true, Ordering::Release);
749 });
750 if spawned.is_err() {
751 return None;
752 }
753 ReplyPump { rx, serve, exited }
754 }
755 };
756
757 while pump.rx.try_recv().is_ok() {}
760
761 let bytes = collect_reply(&pump.rx, deadline, &mut is_complete);
762
763 pump.serve.store(false, Ordering::Release);
771 if crossterm::terminal::is_raw_mode_enabled().unwrap_or(false) {
772 let mut out = io::stdout();
773 let _ = write!(out, "\x1b[5n");
774 let _ = out.flush();
775 }
776 *slot = Some(pump);
777 drop(slot);
778
779 if bytes.is_empty() {
780 return None;
781 }
782 String::from_utf8(bytes).ok()
783}
784
785#[cfg(feature = "crossterm")]
791fn collect_reply(
792 rx: &std::sync::mpsc::Receiver<u8>,
793 deadline: Instant,
794 is_complete: &mut dyn FnMut(&[u8]) -> bool,
795) -> Vec<u8> {
796 let mut bytes = Vec::new();
797 loop {
798 let now = Instant::now();
799 if now >= deadline {
800 break;
801 }
802 match rx.recv_timeout(deadline - now) {
803 Ok(byte) => {
804 bytes.push(byte);
805 if is_complete(&bytes) || bytes.len() >= 4096 {
806 break;
807 }
808 }
809 Err(_) => break,
811 }
812 }
813 bytes
814}
815
816#[cfg(feature = "crossterm")]
819fn osc_reply_complete(bytes: &[u8]) -> bool {
820 let len = bytes.len();
821 bytes[len - 1] == b'\x07' || (len >= 2 && bytes[len - 2] == 0x1B && bytes[len - 1] == b'\\')
822}
823
824#[cfg(feature = "crossterm")]
828fn da_reply_complete() -> impl FnMut(&[u8]) -> bool {
829 let mut terminators = 0usize;
830 move |bytes: &[u8]| {
831 if bytes[bytes.len() - 1] == b'c' {
832 terminators += 1;
833 }
834 terminators >= 2
835 }
836}
837
838#[cfg(feature = "crossterm")]
840fn decrpm_reply_complete(bytes: &[u8]) -> bool {
841 bytes[bytes.len() - 1] == b'y'
842}
843
844#[cfg(feature = "crossterm")]
849fn read_da_response(timeout: Duration) -> Option<String> {
850 read_stdin_reply(timeout, da_reply_complete())
851}
852
853#[cfg(feature = "crossterm")]
857fn parse_da1(response: &str, caps: &mut Capabilities) {
858 let mut search = response;
860 while let Some(pos) = search.find("\x1b[?") {
861 let body = &search[pos + 3..];
862 let Some(end) = body.find('c') else { break };
863 let attrs = &body[..end];
864 for attr in attrs.split(';') {
865 if attr.trim() == "4" {
866 caps.sixel = true;
867 }
868 }
869 search = &body[end + 1..];
870 }
871}
872
873#[cfg(feature = "crossterm")]
881fn parse_da2(response: &str, caps: &mut Capabilities) {
882 let Some((id, _ver)) = parse_da2_identity(response) else {
883 return;
884 };
885 const KITTY_GRAPHICS_DA2_ID: u32 = 41;
890 if id == KITTY_GRAPHICS_DA2_ID {
891 caps.kitty_graphics = true;
892 }
893}
894
895#[cfg(feature = "crossterm")]
897fn parse_da2_identity(response: &str) -> Option<(u32, u32)> {
898 let pos = response.find("\x1b[>")?;
899 let body = &response[pos + 3..];
900 let end = body.find('c')?;
901 let mut parts = body[..end].split(';');
902 let id = parts.next()?.trim().parse::<u32>().ok()?;
903 let ver = parts.next().and_then(|s| s.trim().parse::<u32>().ok());
904 Some((id, ver.unwrap_or(0)))
905}
906
907#[cfg(feature = "crossterm")]
911fn parse_kitty_graphics_ack(response: &str, caps: &mut Capabilities) {
912 if let Some(pos) = response.find("\x1b_G") {
915 let body = &response[pos + 3..];
916 let end = body.find("\x1b\\").unwrap_or(body.len());
917 let payload = &body[..end];
918 if payload.contains("i=31") && payload.contains("OK") {
919 caps.kitty_graphics = true;
920 }
921 }
922}
923
924#[cfg(feature = "crossterm")]
928fn parse_xtgettcap_truecolor(response: &str, caps: &mut Capabilities) {
929 if let Some(pos) = response.find("\x1bP1+r") {
931 let body = &response[pos + 5..];
932 if body
933 .to_ascii_lowercase()
934 .split([';', '\x1b'])
935 .any(|seg| seg.starts_with("5463"))
936 {
937 caps.truecolor = true;
938 }
939 }
940}
941
942#[derive(Debug, Clone, Copy, PartialEq, Eq)]
952enum SyncOutputResolution {
953 Supported,
955 Unsupported,
957}
958
959static SYNC_OUTPUT_RESOLUTION: std::sync::OnceLock<SyncOutputResolution> =
962 std::sync::OnceLock::new();
963
964fn should_emit_synchronized_update() -> bool {
974 !matches!(
975 SYNC_OUTPUT_RESOLUTION.get(),
976 Some(SyncOutputResolution::Unsupported)
977 )
978}
979
980#[cfg(feature = "crossterm")]
984fn read_decrpm_response(timeout: Duration) -> Option<String> {
985 read_stdin_reply(timeout, decrpm_reply_complete)
986}
987
988#[cfg(feature = "crossterm")]
997fn parse_decrpm_sync_output(response: &str) -> Option<bool> {
998 let pos = response.find("\x1b[?2026;")?;
1000 let body = &response[pos + "\x1b[?2026;".len()..];
1001 let end = body.find("$y")?;
1002 let ps = body[..end].trim().parse::<u32>().ok()?;
1003 Some(ps != 0)
1005}
1006
1007fn split_base64(encoded: &str, chunk_size: usize) -> Vec<&str> {
1008 let mut chunks = Vec::new();
1009 let bytes = encoded.as_bytes();
1010 let mut offset = 0;
1011 while offset < bytes.len() {
1012 let end = (offset + chunk_size).min(bytes.len());
1013 chunks.push(&encoded[offset..end]);
1014 offset = end;
1015 }
1016 if chunks.is_empty() {
1017 chunks.push("");
1018 }
1019 chunks
1020}
1021
1022pub struct Terminal {
1030 stdout: Sink,
1031 current: Buffer,
1032 previous: Buffer,
1033 cursor_visible: bool,
1034 session: TerminalSessionGuard,
1035 color_depth: ColorDepth,
1036 pub(crate) theme_bg: Option<Color>,
1037 kitty_mgr: KittyImageManager,
1038 run_buf: String,
1042}
1043
1044pub struct InlineTerminal {
1050 stdout: Sink,
1051 current: Buffer,
1052 previous: Buffer,
1053 cursor_visible: bool,
1054 session: TerminalSessionGuard,
1055 height: u32,
1056 start_row: u16,
1057 reserved: bool,
1058 color_depth: ColorDepth,
1059 pub(crate) theme_bg: Option<Color>,
1060 kitty_mgr: KittyImageManager,
1061 run_buf: String,
1063}
1064
1065const RUN_BUF_INITIAL_CAPACITY: usize = 4096;
1069
1070#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1071enum TerminalSessionMode {
1072 Fullscreen,
1073 Inline,
1074}
1075
1076#[derive(Debug, Clone, Copy)]
1077struct TerminalSessionGuard {
1078 mode: TerminalSessionMode,
1079 mouse_enabled: bool,
1080 kitty_keyboard: bool,
1081 report_all_keys: bool,
1082 harness: bool,
1088}
1089
1090impl TerminalSessionGuard {
1091 fn enter(
1092 mode: TerminalSessionMode,
1093 stdout: &mut impl Write,
1094 mouse_enabled: bool,
1095 kitty_keyboard: bool,
1096 report_all_keys: bool,
1097 ) -> io::Result<Self> {
1098 let guard = Self {
1099 mode,
1100 mouse_enabled,
1101 kitty_keyboard,
1102 report_all_keys,
1103 harness: false,
1104 };
1105
1106 terminal::enable_raw_mode()?;
1107 if let Err(err) = write_session_enter(stdout, &guard) {
1108 guard.restore(stdout, false);
1109 return Err(err);
1110 }
1111
1112 let _ = capabilities();
1119
1120 Ok(guard)
1121 }
1122
1123 fn restore(&self, stdout: &mut impl Write, inline_reserved: bool) {
1124 if self.harness {
1126 return;
1127 }
1128 if self.kitty_keyboard {
1129 use crossterm::event::PopKeyboardEnhancementFlags;
1130 let _ = execute!(stdout, PopKeyboardEnhancementFlags);
1131 }
1132 if self.mouse_enabled {
1133 let _ = execute!(stdout, DisableMouseCapture);
1134 }
1135 let _ = execute!(stdout, DisableFocusChange);
1136 let _ = write_session_cleanup(stdout, self.mode, inline_reserved);
1137 let _ = terminal::disable_raw_mode();
1138 }
1139}
1140
1141impl Terminal {
1142 pub fn new(
1147 mouse: bool,
1148 kitty_keyboard: bool,
1149 report_all_keys: bool,
1150 color_depth: ColorDepth,
1151 ) -> io::Result<Self> {
1152 let (cols, rows) = terminal::size()?;
1153 let area = Rect::new(0, 0, cols as u32, rows as u32);
1154
1155 let mut raw = io::stdout();
1156 let session = TerminalSessionGuard::enter(
1157 TerminalSessionMode::Fullscreen,
1158 &mut raw,
1159 mouse,
1160 kitty_keyboard,
1161 report_all_keys,
1162 )?;
1163
1164 Ok(Self {
1165 stdout: Sink::Stdout(BufWriter::with_capacity(65536, raw)),
1166 current: Buffer::empty(area),
1167 previous: Buffer::empty(area),
1168 cursor_visible: false,
1169 session,
1170 color_depth,
1171 theme_bg: None,
1172 kitty_mgr: KittyImageManager::new(),
1173 run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
1174 })
1175 }
1176
1177 pub fn size(&self) -> (u32, u32) {
1179 (self.current.area.width, self.current.area.height)
1180 }
1181
1182 pub fn buffer_mut(&mut self) -> &mut Buffer {
1184 &mut self.current
1185 }
1186
1187 pub fn flush(&mut self) -> io::Result<()> {
1191 if self.current.area.width < self.previous.area.width {
1192 execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
1193 }
1194
1195 let sync_guard = should_emit_synchronized_update();
1199 if sync_guard {
1200 queue!(self.stdout, BeginSynchronizedUpdate)?;
1201 }
1202 self.current.recompute_line_hashes();
1207 self.previous.recompute_line_hashes();
1208 flush_buffer_diff(
1209 &mut self.stdout,
1210 &self.current,
1211 &self.previous,
1212 self.color_depth,
1213 0,
1214 &mut self.run_buf,
1215 )?;
1216
1217 self.kitty_mgr
1220 .flush(&mut self.stdout, &self.current.kitty_placements, 0)?;
1221
1222 flush_raw_sequences(&mut self.stdout, &self.current, &self.previous, 0)?;
1224
1225 flush_sprixels(&mut self.stdout, &self.current, &self.previous, 0)?;
1227
1228 if sync_guard {
1229 queue!(self.stdout, EndSynchronizedUpdate)?;
1230 }
1231 flush_cursor(
1232 &mut self.stdout,
1233 &mut self.cursor_visible,
1234 self.current.cursor_pos(),
1235 0,
1236 None,
1237 )?;
1238
1239 self.stdout.flush()?;
1240
1241 std::mem::swap(&mut self.current, &mut self.previous);
1242 if let Some(bg) = self.theme_bg {
1243 self.current.reset_with_bg(bg);
1244 } else {
1245 self.current.reset();
1246 }
1247 Ok(())
1248 }
1249
1250 pub fn handle_resize(&mut self) -> io::Result<()> {
1253 let (cols, rows) = terminal::size()?;
1254 let area = Rect::new(0, 0, cols as u32, rows as u32);
1255 self.current.resize(area);
1256 self.previous.resize(area);
1257 execute!(
1258 self.stdout,
1259 terminal::Clear(terminal::ClearType::All),
1260 cursor::MoveTo(0, 0)
1261 )?;
1262 Ok(())
1263 }
1264}
1265
1266#[cfg(any(test, feature = "pty-test"))]
1267impl Terminal {
1268 pub(crate) fn with_sink(width: u32, height: u32, color_depth: ColorDepth) -> Self {
1282 let area = Rect::new(0, 0, width, height);
1283 Self {
1284 stdout: Sink::Capture(Vec::new()),
1285 current: Buffer::empty(area),
1286 previous: Buffer::empty(area),
1287 cursor_visible: false,
1288 session: TerminalSessionGuard {
1289 mode: TerminalSessionMode::Fullscreen,
1290 mouse_enabled: false,
1291 kitty_keyboard: false,
1292 report_all_keys: false,
1293 harness: true,
1294 },
1295 color_depth,
1296 theme_bg: None,
1297 kitty_mgr: KittyImageManager::new(),
1298 run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
1299 }
1300 }
1301
1302 pub(crate) fn take_sink_bytes(&mut self) -> Vec<u8> {
1307 match &mut self.stdout {
1308 Sink::Capture(v) => std::mem::take(v),
1309 Sink::Stdout(_) => panic!("take_sink_bytes called on a non-capture Terminal"),
1310 }
1311 }
1312}
1313
1314impl crate::Backend for Terminal {
1315 fn size(&self) -> (u32, u32) {
1316 Terminal::size(self)
1317 }
1318
1319 fn buffer_mut(&mut self) -> &mut Buffer {
1320 Terminal::buffer_mut(self)
1321 }
1322
1323 fn flush(&mut self) -> io::Result<()> {
1324 Terminal::flush(self)
1325 }
1326}
1327
1328impl InlineTerminal {
1329 pub fn new(
1335 height: u32,
1336 mouse: bool,
1337 kitty_keyboard: bool,
1338 report_all_keys: bool,
1339 color_depth: ColorDepth,
1340 ) -> io::Result<Self> {
1341 let (cols, _) = terminal::size()?;
1342 let area = Rect::new(0, 0, cols as u32, height);
1343
1344 let mut raw = io::stdout();
1345 let session = TerminalSessionGuard::enter(
1346 TerminalSessionMode::Inline,
1347 &mut raw,
1348 mouse,
1349 kitty_keyboard,
1350 report_all_keys,
1351 )?;
1352
1353 let (_, cursor_row) = match cursor::position() {
1354 Ok(pos) => pos,
1355 Err(err) => {
1356 session.restore(&mut raw, false);
1357 return Err(err);
1358 }
1359 };
1360 Ok(Self {
1361 stdout: Sink::Stdout(BufWriter::with_capacity(65536, raw)),
1362 current: Buffer::empty(area),
1363 previous: Buffer::empty(area),
1364 cursor_visible: false,
1365 session,
1366 height,
1367 start_row: cursor_row,
1368 reserved: false,
1369 color_depth,
1370 theme_bg: None,
1371 kitty_mgr: KittyImageManager::new(),
1372 run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
1373 })
1374 }
1375
1376 pub fn size(&self) -> (u32, u32) {
1378 (self.current.area.width, self.current.area.height)
1379 }
1380
1381 pub fn buffer_mut(&mut self) -> &mut Buffer {
1383 &mut self.current
1384 }
1385
1386 pub fn flush(&mut self) -> io::Result<()> {
1390 if self.current.area.width < self.previous.area.width {
1391 execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
1392 }
1393
1394 let sync_guard = should_emit_synchronized_update();
1397 if sync_guard {
1398 queue!(self.stdout, BeginSynchronizedUpdate)?;
1399 }
1400
1401 if !self.reserved {
1402 queue!(self.stdout, cursor::MoveToColumn(0))?;
1403 for _ in 0..self.height {
1404 queue!(self.stdout, Print("\n"))?;
1405 }
1406 self.reserved = true;
1407
1408 let (_, rows) = terminal::size()?;
1409 let bottom = self.start_row.saturating_add(sat_u16(self.height));
1410 if bottom > rows {
1411 self.start_row = rows.saturating_sub(sat_u16(self.height));
1412 }
1413 }
1414 let row_offset = self.start_row as u32;
1415 self.current.recompute_line_hashes();
1418 self.previous.recompute_line_hashes();
1419 flush_buffer_diff(
1420 &mut self.stdout,
1421 &self.current,
1422 &self.previous,
1423 self.color_depth,
1424 row_offset,
1425 &mut self.run_buf,
1426 )?;
1427
1428 self.kitty_mgr
1434 .flush(&mut self.stdout, &self.current.kitty_placements, row_offset)?;
1435
1436 flush_raw_sequences(&mut self.stdout, &self.current, &self.previous, row_offset)?;
1438
1439 flush_sprixels(&mut self.stdout, &self.current, &self.previous, row_offset)?;
1441
1442 if sync_guard {
1443 queue!(self.stdout, EndSynchronizedUpdate)?;
1444 }
1445 let fallback_row = row_offset + self.height.saturating_sub(1);
1446 flush_cursor(
1447 &mut self.stdout,
1448 &mut self.cursor_visible,
1449 self.current.cursor_pos(),
1450 row_offset,
1451 Some(fallback_row),
1452 )?;
1453
1454 self.stdout.flush()?;
1455
1456 std::mem::swap(&mut self.current, &mut self.previous);
1457 reset_current_buffer(&mut self.current, self.theme_bg);
1458 Ok(())
1459 }
1460
1461 pub fn handle_resize(&mut self) -> io::Result<()> {
1464 let (cols, _) = terminal::size()?;
1465 let area = Rect::new(0, 0, cols as u32, self.height);
1466 self.current.resize(area);
1467 self.previous.resize(area);
1468 execute!(
1469 self.stdout,
1470 terminal::Clear(terminal::ClearType::All),
1471 cursor::MoveTo(0, 0)
1472 )?;
1473 Ok(())
1474 }
1475}
1476
1477impl crate::Backend for InlineTerminal {
1478 fn size(&self) -> (u32, u32) {
1479 InlineTerminal::size(self)
1480 }
1481
1482 fn buffer_mut(&mut self) -> &mut Buffer {
1483 InlineTerminal::buffer_mut(self)
1484 }
1485
1486 fn flush(&mut self) -> io::Result<()> {
1487 InlineTerminal::flush(self)
1488 }
1489}
1490
1491impl Drop for Terminal {
1492 fn drop(&mut self) {
1493 let _ = self.kitty_mgr.delete_all(&mut self.stdout);
1495 let _ = self.stdout.flush();
1496 self.session.restore(&mut self.stdout, false);
1497 }
1498}
1499
1500impl Drop for InlineTerminal {
1501 fn drop(&mut self) {
1502 let _ = self.kitty_mgr.delete_all(&mut self.stdout);
1503 let _ = self.stdout.flush();
1504 self.session.restore(&mut self.stdout, self.reserved);
1505 }
1506}
1507
1508mod selection;
1509pub(crate) use selection::{SelectionState, apply_selection_overlay, extract_selection_text};
1510#[cfg(test)]
1511pub(crate) use selection::{find_innermost_rect, normalize_selection};
1512
1513#[non_exhaustive]
1515#[cfg(feature = "crossterm")]
1516#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
1517#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1518pub enum ColorScheme {
1519 Dark,
1521 Light,
1523 Unknown,
1525}
1526
1527#[cfg(feature = "crossterm")]
1529fn read_osc_response(timeout: Duration) -> Option<String> {
1530 read_stdin_reply(timeout, osc_reply_complete)
1531}
1532
1533#[cfg(feature = "crossterm")]
1535#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
1536pub fn detect_color_scheme() -> ColorScheme {
1537 let mut stdout = io::stdout();
1538 if write!(stdout, "\x1b]11;?\x07").is_err() {
1539 return ColorScheme::Unknown;
1540 }
1541 if stdout.flush().is_err() {
1542 return ColorScheme::Unknown;
1543 }
1544
1545 let Some(response) = read_osc_response(Duration::from_millis(100)) else {
1546 return ColorScheme::Unknown;
1547 };
1548
1549 parse_osc11_response(&response)
1550}
1551
1552#[cfg(feature = "crossterm")]
1553pub(crate) fn parse_osc11_response(response: &str) -> ColorScheme {
1554 let Some(rgb_pos) = response.find("rgb:") else {
1555 return ColorScheme::Unknown;
1556 };
1557
1558 let payload = &response[rgb_pos + 4..];
1559 let end = payload
1560 .find(['\x07', '\x1b', '\r', '\n', ' ', '\t'])
1561 .unwrap_or(payload.len());
1562 let rgb = &payload[..end];
1563
1564 let mut channels = rgb.split('/');
1565 let (Some(r), Some(g), Some(b), None) = (
1566 channels.next(),
1567 channels.next(),
1568 channels.next(),
1569 channels.next(),
1570 ) else {
1571 return ColorScheme::Unknown;
1572 };
1573
1574 fn parse_channel(channel: &str) -> Option<f64> {
1575 if channel.is_empty() || channel.len() > 4 {
1576 return None;
1577 }
1578 let value = u16::from_str_radix(channel, 16).ok()? as f64;
1579 let max = ((1u32 << (channel.len() * 4)) - 1) as f64;
1580 if max <= 0.0 {
1581 return None;
1582 }
1583 Some((value / max).clamp(0.0, 1.0))
1584 }
1585
1586 let (Some(r), Some(g), Some(b)) = (parse_channel(r), parse_channel(g), parse_channel(b)) else {
1587 return ColorScheme::Unknown;
1588 };
1589
1590 let luminance = 0.299 * r + 0.587 * g + 0.114 * b;
1591 if luminance < 0.5 {
1592 ColorScheme::Dark
1593 } else {
1594 ColorScheme::Light
1595 }
1596}
1597
1598pub(crate) fn base64_encode(input: &[u8]) -> String {
1599 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1600 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
1601 for chunk in input.chunks(3) {
1602 let b0 = chunk[0] as u32;
1603 let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
1604 let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
1605 let triple = (b0 << 16) | (b1 << 8) | b2;
1606 out.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
1607 out.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
1608 out.push(if chunk.len() > 1 {
1609 CHARS[((triple >> 6) & 0x3F) as usize] as char
1610 } else {
1611 '='
1612 });
1613 out.push(if chunk.len() > 2 {
1614 CHARS[(triple & 0x3F) as usize] as char
1615 } else {
1616 '='
1617 });
1618 }
1619 out
1620}
1621
1622pub(crate) fn copy_to_clipboard(w: &mut impl Write, text: &str) -> io::Result<()> {
1623 let encoded = base64_encode(text.as_bytes());
1624 write!(w, "\x1b]52;c;{encoded}\x1b\\")?;
1625 w.flush()
1626}
1627
1628#[cfg(feature = "crossterm")]
1629fn parse_osc52_response(response: &str) -> Option<String> {
1630 let osc_pos = response.find("]52;")?;
1631 let body = &response[osc_pos + 4..];
1632 let semicolon = body.find(';')?;
1633 let payload = &body[semicolon + 1..];
1634
1635 let end = payload
1636 .find("\x1b\\")
1637 .or_else(|| payload.find('\x07'))
1638 .unwrap_or(payload.len());
1639 let encoded = payload[..end].trim();
1640 if encoded.is_empty() || encoded == "?" {
1641 return None;
1642 }
1643
1644 base64_decode(encoded)
1645}
1646
1647#[cfg(feature = "crossterm")]
1680#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
1681pub fn read_clipboard() -> Option<String> {
1682 let mut stdout = io::stdout();
1683 write!(stdout, "\x1b]52;c;?\x07").ok()?;
1684 stdout.flush().ok()?;
1685
1686 let response = read_osc_response(Duration::from_millis(200))?;
1687 parse_osc52_response(&response)
1688}
1689
1690#[cfg(feature = "crossterm")]
1691fn base64_decode(input: &str) -> Option<String> {
1692 let mut filtered: Vec<u8> = input
1693 .bytes()
1694 .filter(|b| !matches!(b, b' ' | b'\n' | b'\r' | b'\t'))
1695 .collect();
1696
1697 match filtered.len() % 4 {
1698 0 => {}
1699 2 => filtered.extend_from_slice(b"=="),
1700 3 => filtered.push(b'='),
1701 _ => return None,
1702 }
1703
1704 fn decode_val(b: u8) -> Option<u8> {
1705 match b {
1706 b'A'..=b'Z' => Some(b - b'A'),
1707 b'a'..=b'z' => Some(b - b'a' + 26),
1708 b'0'..=b'9' => Some(b - b'0' + 52),
1709 b'+' => Some(62),
1710 b'/' => Some(63),
1711 _ => None,
1712 }
1713 }
1714
1715 let mut out = Vec::with_capacity((filtered.len() / 4) * 3);
1716 for chunk in filtered.chunks_exact(4) {
1717 let p2 = chunk[2] == b'=';
1718 let p3 = chunk[3] == b'=';
1719 if p2 && !p3 {
1720 return None;
1721 }
1722
1723 let v0 = decode_val(chunk[0])? as u32;
1724 let v1 = decode_val(chunk[1])? as u32;
1725 let v2 = if p2 { 0 } else { decode_val(chunk[2])? as u32 };
1726 let v3 = if p3 { 0 } else { decode_val(chunk[3])? as u32 };
1727
1728 let triple = (v0 << 18) | (v1 << 12) | (v2 << 6) | v3;
1729 out.push(((triple >> 16) & 0xFF) as u8);
1730 if !p2 {
1731 out.push(((triple >> 8) & 0xFF) as u8);
1732 }
1733 if !p3 {
1734 out.push((triple & 0xFF) as u8);
1735 }
1736 }
1737
1738 String::from_utf8(out).ok()
1739}
1740
1741#[allow(clippy::too_many_arguments)]
1742#[allow(unused_assignments)]
1743fn flush_buffer_diff(
1744 stdout: &mut impl Write,
1745 current: &Buffer,
1746 previous: &Buffer,
1747 color_depth: ColorDepth,
1748 row_offset: u32,
1749 run_buf: &mut String,
1750) -> io::Result<()> {
1751 let mut last_style = Style::new();
1763 let mut first_style = true;
1764 let mut active_link: Option<&str> = None;
1765 let mut has_updates = false;
1766 let mut last_cursor: Option<(u32, u32)> = None;
1770
1771 run_buf.clear();
1777 let mut run_abs_y: u32 = 0;
1778 let mut run_style: Style = Style::new();
1779 let mut run_link: Option<&str> = None;
1780 let mut run_next_col: u32 = 0;
1781 let mut run_open = false;
1782
1783 macro_rules! flush_run {
1788 ($stdout:expr) => {
1789 if run_open {
1790 queue!($stdout, Print(&run_buf))?;
1791 last_cursor = Some((run_next_col, run_abs_y));
1792 run_buf.clear();
1793 run_open = false;
1794 }
1795 };
1796 }
1797
1798 for y in current.area.y..current.area.bottom() {
1799 if current.row_clean(y)
1808 && current.row_hash(y).is_some()
1809 && current.row_hash(y) == previous.row_hash(y)
1810 {
1811 continue;
1812 }
1813 for x in current.area.x..current.area.right() {
1814 let cell = current.get(x, y);
1815 let prev = previous.get(x, y);
1816 if cell == prev || cell.symbol.is_empty() {
1817 flush_run!(stdout);
1819 continue;
1820 }
1821
1822 let abs_y = row_offset + y;
1823 let cell_link = cell
1828 .hyperlink
1829 .as_deref()
1830 .filter(|u| crate::buffer::is_valid_osc8_url(u));
1831
1832 let extends = run_open
1834 && run_abs_y == abs_y
1835 && run_next_col == x
1836 && run_style == cell.style
1837 && run_link == cell_link;
1838
1839 if !extends {
1840 flush_run!(stdout);
1841
1842 has_updates = true;
1846
1847 let need_move = last_cursor.is_none_or(|(lx, ly)| lx != x || ly != abs_y);
1848 if need_move {
1849 queue!(stdout, cursor::MoveTo(sat_u16(x), sat_u16(abs_y)))?;
1850 }
1851
1852 if cell.style != last_style {
1853 if first_style {
1854 queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
1855 apply_style(stdout, &cell.style, color_depth)?;
1856 first_style = false;
1857 } else {
1858 apply_style_delta(stdout, &last_style, &cell.style, color_depth)?;
1859 }
1860 last_style = cell.style;
1861 }
1862
1863 if cell_link != active_link {
1864 if let Some(url) = cell_link {
1865 queue!(stdout, Print("\x1b]8;;"))?;
1870 queue!(stdout, Print(url))?;
1871 queue!(stdout, Print("\x07"))?;
1872 } else {
1873 queue!(stdout, Print("\x1b]8;;\x07"))?;
1874 }
1875 active_link = cell_link;
1876 }
1877
1878 run_open = true;
1879 run_abs_y = abs_y;
1880 run_style = cell.style;
1881 run_link = cell_link;
1882 }
1883
1884 run_buf.push_str(&cell.symbol);
1888 let char_width = UnicodeWidthStr::width(cell.symbol.as_str()).max(1) as u32;
1889 if char_width > 1 && cell.symbol.chars().any(|c| c == '\u{FE0F}') {
1890 run_buf.push(' ');
1894 }
1895 run_next_col = x + char_width;
1896 }
1897
1898 flush_run!(stdout);
1900 }
1901
1902 if has_updates {
1903 if active_link.is_some() {
1904 queue!(stdout, Print("\x1b]8;;\x07"))?;
1905 }
1906 queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
1907 }
1908
1909 Ok(())
1910}
1911
1912#[doc(hidden)]
1922pub fn __bench_flush_buffer_diff<W: Write>(
1923 w: &mut W,
1924 current: &Buffer,
1925 previous: &Buffer,
1926 color_depth: ColorDepth,
1927) -> io::Result<()> {
1928 let mut run_buf = String::with_capacity(RUN_BUF_INITIAL_CAPACITY);
1931 flush_buffer_diff(w, current, previous, color_depth, 0, &mut run_buf)
1932}
1933
1934#[doc(hidden)]
1943pub fn __bench_flush_buffer_diff_mut<W: Write>(
1944 w: &mut W,
1945 current: &mut Buffer,
1946 previous: &mut Buffer,
1947 color_depth: ColorDepth,
1948) -> io::Result<()> {
1949 let mut run_buf = String::with_capacity(RUN_BUF_INITIAL_CAPACITY);
1953 __bench_flush_buffer_diff_mut_with_buf(w, current, previous, color_depth, &mut run_buf)
1954}
1955
1956#[doc(hidden)]
1981pub fn __bench_flush_buffer_diff_mut_with_buf<W: Write>(
1982 w: &mut W,
1983 current: &mut Buffer,
1984 previous: &mut Buffer,
1985 color_depth: ColorDepth,
1986 run_buf: &mut String,
1987) -> io::Result<()> {
1988 current.recompute_line_hashes();
1989 previous.recompute_line_hashes();
1990 flush_buffer_diff(w, current, previous, color_depth, 0, run_buf)
1991}
1992
1993#[doc(hidden)]
1998pub struct __BenchKittyFixture {
1999 mgr: KittyImageManager,
2000 placements: Vec<KittyPlacement>,
2001}
2002
2003#[doc(hidden)]
2006pub fn __bench_new_kitty_fixture(n: usize) -> __BenchKittyFixture {
2007 let mut placements = Vec::with_capacity(n);
2008 for i in 0..n {
2009 let mut rgba = vec![0u8; 256];
2011 rgba[0] = i as u8;
2013 let content_hash = crate::buffer::hash_rgba(&rgba);
2014 placements.push(KittyPlacement {
2015 content_hash,
2016 rgba: std::sync::Arc::new(rgba),
2017 src_width: 8,
2018 src_height: 8,
2019 x: (i as u32) * 4,
2020 y: (i as u32) * 2,
2021 cols: 4,
2022 rows: 2,
2023 crop_y: 0,
2024 crop_h: 0,
2025 });
2026 }
2027 __BenchKittyFixture {
2028 mgr: KittyImageManager::new(),
2029 placements,
2030 }
2031}
2032
2033impl __BenchKittyFixture {
2034 #[doc(hidden)]
2038 pub fn rgba_strong_counts(&self) -> Vec<usize> {
2039 self.placements
2040 .iter()
2041 .map(|p| std::sync::Arc::strong_count(&p.rgba))
2042 .collect()
2043 }
2044
2045 #[doc(hidden)]
2048 pub fn flush_inline<W: Write>(&mut self, sink: &mut W, row_offset: u32) -> io::Result<()> {
2049 self.mgr.flush(sink, &self.placements, row_offset)
2050 }
2051
2052 #[doc(hidden)]
2054 pub fn len(&self) -> usize {
2055 self.placements.len()
2056 }
2057
2058 #[doc(hidden)]
2060 pub fn is_empty(&self) -> bool {
2061 self.placements.is_empty()
2062 }
2063}
2064
2065#[doc(hidden)]
2075pub fn __bench_flush_kitty<W: Write>(sink: &mut W, n: usize, row_offset: u32) -> io::Result<()> {
2076 let mut fixture = __bench_new_kitty_fixture(n);
2077 fixture.flush_inline(sink, row_offset)
2078}
2079
2080#[doc(hidden)]
2087pub struct __BenchSprixelFixture {
2088 current: Buffer,
2089 previous: Buffer,
2090}
2091
2092#[doc(hidden)]
2101pub fn __bench_new_sprixel_fixture(n: usize) -> __BenchSprixelFixture {
2102 use crate::buffer::{SprixelCell, SprixelPlacement};
2103
2104 let height = (n as u32 * 3).max(1);
2106 let area = Rect::new(0, 0, 8, height);
2107 let mut current = Buffer::empty(area);
2108 let mut previous = Buffer::empty(area);
2109
2110 for i in 0..n {
2111 let placement = SprixelPlacement {
2112 content_hash: 0x5000 + i as u64,
2113 seq: "<SIXEL>".to_string(),
2114 x: 0,
2115 y: i as u32 * 3,
2116 cols: 4,
2117 rows: 2,
2118 cells: vec![SprixelCell::Opaque; 8],
2119 };
2120 current.sprixels.push(placement.clone());
2121 previous.sprixels.push(placement);
2122 }
2123
2124 current.recompute_line_hashes();
2127 previous.recompute_line_hashes();
2128
2129 __BenchSprixelFixture { current, previous }
2130}
2131
2132#[allow(dead_code)]
2141impl __BenchSprixelFixture {
2142 #[doc(hidden)]
2146 pub fn flush<W: Write>(&self, sink: &mut W, row_offset: u32) -> io::Result<()> {
2147 flush_sprixels(sink, &self.current, &self.previous, row_offset)
2148 }
2149
2150 #[doc(hidden)]
2152 pub fn len(&self) -> usize {
2153 self.current.sprixels.len()
2154 }
2155
2156 #[doc(hidden)]
2158 pub fn is_empty(&self) -> bool {
2159 self.current.sprixels.is_empty()
2160 }
2161}
2162
2163#[doc(hidden)]
2173pub fn __bench_flush_sprixels<W: Write>(sink: &mut W, n: usize, row_offset: u32) -> io::Result<()> {
2174 let fixture = __bench_new_sprixel_fixture(n);
2175 if fixture.is_empty() {
2176 return Ok(());
2177 }
2178 debug_assert_eq!(fixture.len(), n);
2179 fixture.flush(sink, row_offset)
2180}
2181
2182fn flush_raw_sequences(
2183 stdout: &mut impl Write,
2184 current: &Buffer,
2185 previous: &Buffer,
2186 row_offset: u32,
2187) -> io::Result<()> {
2188 if current.raw_sequences == previous.raw_sequences {
2189 return Ok(());
2190 }
2191
2192 for (x, y, seq) in ¤t.raw_sequences {
2193 queue!(
2194 stdout,
2195 cursor::MoveTo(sat_u16(*x), sat_u16(row_offset + *y)),
2196 Print(seq)
2197 )?;
2198 }
2199
2200 Ok(())
2201}
2202
2203type SprixelKey = (u64, u32, u32, u32, u32);
2208
2209#[inline]
2211fn sprixel_key(p: &crate::buffer::SprixelPlacement) -> SprixelKey {
2212 (p.content_hash, p.x, p.y, p.cols, p.rows)
2213}
2214
2215fn sprixel_needs_reblit(
2238 placement: &crate::buffer::SprixelPlacement,
2239 current: &Buffer,
2240 previous: &Buffer,
2241 prev_keys: &std::collections::HashSet<SprixelKey>,
2242) -> bool {
2243 use crate::buffer::SprixelCell;
2244
2245 if !prev_keys.contains(&sprixel_key(placement)) {
2250 return true;
2251 }
2252
2253 for row in 0..placement.rows {
2257 let y = placement.y + row;
2258 if current.row_clean(y) && current.row_hash(y) == previous.row_hash(y) {
2262 continue;
2263 }
2264 for col in 0..placement.cols {
2265 let idx = (row * placement.cols + col) as usize;
2266 match placement.cells.get(idx) {
2267 Some(SprixelCell::Opaque) | Some(SprixelCell::Mixed) => {}
2268 _ => continue,
2271 }
2272 let x = placement.x + col;
2273 let (Some(cell), Some(prev)) = (current.try_get(x, y), previous.try_get(x, y)) else {
2278 continue;
2279 };
2280 if cell != prev && !cell.symbol.is_empty() {
2286 return true;
2287 }
2288 }
2289 }
2290
2291 false
2292}
2293
2294fn flush_sprixels(
2306 stdout: &mut impl Write,
2307 current: &Buffer,
2308 previous: &Buffer,
2309 row_offset: u32,
2310) -> io::Result<()> {
2311 if current.sprixels.is_empty() {
2314 return Ok(());
2315 }
2316
2317 let prev_keys: std::collections::HashSet<SprixelKey> =
2318 previous.sprixels.iter().map(sprixel_key).collect();
2319
2320 for placement in ¤t.sprixels {
2321 if sprixel_needs_reblit(placement, current, previous, &prev_keys) {
2322 queue!(
2323 stdout,
2324 cursor::MoveTo(sat_u16(placement.x), sat_u16(row_offset + placement.y)),
2325 Print(&placement.seq)
2326 )?;
2327 }
2328 }
2329 Ok(())
2330}
2331
2332fn flush_cursor(
2333 stdout: &mut impl Write,
2334 cursor_visible: &mut bool,
2335 cursor_pos: Option<(u32, u32)>,
2336 row_offset: u32,
2337 fallback_row: Option<u32>,
2338) -> io::Result<()> {
2339 match cursor_pos {
2340 Some((cx, cy)) => {
2341 if !*cursor_visible {
2342 queue!(stdout, cursor::Show)?;
2343 *cursor_visible = true;
2344 }
2345 queue!(
2346 stdout,
2347 cursor::MoveTo(sat_u16(cx), sat_u16(row_offset + cy))
2348 )?;
2349 }
2350 None => {
2351 if *cursor_visible {
2352 queue!(stdout, cursor::Hide)?;
2353 *cursor_visible = false;
2354 }
2355 if let Some(row) = fallback_row {
2356 queue!(stdout, cursor::MoveTo(0, sat_u16(row)))?;
2357 }
2358 }
2359 }
2360
2361 Ok(())
2362}
2363
2364fn apply_style_delta(
2365 w: &mut impl Write,
2366 old: &Style,
2367 new: &Style,
2368 depth: ColorDepth,
2369) -> io::Result<()> {
2370 if old.fg != new.fg {
2371 match new.fg {
2372 Some(fg) => queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?,
2373 None => queue!(w, SetForegroundColor(CtColor::Reset))?,
2374 }
2375 }
2376 if old.bg != new.bg {
2377 match new.bg {
2378 Some(bg) => queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?,
2379 None => queue!(w, SetBackgroundColor(CtColor::Reset))?,
2380 }
2381 }
2382 let removed = Modifiers(old.modifiers.0 & !new.modifiers.0);
2383 let added = Modifiers(new.modifiers.0 & !old.modifiers.0);
2384 if removed.contains(Modifiers::BOLD) || removed.contains(Modifiers::DIM) {
2385 queue!(w, SetAttribute(Attribute::NormalIntensity))?;
2386 if new.modifiers.contains(Modifiers::BOLD) {
2387 queue!(w, SetAttribute(Attribute::Bold))?;
2388 }
2389 if new.modifiers.contains(Modifiers::DIM) {
2390 queue!(w, SetAttribute(Attribute::Dim))?;
2391 }
2392 } else {
2393 if added.contains(Modifiers::BOLD) {
2394 queue!(w, SetAttribute(Attribute::Bold))?;
2395 }
2396 if added.contains(Modifiers::DIM) {
2397 queue!(w, SetAttribute(Attribute::Dim))?;
2398 }
2399 }
2400 if removed.contains(Modifiers::ITALIC) {
2401 queue!(w, SetAttribute(Attribute::NoItalic))?;
2402 }
2403 if added.contains(Modifiers::ITALIC) {
2404 queue!(w, SetAttribute(Attribute::Italic))?;
2405 }
2406 if removed.contains(Modifiers::UNDERLINE) {
2407 queue!(w, SetAttribute(Attribute::NoUnderline))?;
2408 }
2409 if added.contains(Modifiers::UNDERLINE) {
2410 queue!(w, SetAttribute(Attribute::Underlined))?;
2411 }
2412 if removed.contains(Modifiers::REVERSED) {
2413 queue!(w, SetAttribute(Attribute::NoReverse))?;
2414 }
2415 if added.contains(Modifiers::REVERSED) {
2416 queue!(w, SetAttribute(Attribute::Reverse))?;
2417 }
2418 if removed.contains(Modifiers::STRIKETHROUGH) {
2419 queue!(w, SetAttribute(Attribute::NotCrossedOut))?;
2420 }
2421 if added.contains(Modifiers::STRIKETHROUGH) {
2422 queue!(w, SetAttribute(Attribute::CrossedOut))?;
2423 }
2424 if removed.contains(Modifiers::BLINK) {
2425 queue!(w, SetAttribute(Attribute::NoBlink))?;
2426 }
2427 if added.contains(Modifiers::BLINK) {
2428 queue!(w, SetAttribute(Attribute::SlowBlink))?;
2429 }
2430 if removed.contains(Modifiers::OVERLINE) {
2431 queue!(w, SetAttribute(Attribute::NotOverLined))?;
2432 }
2433 if added.contains(Modifiers::OVERLINE) {
2434 queue!(w, SetAttribute(Attribute::OverLined))?;
2435 }
2436 if old.underline_style != new.underline_style {
2440 write!(w, "\x1b[4:{}m", underline_style_param(new.underline_style))?;
2441 }
2442 if old.underline_color != new.underline_color {
2443 emit_underline_color(w, new.underline_color, depth)?;
2444 }
2445 Ok(())
2446}
2447
2448fn underline_style_param(style: UnderlineStyle) -> u8 {
2450 match style {
2451 UnderlineStyle::Straight => 1,
2452 UnderlineStyle::Double => 2,
2453 UnderlineStyle::Curly => 3,
2454 UnderlineStyle::Dotted => 4,
2455 UnderlineStyle::Dashed => 5,
2456 }
2457}
2458
2459fn emit_underline_color(
2465 w: &mut impl Write,
2466 color: Option<Color>,
2467 depth: ColorDepth,
2468) -> io::Result<()> {
2469 match color {
2470 None => write!(w, "\x1b[59m"),
2471 Some(c) => match c.downsampled(depth) {
2472 Color::Reset => write!(w, "\x1b[59m"),
2473 Color::Rgb(r, g, b) => write!(w, "\x1b[58:2::{r}:{g}:{b}m"),
2474 Color::Indexed(i) => write!(w, "\x1b[58:5:{i}m"),
2475 named => {
2478 let (r, g, b) = named.to_rgb();
2479 write!(w, "\x1b[58:2::{r}:{g}:{b}m")
2480 }
2481 },
2482 }
2483}
2484
2485fn apply_style(w: &mut impl Write, style: &Style, depth: ColorDepth) -> io::Result<()> {
2486 if let Some(fg) = style.fg {
2487 queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?;
2488 }
2489 if let Some(bg) = style.bg {
2490 queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?;
2491 }
2492 let m = style.modifiers;
2493 if m.contains(Modifiers::BOLD) {
2494 queue!(w, SetAttribute(Attribute::Bold))?;
2495 }
2496 if m.contains(Modifiers::DIM) {
2497 queue!(w, SetAttribute(Attribute::Dim))?;
2498 }
2499 if m.contains(Modifiers::ITALIC) {
2500 queue!(w, SetAttribute(Attribute::Italic))?;
2501 }
2502 if m.contains(Modifiers::UNDERLINE) {
2503 queue!(w, SetAttribute(Attribute::Underlined))?;
2504 }
2505 if m.contains(Modifiers::REVERSED) {
2506 queue!(w, SetAttribute(Attribute::Reverse))?;
2507 }
2508 if m.contains(Modifiers::STRIKETHROUGH) {
2509 queue!(w, SetAttribute(Attribute::CrossedOut))?;
2510 }
2511 if m.contains(Modifiers::BLINK) {
2512 queue!(w, SetAttribute(Attribute::SlowBlink))?;
2513 }
2514 if m.contains(Modifiers::OVERLINE) {
2515 queue!(w, SetAttribute(Attribute::OverLined))?;
2516 }
2517 if style.underline_style != UnderlineStyle::Straight {
2518 write!(
2519 w,
2520 "\x1b[4:{}m",
2521 underline_style_param(style.underline_style)
2522 )?;
2523 }
2524 if style.underline_color.is_some() {
2525 emit_underline_color(w, style.underline_color, depth)?;
2526 }
2527 Ok(())
2528}
2529
2530fn to_crossterm_color(color: Color, depth: ColorDepth) -> CtColor {
2531 let color = color.downsampled(depth);
2532 match color {
2533 Color::Reset => CtColor::Reset,
2534 Color::Black => CtColor::Black,
2535 Color::Red => CtColor::DarkRed,
2536 Color::Green => CtColor::DarkGreen,
2537 Color::Yellow => CtColor::DarkYellow,
2538 Color::Blue => CtColor::DarkBlue,
2539 Color::Magenta => CtColor::DarkMagenta,
2540 Color::Cyan => CtColor::DarkCyan,
2541 Color::White => CtColor::White,
2542 Color::DarkGray => CtColor::DarkGrey,
2543 Color::LightRed => CtColor::Red,
2544 Color::LightGreen => CtColor::Green,
2545 Color::LightYellow => CtColor::Yellow,
2546 Color::LightBlue => CtColor::Blue,
2547 Color::LightMagenta => CtColor::Magenta,
2548 Color::LightCyan => CtColor::Cyan,
2549 Color::LightWhite => CtColor::White,
2550 Color::Rgb(r, g, b) => CtColor::Rgb { r, g, b },
2551 Color::Indexed(i) => CtColor::AnsiValue(i),
2552 }
2553}
2554
2555fn reset_current_buffer(buffer: &mut Buffer, theme_bg: Option<Color>) {
2556 if let Some(bg) = theme_bg {
2557 buffer.reset_with_bg(bg);
2558 } else {
2559 buffer.reset();
2560 }
2561}
2562
2563fn write_session_enter(stdout: &mut impl Write, session: &TerminalSessionGuard) -> io::Result<()> {
2564 match session.mode {
2565 TerminalSessionMode::Fullscreen => {
2566 execute!(
2567 stdout,
2568 terminal::EnterAlternateScreen,
2569 cursor::Hide,
2570 EnableBracketedPaste
2571 )?;
2572 }
2573 TerminalSessionMode::Inline => {
2574 execute!(stdout, cursor::Hide, EnableBracketedPaste)?;
2575 }
2576 }
2577
2578 execute!(stdout, EnableFocusChange)?;
2584 if session.mouse_enabled {
2585 execute!(stdout, EnableMouseCapture)?;
2586 }
2587 if session.kitty_keyboard {
2588 use crossterm::event::PushKeyboardEnhancementFlags;
2589 let _ = execute!(
2590 stdout,
2591 PushKeyboardEnhancementFlags(kitty_flags(session.report_all_keys))
2592 );
2593 }
2594
2595 Ok(())
2596}
2597
2598fn kitty_flags(report_all_keys: bool) -> crossterm::event::KeyboardEnhancementFlags {
2608 use crossterm::event::KeyboardEnhancementFlags;
2609 let mut flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
2610 | KeyboardEnhancementFlags::REPORT_EVENT_TYPES;
2611 if report_all_keys {
2612 flags |= KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES;
2613 }
2614 flags
2615}
2616
2617fn write_session_cleanup(
2618 stdout: &mut impl Write,
2619 mode: TerminalSessionMode,
2620 inline_reserved: bool,
2621) -> io::Result<()> {
2622 execute!(
2623 stdout,
2624 ResetColor,
2625 SetAttribute(Attribute::Reset),
2626 cursor::Show,
2627 DisableBracketedPaste
2628 )?;
2629
2630 match mode {
2631 TerminalSessionMode::Fullscreen => {
2632 execute!(stdout, terminal::LeaveAlternateScreen)?;
2633 }
2634 TerminalSessionMode::Inline => {
2635 if inline_reserved {
2636 execute!(
2637 stdout,
2638 cursor::MoveToColumn(0),
2639 cursor::MoveDown(1),
2640 cursor::MoveToColumn(0),
2641 Print("\n")
2642 )?;
2643 } else {
2644 execute!(stdout, Print("\n"))?;
2645 }
2646 }
2647 }
2648
2649 Ok(())
2650}
2651
2652#[cfg(unix)]
2669#[derive(Debug, Clone, Copy)]
2670pub(crate) struct SessionSnapshot {
2671 mode: TerminalSessionMode,
2672 mouse_enabled: bool,
2673 kitty_keyboard: bool,
2674 report_all_keys: bool,
2675}
2676
2677#[cfg(unix)]
2680pub(crate) static NEEDS_FULL_REDRAW: std::sync::atomic::AtomicBool =
2681 std::sync::atomic::AtomicBool::new(false);
2682
2683#[cfg(unix)]
2684impl Terminal {
2685 pub(crate) fn session_snapshot(&self) -> SessionSnapshot {
2688 SessionSnapshot {
2689 mode: self.session.mode,
2690 mouse_enabled: self.session.mouse_enabled,
2691 kitty_keyboard: self.session.kitty_keyboard,
2692 report_all_keys: self.session.report_all_keys,
2693 }
2694 }
2695}
2696
2697#[cfg(unix)]
2698impl InlineTerminal {
2699 pub(crate) fn session_snapshot(&self) -> SessionSnapshot {
2702 SessionSnapshot {
2703 mode: self.session.mode,
2704 mouse_enabled: self.session.mouse_enabled,
2705 kitty_keyboard: self.session.kitty_keyboard,
2706 report_all_keys: self.session.report_all_keys,
2707 }
2708 }
2709}
2710
2711#[cfg(unix)]
2719fn write_suspend_sequence(stdout: &mut impl Write, snapshot: &SessionSnapshot) -> io::Result<()> {
2720 if snapshot.kitty_keyboard {
2721 use crossterm::event::PopKeyboardEnhancementFlags;
2722 execute!(stdout, PopKeyboardEnhancementFlags)?;
2723 }
2724 if snapshot.mouse_enabled {
2725 execute!(stdout, DisableMouseCapture)?;
2726 }
2727 execute!(stdout, DisableFocusChange)?;
2728 write_session_cleanup(stdout, snapshot.mode, false)
2729}
2730
2731#[cfg(unix)]
2738pub(crate) fn suspend_to_shell(snapshot: &SessionSnapshot) {
2739 let mut out = io::stdout();
2740 let _ = write_suspend_sequence(&mut out, snapshot);
2741 let _ = terminal::disable_raw_mode();
2742 let _ = out.flush();
2743}
2744
2745#[cfg(unix)]
2752pub(crate) fn resume_from_shell(snapshot: &SessionSnapshot) {
2753 let mut out = io::stdout();
2754 let _ = terminal::enable_raw_mode();
2755 let guard = TerminalSessionGuard {
2756 mode: snapshot.mode,
2757 mouse_enabled: snapshot.mouse_enabled,
2758 kitty_keyboard: snapshot.kitty_keyboard,
2759 report_all_keys: snapshot.report_all_keys,
2760 harness: false,
2761 };
2762 let _ = write_session_enter(&mut out, &guard);
2763 let _ = out.flush();
2764 NEEDS_FULL_REDRAW.store(true, std::sync::atomic::Ordering::SeqCst);
2765}
2766
2767#[cfg(all(unix, test))]
2769fn test_snapshot(mode: TerminalSessionMode, mouse: bool, kitty: bool) -> SessionSnapshot {
2770 SessionSnapshot {
2771 mode,
2772 mouse_enabled: mouse,
2773 kitty_keyboard: kitty,
2774 report_all_keys: false,
2775 }
2776}
2777
2778#[cfg(all(unix, test))]
2781pub(crate) fn test_session_snapshot() -> SessionSnapshot {
2782 SessionSnapshot {
2783 mode: TerminalSessionMode::Fullscreen,
2784 mouse_enabled: false,
2785 kitty_keyboard: false,
2786 report_all_keys: false,
2787 }
2788}
2789
2790#[cfg(test)]
2791mod tests {
2792 #![allow(clippy::unwrap_used)]
2793 use super::*;
2794
2795 fn collect_with_feed(
2798 bytes: &'static [u8],
2799 delay: Duration,
2800 budget: Duration,
2801 is_complete: &mut dyn FnMut(&[u8]) -> bool,
2802 ) -> (Vec<u8>, Duration) {
2803 let (tx, rx) = std::sync::mpsc::channel::<u8>();
2804 std::thread::spawn(move || {
2805 std::thread::sleep(delay);
2806 for &b in bytes {
2807 if tx.send(b).is_err() {
2808 return;
2809 }
2810 }
2811 std::thread::sleep(Duration::from_secs(3));
2816 });
2817 let start = Instant::now();
2818 let out = collect_reply(&rx, start + budget, is_complete);
2819 (out, start.elapsed())
2820 }
2821
2822 #[test]
2823 fn collect_reply_osc_bel_terminator_completes_early() {
2824 let reply = b"\x1b]11;rgb:0000/0000/0000\x07";
2825 let (out, elapsed) = collect_with_feed(
2826 reply,
2827 Duration::ZERO,
2828 Duration::from_secs(2),
2829 &mut osc_reply_complete,
2830 );
2831 assert_eq!(out, reply);
2832 assert!(
2833 elapsed < Duration::from_secs(1),
2834 "should not wait out the budget"
2835 );
2836 }
2837
2838 #[test]
2839 fn collect_reply_osc_st_terminator_completes_early() {
2840 let reply = b"\x1bP>|tmux 3.5a\x1b\\";
2841 let (out, elapsed) = collect_with_feed(
2842 reply,
2843 Duration::ZERO,
2844 Duration::from_secs(2),
2845 &mut osc_reply_complete,
2846 );
2847 assert_eq!(out, reply);
2848 assert!(elapsed < Duration::from_secs(1));
2849 }
2850
2851 #[test]
2852 fn collect_reply_silence_returns_empty_at_deadline() {
2853 let budget = Duration::from_millis(150);
2856 let (out, elapsed) =
2857 collect_with_feed(b"", Duration::from_secs(5), budget, &mut osc_reply_complete);
2858 assert!(out.is_empty());
2859 assert!(elapsed >= budget);
2860 assert!(
2861 elapsed < Duration::from_secs(2),
2862 "must not block past the budget"
2863 );
2864 }
2865
2866 #[test]
2867 fn collect_reply_da_drains_two_replies() {
2868 let reply = b"\x1b[?62;4c\x1b[>1;10;0c";
2869 let (out, elapsed) = collect_with_feed(
2870 reply,
2871 Duration::ZERO,
2872 Duration::from_secs(2),
2873 &mut da_reply_complete(),
2874 );
2875 assert_eq!(out, reply);
2876 assert!(elapsed < Duration::from_secs(1));
2877 }
2878
2879 #[test]
2880 fn collect_reply_da_lone_reply_returns_partial_at_deadline() {
2881 let budget = Duration::from_millis(150);
2885 let (out, elapsed) = collect_with_feed(
2886 b"\x1b[?62;4c",
2887 Duration::ZERO,
2888 budget,
2889 &mut da_reply_complete(),
2890 );
2891 assert_eq!(out, b"\x1b[?62;4c");
2892 assert!(elapsed >= budget);
2893 }
2894
2895 #[test]
2896 fn collect_reply_unterminated_caps_at_4096_bytes() {
2897 static BIG: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
2898 let big = BIG.get_or_init(|| vec![b'x'; 5000]).as_slice();
2899 let (tx, rx) = std::sync::mpsc::channel::<u8>();
2900 for &b in big {
2901 tx.send(b).unwrap();
2902 }
2903 let out = collect_reply(
2904 &rx,
2905 Instant::now() + Duration::from_secs(2),
2906 &mut osc_reply_complete,
2907 );
2908 assert_eq!(out.len(), 4096);
2909 }
2910
2911 #[test]
2912 fn decrpm_predicate_terminates_on_y() {
2913 let reply = b"\x1b[?2026;1$y";
2914 let (out, _) = collect_with_feed(
2915 reply,
2916 Duration::ZERO,
2917 Duration::from_secs(2),
2918 &mut decrpm_reply_complete,
2919 );
2920 assert_eq!(out, reply);
2921 }
2922
2923 #[test]
2924 fn reset_current_buffer_applies_theme_background() {
2925 let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 1));
2926
2927 reset_current_buffer(&mut buffer, Some(Color::Rgb(10, 20, 30)));
2928 assert_eq!(buffer.get(0, 0).style.bg, Some(Color::Rgb(10, 20, 30)));
2929
2930 reset_current_buffer(&mut buffer, None);
2931 assert_eq!(buffer.get(0, 0).style.bg, None);
2932 }
2933
2934 #[test]
2935 fn fullscreen_session_enter_writes_alt_screen_sequence() {
2936 let session = TerminalSessionGuard {
2937 mode: TerminalSessionMode::Fullscreen,
2938 mouse_enabled: false,
2939 kitty_keyboard: false,
2940 report_all_keys: false,
2941 harness: false,
2942 };
2943 let mut out = Vec::new();
2944 write_session_enter(&mut out, &session).unwrap();
2945 let output = String::from_utf8(out).unwrap();
2946 assert!(output.contains("\u{1b}[?1049h"));
2947 assert!(output.contains("\u{1b}[?25l"));
2948 assert!(output.contains("\u{1b}[?2004h"));
2949 }
2950
2951 #[test]
2952 fn inline_session_enter_skips_alt_screen_sequence() {
2953 let session = TerminalSessionGuard {
2954 mode: TerminalSessionMode::Inline,
2955 mouse_enabled: false,
2956 kitty_keyboard: false,
2957 report_all_keys: false,
2958 harness: false,
2959 };
2960 let mut out = Vec::new();
2961 write_session_enter(&mut out, &session).unwrap();
2962 let output = String::from_utf8(out).unwrap();
2963 assert!(!output.contains("\u{1b}[?1049h"));
2964 assert!(output.contains("\u{1b}[?25l"));
2965 assert!(output.contains("\u{1b}[?2004h"));
2966 }
2967
2968 #[test]
2969 fn fullscreen_session_cleanup_leaves_alt_screen() {
2970 let mut out = Vec::new();
2971 write_session_cleanup(&mut out, TerminalSessionMode::Fullscreen, false).unwrap();
2972 let output = String::from_utf8(out).unwrap();
2973 assert!(output.contains("\u{1b}[?1049l"));
2974 assert!(output.contains("\u{1b}[?25h"));
2975 assert!(output.contains("\u{1b}[?2004l"));
2976 }
2977
2978 #[test]
2979 fn inline_session_cleanup_keeps_normal_screen() {
2980 let mut out = Vec::new();
2981 write_session_cleanup(&mut out, TerminalSessionMode::Inline, false).unwrap();
2982 let output = String::from_utf8(out).unwrap();
2983 assert!(!output.contains("\u{1b}[?1049l"));
2984 assert!(output.ends_with('\n'));
2985 assert!(output.contains("\u{1b}[?25h"));
2986 assert!(output.contains("\u{1b}[?2004l"));
2987 }
2988
2989 #[cfg(unix)]
2992 #[test]
2993 fn suspend_sequence_fullscreen_leaves_alt_screen() {
2994 let snapshot = test_snapshot(TerminalSessionMode::Fullscreen, false, false);
2995 let mut out = Vec::new();
2996 write_suspend_sequence(&mut out, &snapshot).unwrap();
2997 let output = String::from_utf8(out).unwrap();
2998 assert!(output.contains("\u{1b}[?1049l"), "leaves alt screen");
2999 assert!(output.contains("\u{1b}[?25h"), "shows cursor");
3000 assert!(output.contains("\u{1b}[?2004l"), "disables bracketed paste");
3001 }
3002
3003 #[cfg(unix)]
3004 #[test]
3005 fn suspend_sequence_inline_keeps_normal_screen() {
3006 let snapshot = test_snapshot(TerminalSessionMode::Inline, false, false);
3007 let mut out = Vec::new();
3008 write_suspend_sequence(&mut out, &snapshot).unwrap();
3009 let output = String::from_utf8(out).unwrap();
3010 assert!(
3011 !output.contains("\u{1b}[?1049l"),
3012 "inline must not leave alt screen"
3013 );
3014 assert!(output.contains("\u{1b}[?25h"), "shows cursor");
3015 assert!(output.contains("\u{1b}[?2004l"), "disables bracketed paste");
3016 }
3017
3018 #[cfg(unix)]
3019 #[test]
3020 fn suspend_sequence_disables_mouse_and_kitty_when_enabled() {
3021 let snapshot = test_snapshot(TerminalSessionMode::Fullscreen, true, true);
3022 let mut out = Vec::new();
3023 write_suspend_sequence(&mut out, &snapshot).unwrap();
3024 let output = String::from_utf8(out).unwrap();
3026 assert!(output.contains("\u{1b}[?1006l"), "disables SGR mouse mode");
3027 }
3028
3029 #[cfg(unix)]
3030 #[test]
3031 fn resume_sequence_fullscreen_round_trips_enter_and_flags_redraw() {
3032 let snapshot = test_snapshot(TerminalSessionMode::Fullscreen, false, false);
3033
3034 let guard = TerminalSessionGuard {
3036 mode: snapshot.mode,
3037 mouse_enabled: snapshot.mouse_enabled,
3038 kitty_keyboard: snapshot.kitty_keyboard,
3039 report_all_keys: snapshot.report_all_keys,
3040 harness: false,
3041 };
3042 let mut enter_bytes = Vec::new();
3043 write_session_enter(&mut enter_bytes, &guard).unwrap();
3044 let enter = String::from_utf8(enter_bytes).unwrap();
3045 assert!(enter.contains("\u{1b}[?1049h"));
3046 assert!(enter.contains("\u{1b}[?25l"));
3047 assert!(enter.contains("\u{1b}[?2004h"));
3048
3049 NEEDS_FULL_REDRAW.store(false, std::sync::atomic::Ordering::SeqCst);
3051 resume_from_shell(&snapshot);
3052 assert!(
3053 NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst),
3054 "resume must request a full redraw exactly once"
3055 );
3056 assert!(
3057 !NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst),
3058 "the redraw flag is consumed by the first swap (idempotent)"
3059 );
3060 }
3061
3062 #[cfg(unix)]
3063 #[test]
3064 fn needs_full_redraw_swaps_true_once() {
3065 NEEDS_FULL_REDRAW.store(true, std::sync::atomic::Ordering::SeqCst);
3066 assert!(NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst));
3067 assert!(!NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst));
3068 }
3069
3070 #[test]
3071 fn kitty_flags_base_set_excludes_report_all_keys() {
3072 use crossterm::event::KeyboardEnhancementFlags;
3073 let flags = kitty_flags(false);
3074 assert!(flags.contains(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES));
3075 assert!(flags.contains(KeyboardEnhancementFlags::REPORT_EVENT_TYPES));
3076 assert!(!flags.contains(KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES));
3077 }
3078
3079 #[test]
3080 fn kitty_flags_report_all_keys_sets_flag() {
3081 use crossterm::event::KeyboardEnhancementFlags;
3082 let flags = kitty_flags(true);
3083 assert!(flags.contains(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES));
3084 assert!(flags.contains(KeyboardEnhancementFlags::REPORT_EVENT_TYPES));
3085 assert!(flags.contains(KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES));
3086 }
3087
3088 #[test]
3089 fn base64_encode_empty() {
3090 assert_eq!(base64_encode(b""), "");
3091 }
3092
3093 #[test]
3094 fn base64_encode_hello() {
3095 assert_eq!(base64_encode(b"Hello"), "SGVsbG8=");
3096 }
3097
3098 #[test]
3099 fn base64_encode_padding() {
3100 assert_eq!(base64_encode(b"a"), "YQ==");
3101 assert_eq!(base64_encode(b"ab"), "YWI=");
3102 assert_eq!(base64_encode(b"abc"), "YWJj");
3103 }
3104
3105 #[test]
3106 fn base64_encode_unicode() {
3107 assert_eq!(base64_encode("한글".as_bytes()), "7ZWc6riA");
3108 }
3109
3110 #[cfg(feature = "crossterm")]
3111 #[test]
3112 fn parse_osc11_response_dark_and_light() {
3113 assert_eq!(
3114 parse_osc11_response("\x1b]11;rgb:0000/0000/0000\x1b\\"),
3115 ColorScheme::Dark
3116 );
3117 assert_eq!(
3118 parse_osc11_response("\x1b]11;rgb:ffff/ffff/ffff\x07"),
3119 ColorScheme::Light
3120 );
3121 }
3122
3123 #[test]
3126 fn blitter_support_default_is_conservative() {
3127 let b = BlitterSupport::default();
3128 assert!(b.half);
3129 assert!(b.quad);
3130 assert!(!b.sextant);
3131 }
3132
3133 #[test]
3134 fn capabilities_default_is_all_false_but_half_block() {
3135 let c = Capabilities::default();
3136 assert!(!c.truecolor);
3137 assert!(!c.sixel);
3138 assert!(!c.iterm2);
3139 assert!(!c.kitty_graphics);
3140 assert!(!c.kitty_keyboard);
3141 assert!(!c.sync_output);
3142 assert_eq!(c.best_blitter(), Blitter::HalfBlock);
3144 }
3145
3146 #[test]
3147 fn best_blitter_ladder_table() {
3148 let kitty = Capabilities {
3149 kitty_graphics: true,
3150 ..Default::default()
3151 };
3152 assert_eq!(kitty.best_blitter(), Blitter::Kitty);
3153
3154 let sixel = Capabilities {
3155 sixel: true,
3156 ..Default::default()
3157 };
3158 assert_eq!(sixel.best_blitter(), Blitter::Sixel);
3159
3160 let iterm2 = Capabilities {
3161 iterm2: true,
3162 ..Default::default()
3163 };
3164 assert_eq!(iterm2.best_blitter(), Blitter::Iterm2);
3165
3166 let sixel_and_iterm2 = Capabilities {
3168 sixel: true,
3169 iterm2: true,
3170 ..Default::default()
3171 };
3172 assert_eq!(sixel_and_iterm2.best_blitter(), Blitter::Sixel);
3173
3174 let sextant = Capabilities {
3175 blitters: BlitterSupport {
3176 sextant: true,
3177 ..Default::default()
3178 },
3179 ..Default::default()
3180 };
3181 assert_eq!(sextant.best_blitter(), Blitter::Sextant);
3182
3183 assert_eq!(Capabilities::default().best_blitter(), Blitter::HalfBlock);
3184 }
3185
3186 #[test]
3187 fn best_blitter_precedence_kitty_over_everything() {
3188 let all = Capabilities {
3189 kitty_graphics: true,
3190 sixel: true,
3191 blitters: BlitterSupport {
3192 sextant: true,
3193 ..Default::default()
3194 },
3195 ..Default::default()
3196 };
3197 assert_eq!(all.best_blitter(), Blitter::Kitty);
3198
3199 let sixel_and_sextant = Capabilities {
3200 sixel: true,
3201 blitters: BlitterSupport {
3202 sextant: true,
3203 ..Default::default()
3204 },
3205 ..Default::default()
3206 };
3207 assert_eq!(sixel_and_sextant.best_blitter(), Blitter::Sixel);
3208 }
3209
3210 #[test]
3211 fn best_blitter_never_picks_unsupported_protocol() {
3212 for kitty in [false, true] {
3215 for sixel in [false, true] {
3216 for iterm2 in [false, true] {
3217 for sextant in [false, true] {
3218 let caps = Capabilities {
3219 kitty_graphics: kitty,
3220 sixel,
3221 iterm2,
3222 blitters: BlitterSupport {
3223 sextant,
3224 ..Default::default()
3225 },
3226 ..Default::default()
3227 };
3228 match caps.best_blitter() {
3229 Blitter::Kitty => assert!(kitty),
3230 Blitter::Sixel => assert!(sixel && !kitty),
3231 Blitter::Iterm2 => assert!(iterm2 && !sixel && !kitty),
3232 Blitter::Sextant => {
3233 assert!(sextant && !iterm2 && !sixel && !kitty)
3234 }
3235 Blitter::HalfBlock => {
3236 assert!(!kitty && !sixel && !iterm2 && !sextant)
3237 }
3238 }
3239 }
3240 }
3241 }
3242 }
3243 }
3244
3245 #[cfg(feature = "crossterm")]
3246 #[test]
3247 fn parse_da1_attribute_4_sets_sixel() {
3248 let mut caps = Capabilities::default();
3249 parse_da1("\x1b[?62;4;6c", &mut caps);
3250 assert!(caps.sixel);
3251 }
3252
3253 #[cfg(feature = "crossterm")]
3254 #[test]
3255 fn parse_da1_without_4_leaves_sixel_false() {
3256 let mut caps = Capabilities::default();
3257 parse_da1("\x1b[?62;1;6c", &mut caps);
3258 assert!(!caps.sixel);
3259 }
3260
3261 #[cfg(feature = "crossterm")]
3262 #[test]
3263 fn parse_da1_ignores_da2_segment_in_same_string() {
3264 let mut caps = Capabilities::default();
3266 parse_da1("\x1b[?62;1c\x1b[>0;276;0c", &mut caps);
3267 assert!(!caps.sixel);
3268 }
3269
3270 #[cfg(feature = "crossterm")]
3271 #[test]
3272 fn parse_da2_no_panic_on_garbage() {
3273 let mut caps = Capabilities::default();
3274 parse_da2("\x1b[>99;1;0c", &mut caps);
3276 assert!(!caps.kitty_graphics);
3277 parse_da2("not a da2 reply", &mut caps);
3278 assert!(!caps.kitty_graphics);
3279 }
3280
3281 #[cfg(feature = "crossterm")]
3282 #[test]
3283 fn parse_da2_kitty_id_sets_kitty_graphics() {
3284 let mut caps = Capabilities::default();
3285 parse_da2("\x1b[>41;4000;0c", &mut caps);
3287 assert!(caps.kitty_graphics);
3288 }
3289
3290 #[cfg(feature = "crossterm")]
3291 #[test]
3292 fn parse_da2_identity_extracts_id_and_version() {
3293 assert_eq!(parse_da2_identity("\x1b[>0;276;0c"), Some((0, 276)));
3294 assert_eq!(parse_da2_identity("\x1b[>41;4000;0c"), Some((41, 4000)));
3295 assert_eq!(parse_da2_identity("no reply here"), None);
3296 }
3297
3298 #[cfg(feature = "crossterm")]
3299 #[test]
3300 fn parse_kitty_graphics_ack_ok_sets_flag() {
3301 let mut caps = Capabilities::default();
3302 parse_kitty_graphics_ack("\x1b_Gi=31;OK\x1b\\", &mut caps);
3303 assert!(caps.kitty_graphics);
3304 }
3305
3306 #[cfg(feature = "crossterm")]
3307 #[test]
3308 fn parse_kitty_graphics_ack_error_or_wrong_id_leaves_flag() {
3309 let mut caps = Capabilities::default();
3310 parse_kitty_graphics_ack("\x1b_Gi=31;ENOENT:bad\x1b\\", &mut caps);
3312 assert!(!caps.kitty_graphics);
3313 parse_kitty_graphics_ack("\x1b_Gi=99;OK\x1b\\", &mut caps);
3315 assert!(!caps.kitty_graphics);
3316 parse_kitty_graphics_ack("garbage", &mut caps);
3318 assert!(!caps.kitty_graphics);
3319 }
3320
3321 #[cfg(feature = "crossterm")]
3322 #[test]
3323 fn parse_decrpm_sync_output_recognized_states_are_supported() {
3324 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;1$y"), Some(true));
3327 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;2$y"), Some(true));
3328 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;3$y"), Some(true));
3329 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;4$y"), Some(true));
3330 }
3331
3332 #[cfg(feature = "crossterm")]
3333 #[test]
3334 fn parse_decrpm_sync_output_ps0_is_unsupported() {
3335 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;0$y"), Some(false));
3337 }
3338
3339 #[cfg(feature = "crossterm")]
3340 #[test]
3341 fn parse_decrpm_sync_output_garbage_is_none() {
3342 assert_eq!(parse_decrpm_sync_output("not a decrpm reply"), None);
3344 assert_eq!(parse_decrpm_sync_output("\x1b[?2004;1$y"), None);
3346 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;1"), None);
3348 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;x$y"), None);
3350 }
3351
3352 #[test]
3353 fn sync_output_gate_defaults_to_emit() {
3354 assert!(should_emit_synchronized_update());
3359 }
3360
3361 #[cfg(feature = "crossterm")]
3362 #[test]
3363 fn parse_xtgettcap_tc_sets_truecolor() {
3364 let mut caps = Capabilities::default();
3365 parse_xtgettcap_truecolor("\x1bP1+r5463=\x1b\\", &mut caps);
3367 assert!(caps.truecolor);
3368 }
3369
3370 #[cfg(feature = "crossterm")]
3371 #[test]
3372 fn parse_xtgettcap_invalid_leaves_truecolor_false() {
3373 let mut caps = Capabilities::default();
3374 parse_xtgettcap_truecolor("\x1bP0+r5463\x1b\\", &mut caps);
3376 assert!(!caps.truecolor);
3377 parse_xtgettcap_truecolor("\x1bP1+r1234=\x1b\\", &mut caps);
3379 assert!(!caps.truecolor);
3380 }
3381
3382 #[cfg(feature = "crossterm")]
3383 #[test]
3384 fn base64_decode_round_trip_hello() {
3385 let encoded = base64_encode("hello".as_bytes());
3386 assert_eq!(base64_decode(&encoded), Some("hello".to_string()));
3387 }
3388
3389 #[cfg(feature = "crossterm")]
3390 #[test]
3391 fn color_scheme_equality() {
3392 assert_eq!(ColorScheme::Dark, ColorScheme::Dark);
3393 assert_ne!(ColorScheme::Dark, ColorScheme::Light);
3394 assert_eq!(ColorScheme::Unknown, ColorScheme::Unknown);
3395 }
3396
3397 fn pair(r: Rect) -> (Rect, Rect) {
3398 (r, r)
3399 }
3400
3401 #[test]
3402 fn find_innermost_rect_picks_smallest() {
3403 let rects = vec![
3404 pair(Rect::new(0, 0, 80, 24)),
3405 pair(Rect::new(5, 2, 30, 10)),
3406 pair(Rect::new(10, 4, 10, 5)),
3407 ];
3408 let result = find_innermost_rect(&rects, 12, 5);
3409 assert_eq!(result, Some(Rect::new(10, 4, 10, 5)));
3410 }
3411
3412 #[test]
3413 fn find_innermost_rect_no_match() {
3414 let rects = vec![pair(Rect::new(10, 10, 5, 5))];
3415 assert_eq!(find_innermost_rect(&rects, 0, 0), None);
3416 }
3417
3418 #[test]
3419 fn find_innermost_rect_empty() {
3420 assert_eq!(find_innermost_rect(&[], 5, 5), None);
3421 }
3422
3423 #[test]
3424 fn find_innermost_rect_returns_content_rect() {
3425 let rects = vec![
3426 (Rect::new(0, 0, 80, 24), Rect::new(1, 1, 78, 22)),
3427 (Rect::new(5, 2, 30, 10), Rect::new(6, 3, 28, 8)),
3428 ];
3429 let result = find_innermost_rect(&rects, 10, 5);
3430 assert_eq!(result, Some(Rect::new(6, 3, 28, 8)));
3431 }
3432
3433 #[test]
3434 fn normalize_selection_already_ordered() {
3435 let (s, e) = normalize_selection((2, 1), (5, 3));
3436 assert_eq!(s, (2, 1));
3437 assert_eq!(e, (5, 3));
3438 }
3439
3440 #[test]
3441 fn normalize_selection_reversed() {
3442 let (s, e) = normalize_selection((5, 3), (2, 1));
3443 assert_eq!(s, (2, 1));
3444 assert_eq!(e, (5, 3));
3445 }
3446
3447 #[test]
3448 fn normalize_selection_same_row() {
3449 let (s, e) = normalize_selection((10, 5), (3, 5));
3450 assert_eq!(s, (3, 5));
3451 assert_eq!(e, (10, 5));
3452 }
3453
3454 #[test]
3455 fn selection_state_mouse_down_finds_rect() {
3456 let hit_map = vec![pair(Rect::new(0, 0, 80, 24)), pair(Rect::new(5, 2, 20, 10))];
3457 let mut sel = SelectionState::default();
3458 sel.mouse_down(10, 5, &hit_map);
3459 assert_eq!(sel.anchor, Some((10, 5)));
3460 assert_eq!(sel.current, Some((10, 5)));
3461 assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 20, 10)));
3462 assert!(!sel.active);
3463 }
3464
3465 #[test]
3466 fn selection_state_drag_activates() {
3467 let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
3468 let mut sel = SelectionState {
3469 anchor: Some((10, 5)),
3470 current: Some((10, 5)),
3471 widget_rect: Some(Rect::new(0, 0, 80, 24)),
3472 ..Default::default()
3473 };
3474 sel.mouse_drag(10, 5, &hit_map);
3475 assert!(!sel.active, "no movement = not active");
3476 sel.mouse_drag(11, 5, &hit_map);
3477 assert!(!sel.active, "1 cell horizontal = not active yet");
3478 sel.mouse_drag(13, 5, &hit_map);
3479 assert!(sel.active, ">1 cell horizontal = active");
3480 }
3481
3482 #[test]
3483 fn selection_state_drag_vertical_activates() {
3484 let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
3485 let mut sel = SelectionState {
3486 anchor: Some((10, 5)),
3487 current: Some((10, 5)),
3488 widget_rect: Some(Rect::new(0, 0, 80, 24)),
3489 ..Default::default()
3490 };
3491 sel.mouse_drag(10, 6, &hit_map);
3492 assert!(sel.active, "any vertical movement = active");
3493 }
3494
3495 #[test]
3496 fn selection_state_drag_expands_widget_rect() {
3497 let hit_map = vec![
3498 pair(Rect::new(0, 0, 80, 24)),
3499 pair(Rect::new(5, 2, 30, 10)),
3500 pair(Rect::new(5, 2, 30, 3)),
3501 ];
3502 let mut sel = SelectionState {
3503 anchor: Some((10, 3)),
3504 current: Some((10, 3)),
3505 widget_rect: Some(Rect::new(5, 2, 30, 3)),
3506 ..Default::default()
3507 };
3508 sel.mouse_drag(10, 6, &hit_map);
3509 assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 30, 10)));
3510 }
3511
3512 #[test]
3513 fn selection_state_clear_resets() {
3514 let mut sel = SelectionState {
3515 anchor: Some((1, 2)),
3516 current: Some((3, 4)),
3517 widget_rect: Some(Rect::new(0, 0, 10, 10)),
3518 active: true,
3519 };
3520 sel.clear();
3521 assert_eq!(sel.anchor, None);
3522 assert_eq!(sel.current, None);
3523 assert_eq!(sel.widget_rect, None);
3524 assert!(!sel.active);
3525 }
3526
3527 #[test]
3528 fn extract_selection_text_single_line() {
3529 let area = Rect::new(0, 0, 20, 5);
3530 let mut buf = Buffer::empty(area);
3531 buf.set_string(0, 0, "Hello World", Style::default());
3532 let sel = SelectionState {
3533 anchor: Some((0, 0)),
3534 current: Some((4, 0)),
3535 widget_rect: Some(area),
3536 active: true,
3537 };
3538 let text = extract_selection_text(&buf, &sel, &[]);
3539 assert_eq!(text, "Hello");
3540 }
3541
3542 #[test]
3543 fn extract_selection_text_multi_line() {
3544 let area = Rect::new(0, 0, 20, 5);
3545 let mut buf = Buffer::empty(area);
3546 buf.set_string(0, 0, "Line one", Style::default());
3547 buf.set_string(0, 1, "Line two", Style::default());
3548 buf.set_string(0, 2, "Line three", Style::default());
3549 let sel = SelectionState {
3550 anchor: Some((5, 0)),
3551 current: Some((3, 2)),
3552 widget_rect: Some(area),
3553 active: true,
3554 };
3555 let text = extract_selection_text(&buf, &sel, &[]);
3556 assert_eq!(text, "one\nLine two\nLine");
3557 }
3558
3559 #[test]
3560 fn extract_selection_text_clamped_to_widget() {
3561 let area = Rect::new(0, 0, 40, 10);
3562 let widget = Rect::new(5, 2, 10, 3);
3563 let mut buf = Buffer::empty(area);
3564 buf.set_string(5, 2, "ABCDEFGHIJ", Style::default());
3565 buf.set_string(5, 3, "KLMNOPQRST", Style::default());
3566 let sel = SelectionState {
3567 anchor: Some((3, 1)),
3568 current: Some((20, 5)),
3569 widget_rect: Some(widget),
3570 active: true,
3571 };
3572 let text = extract_selection_text(&buf, &sel, &[]);
3573 assert_eq!(text, "ABCDEFGHIJ\nKLMNOPQRST");
3574 }
3575
3576 #[test]
3577 fn extract_selection_text_inactive_returns_empty() {
3578 let area = Rect::new(0, 0, 10, 5);
3579 let buf = Buffer::empty(area);
3580 let sel = SelectionState {
3581 anchor: Some((0, 0)),
3582 current: Some((5, 2)),
3583 widget_rect: Some(area),
3584 active: false,
3585 };
3586 assert_eq!(extract_selection_text(&buf, &sel, &[]), "");
3587 }
3588
3589 #[test]
3590 fn apply_selection_overlay_reverses_cells() {
3591 let area = Rect::new(0, 0, 10, 3);
3592 let mut buf = Buffer::empty(area);
3593 buf.set_string(0, 0, "ABCDE", Style::default());
3594 let sel = SelectionState {
3595 anchor: Some((1, 0)),
3596 current: Some((3, 0)),
3597 widget_rect: Some(area),
3598 active: true,
3599 };
3600 apply_selection_overlay(&mut buf, &sel, &[]);
3601 assert!(!buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED));
3602 assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
3603 assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
3604 assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
3605 assert!(!buf.get(4, 0).style.modifiers.contains(Modifiers::REVERSED));
3606 }
3607
3608 #[test]
3609 fn extract_selection_text_skips_border_cells() {
3610 let area = Rect::new(0, 0, 40, 5);
3615 let mut buf = Buffer::empty(area);
3616 buf.set_string(0, 0, "╭", Style::default());
3618 buf.set_string(0, 1, "│", Style::default());
3619 buf.set_string(0, 2, "│", Style::default());
3620 buf.set_string(0, 3, "│", Style::default());
3621 buf.set_string(0, 4, "╰", Style::default());
3622 buf.set_string(19, 0, "╮", Style::default());
3623 buf.set_string(19, 1, "│", Style::default());
3624 buf.set_string(19, 2, "│", Style::default());
3625 buf.set_string(19, 3, "│", Style::default());
3626 buf.set_string(19, 4, "╯", Style::default());
3627 buf.set_string(20, 0, "╭", Style::default());
3629 buf.set_string(20, 1, "│", Style::default());
3630 buf.set_string(20, 2, "│", Style::default());
3631 buf.set_string(20, 3, "│", Style::default());
3632 buf.set_string(20, 4, "╰", Style::default());
3633 buf.set_string(39, 0, "╮", Style::default());
3634 buf.set_string(39, 1, "│", Style::default());
3635 buf.set_string(39, 2, "│", Style::default());
3636 buf.set_string(39, 3, "│", Style::default());
3637 buf.set_string(39, 4, "╯", Style::default());
3638 buf.set_string(1, 1, "Hello Col1", Style::default());
3640 buf.set_string(1, 2, "Line2 Col1", Style::default());
3641 buf.set_string(21, 1, "Hello Col2", Style::default());
3643 buf.set_string(21, 2, "Line2 Col2", Style::default());
3644
3645 let content_map = vec![
3646 (Rect::new(0, 0, 20, 5), Rect::new(1, 1, 18, 3)),
3647 (Rect::new(20, 0, 20, 5), Rect::new(21, 1, 18, 3)),
3648 ];
3649
3650 let sel = SelectionState {
3652 anchor: Some((0, 1)),
3653 current: Some((39, 2)),
3654 widget_rect: Some(area),
3655 active: true,
3656 };
3657 let text = extract_selection_text(&buf, &sel, &content_map);
3658 assert!(!text.contains('│'), "Border char │ found in: {text}");
3660 assert!(!text.contains('╭'), "Border char ╭ found in: {text}");
3661 assert!(!text.contains('╮'), "Border char ╮ found in: {text}");
3662 assert!(
3664 text.contains("Hello Col1"),
3665 "Missing Col1 content in: {text}"
3666 );
3667 assert!(
3668 text.contains("Hello Col2"),
3669 "Missing Col2 content in: {text}"
3670 );
3671 assert!(text.contains("Line2 Col1"), "Missing Col1 line2 in: {text}");
3672 assert!(text.contains("Line2 Col2"), "Missing Col2 line2 in: {text}");
3673 }
3674
3675 #[test]
3676 fn apply_selection_overlay_skips_border_cells() {
3677 let area = Rect::new(0, 0, 20, 3);
3678 let mut buf = Buffer::empty(area);
3679 buf.set_string(0, 0, "│", Style::default());
3680 buf.set_string(1, 0, "ABC", Style::default());
3681 buf.set_string(19, 0, "│", Style::default());
3682
3683 let content_map = vec![(Rect::new(0, 0, 20, 3), Rect::new(1, 0, 18, 3))];
3684 let sel = SelectionState {
3685 anchor: Some((0, 0)),
3686 current: Some((19, 0)),
3687 widget_rect: Some(area),
3688 active: true,
3689 };
3690 apply_selection_overlay(&mut buf, &sel, &content_map);
3691 assert!(
3693 !buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED),
3694 "Left border cell should not be reversed"
3695 );
3696 assert!(
3697 !buf.get(19, 0).style.modifiers.contains(Modifiers::REVERSED),
3698 "Right border cell should not be reversed"
3699 );
3700 assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
3702 assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
3703 assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
3704 }
3705
3706 #[test]
3707 fn copy_to_clipboard_writes_osc52() {
3708 let mut output: Vec<u8> = Vec::new();
3709 copy_to_clipboard(&mut output, "test").unwrap();
3710 let s = String::from_utf8(output).unwrap();
3711 assert!(s.starts_with("\x1b]52;c;"));
3712 assert!(s.ends_with("\x1b\\"));
3713 assert!(s.contains(&base64_encode(b"test")));
3714 }
3715
3716 fn count_move_tos(s: &str) -> usize {
3718 let bytes = s.as_bytes();
3719 let mut count = 0;
3720 let mut i = 0;
3721 while i + 1 < bytes.len() {
3722 if bytes[i] == 0x1b && bytes[i + 1] == b'[' {
3723 let mut j = i + 2;
3725 while j < bytes.len() && !(0x40..=0x7e).contains(&bytes[j]) {
3726 j += 1;
3727 }
3728 if j < bytes.len() && bytes[j] == b'H' {
3729 count += 1;
3730 }
3731 i = j + 1;
3732 } else {
3733 i += 1;
3734 }
3735 }
3736 count
3737 }
3738
3739 #[test]
3740 fn flush_coalesces_consecutive_same_style_cells_into_one_run() {
3741 let area = Rect::new(0, 0, 20, 1);
3743 let mut current = Buffer::empty(area);
3744 let previous = Buffer::empty(area);
3745 let style = Style::new().fg(Color::Red);
3746 for x in 0..10u32 {
3747 let cell = current.get_mut(x, 0);
3748 cell.set_char('X');
3749 cell.set_style(style);
3750 }
3751
3752 let mut out: Vec<u8> = Vec::new();
3753 flush_buffer_diff(
3754 &mut out,
3755 ¤t,
3756 &previous,
3757 ColorDepth::TrueColor,
3758 0,
3759 &mut String::new(),
3760 )
3761 .unwrap();
3762 let s = String::from_utf8(out).unwrap();
3763
3764 assert_eq!(
3766 count_move_tos(&s),
3767 1,
3768 "expected 1 MoveTo for a coalesced run, got {} in {:?}",
3769 count_move_tos(&s),
3770 s
3771 );
3772 assert!(
3774 s.contains("XXXXXXXXXX"),
3775 "expected contiguous run 'XXXXXXXXXX' in {:?}",
3776 s
3777 );
3778 }
3779
3780 #[test]
3781 fn flush_breaks_run_on_style_change() {
3782 let area = Rect::new(0, 0, 20, 1);
3784 let mut current = Buffer::empty(area);
3785 let previous = Buffer::empty(area);
3786 let red = Style::new().fg(Color::Red);
3787 let blue = Style::new().fg(Color::Blue);
3788 for x in 0..5u32 {
3789 let cell = current.get_mut(x, 0);
3790 cell.set_char('R');
3791 cell.set_style(red);
3792 }
3793 for x in 5..10u32 {
3794 let cell = current.get_mut(x, 0);
3795 cell.set_char('B');
3796 cell.set_style(blue);
3797 }
3798
3799 let mut out: Vec<u8> = Vec::new();
3800 flush_buffer_diff(
3801 &mut out,
3802 ¤t,
3803 &previous,
3804 ColorDepth::TrueColor,
3805 0,
3806 &mut String::new(),
3807 )
3808 .unwrap();
3809 let s = String::from_utf8(out).unwrap();
3810
3811 let moves = count_move_tos(&s);
3815 assert!(
3816 moves <= 2,
3817 "expected at most 2 MoveTos across a style boundary, got {} in {:?}",
3818 moves,
3819 s
3820 );
3821 assert!(s.contains("RRRRR"), "missing 'RRRRR' run in {:?}", s);
3822 assert!(s.contains("BBBBB"), "missing 'BBBBB' run in {:?}", s);
3823 }
3824
3825 #[test]
3826 fn flush_breaks_run_on_column_gap() {
3827 let area = Rect::new(0, 0, 20, 1);
3829 let mut current = Buffer::empty(area);
3830 let previous = Buffer::empty(area);
3831 let style = Style::new().fg(Color::Green);
3832 for x in 0..3u32 {
3833 current.get_mut(x, 0).set_char('A').set_style(style);
3834 }
3835 for x in 6..9u32 {
3836 current.get_mut(x, 0).set_char('B').set_style(style);
3837 }
3838
3839 let mut out: Vec<u8> = Vec::new();
3840 flush_buffer_diff(
3841 &mut out,
3842 ¤t,
3843 &previous,
3844 ColorDepth::TrueColor,
3845 0,
3846 &mut String::new(),
3847 )
3848 .unwrap();
3849 let s = String::from_utf8(out).unwrap();
3850
3851 assert_eq!(
3853 count_move_tos(&s),
3854 2,
3855 "expected 2 MoveTos across a column gap, got {} in {:?}",
3856 count_move_tos(&s),
3857 s
3858 );
3859 assert!(s.contains("AAA"), "missing 'AAA' run in {:?}", s);
3860 assert!(s.contains("BBB"), "missing 'BBB' run in {:?}", s);
3861 }
3862
3863 #[test]
3867 fn bufwriter_output_identical_to_direct_write() {
3868 let area = Rect::new(0, 0, 5, 1);
3869 let mut current = Buffer::empty(area);
3870 let previous = Buffer::empty(area);
3871 let style = Style::new().fg(Color::Rgb(255, 128, 0));
3872 for x in 0..5u32 {
3873 current.get_mut(x, 0).set_char('X').set_style(style);
3874 }
3875
3876 let mut direct: Vec<u8> = Vec::new();
3877 flush_buffer_diff(
3878 &mut direct,
3879 ¤t,
3880 &previous,
3881 ColorDepth::TrueColor,
3882 0,
3883 &mut String::new(),
3884 )
3885 .unwrap();
3886
3887 let mut buffered: BufWriter<Vec<u8>> = BufWriter::with_capacity(65536, Vec::new());
3888 flush_buffer_diff(
3889 &mut buffered,
3890 ¤t,
3891 &previous,
3892 ColorDepth::TrueColor,
3893 0,
3894 &mut String::new(),
3895 )
3896 .unwrap();
3897 buffered.flush().unwrap();
3898 let via_buf = buffered.into_inner().unwrap();
3899
3900 assert_eq!(
3901 direct, via_buf,
3902 "BufWriter output must be byte-for-byte identical to direct write"
3903 );
3904 }
3905
3906 #[test]
3910 fn bufwriter_coalesces_writes_into_single_flush() {
3911 #[derive(Debug)]
3912 struct CountingWriter {
3913 buf: Vec<u8>,
3914 write_call_count: usize,
3915 }
3916 impl Write for CountingWriter {
3917 fn write(&mut self, data: &[u8]) -> io::Result<usize> {
3918 self.write_call_count += 1;
3919 self.buf.extend_from_slice(data);
3920 Ok(data.len())
3921 }
3922 fn flush(&mut self) -> io::Result<()> {
3923 Ok(())
3924 }
3925 }
3926
3927 let area = Rect::new(0, 0, 10, 1);
3928 let mut current = Buffer::empty(area);
3929 let previous = Buffer::empty(area);
3930 for x in 0..10u32 {
3932 let color = if x % 2 == 0 {
3933 Color::Rgb(255, 0, 0)
3934 } else {
3935 Color::Rgb(0, 255, 0)
3936 };
3937 current
3938 .get_mut(x, 0)
3939 .set_char('Z')
3940 .set_style(Style::new().fg(color));
3941 }
3942
3943 let sink = CountingWriter {
3944 buf: Vec::new(),
3945 write_call_count: 0,
3946 };
3947 let mut bw = BufWriter::with_capacity(65536, sink);
3948 flush_buffer_diff(
3949 &mut bw,
3950 ¤t,
3951 &previous,
3952 ColorDepth::TrueColor,
3953 0,
3954 &mut String::new(),
3955 )
3956 .unwrap();
3957 bw.flush().unwrap();
3958 let inner = bw.into_inner().unwrap();
3959
3960 assert_eq!(
3962 inner.write_call_count, 1,
3963 "expected 1 write syscall to sink, got {}",
3964 inner.write_call_count
3965 );
3966 }
3967
3968 #[test]
3974 fn flush_skips_unchanged_rows_when_hashes_match() {
3975 let area = Rect::new(0, 0, 20, 4);
3976 let mut current = Buffer::empty(area);
3977 let mut previous = Buffer::empty(area);
3978 for y in 0..4u32 {
3980 current.set_string(0, y, "identical-row-content", Style::new());
3981 previous.set_string(0, y, "identical-row-content", Style::new());
3982 }
3983 current.recompute_line_hashes();
3984 previous.recompute_line_hashes();
3985
3986 let mut out: Vec<u8> = Vec::new();
3987 flush_buffer_diff(
3988 &mut out,
3989 ¤t,
3990 &previous,
3991 ColorDepth::TrueColor,
3992 0,
3993 &mut String::new(),
3994 )
3995 .unwrap();
3996 assert!(
3997 out.is_empty(),
3998 "identical buffers must emit zero flush bytes; got {} bytes: {:?}",
3999 out.len(),
4000 out
4001 );
4002 }
4003
4004 #[test]
4008 fn flush_skips_only_matching_rows_in_mixed_diff() {
4009 let area = Rect::new(0, 0, 6, 3);
4010 let mut current = Buffer::empty(area);
4011 let mut previous = Buffer::empty(area);
4012 current.set_string(0, 0, "abcdef", Style::new());
4013 previous.set_string(0, 0, "abcdef", Style::new());
4014 current.set_string(0, 1, "xxxxxx", Style::new());
4015 previous.set_string(0, 1, "yyyyyy", Style::new());
4016 current.set_string(0, 2, "zzzzzz", Style::new());
4017 previous.set_string(0, 2, "zzzzzz", Style::new());
4018 current.recompute_line_hashes();
4019 previous.recompute_line_hashes();
4020
4021 let mut out: Vec<u8> = Vec::new();
4022 flush_buffer_diff(
4023 &mut out,
4024 ¤t,
4025 &previous,
4026 ColorDepth::TrueColor,
4027 0,
4028 &mut String::new(),
4029 )
4030 .unwrap();
4031 let s = String::from_utf8_lossy(&out);
4032 assert!(s.contains("xxxxxx"), "differing row must flush: {s:?}");
4035 assert!(
4036 !s.contains("abcdef"),
4037 "matching row 0 must not flush: {s:?}"
4038 );
4039 assert!(
4040 !s.contains("zzzzzz"),
4041 "matching row 2 must not flush: {s:?}"
4042 );
4043 }
4044
4045 fn delta_bytes(old: &Style, new: &Style) -> Vec<u8> {
4046 let mut out = Vec::new();
4047 apply_style_delta(&mut out, old, new, ColorDepth::TrueColor).unwrap();
4048 out
4049 }
4050
4051 fn contains_seq(haystack: &[u8], needle: &[u8]) -> bool {
4052 haystack.windows(needle.len()).any(|w| w == needle)
4053 }
4054
4055 #[test]
4056 fn apply_style_delta_emits_blink_set_and_reset() {
4057 let on = delta_bytes(&Style::new(), &Style::new().blink());
4058 assert!(contains_seq(&on, b"\x1b[5m"), "blink set: {on:?}");
4060 let off = delta_bytes(&Style::new().blink(), &Style::new());
4061 assert!(contains_seq(&off, b"\x1b[25m"), "blink reset: {off:?}");
4063 }
4064
4065 #[test]
4066 fn apply_style_delta_emits_overline_set_and_reset() {
4067 let on = delta_bytes(&Style::new(), &Style::new().overline());
4068 assert!(contains_seq(&on, b"\x1b[53m"), "overline set: {on:?}");
4070 let off = delta_bytes(&Style::new().overline(), &Style::new());
4071 assert!(contains_seq(&off, b"\x1b[55m"), "overline reset: {off:?}");
4073 }
4074
4075 #[test]
4076 fn apply_style_delta_emits_curly_underline_subparameter() {
4077 let out = delta_bytes(
4078 &Style::new(),
4079 &Style::new().underline_style(UnderlineStyle::Curly),
4080 );
4081 assert!(contains_seq(&out, b"\x1b[4:3m"), "curly underline: {out:?}");
4082 }
4083
4084 #[test]
4085 fn apply_style_delta_emits_underline_color_and_reset() {
4086 let set = delta_bytes(
4087 &Style::new(),
4088 &Style::new().underline_color(Color::Rgb(255, 0, 0)),
4089 );
4090 assert!(
4091 contains_seq(&set, b"\x1b[58:2::255:0:0m"),
4092 "underline color set: {set:?}"
4093 );
4094 let clear = delta_bytes(
4095 &Style::new().underline_color(Color::Rgb(255, 0, 0)),
4096 &Style::new(),
4097 );
4098 assert!(
4099 contains_seq(&clear, b"\x1b[59m"),
4100 "underline color reset: {clear:?}"
4101 );
4102 }
4103
4104 #[test]
4105 fn apply_style_delta_underline_color_indexed_uses_sgr_58_5() {
4106 let out = delta_bytes(
4107 &Style::new(),
4108 &Style::new().underline_color(Color::Indexed(42)),
4109 );
4110 assert!(
4111 contains_seq(&out, b"\x1b[58:5:42m"),
4112 "indexed underline: {out:?}"
4113 );
4114 }
4115
4116 #[test]
4117 fn apply_style_full_emits_blink_overline_and_underline() {
4118 let mut out = Vec::new();
4119 let style = Style::new()
4120 .blink()
4121 .overline()
4122 .underline_style(UnderlineStyle::Dotted)
4123 .underline_color(Color::Rgb(0, 0, 255));
4124 apply_style(&mut out, &style, ColorDepth::TrueColor).unwrap();
4125 assert!(contains_seq(&out, b"\x1b[5m"), "blink: {out:?}");
4126 assert!(contains_seq(&out, b"\x1b[53m"), "overline: {out:?}");
4127 assert!(
4128 contains_seq(&out, b"\x1b[4:4m"),
4129 "dotted underline: {out:?}"
4130 );
4131 assert!(
4132 contains_seq(&out, b"\x1b[58:2::0:0:255m"),
4133 "underline color: {out:?}"
4134 );
4135 }
4136 #[test]
4140 fn with_sink_captures_flush_bytes_and_drops_clean() {
4141 let mut term = Terminal::with_sink(10, 1, ColorDepth::TrueColor);
4142 term.buffer_mut()
4143 .set_string(0, 0, "Z", Style::new().fg(Color::Rgb(200, 50, 50)));
4144 term.flush().unwrap();
4145 let bytes = term.take_sink_bytes();
4146 let s = String::from_utf8_lossy(&bytes);
4147 assert!(s.contains("\u{1b}[38;2;200;50;50m"), "missing SGR: {s:?}");
4149 assert!(s.contains('Z'), "missing glyph: {s:?}");
4150 assert!(term.take_sink_bytes().is_empty());
4152 drop(term);
4154 }
4155
4156 #[test]
4161 fn reused_run_buf_byte_identical_across_frames() {
4162 let area = Rect::new(0, 0, 12, 2);
4163 let make_frame = || {
4165 let mut current = Buffer::empty(area);
4166 let previous = Buffer::empty(area);
4167 current.set_string(0, 0, "hello world", Style::new().fg(Color::Rgb(1, 2, 3)));
4168 current.set_string(0, 1, "second line", Style::new().fg(Color::Rgb(4, 5, 6)));
4169 (current, previous)
4170 };
4171
4172 let mut baseline: Vec<u8> = Vec::new();
4174 {
4175 let (mut a, mut b) = make_frame();
4176 __bench_flush_buffer_diff_mut_with_buf(
4177 &mut baseline,
4178 &mut a,
4179 &mut b,
4180 ColorDepth::TrueColor,
4181 &mut String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
4182 )
4183 .unwrap();
4184 }
4185
4186 let mut shared = String::with_capacity(RUN_BUF_INITIAL_CAPACITY);
4189 {
4190 let mut warm: Vec<u8> = Vec::new();
4191 let (mut a, mut b) = make_frame();
4192 __bench_flush_buffer_diff_mut_with_buf(
4193 &mut warm,
4194 &mut a,
4195 &mut b,
4196 ColorDepth::TrueColor,
4197 &mut shared,
4198 )
4199 .unwrap();
4200 }
4201 let cap_after_warm = shared.capacity();
4202
4203 let mut reused: Vec<u8> = Vec::new();
4204 let (mut current, mut previous) = make_frame();
4205 __bench_flush_buffer_diff_mut_with_buf(
4206 &mut reused,
4207 &mut current,
4208 &mut previous,
4209 ColorDepth::TrueColor,
4210 &mut shared,
4211 )
4212 .unwrap();
4213
4214 assert_eq!(
4215 baseline, reused,
4216 "reused run_buf must emit byte-identical output"
4217 );
4218 assert!(
4221 shared.capacity() >= cap_after_warm,
4222 "run_buf capacity must persist across frames"
4223 );
4224 }
4225
4226 #[test]
4230 fn osc8_hyperlink_emitted_verbatim_after_write_rewrite() {
4231 let area = Rect::new(0, 0, 8, 1);
4232 let mut current = Buffer::empty(area);
4233 let previous = Buffer::empty(area);
4234 let url = "https://example.com/x";
4235 current.set_string_linked(0, 0, "link", Style::new(), url);
4237
4238 let mut out: Vec<u8> = Vec::new();
4239 flush_buffer_diff(
4240 &mut out,
4241 ¤t,
4242 &previous,
4243 ColorDepth::TrueColor,
4244 0,
4245 &mut String::new(),
4246 )
4247 .unwrap();
4248
4249 let open = format!("\x1b]8;;{url}\x07");
4250 assert!(
4251 contains_seq(&out, open.as_bytes()),
4252 "OSC 8 open must appear verbatim: {:?}",
4253 String::from_utf8_lossy(&out)
4254 );
4255 assert!(
4256 contains_seq(&out, b"\x1b]8;;\x07"),
4257 "OSC 8 close must appear: {:?}",
4258 String::from_utf8_lossy(&out)
4259 );
4260 }
4261
4262 fn kitty_placements(n: usize) -> Vec<KittyPlacement> {
4264 (0..n)
4265 .map(|i| {
4266 let mut rgba = vec![0u8; 256];
4267 rgba[0] = i as u8;
4268 let content_hash = crate::buffer::hash_rgba(&rgba);
4269 KittyPlacement {
4270 content_hash,
4271 rgba: std::sync::Arc::new(rgba),
4272 src_width: 8,
4273 src_height: 8,
4274 x: (i as u32) * 4,
4275 y: (i as u32) * 2,
4276 cols: 4,
4277 rows: 2,
4278 crop_y: 0,
4279 crop_h: 0,
4280 }
4281 })
4282 .collect()
4283 }
4284
4285 #[test]
4291 fn kitty_flush_smallvec_dedup_matches_for_small_n() {
4292 for n in [0usize, 1, 5] {
4293 let placements = kitty_placements(n);
4294 let mut mgr = KittyImageManager::new();
4295
4296 let mut frame1: Vec<u8> = Vec::new();
4298 mgr.flush(&mut frame1, &placements, 0).unwrap();
4299 let s1 = String::from_utf8_lossy(&frame1);
4300 assert_eq!(
4302 s1.matches("a=t,").count(),
4303 n,
4304 "n={n}: expected {n} uploads in frame 1: {s1:?}"
4305 );
4306 assert_eq!(
4307 s1.matches("a=p,").count(),
4308 n,
4309 "n={n}: expected {n} placements in frame 1: {s1:?}"
4310 );
4311
4312 let mut frame2: Vec<u8> = Vec::new();
4314 mgr.flush(&mut frame2, &placements, 0).unwrap();
4315 assert!(
4316 frame2.is_empty(),
4317 "n={n}: identical frame must hit the kitty fast path, got {} bytes",
4318 frame2.len()
4319 );
4320
4321 let mut frame3: Vec<u8> = Vec::new();
4325 mgr.flush(&mut frame3, &[], 0).unwrap();
4326 let s3 = String::from_utf8_lossy(&frame3);
4327 assert_eq!(
4328 s3.matches("a=d,d=i,").count(),
4329 n,
4330 "n={n}: expected {n} placement deletes in frame 3: {s3:?}"
4331 );
4332 assert_eq!(
4333 s3.matches("a=d,d=I,").count(),
4334 n,
4335 "n={n}: expected {n} image-data deletes in frame 3: {s3:?}"
4336 );
4337 }
4338 }
4339
4340 use crate::buffer::{SprixelCell, SprixelPlacement};
4343
4344 fn make_sprixel(cells: Vec<SprixelCell>) -> SprixelPlacement {
4346 SprixelPlacement {
4347 content_hash: 0xABCD,
4348 seq: "<SIXEL>".to_string(),
4349 x: 1,
4350 y: 1,
4351 cols: 2,
4352 rows: 2,
4353 cells,
4354 }
4355 }
4356
4357 #[test]
4358 fn sprixel_no_text_change_emits_zero_bytes() {
4359 let area = Rect::new(0, 0, 10, 5);
4361 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
4362
4363 let mut current = Buffer::empty(area);
4364 current.sprixels.push(placement.clone());
4365 let mut previous = Buffer::empty(area);
4366 previous.sprixels.push(placement);
4367
4368 let mut out: Vec<u8> = Vec::new();
4369 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4370 assert!(out.is_empty(), "stable frame should emit no sprixel bytes");
4371 }
4372
4373 #[test]
4374 fn sprixel_first_frame_blits_once() {
4375 let area = Rect::new(0, 0, 10, 5);
4377 let mut current = Buffer::empty(area);
4378 current
4379 .sprixels
4380 .push(make_sprixel(vec![SprixelCell::Opaque; 4]));
4381 let previous = Buffer::empty(area);
4382
4383 let mut out: Vec<u8> = Vec::new();
4384 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4385 let s = String::from_utf8(out).unwrap();
4386 assert_eq!(s.matches("<SIXEL>").count(), 1);
4387 }
4388
4389 #[test]
4390 fn sprixel_text_in_opaque_cell_reblits_once() {
4391 let area = Rect::new(0, 0, 10, 5);
4393 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
4394
4395 let mut current = Buffer::empty(area);
4396 current.sprixels.push(placement.clone());
4397 current.set_char(1, 1, 'X', Style::new());
4399
4400 let mut previous = Buffer::empty(area);
4401 previous.sprixels.push(placement);
4402
4403 let mut out: Vec<u8> = Vec::new();
4404 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4405 let s = String::from_utf8(out).unwrap();
4406 assert_eq!(
4407 s.matches("<SIXEL>").count(),
4408 1,
4409 "opaque-cell text write must re-blit the graphic exactly once"
4410 );
4411 }
4412
4413 #[test]
4414 fn sprixel_text_in_transparent_cell_does_not_reblit() {
4415 let area = Rect::new(0, 0, 10, 5);
4418 let cells = vec![
4419 SprixelCell::Transparent, SprixelCell::Opaque, SprixelCell::Opaque, SprixelCell::Opaque, ];
4424 let placement = make_sprixel(cells);
4425
4426 let mut current = Buffer::empty(area);
4427 current.sprixels.push(placement.clone());
4428 current.set_char(1, 1, 'X', Style::new());
4429
4430 let mut previous = Buffer::empty(area);
4431 previous.sprixels.push(placement);
4432
4433 let mut out: Vec<u8> = Vec::new();
4434 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4435 assert!(
4436 out.is_empty(),
4437 "text in a transparent footprint cell must emit zero sprixel bytes"
4438 );
4439 }
4440
4441 #[test]
4442 fn sprixel_text_outside_footprint_does_not_reblit() {
4443 let area = Rect::new(0, 0, 10, 5);
4445 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
4446
4447 let mut current = Buffer::empty(area);
4448 current.sprixels.push(placement.clone());
4449 current.set_char(5, 0, 'Z', Style::new());
4451
4452 let mut previous = Buffer::empty(area);
4453 previous.sprixels.push(placement);
4454
4455 let mut out: Vec<u8> = Vec::new();
4456 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4457 assert!(
4458 out.is_empty(),
4459 "text outside the footprint must not re-blit the graphic"
4460 );
4461 }
4462
4463 #[test]
4464 fn sprixel_position_change_reblits() {
4465 let area = Rect::new(0, 0, 10, 5);
4467 let mut moved = make_sprixel(vec![SprixelCell::Opaque; 4]);
4468 let original = moved.clone();
4469 moved.x = 4;
4470
4471 let mut current = Buffer::empty(area);
4472 current.sprixels.push(moved);
4473 let mut previous = Buffer::empty(area);
4474 previous.sprixels.push(original);
4475
4476 let mut out: Vec<u8> = Vec::new();
4477 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4478 let s = String::from_utf8(out).unwrap();
4479 assert_eq!(s.matches("<SIXEL>").count(), 1);
4480 }
4481
4482 #[test]
4483 fn sprixel_content_change_reblits() {
4484 let area = Rect::new(0, 0, 10, 5);
4486 let mut recolored = make_sprixel(vec![SprixelCell::Opaque; 4]);
4487 let original = recolored.clone();
4488 recolored.content_hash = 0x1234;
4489 recolored.seq = "<SIXEL2>".to_string();
4490
4491 let mut current = Buffer::empty(area);
4492 current.sprixels.push(recolored);
4493 let mut previous = Buffer::empty(area);
4494 previous.sprixels.push(original);
4495
4496 let mut out: Vec<u8> = Vec::new();
4497 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4498 let s = String::from_utf8(out).unwrap();
4499 assert_eq!(s.matches("<SIXEL2>").count(), 1);
4500 }
4501
4502 #[test]
4503 fn sprixel_reblit_count_invariant_over_single_cell_writes() {
4504 let area = Rect::new(0, 0, 10, 5);
4508 for (idx, (col, row)) in [(0u32, 0u32), (1, 0), (0, 1), (1, 1)]
4509 .into_iter()
4510 .enumerate()
4511 {
4512 for state in [
4513 SprixelCell::Opaque,
4514 SprixelCell::Mixed,
4515 SprixelCell::Transparent,
4516 ] {
4517 let mut cells = vec![SprixelCell::Opaque; 4];
4518 cells[idx] = state;
4519 let placement = make_sprixel(cells);
4520
4521 let mut current = Buffer::empty(area);
4522 current.sprixels.push(placement.clone());
4523 current.set_char(1 + col, 1 + row, 'A', Style::new());
4524
4525 let mut previous = Buffer::empty(area);
4526 previous.sprixels.push(placement);
4527
4528 let mut out: Vec<u8> = Vec::new();
4529 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4530 let count = String::from_utf8(out).unwrap().matches("<SIXEL>").count();
4531 let expected = if matches!(state, SprixelCell::Transparent) {
4532 0
4533 } else {
4534 1
4535 };
4536 assert_eq!(
4537 count, expected,
4538 "cell ({col},{row}) state {state:?}: expected {expected} re-blits"
4539 );
4540 }
4541 }
4542 }
4543
4544 #[test]
4551 fn sprixel_unchanged_with_hashes_engaged_emits_zero_bytes() {
4552 let area = Rect::new(0, 0, 10, 5);
4557 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
4558
4559 let mut current = Buffer::empty(area);
4560 current.sprixels.push(placement.clone());
4561 let mut previous = Buffer::empty(area);
4562 previous.sprixels.push(placement);
4563
4564 current.recompute_line_hashes();
4566 previous.recompute_line_hashes();
4567 assert!(current.row_clean(1) && current.row_clean(2));
4570 assert_eq!(current.row_hash(1), previous.row_hash(1));
4571
4572 let mut out: Vec<u8> = Vec::new();
4573 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4574 assert!(
4575 out.is_empty(),
4576 "unchanged sprixel must not be re-blitted (per-row shortcut)"
4577 );
4578 }
4579
4580 #[test]
4581 fn sprixel_changed_text_with_hashes_engaged_reblits_once() {
4582 let area = Rect::new(0, 0, 10, 5);
4587 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
4588
4589 let mut current = Buffer::empty(area);
4590 current.sprixels.push(placement.clone());
4591 current.set_char(1, 1, 'X', Style::new());
4592 let mut previous = Buffer::empty(area);
4593 previous.sprixels.push(placement);
4594
4595 current.recompute_line_hashes();
4596 previous.recompute_line_hashes();
4597 assert_ne!(current.row_hash(1), previous.row_hash(1));
4599
4600 let mut out: Vec<u8> = Vec::new();
4601 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4602 let s = String::from_utf8(out).unwrap();
4603 assert_eq!(
4604 s.matches("<SIXEL>").count(),
4605 1,
4606 "annihilating text write must re-blit exactly once"
4607 );
4608 }
4609
4610 #[test]
4611 fn sprixel_changed_text_in_transparent_cell_with_hashes_does_not_reblit() {
4612 let area = Rect::new(0, 0, 10, 5);
4617 let cells = vec![
4618 SprixelCell::Transparent, SprixelCell::Opaque, SprixelCell::Opaque, SprixelCell::Opaque, ];
4623 let placement = make_sprixel(cells);
4624
4625 let mut current = Buffer::empty(area);
4626 current.sprixels.push(placement.clone());
4627 current.set_char(1, 1, 'X', Style::new());
4628 let mut previous = Buffer::empty(area);
4629 previous.sprixels.push(placement);
4630
4631 current.recompute_line_hashes();
4632 previous.recompute_line_hashes();
4633
4634 let mut out: Vec<u8> = Vec::new();
4635 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4636 assert!(
4637 out.is_empty(),
4638 "transparent-cell text write must not re-blit even with hashes engaged"
4639 );
4640 }
4641
4642 #[test]
4643 fn sprixel_key_matches_partial_eq_contract() {
4644 let base = make_sprixel(vec![SprixelCell::Opaque; 4]);
4648 assert_eq!(sprixel_key(&base), sprixel_key(&base.clone()));
4649
4650 let mut moved = base.clone();
4651 moved.x = 7;
4652 assert_ne!(sprixel_key(&base), sprixel_key(&moved));
4653
4654 let mut recolored = base.clone();
4655 recolored.content_hash = 0x9999;
4656 assert_ne!(sprixel_key(&base), sprixel_key(&recolored));
4657
4658 let mut annihilated = base.clone();
4660 annihilated.cells = vec![SprixelCell::Annihilated; 4];
4661 assert_eq!(sprixel_key(&base), sprixel_key(&annihilated));
4662 assert_eq!(base, annihilated);
4663 }
4664
4665 #[test]
4666 fn sprixel_multi_placement_only_changed_one_reblits() {
4667 let area = Rect::new(0, 0, 10, 9);
4671 let mut current = Buffer::empty(area);
4672 let mut previous = Buffer::empty(area);
4673 for i in 0..3u32 {
4674 let p = SprixelPlacement {
4675 content_hash: 0x100 + i as u64,
4676 seq: format!("<S{i}>"),
4677 x: 0,
4678 y: i * 3,
4679 cols: 2,
4680 rows: 2,
4681 cells: vec![SprixelCell::Opaque; 4],
4682 };
4683 current.sprixels.push(p.clone());
4684 previous.sprixels.push(p);
4685 }
4686 current.sprixels[1].x = 5;
4688
4689 current.recompute_line_hashes();
4690 previous.recompute_line_hashes();
4691
4692 let mut out: Vec<u8> = Vec::new();
4693 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4694 let s = String::from_utf8(out).unwrap();
4695 assert_eq!(s.matches("<S0>").count(), 0);
4696 assert_eq!(
4697 s.matches("<S1>").count(),
4698 1,
4699 "only the moved sprixel reblits"
4700 );
4701 assert_eq!(s.matches("<S2>").count(), 0);
4702 }
4703
4704 #[test]
4705 fn bench_sprixel_fixture_steady_state_emits_nothing() {
4706 let fixture = __bench_new_sprixel_fixture(4);
4710 assert_eq!(fixture.len(), 4);
4711 assert!(!fixture.is_empty());
4712 let mut out: Vec<u8> = Vec::new();
4713 fixture.flush(&mut out, 0).unwrap();
4714 assert!(
4715 out.is_empty(),
4716 "steady-state bench fixture re-blits nothing"
4717 );
4718 }
4719}