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(crate) struct Terminal {
1023 stdout: Sink,
1024 current: Buffer,
1025 previous: Buffer,
1026 cursor_visible: bool,
1027 session: TerminalSessionGuard,
1028 color_depth: ColorDepth,
1029 pub(crate) theme_bg: Option<Color>,
1030 kitty_mgr: KittyImageManager,
1031 run_buf: String,
1035}
1036
1037pub(crate) struct InlineTerminal {
1038 stdout: Sink,
1039 current: Buffer,
1040 previous: Buffer,
1041 cursor_visible: bool,
1042 session: TerminalSessionGuard,
1043 height: u32,
1044 start_row: u16,
1045 reserved: bool,
1046 color_depth: ColorDepth,
1047 pub(crate) theme_bg: Option<Color>,
1048 kitty_mgr: KittyImageManager,
1049 run_buf: String,
1051}
1052
1053const RUN_BUF_INITIAL_CAPACITY: usize = 4096;
1057
1058#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1059enum TerminalSessionMode {
1060 Fullscreen,
1061 Inline,
1062}
1063
1064#[derive(Debug, Clone, Copy)]
1065struct TerminalSessionGuard {
1066 mode: TerminalSessionMode,
1067 mouse_enabled: bool,
1068 kitty_keyboard: bool,
1069 report_all_keys: bool,
1070 harness: bool,
1076}
1077
1078impl TerminalSessionGuard {
1079 fn enter(
1080 mode: TerminalSessionMode,
1081 stdout: &mut impl Write,
1082 mouse_enabled: bool,
1083 kitty_keyboard: bool,
1084 report_all_keys: bool,
1085 ) -> io::Result<Self> {
1086 let guard = Self {
1087 mode,
1088 mouse_enabled,
1089 kitty_keyboard,
1090 report_all_keys,
1091 harness: false,
1092 };
1093
1094 terminal::enable_raw_mode()?;
1095 if let Err(err) = write_session_enter(stdout, &guard) {
1096 guard.restore(stdout, false);
1097 return Err(err);
1098 }
1099
1100 let _ = capabilities();
1107
1108 Ok(guard)
1109 }
1110
1111 fn restore(&self, stdout: &mut impl Write, inline_reserved: bool) {
1112 if self.harness {
1114 return;
1115 }
1116 if self.kitty_keyboard {
1117 use crossterm::event::PopKeyboardEnhancementFlags;
1118 let _ = execute!(stdout, PopKeyboardEnhancementFlags);
1119 }
1120 if self.mouse_enabled {
1121 let _ = execute!(stdout, DisableMouseCapture);
1122 }
1123 let _ = execute!(stdout, DisableFocusChange);
1124 let _ = write_session_cleanup(stdout, self.mode, inline_reserved);
1125 let _ = terminal::disable_raw_mode();
1126 }
1127}
1128
1129impl Terminal {
1130 pub(crate) fn new(
1135 mouse: bool,
1136 kitty_keyboard: bool,
1137 report_all_keys: bool,
1138 color_depth: ColorDepth,
1139 ) -> io::Result<Self> {
1140 let (cols, rows) = terminal::size()?;
1141 let area = Rect::new(0, 0, cols as u32, rows as u32);
1142
1143 let mut raw = io::stdout();
1144 let session = TerminalSessionGuard::enter(
1145 TerminalSessionMode::Fullscreen,
1146 &mut raw,
1147 mouse,
1148 kitty_keyboard,
1149 report_all_keys,
1150 )?;
1151
1152 Ok(Self {
1153 stdout: Sink::Stdout(BufWriter::with_capacity(65536, raw)),
1154 current: Buffer::empty(area),
1155 previous: Buffer::empty(area),
1156 cursor_visible: false,
1157 session,
1158 color_depth,
1159 theme_bg: None,
1160 kitty_mgr: KittyImageManager::new(),
1161 run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
1162 })
1163 }
1164
1165 pub(crate) fn size(&self) -> (u32, u32) {
1167 (self.current.area.width, self.current.area.height)
1168 }
1169
1170 pub(crate) fn buffer_mut(&mut self) -> &mut Buffer {
1172 &mut self.current
1173 }
1174
1175 pub(crate) fn flush(&mut self) -> io::Result<()> {
1179 if self.current.area.width < self.previous.area.width {
1180 execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
1181 }
1182
1183 let sync_guard = should_emit_synchronized_update();
1187 if sync_guard {
1188 queue!(self.stdout, BeginSynchronizedUpdate)?;
1189 }
1190 self.current.recompute_line_hashes();
1195 self.previous.recompute_line_hashes();
1196 flush_buffer_diff(
1197 &mut self.stdout,
1198 &self.current,
1199 &self.previous,
1200 self.color_depth,
1201 0,
1202 &mut self.run_buf,
1203 )?;
1204
1205 self.kitty_mgr
1208 .flush(&mut self.stdout, &self.current.kitty_placements, 0)?;
1209
1210 flush_raw_sequences(&mut self.stdout, &self.current, &self.previous, 0)?;
1212
1213 flush_sprixels(&mut self.stdout, &self.current, &self.previous, 0)?;
1215
1216 if sync_guard {
1217 queue!(self.stdout, EndSynchronizedUpdate)?;
1218 }
1219 flush_cursor(
1220 &mut self.stdout,
1221 &mut self.cursor_visible,
1222 self.current.cursor_pos(),
1223 0,
1224 None,
1225 )?;
1226
1227 self.stdout.flush()?;
1228
1229 std::mem::swap(&mut self.current, &mut self.previous);
1230 if let Some(bg) = self.theme_bg {
1231 self.current.reset_with_bg(bg);
1232 } else {
1233 self.current.reset();
1234 }
1235 Ok(())
1236 }
1237
1238 pub(crate) fn handle_resize(&mut self) -> io::Result<()> {
1241 let (cols, rows) = terminal::size()?;
1242 let area = Rect::new(0, 0, cols as u32, rows as u32);
1243 self.current.resize(area);
1244 self.previous.resize(area);
1245 execute!(
1246 self.stdout,
1247 terminal::Clear(terminal::ClearType::All),
1248 cursor::MoveTo(0, 0)
1249 )?;
1250 Ok(())
1251 }
1252}
1253
1254#[cfg(any(test, feature = "pty-test"))]
1255impl Terminal {
1256 pub(crate) fn with_sink(width: u32, height: u32, color_depth: ColorDepth) -> Self {
1270 let area = Rect::new(0, 0, width, height);
1271 Self {
1272 stdout: Sink::Capture(Vec::new()),
1273 current: Buffer::empty(area),
1274 previous: Buffer::empty(area),
1275 cursor_visible: false,
1276 session: TerminalSessionGuard {
1277 mode: TerminalSessionMode::Fullscreen,
1278 mouse_enabled: false,
1279 kitty_keyboard: false,
1280 report_all_keys: false,
1281 harness: true,
1282 },
1283 color_depth,
1284 theme_bg: None,
1285 kitty_mgr: KittyImageManager::new(),
1286 run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
1287 }
1288 }
1289
1290 pub(crate) fn take_sink_bytes(&mut self) -> Vec<u8> {
1295 match &mut self.stdout {
1296 Sink::Capture(v) => std::mem::take(v),
1297 Sink::Stdout(_) => panic!("take_sink_bytes called on a non-capture Terminal"),
1298 }
1299 }
1300}
1301
1302impl crate::Backend for Terminal {
1303 fn size(&self) -> (u32, u32) {
1304 Terminal::size(self)
1305 }
1306
1307 fn buffer_mut(&mut self) -> &mut Buffer {
1308 Terminal::buffer_mut(self)
1309 }
1310
1311 fn flush(&mut self) -> io::Result<()> {
1312 Terminal::flush(self)
1313 }
1314}
1315
1316impl InlineTerminal {
1317 pub(crate) fn new(
1323 height: u32,
1324 mouse: bool,
1325 kitty_keyboard: bool,
1326 report_all_keys: bool,
1327 color_depth: ColorDepth,
1328 ) -> io::Result<Self> {
1329 let (cols, _) = terminal::size()?;
1330 let area = Rect::new(0, 0, cols as u32, height);
1331
1332 let mut raw = io::stdout();
1333 let session = TerminalSessionGuard::enter(
1334 TerminalSessionMode::Inline,
1335 &mut raw,
1336 mouse,
1337 kitty_keyboard,
1338 report_all_keys,
1339 )?;
1340
1341 let (_, cursor_row) = match cursor::position() {
1342 Ok(pos) => pos,
1343 Err(err) => {
1344 session.restore(&mut raw, false);
1345 return Err(err);
1346 }
1347 };
1348 Ok(Self {
1349 stdout: Sink::Stdout(BufWriter::with_capacity(65536, raw)),
1350 current: Buffer::empty(area),
1351 previous: Buffer::empty(area),
1352 cursor_visible: false,
1353 session,
1354 height,
1355 start_row: cursor_row,
1356 reserved: false,
1357 color_depth,
1358 theme_bg: None,
1359 kitty_mgr: KittyImageManager::new(),
1360 run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
1361 })
1362 }
1363
1364 pub(crate) fn size(&self) -> (u32, u32) {
1366 (self.current.area.width, self.current.area.height)
1367 }
1368
1369 pub(crate) fn buffer_mut(&mut self) -> &mut Buffer {
1371 &mut self.current
1372 }
1373
1374 pub(crate) fn flush(&mut self) -> io::Result<()> {
1378 if self.current.area.width < self.previous.area.width {
1379 execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
1380 }
1381
1382 let sync_guard = should_emit_synchronized_update();
1385 if sync_guard {
1386 queue!(self.stdout, BeginSynchronizedUpdate)?;
1387 }
1388
1389 if !self.reserved {
1390 queue!(self.stdout, cursor::MoveToColumn(0))?;
1391 for _ in 0..self.height {
1392 queue!(self.stdout, Print("\n"))?;
1393 }
1394 self.reserved = true;
1395
1396 let (_, rows) = terminal::size()?;
1397 let bottom = self.start_row.saturating_add(sat_u16(self.height));
1398 if bottom > rows {
1399 self.start_row = rows.saturating_sub(sat_u16(self.height));
1400 }
1401 }
1402 let row_offset = self.start_row as u32;
1403 self.current.recompute_line_hashes();
1406 self.previous.recompute_line_hashes();
1407 flush_buffer_diff(
1408 &mut self.stdout,
1409 &self.current,
1410 &self.previous,
1411 self.color_depth,
1412 row_offset,
1413 &mut self.run_buf,
1414 )?;
1415
1416 self.kitty_mgr
1422 .flush(&mut self.stdout, &self.current.kitty_placements, row_offset)?;
1423
1424 flush_raw_sequences(&mut self.stdout, &self.current, &self.previous, row_offset)?;
1426
1427 flush_sprixels(&mut self.stdout, &self.current, &self.previous, row_offset)?;
1429
1430 if sync_guard {
1431 queue!(self.stdout, EndSynchronizedUpdate)?;
1432 }
1433 let fallback_row = row_offset + self.height.saturating_sub(1);
1434 flush_cursor(
1435 &mut self.stdout,
1436 &mut self.cursor_visible,
1437 self.current.cursor_pos(),
1438 row_offset,
1439 Some(fallback_row),
1440 )?;
1441
1442 self.stdout.flush()?;
1443
1444 std::mem::swap(&mut self.current, &mut self.previous);
1445 reset_current_buffer(&mut self.current, self.theme_bg);
1446 Ok(())
1447 }
1448
1449 pub(crate) fn handle_resize(&mut self) -> io::Result<()> {
1452 let (cols, _) = terminal::size()?;
1453 let area = Rect::new(0, 0, cols as u32, self.height);
1454 self.current.resize(area);
1455 self.previous.resize(area);
1456 execute!(
1457 self.stdout,
1458 terminal::Clear(terminal::ClearType::All),
1459 cursor::MoveTo(0, 0)
1460 )?;
1461 Ok(())
1462 }
1463}
1464
1465impl crate::Backend for InlineTerminal {
1466 fn size(&self) -> (u32, u32) {
1467 InlineTerminal::size(self)
1468 }
1469
1470 fn buffer_mut(&mut self) -> &mut Buffer {
1471 InlineTerminal::buffer_mut(self)
1472 }
1473
1474 fn flush(&mut self) -> io::Result<()> {
1475 InlineTerminal::flush(self)
1476 }
1477}
1478
1479impl Drop for Terminal {
1480 fn drop(&mut self) {
1481 let _ = self.kitty_mgr.delete_all(&mut self.stdout);
1483 let _ = self.stdout.flush();
1484 self.session.restore(&mut self.stdout, false);
1485 }
1486}
1487
1488impl Drop for InlineTerminal {
1489 fn drop(&mut self) {
1490 let _ = self.kitty_mgr.delete_all(&mut self.stdout);
1491 let _ = self.stdout.flush();
1492 self.session.restore(&mut self.stdout, self.reserved);
1493 }
1494}
1495
1496mod selection;
1497pub(crate) use selection::{SelectionState, apply_selection_overlay, extract_selection_text};
1498#[cfg(test)]
1499pub(crate) use selection::{find_innermost_rect, normalize_selection};
1500
1501#[non_exhaustive]
1503#[cfg(feature = "crossterm")]
1504#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
1505#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1506pub enum ColorScheme {
1507 Dark,
1509 Light,
1511 Unknown,
1513}
1514
1515#[cfg(feature = "crossterm")]
1517fn read_osc_response(timeout: Duration) -> Option<String> {
1518 read_stdin_reply(timeout, osc_reply_complete)
1519}
1520
1521#[cfg(feature = "crossterm")]
1523#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
1524pub fn detect_color_scheme() -> ColorScheme {
1525 let mut stdout = io::stdout();
1526 if write!(stdout, "\x1b]11;?\x07").is_err() {
1527 return ColorScheme::Unknown;
1528 }
1529 if stdout.flush().is_err() {
1530 return ColorScheme::Unknown;
1531 }
1532
1533 let Some(response) = read_osc_response(Duration::from_millis(100)) else {
1534 return ColorScheme::Unknown;
1535 };
1536
1537 parse_osc11_response(&response)
1538}
1539
1540#[cfg(feature = "crossterm")]
1541pub(crate) fn parse_osc11_response(response: &str) -> ColorScheme {
1542 let Some(rgb_pos) = response.find("rgb:") else {
1543 return ColorScheme::Unknown;
1544 };
1545
1546 let payload = &response[rgb_pos + 4..];
1547 let end = payload
1548 .find(['\x07', '\x1b', '\r', '\n', ' ', '\t'])
1549 .unwrap_or(payload.len());
1550 let rgb = &payload[..end];
1551
1552 let mut channels = rgb.split('/');
1553 let (Some(r), Some(g), Some(b), None) = (
1554 channels.next(),
1555 channels.next(),
1556 channels.next(),
1557 channels.next(),
1558 ) else {
1559 return ColorScheme::Unknown;
1560 };
1561
1562 fn parse_channel(channel: &str) -> Option<f64> {
1563 if channel.is_empty() || channel.len() > 4 {
1564 return None;
1565 }
1566 let value = u16::from_str_radix(channel, 16).ok()? as f64;
1567 let max = ((1u32 << (channel.len() * 4)) - 1) as f64;
1568 if max <= 0.0 {
1569 return None;
1570 }
1571 Some((value / max).clamp(0.0, 1.0))
1572 }
1573
1574 let (Some(r), Some(g), Some(b)) = (parse_channel(r), parse_channel(g), parse_channel(b)) else {
1575 return ColorScheme::Unknown;
1576 };
1577
1578 let luminance = 0.299 * r + 0.587 * g + 0.114 * b;
1579 if luminance < 0.5 {
1580 ColorScheme::Dark
1581 } else {
1582 ColorScheme::Light
1583 }
1584}
1585
1586pub(crate) fn base64_encode(input: &[u8]) -> String {
1587 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1588 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
1589 for chunk in input.chunks(3) {
1590 let b0 = chunk[0] as u32;
1591 let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
1592 let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
1593 let triple = (b0 << 16) | (b1 << 8) | b2;
1594 out.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
1595 out.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
1596 out.push(if chunk.len() > 1 {
1597 CHARS[((triple >> 6) & 0x3F) as usize] as char
1598 } else {
1599 '='
1600 });
1601 out.push(if chunk.len() > 2 {
1602 CHARS[(triple & 0x3F) as usize] as char
1603 } else {
1604 '='
1605 });
1606 }
1607 out
1608}
1609
1610pub(crate) fn copy_to_clipboard(w: &mut impl Write, text: &str) -> io::Result<()> {
1611 let encoded = base64_encode(text.as_bytes());
1612 write!(w, "\x1b]52;c;{encoded}\x1b\\")?;
1613 w.flush()
1614}
1615
1616#[cfg(feature = "crossterm")]
1617fn parse_osc52_response(response: &str) -> Option<String> {
1618 let osc_pos = response.find("]52;")?;
1619 let body = &response[osc_pos + 4..];
1620 let semicolon = body.find(';')?;
1621 let payload = &body[semicolon + 1..];
1622
1623 let end = payload
1624 .find("\x1b\\")
1625 .or_else(|| payload.find('\x07'))
1626 .unwrap_or(payload.len());
1627 let encoded = payload[..end].trim();
1628 if encoded.is_empty() || encoded == "?" {
1629 return None;
1630 }
1631
1632 base64_decode(encoded)
1633}
1634
1635#[cfg(feature = "crossterm")]
1668#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
1669pub fn read_clipboard() -> Option<String> {
1670 let mut stdout = io::stdout();
1671 write!(stdout, "\x1b]52;c;?\x07").ok()?;
1672 stdout.flush().ok()?;
1673
1674 let response = read_osc_response(Duration::from_millis(200))?;
1675 parse_osc52_response(&response)
1676}
1677
1678#[cfg(feature = "crossterm")]
1679fn base64_decode(input: &str) -> Option<String> {
1680 let mut filtered: Vec<u8> = input
1681 .bytes()
1682 .filter(|b| !matches!(b, b' ' | b'\n' | b'\r' | b'\t'))
1683 .collect();
1684
1685 match filtered.len() % 4 {
1686 0 => {}
1687 2 => filtered.extend_from_slice(b"=="),
1688 3 => filtered.push(b'='),
1689 _ => return None,
1690 }
1691
1692 fn decode_val(b: u8) -> Option<u8> {
1693 match b {
1694 b'A'..=b'Z' => Some(b - b'A'),
1695 b'a'..=b'z' => Some(b - b'a' + 26),
1696 b'0'..=b'9' => Some(b - b'0' + 52),
1697 b'+' => Some(62),
1698 b'/' => Some(63),
1699 _ => None,
1700 }
1701 }
1702
1703 let mut out = Vec::with_capacity((filtered.len() / 4) * 3);
1704 for chunk in filtered.chunks_exact(4) {
1705 let p2 = chunk[2] == b'=';
1706 let p3 = chunk[3] == b'=';
1707 if p2 && !p3 {
1708 return None;
1709 }
1710
1711 let v0 = decode_val(chunk[0])? as u32;
1712 let v1 = decode_val(chunk[1])? as u32;
1713 let v2 = if p2 { 0 } else { decode_val(chunk[2])? as u32 };
1714 let v3 = if p3 { 0 } else { decode_val(chunk[3])? as u32 };
1715
1716 let triple = (v0 << 18) | (v1 << 12) | (v2 << 6) | v3;
1717 out.push(((triple >> 16) & 0xFF) as u8);
1718 if !p2 {
1719 out.push(((triple >> 8) & 0xFF) as u8);
1720 }
1721 if !p3 {
1722 out.push((triple & 0xFF) as u8);
1723 }
1724 }
1725
1726 String::from_utf8(out).ok()
1727}
1728
1729#[allow(clippy::too_many_arguments)]
1730#[allow(unused_assignments)]
1731fn flush_buffer_diff(
1732 stdout: &mut impl Write,
1733 current: &Buffer,
1734 previous: &Buffer,
1735 color_depth: ColorDepth,
1736 row_offset: u32,
1737 run_buf: &mut String,
1738) -> io::Result<()> {
1739 let mut last_style = Style::new();
1751 let mut first_style = true;
1752 let mut active_link: Option<&str> = None;
1753 let mut has_updates = false;
1754 let mut last_cursor: Option<(u32, u32)> = None;
1758
1759 run_buf.clear();
1765 let mut run_abs_y: u32 = 0;
1766 let mut run_style: Style = Style::new();
1767 let mut run_link: Option<&str> = None;
1768 let mut run_next_col: u32 = 0;
1769 let mut run_open = false;
1770
1771 macro_rules! flush_run {
1776 ($stdout:expr) => {
1777 if run_open {
1778 queue!($stdout, Print(&run_buf))?;
1779 last_cursor = Some((run_next_col, run_abs_y));
1780 run_buf.clear();
1781 run_open = false;
1782 }
1783 };
1784 }
1785
1786 for y in current.area.y..current.area.bottom() {
1787 if current.row_clean(y)
1796 && current.row_hash(y).is_some()
1797 && current.row_hash(y) == previous.row_hash(y)
1798 {
1799 continue;
1800 }
1801 for x in current.area.x..current.area.right() {
1802 let cell = current.get(x, y);
1803 let prev = previous.get(x, y);
1804 if cell == prev || cell.symbol.is_empty() {
1805 flush_run!(stdout);
1807 continue;
1808 }
1809
1810 let abs_y = row_offset + y;
1811 let cell_link = cell
1816 .hyperlink
1817 .as_deref()
1818 .filter(|u| crate::buffer::is_valid_osc8_url(u));
1819
1820 let extends = run_open
1822 && run_abs_y == abs_y
1823 && run_next_col == x
1824 && run_style == cell.style
1825 && run_link == cell_link;
1826
1827 if !extends {
1828 flush_run!(stdout);
1829
1830 has_updates = true;
1834
1835 let need_move = last_cursor.is_none_or(|(lx, ly)| lx != x || ly != abs_y);
1836 if need_move {
1837 queue!(stdout, cursor::MoveTo(sat_u16(x), sat_u16(abs_y)))?;
1838 }
1839
1840 if cell.style != last_style {
1841 if first_style {
1842 queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
1843 apply_style(stdout, &cell.style, color_depth)?;
1844 first_style = false;
1845 } else {
1846 apply_style_delta(stdout, &last_style, &cell.style, color_depth)?;
1847 }
1848 last_style = cell.style;
1849 }
1850
1851 if cell_link != active_link {
1852 if let Some(url) = cell_link {
1853 queue!(stdout, Print("\x1b]8;;"))?;
1858 queue!(stdout, Print(url))?;
1859 queue!(stdout, Print("\x07"))?;
1860 } else {
1861 queue!(stdout, Print("\x1b]8;;\x07"))?;
1862 }
1863 active_link = cell_link;
1864 }
1865
1866 run_open = true;
1867 run_abs_y = abs_y;
1868 run_style = cell.style;
1869 run_link = cell_link;
1870 }
1871
1872 run_buf.push_str(&cell.symbol);
1876 let char_width = UnicodeWidthStr::width(cell.symbol.as_str()).max(1) as u32;
1877 if char_width > 1 && cell.symbol.chars().any(|c| c == '\u{FE0F}') {
1878 run_buf.push(' ');
1882 }
1883 run_next_col = x + char_width;
1884 }
1885
1886 flush_run!(stdout);
1888 }
1889
1890 if has_updates {
1891 if active_link.is_some() {
1892 queue!(stdout, Print("\x1b]8;;\x07"))?;
1893 }
1894 queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
1895 }
1896
1897 Ok(())
1898}
1899
1900#[doc(hidden)]
1910pub fn __bench_flush_buffer_diff<W: Write>(
1911 w: &mut W,
1912 current: &Buffer,
1913 previous: &Buffer,
1914 color_depth: ColorDepth,
1915) -> io::Result<()> {
1916 let mut run_buf = String::with_capacity(RUN_BUF_INITIAL_CAPACITY);
1919 flush_buffer_diff(w, current, previous, color_depth, 0, &mut run_buf)
1920}
1921
1922#[doc(hidden)]
1931pub fn __bench_flush_buffer_diff_mut<W: Write>(
1932 w: &mut W,
1933 current: &mut Buffer,
1934 previous: &mut Buffer,
1935 color_depth: ColorDepth,
1936) -> io::Result<()> {
1937 let mut run_buf = String::with_capacity(RUN_BUF_INITIAL_CAPACITY);
1941 __bench_flush_buffer_diff_mut_with_buf(w, current, previous, color_depth, &mut run_buf)
1942}
1943
1944#[doc(hidden)]
1969pub fn __bench_flush_buffer_diff_mut_with_buf<W: Write>(
1970 w: &mut W,
1971 current: &mut Buffer,
1972 previous: &mut Buffer,
1973 color_depth: ColorDepth,
1974 run_buf: &mut String,
1975) -> io::Result<()> {
1976 current.recompute_line_hashes();
1977 previous.recompute_line_hashes();
1978 flush_buffer_diff(w, current, previous, color_depth, 0, run_buf)
1979}
1980
1981#[doc(hidden)]
1986pub struct __BenchKittyFixture {
1987 mgr: KittyImageManager,
1988 placements: Vec<KittyPlacement>,
1989}
1990
1991#[doc(hidden)]
1994pub fn __bench_new_kitty_fixture(n: usize) -> __BenchKittyFixture {
1995 let mut placements = Vec::with_capacity(n);
1996 for i in 0..n {
1997 let mut rgba = vec![0u8; 256];
1999 rgba[0] = i as u8;
2001 let content_hash = crate::buffer::hash_rgba(&rgba);
2002 placements.push(KittyPlacement {
2003 content_hash,
2004 rgba: std::sync::Arc::new(rgba),
2005 src_width: 8,
2006 src_height: 8,
2007 x: (i as u32) * 4,
2008 y: (i as u32) * 2,
2009 cols: 4,
2010 rows: 2,
2011 crop_y: 0,
2012 crop_h: 0,
2013 });
2014 }
2015 __BenchKittyFixture {
2016 mgr: KittyImageManager::new(),
2017 placements,
2018 }
2019}
2020
2021impl __BenchKittyFixture {
2022 #[doc(hidden)]
2026 pub fn rgba_strong_counts(&self) -> Vec<usize> {
2027 self.placements
2028 .iter()
2029 .map(|p| std::sync::Arc::strong_count(&p.rgba))
2030 .collect()
2031 }
2032
2033 #[doc(hidden)]
2036 pub fn flush_inline<W: Write>(&mut self, sink: &mut W, row_offset: u32) -> io::Result<()> {
2037 self.mgr.flush(sink, &self.placements, row_offset)
2038 }
2039
2040 #[doc(hidden)]
2042 pub fn len(&self) -> usize {
2043 self.placements.len()
2044 }
2045
2046 #[doc(hidden)]
2048 pub fn is_empty(&self) -> bool {
2049 self.placements.is_empty()
2050 }
2051}
2052
2053#[doc(hidden)]
2063pub fn __bench_flush_kitty<W: Write>(sink: &mut W, n: usize, row_offset: u32) -> io::Result<()> {
2064 let mut fixture = __bench_new_kitty_fixture(n);
2065 fixture.flush_inline(sink, row_offset)
2066}
2067
2068#[doc(hidden)]
2075pub struct __BenchSprixelFixture {
2076 current: Buffer,
2077 previous: Buffer,
2078}
2079
2080#[doc(hidden)]
2089pub fn __bench_new_sprixel_fixture(n: usize) -> __BenchSprixelFixture {
2090 use crate::buffer::{SprixelCell, SprixelPlacement};
2091
2092 let height = (n as u32 * 3).max(1);
2094 let area = Rect::new(0, 0, 8, height);
2095 let mut current = Buffer::empty(area);
2096 let mut previous = Buffer::empty(area);
2097
2098 for i in 0..n {
2099 let placement = SprixelPlacement {
2100 content_hash: 0x5000 + i as u64,
2101 seq: "<SIXEL>".to_string(),
2102 x: 0,
2103 y: i as u32 * 3,
2104 cols: 4,
2105 rows: 2,
2106 cells: vec![SprixelCell::Opaque; 8],
2107 };
2108 current.sprixels.push(placement.clone());
2109 previous.sprixels.push(placement);
2110 }
2111
2112 current.recompute_line_hashes();
2115 previous.recompute_line_hashes();
2116
2117 __BenchSprixelFixture { current, previous }
2118}
2119
2120#[allow(dead_code)]
2129impl __BenchSprixelFixture {
2130 #[doc(hidden)]
2134 pub fn flush<W: Write>(&self, sink: &mut W, row_offset: u32) -> io::Result<()> {
2135 flush_sprixels(sink, &self.current, &self.previous, row_offset)
2136 }
2137
2138 #[doc(hidden)]
2140 pub fn len(&self) -> usize {
2141 self.current.sprixels.len()
2142 }
2143
2144 #[doc(hidden)]
2146 pub fn is_empty(&self) -> bool {
2147 self.current.sprixels.is_empty()
2148 }
2149}
2150
2151#[doc(hidden)]
2161pub fn __bench_flush_sprixels<W: Write>(sink: &mut W, n: usize, row_offset: u32) -> io::Result<()> {
2162 let fixture = __bench_new_sprixel_fixture(n);
2163 if fixture.is_empty() {
2164 return Ok(());
2165 }
2166 debug_assert_eq!(fixture.len(), n);
2167 fixture.flush(sink, row_offset)
2168}
2169
2170fn flush_raw_sequences(
2171 stdout: &mut impl Write,
2172 current: &Buffer,
2173 previous: &Buffer,
2174 row_offset: u32,
2175) -> io::Result<()> {
2176 if current.raw_sequences == previous.raw_sequences {
2177 return Ok(());
2178 }
2179
2180 for (x, y, seq) in ¤t.raw_sequences {
2181 queue!(
2182 stdout,
2183 cursor::MoveTo(sat_u16(*x), sat_u16(row_offset + *y)),
2184 Print(seq)
2185 )?;
2186 }
2187
2188 Ok(())
2189}
2190
2191type SprixelKey = (u64, u32, u32, u32, u32);
2196
2197#[inline]
2199fn sprixel_key(p: &crate::buffer::SprixelPlacement) -> SprixelKey {
2200 (p.content_hash, p.x, p.y, p.cols, p.rows)
2201}
2202
2203fn sprixel_needs_reblit(
2226 placement: &crate::buffer::SprixelPlacement,
2227 current: &Buffer,
2228 previous: &Buffer,
2229 prev_keys: &std::collections::HashSet<SprixelKey>,
2230) -> bool {
2231 use crate::buffer::SprixelCell;
2232
2233 if !prev_keys.contains(&sprixel_key(placement)) {
2238 return true;
2239 }
2240
2241 for row in 0..placement.rows {
2245 let y = placement.y + row;
2246 if current.row_clean(y) && current.row_hash(y) == previous.row_hash(y) {
2250 continue;
2251 }
2252 for col in 0..placement.cols {
2253 let idx = (row * placement.cols + col) as usize;
2254 match placement.cells.get(idx) {
2255 Some(SprixelCell::Opaque) | Some(SprixelCell::Mixed) => {}
2256 _ => continue,
2259 }
2260 let x = placement.x + col;
2261 let (Some(cell), Some(prev)) = (current.try_get(x, y), previous.try_get(x, y)) else {
2266 continue;
2267 };
2268 if cell != prev && !cell.symbol.is_empty() {
2274 return true;
2275 }
2276 }
2277 }
2278
2279 false
2280}
2281
2282fn flush_sprixels(
2294 stdout: &mut impl Write,
2295 current: &Buffer,
2296 previous: &Buffer,
2297 row_offset: u32,
2298) -> io::Result<()> {
2299 if current.sprixels.is_empty() {
2302 return Ok(());
2303 }
2304
2305 let prev_keys: std::collections::HashSet<SprixelKey> =
2306 previous.sprixels.iter().map(sprixel_key).collect();
2307
2308 for placement in ¤t.sprixels {
2309 if sprixel_needs_reblit(placement, current, previous, &prev_keys) {
2310 queue!(
2311 stdout,
2312 cursor::MoveTo(sat_u16(placement.x), sat_u16(row_offset + placement.y)),
2313 Print(&placement.seq)
2314 )?;
2315 }
2316 }
2317 Ok(())
2318}
2319
2320fn flush_cursor(
2321 stdout: &mut impl Write,
2322 cursor_visible: &mut bool,
2323 cursor_pos: Option<(u32, u32)>,
2324 row_offset: u32,
2325 fallback_row: Option<u32>,
2326) -> io::Result<()> {
2327 match cursor_pos {
2328 Some((cx, cy)) => {
2329 if !*cursor_visible {
2330 queue!(stdout, cursor::Show)?;
2331 *cursor_visible = true;
2332 }
2333 queue!(
2334 stdout,
2335 cursor::MoveTo(sat_u16(cx), sat_u16(row_offset + cy))
2336 )?;
2337 }
2338 None => {
2339 if *cursor_visible {
2340 queue!(stdout, cursor::Hide)?;
2341 *cursor_visible = false;
2342 }
2343 if let Some(row) = fallback_row {
2344 queue!(stdout, cursor::MoveTo(0, sat_u16(row)))?;
2345 }
2346 }
2347 }
2348
2349 Ok(())
2350}
2351
2352fn apply_style_delta(
2353 w: &mut impl Write,
2354 old: &Style,
2355 new: &Style,
2356 depth: ColorDepth,
2357) -> io::Result<()> {
2358 if old.fg != new.fg {
2359 match new.fg {
2360 Some(fg) => queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?,
2361 None => queue!(w, SetForegroundColor(CtColor::Reset))?,
2362 }
2363 }
2364 if old.bg != new.bg {
2365 match new.bg {
2366 Some(bg) => queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?,
2367 None => queue!(w, SetBackgroundColor(CtColor::Reset))?,
2368 }
2369 }
2370 let removed = Modifiers(old.modifiers.0 & !new.modifiers.0);
2371 let added = Modifiers(new.modifiers.0 & !old.modifiers.0);
2372 if removed.contains(Modifiers::BOLD) || removed.contains(Modifiers::DIM) {
2373 queue!(w, SetAttribute(Attribute::NormalIntensity))?;
2374 if new.modifiers.contains(Modifiers::BOLD) {
2375 queue!(w, SetAttribute(Attribute::Bold))?;
2376 }
2377 if new.modifiers.contains(Modifiers::DIM) {
2378 queue!(w, SetAttribute(Attribute::Dim))?;
2379 }
2380 } else {
2381 if added.contains(Modifiers::BOLD) {
2382 queue!(w, SetAttribute(Attribute::Bold))?;
2383 }
2384 if added.contains(Modifiers::DIM) {
2385 queue!(w, SetAttribute(Attribute::Dim))?;
2386 }
2387 }
2388 if removed.contains(Modifiers::ITALIC) {
2389 queue!(w, SetAttribute(Attribute::NoItalic))?;
2390 }
2391 if added.contains(Modifiers::ITALIC) {
2392 queue!(w, SetAttribute(Attribute::Italic))?;
2393 }
2394 if removed.contains(Modifiers::UNDERLINE) {
2395 queue!(w, SetAttribute(Attribute::NoUnderline))?;
2396 }
2397 if added.contains(Modifiers::UNDERLINE) {
2398 queue!(w, SetAttribute(Attribute::Underlined))?;
2399 }
2400 if removed.contains(Modifiers::REVERSED) {
2401 queue!(w, SetAttribute(Attribute::NoReverse))?;
2402 }
2403 if added.contains(Modifiers::REVERSED) {
2404 queue!(w, SetAttribute(Attribute::Reverse))?;
2405 }
2406 if removed.contains(Modifiers::STRIKETHROUGH) {
2407 queue!(w, SetAttribute(Attribute::NotCrossedOut))?;
2408 }
2409 if added.contains(Modifiers::STRIKETHROUGH) {
2410 queue!(w, SetAttribute(Attribute::CrossedOut))?;
2411 }
2412 if removed.contains(Modifiers::BLINK) {
2413 queue!(w, SetAttribute(Attribute::NoBlink))?;
2414 }
2415 if added.contains(Modifiers::BLINK) {
2416 queue!(w, SetAttribute(Attribute::SlowBlink))?;
2417 }
2418 if removed.contains(Modifiers::OVERLINE) {
2419 queue!(w, SetAttribute(Attribute::NotOverLined))?;
2420 }
2421 if added.contains(Modifiers::OVERLINE) {
2422 queue!(w, SetAttribute(Attribute::OverLined))?;
2423 }
2424 if old.underline_style != new.underline_style {
2428 write!(w, "\x1b[4:{}m", underline_style_param(new.underline_style))?;
2429 }
2430 if old.underline_color != new.underline_color {
2431 emit_underline_color(w, new.underline_color, depth)?;
2432 }
2433 Ok(())
2434}
2435
2436fn underline_style_param(style: UnderlineStyle) -> u8 {
2438 match style {
2439 UnderlineStyle::Straight => 1,
2440 UnderlineStyle::Double => 2,
2441 UnderlineStyle::Curly => 3,
2442 UnderlineStyle::Dotted => 4,
2443 UnderlineStyle::Dashed => 5,
2444 }
2445}
2446
2447fn emit_underline_color(
2453 w: &mut impl Write,
2454 color: Option<Color>,
2455 depth: ColorDepth,
2456) -> io::Result<()> {
2457 match color {
2458 None => write!(w, "\x1b[59m"),
2459 Some(c) => match c.downsampled(depth) {
2460 Color::Reset => write!(w, "\x1b[59m"),
2461 Color::Rgb(r, g, b) => write!(w, "\x1b[58:2::{r}:{g}:{b}m"),
2462 Color::Indexed(i) => write!(w, "\x1b[58:5:{i}m"),
2463 named => {
2466 let (r, g, b) = named.to_rgb();
2467 write!(w, "\x1b[58:2::{r}:{g}:{b}m")
2468 }
2469 },
2470 }
2471}
2472
2473fn apply_style(w: &mut impl Write, style: &Style, depth: ColorDepth) -> io::Result<()> {
2474 if let Some(fg) = style.fg {
2475 queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?;
2476 }
2477 if let Some(bg) = style.bg {
2478 queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?;
2479 }
2480 let m = style.modifiers;
2481 if m.contains(Modifiers::BOLD) {
2482 queue!(w, SetAttribute(Attribute::Bold))?;
2483 }
2484 if m.contains(Modifiers::DIM) {
2485 queue!(w, SetAttribute(Attribute::Dim))?;
2486 }
2487 if m.contains(Modifiers::ITALIC) {
2488 queue!(w, SetAttribute(Attribute::Italic))?;
2489 }
2490 if m.contains(Modifiers::UNDERLINE) {
2491 queue!(w, SetAttribute(Attribute::Underlined))?;
2492 }
2493 if m.contains(Modifiers::REVERSED) {
2494 queue!(w, SetAttribute(Attribute::Reverse))?;
2495 }
2496 if m.contains(Modifiers::STRIKETHROUGH) {
2497 queue!(w, SetAttribute(Attribute::CrossedOut))?;
2498 }
2499 if m.contains(Modifiers::BLINK) {
2500 queue!(w, SetAttribute(Attribute::SlowBlink))?;
2501 }
2502 if m.contains(Modifiers::OVERLINE) {
2503 queue!(w, SetAttribute(Attribute::OverLined))?;
2504 }
2505 if style.underline_style != UnderlineStyle::Straight {
2506 write!(
2507 w,
2508 "\x1b[4:{}m",
2509 underline_style_param(style.underline_style)
2510 )?;
2511 }
2512 if style.underline_color.is_some() {
2513 emit_underline_color(w, style.underline_color, depth)?;
2514 }
2515 Ok(())
2516}
2517
2518fn to_crossterm_color(color: Color, depth: ColorDepth) -> CtColor {
2519 let color = color.downsampled(depth);
2520 match color {
2521 Color::Reset => CtColor::Reset,
2522 Color::Black => CtColor::Black,
2523 Color::Red => CtColor::DarkRed,
2524 Color::Green => CtColor::DarkGreen,
2525 Color::Yellow => CtColor::DarkYellow,
2526 Color::Blue => CtColor::DarkBlue,
2527 Color::Magenta => CtColor::DarkMagenta,
2528 Color::Cyan => CtColor::DarkCyan,
2529 Color::White => CtColor::White,
2530 Color::DarkGray => CtColor::DarkGrey,
2531 Color::LightRed => CtColor::Red,
2532 Color::LightGreen => CtColor::Green,
2533 Color::LightYellow => CtColor::Yellow,
2534 Color::LightBlue => CtColor::Blue,
2535 Color::LightMagenta => CtColor::Magenta,
2536 Color::LightCyan => CtColor::Cyan,
2537 Color::LightWhite => CtColor::White,
2538 Color::Rgb(r, g, b) => CtColor::Rgb { r, g, b },
2539 Color::Indexed(i) => CtColor::AnsiValue(i),
2540 }
2541}
2542
2543fn reset_current_buffer(buffer: &mut Buffer, theme_bg: Option<Color>) {
2544 if let Some(bg) = theme_bg {
2545 buffer.reset_with_bg(bg);
2546 } else {
2547 buffer.reset();
2548 }
2549}
2550
2551fn write_session_enter(stdout: &mut impl Write, session: &TerminalSessionGuard) -> io::Result<()> {
2552 match session.mode {
2553 TerminalSessionMode::Fullscreen => {
2554 execute!(
2555 stdout,
2556 terminal::EnterAlternateScreen,
2557 cursor::Hide,
2558 EnableBracketedPaste
2559 )?;
2560 }
2561 TerminalSessionMode::Inline => {
2562 execute!(stdout, cursor::Hide, EnableBracketedPaste)?;
2563 }
2564 }
2565
2566 execute!(stdout, EnableFocusChange)?;
2572 if session.mouse_enabled {
2573 execute!(stdout, EnableMouseCapture)?;
2574 }
2575 if session.kitty_keyboard {
2576 use crossterm::event::PushKeyboardEnhancementFlags;
2577 let _ = execute!(
2578 stdout,
2579 PushKeyboardEnhancementFlags(kitty_flags(session.report_all_keys))
2580 );
2581 }
2582
2583 Ok(())
2584}
2585
2586fn kitty_flags(report_all_keys: bool) -> crossterm::event::KeyboardEnhancementFlags {
2596 use crossterm::event::KeyboardEnhancementFlags;
2597 let mut flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
2598 | KeyboardEnhancementFlags::REPORT_EVENT_TYPES;
2599 if report_all_keys {
2600 flags |= KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES;
2601 }
2602 flags
2603}
2604
2605fn write_session_cleanup(
2606 stdout: &mut impl Write,
2607 mode: TerminalSessionMode,
2608 inline_reserved: bool,
2609) -> io::Result<()> {
2610 execute!(
2611 stdout,
2612 ResetColor,
2613 SetAttribute(Attribute::Reset),
2614 cursor::Show,
2615 DisableBracketedPaste
2616 )?;
2617
2618 match mode {
2619 TerminalSessionMode::Fullscreen => {
2620 execute!(stdout, terminal::LeaveAlternateScreen)?;
2621 }
2622 TerminalSessionMode::Inline => {
2623 if inline_reserved {
2624 execute!(
2625 stdout,
2626 cursor::MoveToColumn(0),
2627 cursor::MoveDown(1),
2628 cursor::MoveToColumn(0),
2629 Print("\n")
2630 )?;
2631 } else {
2632 execute!(stdout, Print("\n"))?;
2633 }
2634 }
2635 }
2636
2637 Ok(())
2638}
2639
2640#[cfg(unix)]
2657#[derive(Debug, Clone, Copy)]
2658pub(crate) struct SessionSnapshot {
2659 mode: TerminalSessionMode,
2660 mouse_enabled: bool,
2661 kitty_keyboard: bool,
2662 report_all_keys: bool,
2663}
2664
2665#[cfg(unix)]
2668pub(crate) static NEEDS_FULL_REDRAW: std::sync::atomic::AtomicBool =
2669 std::sync::atomic::AtomicBool::new(false);
2670
2671#[cfg(unix)]
2672impl Terminal {
2673 pub(crate) fn session_snapshot(&self) -> SessionSnapshot {
2676 SessionSnapshot {
2677 mode: self.session.mode,
2678 mouse_enabled: self.session.mouse_enabled,
2679 kitty_keyboard: self.session.kitty_keyboard,
2680 report_all_keys: self.session.report_all_keys,
2681 }
2682 }
2683}
2684
2685#[cfg(unix)]
2686impl InlineTerminal {
2687 pub(crate) fn session_snapshot(&self) -> SessionSnapshot {
2690 SessionSnapshot {
2691 mode: self.session.mode,
2692 mouse_enabled: self.session.mouse_enabled,
2693 kitty_keyboard: self.session.kitty_keyboard,
2694 report_all_keys: self.session.report_all_keys,
2695 }
2696 }
2697}
2698
2699#[cfg(unix)]
2707fn write_suspend_sequence(stdout: &mut impl Write, snapshot: &SessionSnapshot) -> io::Result<()> {
2708 if snapshot.kitty_keyboard {
2709 use crossterm::event::PopKeyboardEnhancementFlags;
2710 execute!(stdout, PopKeyboardEnhancementFlags)?;
2711 }
2712 if snapshot.mouse_enabled {
2713 execute!(stdout, DisableMouseCapture)?;
2714 }
2715 execute!(stdout, DisableFocusChange)?;
2716 write_session_cleanup(stdout, snapshot.mode, false)
2717}
2718
2719#[cfg(unix)]
2726pub(crate) fn suspend_to_shell(snapshot: &SessionSnapshot) {
2727 let mut out = io::stdout();
2728 let _ = write_suspend_sequence(&mut out, snapshot);
2729 let _ = terminal::disable_raw_mode();
2730 let _ = out.flush();
2731}
2732
2733#[cfg(unix)]
2740pub(crate) fn resume_from_shell(snapshot: &SessionSnapshot) {
2741 let mut out = io::stdout();
2742 let _ = terminal::enable_raw_mode();
2743 let guard = TerminalSessionGuard {
2744 mode: snapshot.mode,
2745 mouse_enabled: snapshot.mouse_enabled,
2746 kitty_keyboard: snapshot.kitty_keyboard,
2747 report_all_keys: snapshot.report_all_keys,
2748 harness: false,
2749 };
2750 let _ = write_session_enter(&mut out, &guard);
2751 let _ = out.flush();
2752 NEEDS_FULL_REDRAW.store(true, std::sync::atomic::Ordering::SeqCst);
2753}
2754
2755#[cfg(all(unix, test))]
2757fn test_snapshot(mode: TerminalSessionMode, mouse: bool, kitty: bool) -> SessionSnapshot {
2758 SessionSnapshot {
2759 mode,
2760 mouse_enabled: mouse,
2761 kitty_keyboard: kitty,
2762 report_all_keys: false,
2763 }
2764}
2765
2766#[cfg(all(unix, test))]
2769pub(crate) fn test_session_snapshot() -> SessionSnapshot {
2770 SessionSnapshot {
2771 mode: TerminalSessionMode::Fullscreen,
2772 mouse_enabled: false,
2773 kitty_keyboard: false,
2774 report_all_keys: false,
2775 }
2776}
2777
2778#[cfg(test)]
2779mod tests {
2780 #![allow(clippy::unwrap_used)]
2781 use super::*;
2782
2783 fn collect_with_feed(
2786 bytes: &'static [u8],
2787 delay: Duration,
2788 budget: Duration,
2789 is_complete: &mut dyn FnMut(&[u8]) -> bool,
2790 ) -> (Vec<u8>, Duration) {
2791 let (tx, rx) = std::sync::mpsc::channel::<u8>();
2792 std::thread::spawn(move || {
2793 std::thread::sleep(delay);
2794 for &b in bytes {
2795 if tx.send(b).is_err() {
2796 return;
2797 }
2798 }
2799 std::thread::sleep(Duration::from_secs(3));
2804 });
2805 let start = Instant::now();
2806 let out = collect_reply(&rx, start + budget, is_complete);
2807 (out, start.elapsed())
2808 }
2809
2810 #[test]
2811 fn collect_reply_osc_bel_terminator_completes_early() {
2812 let reply = b"\x1b]11;rgb:0000/0000/0000\x07";
2813 let (out, elapsed) = collect_with_feed(
2814 reply,
2815 Duration::ZERO,
2816 Duration::from_secs(2),
2817 &mut osc_reply_complete,
2818 );
2819 assert_eq!(out, reply);
2820 assert!(
2821 elapsed < Duration::from_secs(1),
2822 "should not wait out the budget"
2823 );
2824 }
2825
2826 #[test]
2827 fn collect_reply_osc_st_terminator_completes_early() {
2828 let reply = b"\x1bP>|tmux 3.5a\x1b\\";
2829 let (out, elapsed) = collect_with_feed(
2830 reply,
2831 Duration::ZERO,
2832 Duration::from_secs(2),
2833 &mut osc_reply_complete,
2834 );
2835 assert_eq!(out, reply);
2836 assert!(elapsed < Duration::from_secs(1));
2837 }
2838
2839 #[test]
2840 fn collect_reply_silence_returns_empty_at_deadline() {
2841 let budget = Duration::from_millis(150);
2844 let (out, elapsed) =
2845 collect_with_feed(b"", Duration::from_secs(5), budget, &mut osc_reply_complete);
2846 assert!(out.is_empty());
2847 assert!(elapsed >= budget);
2848 assert!(
2849 elapsed < Duration::from_secs(2),
2850 "must not block past the budget"
2851 );
2852 }
2853
2854 #[test]
2855 fn collect_reply_da_drains_two_replies() {
2856 let reply = b"\x1b[?62;4c\x1b[>1;10;0c";
2857 let (out, elapsed) = collect_with_feed(
2858 reply,
2859 Duration::ZERO,
2860 Duration::from_secs(2),
2861 &mut da_reply_complete(),
2862 );
2863 assert_eq!(out, reply);
2864 assert!(elapsed < Duration::from_secs(1));
2865 }
2866
2867 #[test]
2868 fn collect_reply_da_lone_reply_returns_partial_at_deadline() {
2869 let budget = Duration::from_millis(150);
2873 let (out, elapsed) = collect_with_feed(
2874 b"\x1b[?62;4c",
2875 Duration::ZERO,
2876 budget,
2877 &mut da_reply_complete(),
2878 );
2879 assert_eq!(out, b"\x1b[?62;4c");
2880 assert!(elapsed >= budget);
2881 }
2882
2883 #[test]
2884 fn collect_reply_unterminated_caps_at_4096_bytes() {
2885 static BIG: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
2886 let big = BIG.get_or_init(|| vec![b'x'; 5000]).as_slice();
2887 let (tx, rx) = std::sync::mpsc::channel::<u8>();
2888 for &b in big {
2889 tx.send(b).unwrap();
2890 }
2891 let out = collect_reply(
2892 &rx,
2893 Instant::now() + Duration::from_secs(2),
2894 &mut osc_reply_complete,
2895 );
2896 assert_eq!(out.len(), 4096);
2897 }
2898
2899 #[test]
2900 fn decrpm_predicate_terminates_on_y() {
2901 let reply = b"\x1b[?2026;1$y";
2902 let (out, _) = collect_with_feed(
2903 reply,
2904 Duration::ZERO,
2905 Duration::from_secs(2),
2906 &mut decrpm_reply_complete,
2907 );
2908 assert_eq!(out, reply);
2909 }
2910
2911 #[test]
2912 fn reset_current_buffer_applies_theme_background() {
2913 let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 1));
2914
2915 reset_current_buffer(&mut buffer, Some(Color::Rgb(10, 20, 30)));
2916 assert_eq!(buffer.get(0, 0).style.bg, Some(Color::Rgb(10, 20, 30)));
2917
2918 reset_current_buffer(&mut buffer, None);
2919 assert_eq!(buffer.get(0, 0).style.bg, None);
2920 }
2921
2922 #[test]
2923 fn fullscreen_session_enter_writes_alt_screen_sequence() {
2924 let session = TerminalSessionGuard {
2925 mode: TerminalSessionMode::Fullscreen,
2926 mouse_enabled: false,
2927 kitty_keyboard: false,
2928 report_all_keys: false,
2929 harness: false,
2930 };
2931 let mut out = Vec::new();
2932 write_session_enter(&mut out, &session).unwrap();
2933 let output = String::from_utf8(out).unwrap();
2934 assert!(output.contains("\u{1b}[?1049h"));
2935 assert!(output.contains("\u{1b}[?25l"));
2936 assert!(output.contains("\u{1b}[?2004h"));
2937 }
2938
2939 #[test]
2940 fn inline_session_enter_skips_alt_screen_sequence() {
2941 let session = TerminalSessionGuard {
2942 mode: TerminalSessionMode::Inline,
2943 mouse_enabled: false,
2944 kitty_keyboard: false,
2945 report_all_keys: false,
2946 harness: false,
2947 };
2948 let mut out = Vec::new();
2949 write_session_enter(&mut out, &session).unwrap();
2950 let output = String::from_utf8(out).unwrap();
2951 assert!(!output.contains("\u{1b}[?1049h"));
2952 assert!(output.contains("\u{1b}[?25l"));
2953 assert!(output.contains("\u{1b}[?2004h"));
2954 }
2955
2956 #[test]
2957 fn fullscreen_session_cleanup_leaves_alt_screen() {
2958 let mut out = Vec::new();
2959 write_session_cleanup(&mut out, TerminalSessionMode::Fullscreen, false).unwrap();
2960 let output = String::from_utf8(out).unwrap();
2961 assert!(output.contains("\u{1b}[?1049l"));
2962 assert!(output.contains("\u{1b}[?25h"));
2963 assert!(output.contains("\u{1b}[?2004l"));
2964 }
2965
2966 #[test]
2967 fn inline_session_cleanup_keeps_normal_screen() {
2968 let mut out = Vec::new();
2969 write_session_cleanup(&mut out, TerminalSessionMode::Inline, false).unwrap();
2970 let output = String::from_utf8(out).unwrap();
2971 assert!(!output.contains("\u{1b}[?1049l"));
2972 assert!(output.ends_with('\n'));
2973 assert!(output.contains("\u{1b}[?25h"));
2974 assert!(output.contains("\u{1b}[?2004l"));
2975 }
2976
2977 #[cfg(unix)]
2980 #[test]
2981 fn suspend_sequence_fullscreen_leaves_alt_screen() {
2982 let snapshot = test_snapshot(TerminalSessionMode::Fullscreen, false, false);
2983 let mut out = Vec::new();
2984 write_suspend_sequence(&mut out, &snapshot).unwrap();
2985 let output = String::from_utf8(out).unwrap();
2986 assert!(output.contains("\u{1b}[?1049l"), "leaves alt screen");
2987 assert!(output.contains("\u{1b}[?25h"), "shows cursor");
2988 assert!(output.contains("\u{1b}[?2004l"), "disables bracketed paste");
2989 }
2990
2991 #[cfg(unix)]
2992 #[test]
2993 fn suspend_sequence_inline_keeps_normal_screen() {
2994 let snapshot = test_snapshot(TerminalSessionMode::Inline, 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!(
2999 !output.contains("\u{1b}[?1049l"),
3000 "inline must not leave alt screen"
3001 );
3002 assert!(output.contains("\u{1b}[?25h"), "shows cursor");
3003 assert!(output.contains("\u{1b}[?2004l"), "disables bracketed paste");
3004 }
3005
3006 #[cfg(unix)]
3007 #[test]
3008 fn suspend_sequence_disables_mouse_and_kitty_when_enabled() {
3009 let snapshot = test_snapshot(TerminalSessionMode::Fullscreen, true, true);
3010 let mut out = Vec::new();
3011 write_suspend_sequence(&mut out, &snapshot).unwrap();
3012 let output = String::from_utf8(out).unwrap();
3014 assert!(output.contains("\u{1b}[?1006l"), "disables SGR mouse mode");
3015 }
3016
3017 #[cfg(unix)]
3018 #[test]
3019 fn resume_sequence_fullscreen_round_trips_enter_and_flags_redraw() {
3020 let snapshot = test_snapshot(TerminalSessionMode::Fullscreen, false, false);
3021
3022 let guard = TerminalSessionGuard {
3024 mode: snapshot.mode,
3025 mouse_enabled: snapshot.mouse_enabled,
3026 kitty_keyboard: snapshot.kitty_keyboard,
3027 report_all_keys: snapshot.report_all_keys,
3028 harness: false,
3029 };
3030 let mut enter_bytes = Vec::new();
3031 write_session_enter(&mut enter_bytes, &guard).unwrap();
3032 let enter = String::from_utf8(enter_bytes).unwrap();
3033 assert!(enter.contains("\u{1b}[?1049h"));
3034 assert!(enter.contains("\u{1b}[?25l"));
3035 assert!(enter.contains("\u{1b}[?2004h"));
3036
3037 NEEDS_FULL_REDRAW.store(false, std::sync::atomic::Ordering::SeqCst);
3039 resume_from_shell(&snapshot);
3040 assert!(
3041 NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst),
3042 "resume must request a full redraw exactly once"
3043 );
3044 assert!(
3045 !NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst),
3046 "the redraw flag is consumed by the first swap (idempotent)"
3047 );
3048 }
3049
3050 #[cfg(unix)]
3051 #[test]
3052 fn needs_full_redraw_swaps_true_once() {
3053 NEEDS_FULL_REDRAW.store(true, std::sync::atomic::Ordering::SeqCst);
3054 assert!(NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst));
3055 assert!(!NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst));
3056 }
3057
3058 #[test]
3059 fn kitty_flags_base_set_excludes_report_all_keys() {
3060 use crossterm::event::KeyboardEnhancementFlags;
3061 let flags = kitty_flags(false);
3062 assert!(flags.contains(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES));
3063 assert!(flags.contains(KeyboardEnhancementFlags::REPORT_EVENT_TYPES));
3064 assert!(!flags.contains(KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES));
3065 }
3066
3067 #[test]
3068 fn kitty_flags_report_all_keys_sets_flag() {
3069 use crossterm::event::KeyboardEnhancementFlags;
3070 let flags = kitty_flags(true);
3071 assert!(flags.contains(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES));
3072 assert!(flags.contains(KeyboardEnhancementFlags::REPORT_EVENT_TYPES));
3073 assert!(flags.contains(KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES));
3074 }
3075
3076 #[test]
3077 fn base64_encode_empty() {
3078 assert_eq!(base64_encode(b""), "");
3079 }
3080
3081 #[test]
3082 fn base64_encode_hello() {
3083 assert_eq!(base64_encode(b"Hello"), "SGVsbG8=");
3084 }
3085
3086 #[test]
3087 fn base64_encode_padding() {
3088 assert_eq!(base64_encode(b"a"), "YQ==");
3089 assert_eq!(base64_encode(b"ab"), "YWI=");
3090 assert_eq!(base64_encode(b"abc"), "YWJj");
3091 }
3092
3093 #[test]
3094 fn base64_encode_unicode() {
3095 assert_eq!(base64_encode("한글".as_bytes()), "7ZWc6riA");
3096 }
3097
3098 #[cfg(feature = "crossterm")]
3099 #[test]
3100 fn parse_osc11_response_dark_and_light() {
3101 assert_eq!(
3102 parse_osc11_response("\x1b]11;rgb:0000/0000/0000\x1b\\"),
3103 ColorScheme::Dark
3104 );
3105 assert_eq!(
3106 parse_osc11_response("\x1b]11;rgb:ffff/ffff/ffff\x07"),
3107 ColorScheme::Light
3108 );
3109 }
3110
3111 #[test]
3114 fn blitter_support_default_is_conservative() {
3115 let b = BlitterSupport::default();
3116 assert!(b.half);
3117 assert!(b.quad);
3118 assert!(!b.sextant);
3119 }
3120
3121 #[test]
3122 fn capabilities_default_is_all_false_but_half_block() {
3123 let c = Capabilities::default();
3124 assert!(!c.truecolor);
3125 assert!(!c.sixel);
3126 assert!(!c.iterm2);
3127 assert!(!c.kitty_graphics);
3128 assert!(!c.kitty_keyboard);
3129 assert!(!c.sync_output);
3130 assert_eq!(c.best_blitter(), Blitter::HalfBlock);
3132 }
3133
3134 #[test]
3135 fn best_blitter_ladder_table() {
3136 let kitty = Capabilities {
3137 kitty_graphics: true,
3138 ..Default::default()
3139 };
3140 assert_eq!(kitty.best_blitter(), Blitter::Kitty);
3141
3142 let sixel = Capabilities {
3143 sixel: true,
3144 ..Default::default()
3145 };
3146 assert_eq!(sixel.best_blitter(), Blitter::Sixel);
3147
3148 let iterm2 = Capabilities {
3149 iterm2: true,
3150 ..Default::default()
3151 };
3152 assert_eq!(iterm2.best_blitter(), Blitter::Iterm2);
3153
3154 let sixel_and_iterm2 = Capabilities {
3156 sixel: true,
3157 iterm2: true,
3158 ..Default::default()
3159 };
3160 assert_eq!(sixel_and_iterm2.best_blitter(), Blitter::Sixel);
3161
3162 let sextant = Capabilities {
3163 blitters: BlitterSupport {
3164 sextant: true,
3165 ..Default::default()
3166 },
3167 ..Default::default()
3168 };
3169 assert_eq!(sextant.best_blitter(), Blitter::Sextant);
3170
3171 assert_eq!(Capabilities::default().best_blitter(), Blitter::HalfBlock);
3172 }
3173
3174 #[test]
3175 fn best_blitter_precedence_kitty_over_everything() {
3176 let all = Capabilities {
3177 kitty_graphics: true,
3178 sixel: true,
3179 blitters: BlitterSupport {
3180 sextant: true,
3181 ..Default::default()
3182 },
3183 ..Default::default()
3184 };
3185 assert_eq!(all.best_blitter(), Blitter::Kitty);
3186
3187 let sixel_and_sextant = Capabilities {
3188 sixel: true,
3189 blitters: BlitterSupport {
3190 sextant: true,
3191 ..Default::default()
3192 },
3193 ..Default::default()
3194 };
3195 assert_eq!(sixel_and_sextant.best_blitter(), Blitter::Sixel);
3196 }
3197
3198 #[test]
3199 fn best_blitter_never_picks_unsupported_protocol() {
3200 for kitty in [false, true] {
3203 for sixel in [false, true] {
3204 for iterm2 in [false, true] {
3205 for sextant in [false, true] {
3206 let caps = Capabilities {
3207 kitty_graphics: kitty,
3208 sixel,
3209 iterm2,
3210 blitters: BlitterSupport {
3211 sextant,
3212 ..Default::default()
3213 },
3214 ..Default::default()
3215 };
3216 match caps.best_blitter() {
3217 Blitter::Kitty => assert!(kitty),
3218 Blitter::Sixel => assert!(sixel && !kitty),
3219 Blitter::Iterm2 => assert!(iterm2 && !sixel && !kitty),
3220 Blitter::Sextant => {
3221 assert!(sextant && !iterm2 && !sixel && !kitty)
3222 }
3223 Blitter::HalfBlock => {
3224 assert!(!kitty && !sixel && !iterm2 && !sextant)
3225 }
3226 }
3227 }
3228 }
3229 }
3230 }
3231 }
3232
3233 #[cfg(feature = "crossterm")]
3234 #[test]
3235 fn parse_da1_attribute_4_sets_sixel() {
3236 let mut caps = Capabilities::default();
3237 parse_da1("\x1b[?62;4;6c", &mut caps);
3238 assert!(caps.sixel);
3239 }
3240
3241 #[cfg(feature = "crossterm")]
3242 #[test]
3243 fn parse_da1_without_4_leaves_sixel_false() {
3244 let mut caps = Capabilities::default();
3245 parse_da1("\x1b[?62;1;6c", &mut caps);
3246 assert!(!caps.sixel);
3247 }
3248
3249 #[cfg(feature = "crossterm")]
3250 #[test]
3251 fn parse_da1_ignores_da2_segment_in_same_string() {
3252 let mut caps = Capabilities::default();
3254 parse_da1("\x1b[?62;1c\x1b[>0;276;0c", &mut caps);
3255 assert!(!caps.sixel);
3256 }
3257
3258 #[cfg(feature = "crossterm")]
3259 #[test]
3260 fn parse_da2_no_panic_on_garbage() {
3261 let mut caps = Capabilities::default();
3262 parse_da2("\x1b[>99;1;0c", &mut caps);
3264 assert!(!caps.kitty_graphics);
3265 parse_da2("not a da2 reply", &mut caps);
3266 assert!(!caps.kitty_graphics);
3267 }
3268
3269 #[cfg(feature = "crossterm")]
3270 #[test]
3271 fn parse_da2_kitty_id_sets_kitty_graphics() {
3272 let mut caps = Capabilities::default();
3273 parse_da2("\x1b[>41;4000;0c", &mut caps);
3275 assert!(caps.kitty_graphics);
3276 }
3277
3278 #[cfg(feature = "crossterm")]
3279 #[test]
3280 fn parse_da2_identity_extracts_id_and_version() {
3281 assert_eq!(parse_da2_identity("\x1b[>0;276;0c"), Some((0, 276)));
3282 assert_eq!(parse_da2_identity("\x1b[>41;4000;0c"), Some((41, 4000)));
3283 assert_eq!(parse_da2_identity("no reply here"), None);
3284 }
3285
3286 #[cfg(feature = "crossterm")]
3287 #[test]
3288 fn parse_kitty_graphics_ack_ok_sets_flag() {
3289 let mut caps = Capabilities::default();
3290 parse_kitty_graphics_ack("\x1b_Gi=31;OK\x1b\\", &mut caps);
3291 assert!(caps.kitty_graphics);
3292 }
3293
3294 #[cfg(feature = "crossterm")]
3295 #[test]
3296 fn parse_kitty_graphics_ack_error_or_wrong_id_leaves_flag() {
3297 let mut caps = Capabilities::default();
3298 parse_kitty_graphics_ack("\x1b_Gi=31;ENOENT:bad\x1b\\", &mut caps);
3300 assert!(!caps.kitty_graphics);
3301 parse_kitty_graphics_ack("\x1b_Gi=99;OK\x1b\\", &mut caps);
3303 assert!(!caps.kitty_graphics);
3304 parse_kitty_graphics_ack("garbage", &mut caps);
3306 assert!(!caps.kitty_graphics);
3307 }
3308
3309 #[cfg(feature = "crossterm")]
3310 #[test]
3311 fn parse_decrpm_sync_output_recognized_states_are_supported() {
3312 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;1$y"), Some(true));
3315 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;2$y"), Some(true));
3316 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;3$y"), Some(true));
3317 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;4$y"), Some(true));
3318 }
3319
3320 #[cfg(feature = "crossterm")]
3321 #[test]
3322 fn parse_decrpm_sync_output_ps0_is_unsupported() {
3323 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;0$y"), Some(false));
3325 }
3326
3327 #[cfg(feature = "crossterm")]
3328 #[test]
3329 fn parse_decrpm_sync_output_garbage_is_none() {
3330 assert_eq!(parse_decrpm_sync_output("not a decrpm reply"), None);
3332 assert_eq!(parse_decrpm_sync_output("\x1b[?2004;1$y"), None);
3334 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;1"), None);
3336 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;x$y"), None);
3338 }
3339
3340 #[test]
3341 fn sync_output_gate_defaults_to_emit() {
3342 assert!(should_emit_synchronized_update());
3347 }
3348
3349 #[cfg(feature = "crossterm")]
3350 #[test]
3351 fn parse_xtgettcap_tc_sets_truecolor() {
3352 let mut caps = Capabilities::default();
3353 parse_xtgettcap_truecolor("\x1bP1+r5463=\x1b\\", &mut caps);
3355 assert!(caps.truecolor);
3356 }
3357
3358 #[cfg(feature = "crossterm")]
3359 #[test]
3360 fn parse_xtgettcap_invalid_leaves_truecolor_false() {
3361 let mut caps = Capabilities::default();
3362 parse_xtgettcap_truecolor("\x1bP0+r5463\x1b\\", &mut caps);
3364 assert!(!caps.truecolor);
3365 parse_xtgettcap_truecolor("\x1bP1+r1234=\x1b\\", &mut caps);
3367 assert!(!caps.truecolor);
3368 }
3369
3370 #[cfg(feature = "crossterm")]
3371 #[test]
3372 fn base64_decode_round_trip_hello() {
3373 let encoded = base64_encode("hello".as_bytes());
3374 assert_eq!(base64_decode(&encoded), Some("hello".to_string()));
3375 }
3376
3377 #[cfg(feature = "crossterm")]
3378 #[test]
3379 fn color_scheme_equality() {
3380 assert_eq!(ColorScheme::Dark, ColorScheme::Dark);
3381 assert_ne!(ColorScheme::Dark, ColorScheme::Light);
3382 assert_eq!(ColorScheme::Unknown, ColorScheme::Unknown);
3383 }
3384
3385 fn pair(r: Rect) -> (Rect, Rect) {
3386 (r, r)
3387 }
3388
3389 #[test]
3390 fn find_innermost_rect_picks_smallest() {
3391 let rects = vec![
3392 pair(Rect::new(0, 0, 80, 24)),
3393 pair(Rect::new(5, 2, 30, 10)),
3394 pair(Rect::new(10, 4, 10, 5)),
3395 ];
3396 let result = find_innermost_rect(&rects, 12, 5);
3397 assert_eq!(result, Some(Rect::new(10, 4, 10, 5)));
3398 }
3399
3400 #[test]
3401 fn find_innermost_rect_no_match() {
3402 let rects = vec![pair(Rect::new(10, 10, 5, 5))];
3403 assert_eq!(find_innermost_rect(&rects, 0, 0), None);
3404 }
3405
3406 #[test]
3407 fn find_innermost_rect_empty() {
3408 assert_eq!(find_innermost_rect(&[], 5, 5), None);
3409 }
3410
3411 #[test]
3412 fn find_innermost_rect_returns_content_rect() {
3413 let rects = vec![
3414 (Rect::new(0, 0, 80, 24), Rect::new(1, 1, 78, 22)),
3415 (Rect::new(5, 2, 30, 10), Rect::new(6, 3, 28, 8)),
3416 ];
3417 let result = find_innermost_rect(&rects, 10, 5);
3418 assert_eq!(result, Some(Rect::new(6, 3, 28, 8)));
3419 }
3420
3421 #[test]
3422 fn normalize_selection_already_ordered() {
3423 let (s, e) = normalize_selection((2, 1), (5, 3));
3424 assert_eq!(s, (2, 1));
3425 assert_eq!(e, (5, 3));
3426 }
3427
3428 #[test]
3429 fn normalize_selection_reversed() {
3430 let (s, e) = normalize_selection((5, 3), (2, 1));
3431 assert_eq!(s, (2, 1));
3432 assert_eq!(e, (5, 3));
3433 }
3434
3435 #[test]
3436 fn normalize_selection_same_row() {
3437 let (s, e) = normalize_selection((10, 5), (3, 5));
3438 assert_eq!(s, (3, 5));
3439 assert_eq!(e, (10, 5));
3440 }
3441
3442 #[test]
3443 fn selection_state_mouse_down_finds_rect() {
3444 let hit_map = vec![pair(Rect::new(0, 0, 80, 24)), pair(Rect::new(5, 2, 20, 10))];
3445 let mut sel = SelectionState::default();
3446 sel.mouse_down(10, 5, &hit_map);
3447 assert_eq!(sel.anchor, Some((10, 5)));
3448 assert_eq!(sel.current, Some((10, 5)));
3449 assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 20, 10)));
3450 assert!(!sel.active);
3451 }
3452
3453 #[test]
3454 fn selection_state_drag_activates() {
3455 let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
3456 let mut sel = SelectionState {
3457 anchor: Some((10, 5)),
3458 current: Some((10, 5)),
3459 widget_rect: Some(Rect::new(0, 0, 80, 24)),
3460 ..Default::default()
3461 };
3462 sel.mouse_drag(10, 5, &hit_map);
3463 assert!(!sel.active, "no movement = not active");
3464 sel.mouse_drag(11, 5, &hit_map);
3465 assert!(!sel.active, "1 cell horizontal = not active yet");
3466 sel.mouse_drag(13, 5, &hit_map);
3467 assert!(sel.active, ">1 cell horizontal = active");
3468 }
3469
3470 #[test]
3471 fn selection_state_drag_vertical_activates() {
3472 let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
3473 let mut sel = SelectionState {
3474 anchor: Some((10, 5)),
3475 current: Some((10, 5)),
3476 widget_rect: Some(Rect::new(0, 0, 80, 24)),
3477 ..Default::default()
3478 };
3479 sel.mouse_drag(10, 6, &hit_map);
3480 assert!(sel.active, "any vertical movement = active");
3481 }
3482
3483 #[test]
3484 fn selection_state_drag_expands_widget_rect() {
3485 let hit_map = vec![
3486 pair(Rect::new(0, 0, 80, 24)),
3487 pair(Rect::new(5, 2, 30, 10)),
3488 pair(Rect::new(5, 2, 30, 3)),
3489 ];
3490 let mut sel = SelectionState {
3491 anchor: Some((10, 3)),
3492 current: Some((10, 3)),
3493 widget_rect: Some(Rect::new(5, 2, 30, 3)),
3494 ..Default::default()
3495 };
3496 sel.mouse_drag(10, 6, &hit_map);
3497 assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 30, 10)));
3498 }
3499
3500 #[test]
3501 fn selection_state_clear_resets() {
3502 let mut sel = SelectionState {
3503 anchor: Some((1, 2)),
3504 current: Some((3, 4)),
3505 widget_rect: Some(Rect::new(0, 0, 10, 10)),
3506 active: true,
3507 };
3508 sel.clear();
3509 assert_eq!(sel.anchor, None);
3510 assert_eq!(sel.current, None);
3511 assert_eq!(sel.widget_rect, None);
3512 assert!(!sel.active);
3513 }
3514
3515 #[test]
3516 fn extract_selection_text_single_line() {
3517 let area = Rect::new(0, 0, 20, 5);
3518 let mut buf = Buffer::empty(area);
3519 buf.set_string(0, 0, "Hello World", Style::default());
3520 let sel = SelectionState {
3521 anchor: Some((0, 0)),
3522 current: Some((4, 0)),
3523 widget_rect: Some(area),
3524 active: true,
3525 };
3526 let text = extract_selection_text(&buf, &sel, &[]);
3527 assert_eq!(text, "Hello");
3528 }
3529
3530 #[test]
3531 fn extract_selection_text_multi_line() {
3532 let area = Rect::new(0, 0, 20, 5);
3533 let mut buf = Buffer::empty(area);
3534 buf.set_string(0, 0, "Line one", Style::default());
3535 buf.set_string(0, 1, "Line two", Style::default());
3536 buf.set_string(0, 2, "Line three", Style::default());
3537 let sel = SelectionState {
3538 anchor: Some((5, 0)),
3539 current: Some((3, 2)),
3540 widget_rect: Some(area),
3541 active: true,
3542 };
3543 let text = extract_selection_text(&buf, &sel, &[]);
3544 assert_eq!(text, "one\nLine two\nLine");
3545 }
3546
3547 #[test]
3548 fn extract_selection_text_clamped_to_widget() {
3549 let area = Rect::new(0, 0, 40, 10);
3550 let widget = Rect::new(5, 2, 10, 3);
3551 let mut buf = Buffer::empty(area);
3552 buf.set_string(5, 2, "ABCDEFGHIJ", Style::default());
3553 buf.set_string(5, 3, "KLMNOPQRST", Style::default());
3554 let sel = SelectionState {
3555 anchor: Some((3, 1)),
3556 current: Some((20, 5)),
3557 widget_rect: Some(widget),
3558 active: true,
3559 };
3560 let text = extract_selection_text(&buf, &sel, &[]);
3561 assert_eq!(text, "ABCDEFGHIJ\nKLMNOPQRST");
3562 }
3563
3564 #[test]
3565 fn extract_selection_text_inactive_returns_empty() {
3566 let area = Rect::new(0, 0, 10, 5);
3567 let buf = Buffer::empty(area);
3568 let sel = SelectionState {
3569 anchor: Some((0, 0)),
3570 current: Some((5, 2)),
3571 widget_rect: Some(area),
3572 active: false,
3573 };
3574 assert_eq!(extract_selection_text(&buf, &sel, &[]), "");
3575 }
3576
3577 #[test]
3578 fn apply_selection_overlay_reverses_cells() {
3579 let area = Rect::new(0, 0, 10, 3);
3580 let mut buf = Buffer::empty(area);
3581 buf.set_string(0, 0, "ABCDE", Style::default());
3582 let sel = SelectionState {
3583 anchor: Some((1, 0)),
3584 current: Some((3, 0)),
3585 widget_rect: Some(area),
3586 active: true,
3587 };
3588 apply_selection_overlay(&mut buf, &sel, &[]);
3589 assert!(!buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED));
3590 assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
3591 assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
3592 assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
3593 assert!(!buf.get(4, 0).style.modifiers.contains(Modifiers::REVERSED));
3594 }
3595
3596 #[test]
3597 fn extract_selection_text_skips_border_cells() {
3598 let area = Rect::new(0, 0, 40, 5);
3603 let mut buf = Buffer::empty(area);
3604 buf.set_string(0, 0, "╭", Style::default());
3606 buf.set_string(0, 1, "│", Style::default());
3607 buf.set_string(0, 2, "│", Style::default());
3608 buf.set_string(0, 3, "│", Style::default());
3609 buf.set_string(0, 4, "╰", Style::default());
3610 buf.set_string(19, 0, "╮", Style::default());
3611 buf.set_string(19, 1, "│", Style::default());
3612 buf.set_string(19, 2, "│", Style::default());
3613 buf.set_string(19, 3, "│", Style::default());
3614 buf.set_string(19, 4, "╯", Style::default());
3615 buf.set_string(20, 0, "╭", Style::default());
3617 buf.set_string(20, 1, "│", Style::default());
3618 buf.set_string(20, 2, "│", Style::default());
3619 buf.set_string(20, 3, "│", Style::default());
3620 buf.set_string(20, 4, "╰", Style::default());
3621 buf.set_string(39, 0, "╮", Style::default());
3622 buf.set_string(39, 1, "│", Style::default());
3623 buf.set_string(39, 2, "│", Style::default());
3624 buf.set_string(39, 3, "│", Style::default());
3625 buf.set_string(39, 4, "╯", Style::default());
3626 buf.set_string(1, 1, "Hello Col1", Style::default());
3628 buf.set_string(1, 2, "Line2 Col1", Style::default());
3629 buf.set_string(21, 1, "Hello Col2", Style::default());
3631 buf.set_string(21, 2, "Line2 Col2", Style::default());
3632
3633 let content_map = vec![
3634 (Rect::new(0, 0, 20, 5), Rect::new(1, 1, 18, 3)),
3635 (Rect::new(20, 0, 20, 5), Rect::new(21, 1, 18, 3)),
3636 ];
3637
3638 let sel = SelectionState {
3640 anchor: Some((0, 1)),
3641 current: Some((39, 2)),
3642 widget_rect: Some(area),
3643 active: true,
3644 };
3645 let text = extract_selection_text(&buf, &sel, &content_map);
3646 assert!(!text.contains('│'), "Border char │ found in: {text}");
3648 assert!(!text.contains('╭'), "Border char ╭ found in: {text}");
3649 assert!(!text.contains('╮'), "Border char ╮ found in: {text}");
3650 assert!(
3652 text.contains("Hello Col1"),
3653 "Missing Col1 content in: {text}"
3654 );
3655 assert!(
3656 text.contains("Hello Col2"),
3657 "Missing Col2 content in: {text}"
3658 );
3659 assert!(text.contains("Line2 Col1"), "Missing Col1 line2 in: {text}");
3660 assert!(text.contains("Line2 Col2"), "Missing Col2 line2 in: {text}");
3661 }
3662
3663 #[test]
3664 fn apply_selection_overlay_skips_border_cells() {
3665 let area = Rect::new(0, 0, 20, 3);
3666 let mut buf = Buffer::empty(area);
3667 buf.set_string(0, 0, "│", Style::default());
3668 buf.set_string(1, 0, "ABC", Style::default());
3669 buf.set_string(19, 0, "│", Style::default());
3670
3671 let content_map = vec![(Rect::new(0, 0, 20, 3), Rect::new(1, 0, 18, 3))];
3672 let sel = SelectionState {
3673 anchor: Some((0, 0)),
3674 current: Some((19, 0)),
3675 widget_rect: Some(area),
3676 active: true,
3677 };
3678 apply_selection_overlay(&mut buf, &sel, &content_map);
3679 assert!(
3681 !buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED),
3682 "Left border cell should not be reversed"
3683 );
3684 assert!(
3685 !buf.get(19, 0).style.modifiers.contains(Modifiers::REVERSED),
3686 "Right border cell should not be reversed"
3687 );
3688 assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
3690 assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
3691 assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
3692 }
3693
3694 #[test]
3695 fn copy_to_clipboard_writes_osc52() {
3696 let mut output: Vec<u8> = Vec::new();
3697 copy_to_clipboard(&mut output, "test").unwrap();
3698 let s = String::from_utf8(output).unwrap();
3699 assert!(s.starts_with("\x1b]52;c;"));
3700 assert!(s.ends_with("\x1b\\"));
3701 assert!(s.contains(&base64_encode(b"test")));
3702 }
3703
3704 fn count_move_tos(s: &str) -> usize {
3706 let bytes = s.as_bytes();
3707 let mut count = 0;
3708 let mut i = 0;
3709 while i + 1 < bytes.len() {
3710 if bytes[i] == 0x1b && bytes[i + 1] == b'[' {
3711 let mut j = i + 2;
3713 while j < bytes.len() && !(0x40..=0x7e).contains(&bytes[j]) {
3714 j += 1;
3715 }
3716 if j < bytes.len() && bytes[j] == b'H' {
3717 count += 1;
3718 }
3719 i = j + 1;
3720 } else {
3721 i += 1;
3722 }
3723 }
3724 count
3725 }
3726
3727 #[test]
3728 fn flush_coalesces_consecutive_same_style_cells_into_one_run() {
3729 let area = Rect::new(0, 0, 20, 1);
3731 let mut current = Buffer::empty(area);
3732 let previous = Buffer::empty(area);
3733 let style = Style::new().fg(Color::Red);
3734 for x in 0..10u32 {
3735 let cell = current.get_mut(x, 0);
3736 cell.set_char('X');
3737 cell.set_style(style);
3738 }
3739
3740 let mut out: Vec<u8> = Vec::new();
3741 flush_buffer_diff(
3742 &mut out,
3743 ¤t,
3744 &previous,
3745 ColorDepth::TrueColor,
3746 0,
3747 &mut String::new(),
3748 )
3749 .unwrap();
3750 let s = String::from_utf8(out).unwrap();
3751
3752 assert_eq!(
3754 count_move_tos(&s),
3755 1,
3756 "expected 1 MoveTo for a coalesced run, got {} in {:?}",
3757 count_move_tos(&s),
3758 s
3759 );
3760 assert!(
3762 s.contains("XXXXXXXXXX"),
3763 "expected contiguous run 'XXXXXXXXXX' in {:?}",
3764 s
3765 );
3766 }
3767
3768 #[test]
3769 fn flush_breaks_run_on_style_change() {
3770 let area = Rect::new(0, 0, 20, 1);
3772 let mut current = Buffer::empty(area);
3773 let previous = Buffer::empty(area);
3774 let red = Style::new().fg(Color::Red);
3775 let blue = Style::new().fg(Color::Blue);
3776 for x in 0..5u32 {
3777 let cell = current.get_mut(x, 0);
3778 cell.set_char('R');
3779 cell.set_style(red);
3780 }
3781 for x in 5..10u32 {
3782 let cell = current.get_mut(x, 0);
3783 cell.set_char('B');
3784 cell.set_style(blue);
3785 }
3786
3787 let mut out: Vec<u8> = Vec::new();
3788 flush_buffer_diff(
3789 &mut out,
3790 ¤t,
3791 &previous,
3792 ColorDepth::TrueColor,
3793 0,
3794 &mut String::new(),
3795 )
3796 .unwrap();
3797 let s = String::from_utf8(out).unwrap();
3798
3799 let moves = count_move_tos(&s);
3803 assert!(
3804 moves <= 2,
3805 "expected at most 2 MoveTos across a style boundary, got {} in {:?}",
3806 moves,
3807 s
3808 );
3809 assert!(s.contains("RRRRR"), "missing 'RRRRR' run in {:?}", s);
3810 assert!(s.contains("BBBBB"), "missing 'BBBBB' run in {:?}", s);
3811 }
3812
3813 #[test]
3814 fn flush_breaks_run_on_column_gap() {
3815 let area = Rect::new(0, 0, 20, 1);
3817 let mut current = Buffer::empty(area);
3818 let previous = Buffer::empty(area);
3819 let style = Style::new().fg(Color::Green);
3820 for x in 0..3u32 {
3821 current.get_mut(x, 0).set_char('A').set_style(style);
3822 }
3823 for x in 6..9u32 {
3824 current.get_mut(x, 0).set_char('B').set_style(style);
3825 }
3826
3827 let mut out: Vec<u8> = Vec::new();
3828 flush_buffer_diff(
3829 &mut out,
3830 ¤t,
3831 &previous,
3832 ColorDepth::TrueColor,
3833 0,
3834 &mut String::new(),
3835 )
3836 .unwrap();
3837 let s = String::from_utf8(out).unwrap();
3838
3839 assert_eq!(
3841 count_move_tos(&s),
3842 2,
3843 "expected 2 MoveTos across a column gap, got {} in {:?}",
3844 count_move_tos(&s),
3845 s
3846 );
3847 assert!(s.contains("AAA"), "missing 'AAA' run in {:?}", s);
3848 assert!(s.contains("BBB"), "missing 'BBB' run in {:?}", s);
3849 }
3850
3851 #[test]
3855 fn bufwriter_output_identical_to_direct_write() {
3856 let area = Rect::new(0, 0, 5, 1);
3857 let mut current = Buffer::empty(area);
3858 let previous = Buffer::empty(area);
3859 let style = Style::new().fg(Color::Rgb(255, 128, 0));
3860 for x in 0..5u32 {
3861 current.get_mut(x, 0).set_char('X').set_style(style);
3862 }
3863
3864 let mut direct: Vec<u8> = Vec::new();
3865 flush_buffer_diff(
3866 &mut direct,
3867 ¤t,
3868 &previous,
3869 ColorDepth::TrueColor,
3870 0,
3871 &mut String::new(),
3872 )
3873 .unwrap();
3874
3875 let mut buffered: BufWriter<Vec<u8>> = BufWriter::with_capacity(65536, Vec::new());
3876 flush_buffer_diff(
3877 &mut buffered,
3878 ¤t,
3879 &previous,
3880 ColorDepth::TrueColor,
3881 0,
3882 &mut String::new(),
3883 )
3884 .unwrap();
3885 buffered.flush().unwrap();
3886 let via_buf = buffered.into_inner().unwrap();
3887
3888 assert_eq!(
3889 direct, via_buf,
3890 "BufWriter output must be byte-for-byte identical to direct write"
3891 );
3892 }
3893
3894 #[test]
3898 fn bufwriter_coalesces_writes_into_single_flush() {
3899 #[derive(Debug)]
3900 struct CountingWriter {
3901 buf: Vec<u8>,
3902 write_call_count: usize,
3903 }
3904 impl Write for CountingWriter {
3905 fn write(&mut self, data: &[u8]) -> io::Result<usize> {
3906 self.write_call_count += 1;
3907 self.buf.extend_from_slice(data);
3908 Ok(data.len())
3909 }
3910 fn flush(&mut self) -> io::Result<()> {
3911 Ok(())
3912 }
3913 }
3914
3915 let area = Rect::new(0, 0, 10, 1);
3916 let mut current = Buffer::empty(area);
3917 let previous = Buffer::empty(area);
3918 for x in 0..10u32 {
3920 let color = if x % 2 == 0 {
3921 Color::Rgb(255, 0, 0)
3922 } else {
3923 Color::Rgb(0, 255, 0)
3924 };
3925 current
3926 .get_mut(x, 0)
3927 .set_char('Z')
3928 .set_style(Style::new().fg(color));
3929 }
3930
3931 let sink = CountingWriter {
3932 buf: Vec::new(),
3933 write_call_count: 0,
3934 };
3935 let mut bw = BufWriter::with_capacity(65536, sink);
3936 flush_buffer_diff(
3937 &mut bw,
3938 ¤t,
3939 &previous,
3940 ColorDepth::TrueColor,
3941 0,
3942 &mut String::new(),
3943 )
3944 .unwrap();
3945 bw.flush().unwrap();
3946 let inner = bw.into_inner().unwrap();
3947
3948 assert_eq!(
3950 inner.write_call_count, 1,
3951 "expected 1 write syscall to sink, got {}",
3952 inner.write_call_count
3953 );
3954 }
3955
3956 #[test]
3962 fn flush_skips_unchanged_rows_when_hashes_match() {
3963 let area = Rect::new(0, 0, 20, 4);
3964 let mut current = Buffer::empty(area);
3965 let mut previous = Buffer::empty(area);
3966 for y in 0..4u32 {
3968 current.set_string(0, y, "identical-row-content", Style::new());
3969 previous.set_string(0, y, "identical-row-content", Style::new());
3970 }
3971 current.recompute_line_hashes();
3972 previous.recompute_line_hashes();
3973
3974 let mut out: Vec<u8> = Vec::new();
3975 flush_buffer_diff(
3976 &mut out,
3977 ¤t,
3978 &previous,
3979 ColorDepth::TrueColor,
3980 0,
3981 &mut String::new(),
3982 )
3983 .unwrap();
3984 assert!(
3985 out.is_empty(),
3986 "identical buffers must emit zero flush bytes; got {} bytes: {:?}",
3987 out.len(),
3988 out
3989 );
3990 }
3991
3992 #[test]
3996 fn flush_skips_only_matching_rows_in_mixed_diff() {
3997 let area = Rect::new(0, 0, 6, 3);
3998 let mut current = Buffer::empty(area);
3999 let mut previous = Buffer::empty(area);
4000 current.set_string(0, 0, "abcdef", Style::new());
4001 previous.set_string(0, 0, "abcdef", Style::new());
4002 current.set_string(0, 1, "xxxxxx", Style::new());
4003 previous.set_string(0, 1, "yyyyyy", Style::new());
4004 current.set_string(0, 2, "zzzzzz", Style::new());
4005 previous.set_string(0, 2, "zzzzzz", Style::new());
4006 current.recompute_line_hashes();
4007 previous.recompute_line_hashes();
4008
4009 let mut out: Vec<u8> = Vec::new();
4010 flush_buffer_diff(
4011 &mut out,
4012 ¤t,
4013 &previous,
4014 ColorDepth::TrueColor,
4015 0,
4016 &mut String::new(),
4017 )
4018 .unwrap();
4019 let s = String::from_utf8_lossy(&out);
4020 assert!(s.contains("xxxxxx"), "differing row must flush: {s:?}");
4023 assert!(
4024 !s.contains("abcdef"),
4025 "matching row 0 must not flush: {s:?}"
4026 );
4027 assert!(
4028 !s.contains("zzzzzz"),
4029 "matching row 2 must not flush: {s:?}"
4030 );
4031 }
4032
4033 fn delta_bytes(old: &Style, new: &Style) -> Vec<u8> {
4034 let mut out = Vec::new();
4035 apply_style_delta(&mut out, old, new, ColorDepth::TrueColor).unwrap();
4036 out
4037 }
4038
4039 fn contains_seq(haystack: &[u8], needle: &[u8]) -> bool {
4040 haystack.windows(needle.len()).any(|w| w == needle)
4041 }
4042
4043 #[test]
4044 fn apply_style_delta_emits_blink_set_and_reset() {
4045 let on = delta_bytes(&Style::new(), &Style::new().blink());
4046 assert!(contains_seq(&on, b"\x1b[5m"), "blink set: {on:?}");
4048 let off = delta_bytes(&Style::new().blink(), &Style::new());
4049 assert!(contains_seq(&off, b"\x1b[25m"), "blink reset: {off:?}");
4051 }
4052
4053 #[test]
4054 fn apply_style_delta_emits_overline_set_and_reset() {
4055 let on = delta_bytes(&Style::new(), &Style::new().overline());
4056 assert!(contains_seq(&on, b"\x1b[53m"), "overline set: {on:?}");
4058 let off = delta_bytes(&Style::new().overline(), &Style::new());
4059 assert!(contains_seq(&off, b"\x1b[55m"), "overline reset: {off:?}");
4061 }
4062
4063 #[test]
4064 fn apply_style_delta_emits_curly_underline_subparameter() {
4065 let out = delta_bytes(
4066 &Style::new(),
4067 &Style::new().underline_style(UnderlineStyle::Curly),
4068 );
4069 assert!(contains_seq(&out, b"\x1b[4:3m"), "curly underline: {out:?}");
4070 }
4071
4072 #[test]
4073 fn apply_style_delta_emits_underline_color_and_reset() {
4074 let set = delta_bytes(
4075 &Style::new(),
4076 &Style::new().underline_color(Color::Rgb(255, 0, 0)),
4077 );
4078 assert!(
4079 contains_seq(&set, b"\x1b[58:2::255:0:0m"),
4080 "underline color set: {set:?}"
4081 );
4082 let clear = delta_bytes(
4083 &Style::new().underline_color(Color::Rgb(255, 0, 0)),
4084 &Style::new(),
4085 );
4086 assert!(
4087 contains_seq(&clear, b"\x1b[59m"),
4088 "underline color reset: {clear:?}"
4089 );
4090 }
4091
4092 #[test]
4093 fn apply_style_delta_underline_color_indexed_uses_sgr_58_5() {
4094 let out = delta_bytes(
4095 &Style::new(),
4096 &Style::new().underline_color(Color::Indexed(42)),
4097 );
4098 assert!(
4099 contains_seq(&out, b"\x1b[58:5:42m"),
4100 "indexed underline: {out:?}"
4101 );
4102 }
4103
4104 #[test]
4105 fn apply_style_full_emits_blink_overline_and_underline() {
4106 let mut out = Vec::new();
4107 let style = Style::new()
4108 .blink()
4109 .overline()
4110 .underline_style(UnderlineStyle::Dotted)
4111 .underline_color(Color::Rgb(0, 0, 255));
4112 apply_style(&mut out, &style, ColorDepth::TrueColor).unwrap();
4113 assert!(contains_seq(&out, b"\x1b[5m"), "blink: {out:?}");
4114 assert!(contains_seq(&out, b"\x1b[53m"), "overline: {out:?}");
4115 assert!(
4116 contains_seq(&out, b"\x1b[4:4m"),
4117 "dotted underline: {out:?}"
4118 );
4119 assert!(
4120 contains_seq(&out, b"\x1b[58:2::0:0:255m"),
4121 "underline color: {out:?}"
4122 );
4123 }
4124 #[test]
4128 fn with_sink_captures_flush_bytes_and_drops_clean() {
4129 let mut term = Terminal::with_sink(10, 1, ColorDepth::TrueColor);
4130 term.buffer_mut()
4131 .set_string(0, 0, "Z", Style::new().fg(Color::Rgb(200, 50, 50)));
4132 term.flush().unwrap();
4133 let bytes = term.take_sink_bytes();
4134 let s = String::from_utf8_lossy(&bytes);
4135 assert!(s.contains("\u{1b}[38;2;200;50;50m"), "missing SGR: {s:?}");
4137 assert!(s.contains('Z'), "missing glyph: {s:?}");
4138 assert!(term.take_sink_bytes().is_empty());
4140 drop(term);
4142 }
4143
4144 #[test]
4149 fn reused_run_buf_byte_identical_across_frames() {
4150 let area = Rect::new(0, 0, 12, 2);
4151 let make_frame = || {
4153 let mut current = Buffer::empty(area);
4154 let previous = Buffer::empty(area);
4155 current.set_string(0, 0, "hello world", Style::new().fg(Color::Rgb(1, 2, 3)));
4156 current.set_string(0, 1, "second line", Style::new().fg(Color::Rgb(4, 5, 6)));
4157 (current, previous)
4158 };
4159
4160 let mut baseline: Vec<u8> = Vec::new();
4162 {
4163 let (mut a, mut b) = make_frame();
4164 __bench_flush_buffer_diff_mut_with_buf(
4165 &mut baseline,
4166 &mut a,
4167 &mut b,
4168 ColorDepth::TrueColor,
4169 &mut String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
4170 )
4171 .unwrap();
4172 }
4173
4174 let mut shared = String::with_capacity(RUN_BUF_INITIAL_CAPACITY);
4177 {
4178 let mut warm: Vec<u8> = Vec::new();
4179 let (mut a, mut b) = make_frame();
4180 __bench_flush_buffer_diff_mut_with_buf(
4181 &mut warm,
4182 &mut a,
4183 &mut b,
4184 ColorDepth::TrueColor,
4185 &mut shared,
4186 )
4187 .unwrap();
4188 }
4189 let cap_after_warm = shared.capacity();
4190
4191 let mut reused: Vec<u8> = Vec::new();
4192 let (mut current, mut previous) = make_frame();
4193 __bench_flush_buffer_diff_mut_with_buf(
4194 &mut reused,
4195 &mut current,
4196 &mut previous,
4197 ColorDepth::TrueColor,
4198 &mut shared,
4199 )
4200 .unwrap();
4201
4202 assert_eq!(
4203 baseline, reused,
4204 "reused run_buf must emit byte-identical output"
4205 );
4206 assert!(
4209 shared.capacity() >= cap_after_warm,
4210 "run_buf capacity must persist across frames"
4211 );
4212 }
4213
4214 #[test]
4218 fn osc8_hyperlink_emitted_verbatim_after_write_rewrite() {
4219 let area = Rect::new(0, 0, 8, 1);
4220 let mut current = Buffer::empty(area);
4221 let previous = Buffer::empty(area);
4222 let url = "https://example.com/x";
4223 current.set_string_linked(0, 0, "link", Style::new(), url);
4225
4226 let mut out: Vec<u8> = Vec::new();
4227 flush_buffer_diff(
4228 &mut out,
4229 ¤t,
4230 &previous,
4231 ColorDepth::TrueColor,
4232 0,
4233 &mut String::new(),
4234 )
4235 .unwrap();
4236
4237 let open = format!("\x1b]8;;{url}\x07");
4238 assert!(
4239 contains_seq(&out, open.as_bytes()),
4240 "OSC 8 open must appear verbatim: {:?}",
4241 String::from_utf8_lossy(&out)
4242 );
4243 assert!(
4244 contains_seq(&out, b"\x1b]8;;\x07"),
4245 "OSC 8 close must appear: {:?}",
4246 String::from_utf8_lossy(&out)
4247 );
4248 }
4249
4250 fn kitty_placements(n: usize) -> Vec<KittyPlacement> {
4252 (0..n)
4253 .map(|i| {
4254 let mut rgba = vec![0u8; 256];
4255 rgba[0] = i as u8;
4256 let content_hash = crate::buffer::hash_rgba(&rgba);
4257 KittyPlacement {
4258 content_hash,
4259 rgba: std::sync::Arc::new(rgba),
4260 src_width: 8,
4261 src_height: 8,
4262 x: (i as u32) * 4,
4263 y: (i as u32) * 2,
4264 cols: 4,
4265 rows: 2,
4266 crop_y: 0,
4267 crop_h: 0,
4268 }
4269 })
4270 .collect()
4271 }
4272
4273 #[test]
4279 fn kitty_flush_smallvec_dedup_matches_for_small_n() {
4280 for n in [0usize, 1, 5] {
4281 let placements = kitty_placements(n);
4282 let mut mgr = KittyImageManager::new();
4283
4284 let mut frame1: Vec<u8> = Vec::new();
4286 mgr.flush(&mut frame1, &placements, 0).unwrap();
4287 let s1 = String::from_utf8_lossy(&frame1);
4288 assert_eq!(
4290 s1.matches("a=t,").count(),
4291 n,
4292 "n={n}: expected {n} uploads in frame 1: {s1:?}"
4293 );
4294 assert_eq!(
4295 s1.matches("a=p,").count(),
4296 n,
4297 "n={n}: expected {n} placements in frame 1: {s1:?}"
4298 );
4299
4300 let mut frame2: Vec<u8> = Vec::new();
4302 mgr.flush(&mut frame2, &placements, 0).unwrap();
4303 assert!(
4304 frame2.is_empty(),
4305 "n={n}: identical frame must hit the kitty fast path, got {} bytes",
4306 frame2.len()
4307 );
4308
4309 let mut frame3: Vec<u8> = Vec::new();
4313 mgr.flush(&mut frame3, &[], 0).unwrap();
4314 let s3 = String::from_utf8_lossy(&frame3);
4315 assert_eq!(
4316 s3.matches("a=d,d=i,").count(),
4317 n,
4318 "n={n}: expected {n} placement deletes in frame 3: {s3:?}"
4319 );
4320 assert_eq!(
4321 s3.matches("a=d,d=I,").count(),
4322 n,
4323 "n={n}: expected {n} image-data deletes in frame 3: {s3:?}"
4324 );
4325 }
4326 }
4327
4328 use crate::buffer::{SprixelCell, SprixelPlacement};
4331
4332 fn make_sprixel(cells: Vec<SprixelCell>) -> SprixelPlacement {
4334 SprixelPlacement {
4335 content_hash: 0xABCD,
4336 seq: "<SIXEL>".to_string(),
4337 x: 1,
4338 y: 1,
4339 cols: 2,
4340 rows: 2,
4341 cells,
4342 }
4343 }
4344
4345 #[test]
4346 fn sprixel_no_text_change_emits_zero_bytes() {
4347 let area = Rect::new(0, 0, 10, 5);
4349 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
4350
4351 let mut current = Buffer::empty(area);
4352 current.sprixels.push(placement.clone());
4353 let mut previous = Buffer::empty(area);
4354 previous.sprixels.push(placement);
4355
4356 let mut out: Vec<u8> = Vec::new();
4357 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4358 assert!(out.is_empty(), "stable frame should emit no sprixel bytes");
4359 }
4360
4361 #[test]
4362 fn sprixel_first_frame_blits_once() {
4363 let area = Rect::new(0, 0, 10, 5);
4365 let mut current = Buffer::empty(area);
4366 current
4367 .sprixels
4368 .push(make_sprixel(vec![SprixelCell::Opaque; 4]));
4369 let previous = Buffer::empty(area);
4370
4371 let mut out: Vec<u8> = Vec::new();
4372 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4373 let s = String::from_utf8(out).unwrap();
4374 assert_eq!(s.matches("<SIXEL>").count(), 1);
4375 }
4376
4377 #[test]
4378 fn sprixel_text_in_opaque_cell_reblits_once() {
4379 let area = Rect::new(0, 0, 10, 5);
4381 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
4382
4383 let mut current = Buffer::empty(area);
4384 current.sprixels.push(placement.clone());
4385 current.set_char(1, 1, 'X', Style::new());
4387
4388 let mut previous = Buffer::empty(area);
4389 previous.sprixels.push(placement);
4390
4391 let mut out: Vec<u8> = Vec::new();
4392 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4393 let s = String::from_utf8(out).unwrap();
4394 assert_eq!(
4395 s.matches("<SIXEL>").count(),
4396 1,
4397 "opaque-cell text write must re-blit the graphic exactly once"
4398 );
4399 }
4400
4401 #[test]
4402 fn sprixel_text_in_transparent_cell_does_not_reblit() {
4403 let area = Rect::new(0, 0, 10, 5);
4406 let cells = vec![
4407 SprixelCell::Transparent, SprixelCell::Opaque, SprixelCell::Opaque, SprixelCell::Opaque, ];
4412 let placement = make_sprixel(cells);
4413
4414 let mut current = Buffer::empty(area);
4415 current.sprixels.push(placement.clone());
4416 current.set_char(1, 1, 'X', Style::new());
4417
4418 let mut previous = Buffer::empty(area);
4419 previous.sprixels.push(placement);
4420
4421 let mut out: Vec<u8> = Vec::new();
4422 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4423 assert!(
4424 out.is_empty(),
4425 "text in a transparent footprint cell must emit zero sprixel bytes"
4426 );
4427 }
4428
4429 #[test]
4430 fn sprixel_text_outside_footprint_does_not_reblit() {
4431 let area = Rect::new(0, 0, 10, 5);
4433 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
4434
4435 let mut current = Buffer::empty(area);
4436 current.sprixels.push(placement.clone());
4437 current.set_char(5, 0, 'Z', Style::new());
4439
4440 let mut previous = Buffer::empty(area);
4441 previous.sprixels.push(placement);
4442
4443 let mut out: Vec<u8> = Vec::new();
4444 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4445 assert!(
4446 out.is_empty(),
4447 "text outside the footprint must not re-blit the graphic"
4448 );
4449 }
4450
4451 #[test]
4452 fn sprixel_position_change_reblits() {
4453 let area = Rect::new(0, 0, 10, 5);
4455 let mut moved = make_sprixel(vec![SprixelCell::Opaque; 4]);
4456 let original = moved.clone();
4457 moved.x = 4;
4458
4459 let mut current = Buffer::empty(area);
4460 current.sprixels.push(moved);
4461 let mut previous = Buffer::empty(area);
4462 previous.sprixels.push(original);
4463
4464 let mut out: Vec<u8> = Vec::new();
4465 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4466 let s = String::from_utf8(out).unwrap();
4467 assert_eq!(s.matches("<SIXEL>").count(), 1);
4468 }
4469
4470 #[test]
4471 fn sprixel_content_change_reblits() {
4472 let area = Rect::new(0, 0, 10, 5);
4474 let mut recolored = make_sprixel(vec![SprixelCell::Opaque; 4]);
4475 let original = recolored.clone();
4476 recolored.content_hash = 0x1234;
4477 recolored.seq = "<SIXEL2>".to_string();
4478
4479 let mut current = Buffer::empty(area);
4480 current.sprixels.push(recolored);
4481 let mut previous = Buffer::empty(area);
4482 previous.sprixels.push(original);
4483
4484 let mut out: Vec<u8> = Vec::new();
4485 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4486 let s = String::from_utf8(out).unwrap();
4487 assert_eq!(s.matches("<SIXEL2>").count(), 1);
4488 }
4489
4490 #[test]
4491 fn sprixel_reblit_count_invariant_over_single_cell_writes() {
4492 let area = Rect::new(0, 0, 10, 5);
4496 for (idx, (col, row)) in [(0u32, 0u32), (1, 0), (0, 1), (1, 1)]
4497 .into_iter()
4498 .enumerate()
4499 {
4500 for state in [
4501 SprixelCell::Opaque,
4502 SprixelCell::Mixed,
4503 SprixelCell::Transparent,
4504 ] {
4505 let mut cells = vec![SprixelCell::Opaque; 4];
4506 cells[idx] = state;
4507 let placement = make_sprixel(cells);
4508
4509 let mut current = Buffer::empty(area);
4510 current.sprixels.push(placement.clone());
4511 current.set_char(1 + col, 1 + row, 'A', Style::new());
4512
4513 let mut previous = Buffer::empty(area);
4514 previous.sprixels.push(placement);
4515
4516 let mut out: Vec<u8> = Vec::new();
4517 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4518 let count = String::from_utf8(out).unwrap().matches("<SIXEL>").count();
4519 let expected = if matches!(state, SprixelCell::Transparent) {
4520 0
4521 } else {
4522 1
4523 };
4524 assert_eq!(
4525 count, expected,
4526 "cell ({col},{row}) state {state:?}: expected {expected} re-blits"
4527 );
4528 }
4529 }
4530 }
4531
4532 #[test]
4539 fn sprixel_unchanged_with_hashes_engaged_emits_zero_bytes() {
4540 let area = Rect::new(0, 0, 10, 5);
4545 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
4546
4547 let mut current = Buffer::empty(area);
4548 current.sprixels.push(placement.clone());
4549 let mut previous = Buffer::empty(area);
4550 previous.sprixels.push(placement);
4551
4552 current.recompute_line_hashes();
4554 previous.recompute_line_hashes();
4555 assert!(current.row_clean(1) && current.row_clean(2));
4558 assert_eq!(current.row_hash(1), previous.row_hash(1));
4559
4560 let mut out: Vec<u8> = Vec::new();
4561 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4562 assert!(
4563 out.is_empty(),
4564 "unchanged sprixel must not be re-blitted (per-row shortcut)"
4565 );
4566 }
4567
4568 #[test]
4569 fn sprixel_changed_text_with_hashes_engaged_reblits_once() {
4570 let area = Rect::new(0, 0, 10, 5);
4575 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
4576
4577 let mut current = Buffer::empty(area);
4578 current.sprixels.push(placement.clone());
4579 current.set_char(1, 1, 'X', Style::new());
4580 let mut previous = Buffer::empty(area);
4581 previous.sprixels.push(placement);
4582
4583 current.recompute_line_hashes();
4584 previous.recompute_line_hashes();
4585 assert_ne!(current.row_hash(1), previous.row_hash(1));
4587
4588 let mut out: Vec<u8> = Vec::new();
4589 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4590 let s = String::from_utf8(out).unwrap();
4591 assert_eq!(
4592 s.matches("<SIXEL>").count(),
4593 1,
4594 "annihilating text write must re-blit exactly once"
4595 );
4596 }
4597
4598 #[test]
4599 fn sprixel_changed_text_in_transparent_cell_with_hashes_does_not_reblit() {
4600 let area = Rect::new(0, 0, 10, 5);
4605 let cells = vec![
4606 SprixelCell::Transparent, SprixelCell::Opaque, SprixelCell::Opaque, SprixelCell::Opaque, ];
4611 let placement = make_sprixel(cells);
4612
4613 let mut current = Buffer::empty(area);
4614 current.sprixels.push(placement.clone());
4615 current.set_char(1, 1, 'X', Style::new());
4616 let mut previous = Buffer::empty(area);
4617 previous.sprixels.push(placement);
4618
4619 current.recompute_line_hashes();
4620 previous.recompute_line_hashes();
4621
4622 let mut out: Vec<u8> = Vec::new();
4623 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4624 assert!(
4625 out.is_empty(),
4626 "transparent-cell text write must not re-blit even with hashes engaged"
4627 );
4628 }
4629
4630 #[test]
4631 fn sprixel_key_matches_partial_eq_contract() {
4632 let base = make_sprixel(vec![SprixelCell::Opaque; 4]);
4636 assert_eq!(sprixel_key(&base), sprixel_key(&base.clone()));
4637
4638 let mut moved = base.clone();
4639 moved.x = 7;
4640 assert_ne!(sprixel_key(&base), sprixel_key(&moved));
4641
4642 let mut recolored = base.clone();
4643 recolored.content_hash = 0x9999;
4644 assert_ne!(sprixel_key(&base), sprixel_key(&recolored));
4645
4646 let mut annihilated = base.clone();
4648 annihilated.cells = vec![SprixelCell::Annihilated; 4];
4649 assert_eq!(sprixel_key(&base), sprixel_key(&annihilated));
4650 assert_eq!(base, annihilated);
4651 }
4652
4653 #[test]
4654 fn sprixel_multi_placement_only_changed_one_reblits() {
4655 let area = Rect::new(0, 0, 10, 9);
4659 let mut current = Buffer::empty(area);
4660 let mut previous = Buffer::empty(area);
4661 for i in 0..3u32 {
4662 let p = SprixelPlacement {
4663 content_hash: 0x100 + i as u64,
4664 seq: format!("<S{i}>"),
4665 x: 0,
4666 y: i * 3,
4667 cols: 2,
4668 rows: 2,
4669 cells: vec![SprixelCell::Opaque; 4],
4670 };
4671 current.sprixels.push(p.clone());
4672 previous.sprixels.push(p);
4673 }
4674 current.sprixels[1].x = 5;
4676
4677 current.recompute_line_hashes();
4678 previous.recompute_line_hashes();
4679
4680 let mut out: Vec<u8> = Vec::new();
4681 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4682 let s = String::from_utf8(out).unwrap();
4683 assert_eq!(s.matches("<S0>").count(), 0);
4684 assert_eq!(
4685 s.matches("<S1>").count(),
4686 1,
4687 "only the moved sprixel reblits"
4688 );
4689 assert_eq!(s.matches("<S2>").count(), 0);
4690 }
4691
4692 #[test]
4693 fn bench_sprixel_fixture_steady_state_emits_nothing() {
4694 let fixture = __bench_new_sprixel_fixture(4);
4698 assert_eq!(fixture.len(), 4);
4699 assert!(!fixture.is_empty());
4700 let mut out: Vec<u8> = Vec::new();
4701 fixture.flush(&mut out, 0).unwrap();
4702 assert!(
4703 out.is_empty(),
4704 "steady-state bench fixture re-blits nothing"
4705 );
4706 }
4707}