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 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 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 if !self.scratch_ids.contains(&img_id) {
138 self.scratch_ids.push(img_id);
139 queue!(
141 stdout,
142 Print(format!("\x1b_Ga=d,d=i,i={},q=2\x1b\\", img_id))
143 )?;
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 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::write::ZlibEncoder;
303 use flate2::Compression;
304 let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast());
305 if encoder.write_all(data).is_ok() {
306 if let Ok(compressed) = encoder.finish() {
307 if compressed.len() < data.len() {
309 return (Cow::Owned(compressed), "o=z,");
310 }
311 }
312 }
313 }
314 (Cow::Borrowed(data), "")
315}
316
317pub 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 body = response.strip_prefix("\x1b[6;").or_else(|| {
339 let bytes = response.as_bytes();
341 if bytes.len() > 3 && bytes[0] == 0x9b && bytes[1] == b'6' && bytes[2] == b';' {
342 Some(&response[3..])
343 } else {
344 None
345 }
346 })?;
347 let body = body
348 .strip_suffix('t')
349 .or_else(|| body.strip_suffix("t\x1b"))?;
350 let mut parts = body.split(';');
351 let ch: u32 = parts.next()?.parse().ok()?;
352 let cw: u32 = parts.next()?.parse().ok()?;
353 if cw > 0 && ch > 0 {
354 Some((cw, ch))
355 } else {
356 None
357 }
358}
359
360#[derive(Debug, Clone, Copy, PartialEq, Eq)]
392pub struct BlitterSupport {
393 pub half: bool,
395 pub quad: bool,
397 pub sextant: bool,
401}
402
403impl Default for BlitterSupport {
404 fn default() -> Self {
405 Self {
406 half: true,
407 quad: true,
408 sextant: false,
409 }
410 }
411}
412
413#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
435pub struct Capabilities {
436 pub truecolor: bool,
438 pub sixel: bool,
440 pub iterm2: bool,
443 pub kitty_graphics: bool,
445 pub kitty_keyboard: bool,
447 pub sync_output: bool,
449 pub blitters: BlitterSupport,
451}
452
453#[derive(Debug, Clone, Copy, PartialEq, Eq)]
462pub enum Blitter {
463 Kitty,
465 Sixel,
467 Iterm2,
470 Sextant,
472 HalfBlock,
474}
475
476impl Capabilities {
477 pub fn best_blitter(&self) -> Blitter {
492 if self.kitty_graphics {
493 Blitter::Kitty
494 } else if self.sixel {
495 Blitter::Sixel
496 } else if self.iterm2 {
497 Blitter::Iterm2
498 } else if self.blitters.sextant {
499 Blitter::Sextant
500 } else {
501 Blitter::HalfBlock
502 }
503 }
504}
505
506#[cfg(feature = "crossterm")]
514pub fn capabilities() -> Capabilities {
515 use std::sync::OnceLock;
516 static CACHED: OnceLock<Capabilities> = OnceLock::new();
517 *CACHED.get_or_init(probe_capabilities)
518}
519
520#[cfg(feature = "crossterm")]
526fn probe_capabilities() -> Capabilities {
527 let mut caps = Capabilities::default();
528
529 let mut out = io::stdout();
534 if write!(out, "\x1b[c\x1b[>c").is_ok() && out.flush().is_ok() {
537 if let Some(resp) = read_da_response(Duration::from_millis(90)) {
538 parse_da1(&resp, &mut caps);
539 parse_da2(&resp, &mut caps);
540 }
541 }
542
543 if write!(out, "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\").is_ok() && out.flush().is_ok() {
547 if let Some(resp) = read_osc_response(Duration::from_millis(30)) {
548 parse_kitty_graphics_ack(&resp, &mut caps);
549 }
550 }
551
552 if write!(out, "\x1bP+q5463\x1b\\").is_ok() && out.flush().is_ok() {
555 if let Some(resp) = read_osc_response(Duration::from_millis(30)) {
556 parse_xtgettcap_truecolor(&resp, &mut caps);
557 }
558 }
559
560 if write!(out, "\x1b[?2026$p").is_ok() && out.flush().is_ok() {
568 if let Some(resp) = read_decrpm_response(Duration::from_millis(30)) {
569 match parse_decrpm_sync_output(&resp) {
570 Some(true) => {
571 caps.sync_output = true;
572 let _ = SYNC_OUTPUT_RESOLUTION.set(SyncOutputResolution::Supported);
573 }
574 Some(false) => {
575 let _ = SYNC_OUTPUT_RESOLUTION.set(SyncOutputResolution::Unsupported);
576 }
577 None => {}
578 }
579 }
580 }
581
582 if matches!(ColorDepth::detect(), ColorDepth::TrueColor) {
585 caps.truecolor = true;
586 }
587
588 if !caps.kitty_graphics && term_is_kitty_graphics_host() {
593 caps.kitty_graphics = true;
594 }
595
596 if term_is_iterm_host() {
600 caps.iterm2 = true;
601 }
602
603 caps
604}
605
606#[cfg(feature = "crossterm")]
611fn term_is_iterm_host() -> bool {
612 let term_program = std::env::var("TERM_PROGRAM")
613 .unwrap_or_default()
614 .to_ascii_lowercase();
615 matches!(
616 term_program.as_str(),
617 "iterm.app" | "wezterm" | "tabby" | "mintty"
618 )
619}
620
621#[cfg(feature = "crossterm")]
626fn term_is_kitty_graphics_host() -> bool {
627 let term = std::env::var("TERM")
628 .unwrap_or_default()
629 .to_ascii_lowercase();
630 let term_program = std::env::var("TERM_PROGRAM")
631 .unwrap_or_default()
632 .to_ascii_lowercase();
633 term.contains("kitty") || matches!(term_program.as_str(), "ghostty" | "wezterm" | "kitty")
635}
636
637#[cfg(feature = "crossterm")]
642fn read_da_response(timeout: Duration) -> Option<String> {
643 let deadline = Instant::now() + timeout;
644 let mut stdin = io::stdin();
645 let mut bytes = Vec::new();
646 let mut buf = [0u8; 1];
647 let mut terminators = 0usize;
648
649 while Instant::now() < deadline {
650 if !crossterm::event::poll(Duration::from_millis(10)).ok()? {
651 continue;
652 }
653 let read = stdin.read(&mut buf).ok()?;
654 if read == 0 {
655 continue;
656 }
657 bytes.push(buf[0]);
658 if buf[0] == b'c' {
662 terminators += 1;
663 if terminators >= 2 {
664 break;
665 }
666 }
667 if bytes.len() >= 4096 {
668 break;
669 }
670 }
671
672 if bytes.is_empty() {
673 return None;
674 }
675 String::from_utf8(bytes).ok()
676}
677
678#[cfg(feature = "crossterm")]
682fn parse_da1(response: &str, caps: &mut Capabilities) {
683 let mut search = response;
685 while let Some(pos) = search.find("\x1b[?") {
686 let body = &search[pos + 3..];
687 let Some(end) = body.find('c') else { break };
688 let attrs = &body[..end];
689 for attr in attrs.split(';') {
690 if attr.trim() == "4" {
691 caps.sixel = true;
692 }
693 }
694 search = &body[end + 1..];
695 }
696}
697
698#[cfg(feature = "crossterm")]
706fn parse_da2(response: &str, caps: &mut Capabilities) {
707 let Some((id, _ver)) = parse_da2_identity(response) else {
708 return;
709 };
710 const KITTY_GRAPHICS_DA2_ID: u32 = 41;
715 if id == KITTY_GRAPHICS_DA2_ID {
716 caps.kitty_graphics = true;
717 }
718}
719
720#[cfg(feature = "crossterm")]
722fn parse_da2_identity(response: &str) -> Option<(u32, u32)> {
723 let pos = response.find("\x1b[>")?;
724 let body = &response[pos + 3..];
725 let end = body.find('c')?;
726 let mut parts = body[..end].split(';');
727 let id = parts.next()?.trim().parse::<u32>().ok()?;
728 let ver = parts.next().and_then(|s| s.trim().parse::<u32>().ok());
729 Some((id, ver.unwrap_or(0)))
730}
731
732#[cfg(feature = "crossterm")]
736fn parse_kitty_graphics_ack(response: &str, caps: &mut Capabilities) {
737 if let Some(pos) = response.find("\x1b_G") {
740 let body = &response[pos + 3..];
741 let end = body.find("\x1b\\").unwrap_or(body.len());
742 let payload = &body[..end];
743 if payload.contains("i=31") && payload.contains("OK") {
744 caps.kitty_graphics = true;
745 }
746 }
747}
748
749#[cfg(feature = "crossterm")]
753fn parse_xtgettcap_truecolor(response: &str, caps: &mut Capabilities) {
754 if let Some(pos) = response.find("\x1bP1+r") {
756 let body = &response[pos + 5..];
757 if body
758 .to_ascii_lowercase()
759 .split([';', '\x1b'])
760 .any(|seg| seg.starts_with("5463"))
761 {
762 caps.truecolor = true;
763 }
764 }
765}
766
767#[derive(Debug, Clone, Copy, PartialEq, Eq)]
777enum SyncOutputResolution {
778 Supported,
780 Unsupported,
782}
783
784static SYNC_OUTPUT_RESOLUTION: std::sync::OnceLock<SyncOutputResolution> =
787 std::sync::OnceLock::new();
788
789fn should_emit_synchronized_update() -> bool {
799 !matches!(
800 SYNC_OUTPUT_RESOLUTION.get(),
801 Some(SyncOutputResolution::Unsupported)
802 )
803}
804
805#[cfg(feature = "crossterm")]
809fn read_decrpm_response(timeout: Duration) -> Option<String> {
810 let deadline = Instant::now() + timeout;
811 let mut stdin = io::stdin();
812 let mut bytes = Vec::new();
813 let mut buf = [0u8; 1];
814
815 while Instant::now() < deadline {
816 if !crossterm::event::poll(Duration::from_millis(10)).ok()? {
817 continue;
818 }
819 let read = stdin.read(&mut buf).ok()?;
820 if read == 0 {
821 continue;
822 }
823 bytes.push(buf[0]);
824 if buf[0] == b'y' {
826 break;
827 }
828 if bytes.len() >= 4096 {
829 break;
830 }
831 }
832
833 if bytes.is_empty() {
834 return None;
835 }
836 String::from_utf8(bytes).ok()
837}
838
839#[cfg(feature = "crossterm")]
848fn parse_decrpm_sync_output(response: &str) -> Option<bool> {
849 let pos = response.find("\x1b[?2026;")?;
851 let body = &response[pos + "\x1b[?2026;".len()..];
852 let end = body.find("$y")?;
853 let ps = body[..end].trim().parse::<u32>().ok()?;
854 Some(ps != 0)
856}
857
858fn split_base64(encoded: &str, chunk_size: usize) -> Vec<&str> {
859 let mut chunks = Vec::new();
860 let bytes = encoded.as_bytes();
861 let mut offset = 0;
862 while offset < bytes.len() {
863 let end = (offset + chunk_size).min(bytes.len());
864 chunks.push(&encoded[offset..end]);
865 offset = end;
866 }
867 if chunks.is_empty() {
868 chunks.push("");
869 }
870 chunks
871}
872
873pub(crate) struct Terminal {
874 stdout: Sink,
875 current: Buffer,
876 previous: Buffer,
877 cursor_visible: bool,
878 session: TerminalSessionGuard,
879 color_depth: ColorDepth,
880 pub(crate) theme_bg: Option<Color>,
881 kitty_mgr: KittyImageManager,
882 run_buf: String,
886}
887
888pub(crate) struct InlineTerminal {
889 stdout: Sink,
890 current: Buffer,
891 previous: Buffer,
892 cursor_visible: bool,
893 session: TerminalSessionGuard,
894 height: u32,
895 start_row: u16,
896 reserved: bool,
897 color_depth: ColorDepth,
898 pub(crate) theme_bg: Option<Color>,
899 kitty_mgr: KittyImageManager,
900 run_buf: String,
902}
903
904const RUN_BUF_INITIAL_CAPACITY: usize = 4096;
908
909#[derive(Debug, Clone, Copy, PartialEq, Eq)]
910enum TerminalSessionMode {
911 Fullscreen,
912 Inline,
913}
914
915#[derive(Debug, Clone, Copy)]
916struct TerminalSessionGuard {
917 mode: TerminalSessionMode,
918 mouse_enabled: bool,
919 kitty_keyboard: bool,
920 report_all_keys: bool,
921 harness: bool,
927}
928
929impl TerminalSessionGuard {
930 fn enter(
931 mode: TerminalSessionMode,
932 stdout: &mut impl Write,
933 mouse_enabled: bool,
934 kitty_keyboard: bool,
935 report_all_keys: bool,
936 ) -> io::Result<Self> {
937 let guard = Self {
938 mode,
939 mouse_enabled,
940 kitty_keyboard,
941 report_all_keys,
942 harness: false,
943 };
944
945 terminal::enable_raw_mode()?;
946 if let Err(err) = write_session_enter(stdout, &guard) {
947 guard.restore(stdout, false);
948 return Err(err);
949 }
950
951 let _ = capabilities();
958
959 Ok(guard)
960 }
961
962 fn restore(&self, stdout: &mut impl Write, inline_reserved: bool) {
963 if self.harness {
965 return;
966 }
967 if self.kitty_keyboard {
968 use crossterm::event::PopKeyboardEnhancementFlags;
969 let _ = execute!(stdout, PopKeyboardEnhancementFlags);
970 }
971 if self.mouse_enabled {
972 let _ = execute!(stdout, DisableMouseCapture);
973 }
974 let _ = execute!(stdout, DisableFocusChange);
975 let _ = write_session_cleanup(stdout, self.mode, inline_reserved);
976 let _ = terminal::disable_raw_mode();
977 }
978}
979
980impl Terminal {
981 pub fn new(
986 mouse: bool,
987 kitty_keyboard: bool,
988 report_all_keys: bool,
989 color_depth: ColorDepth,
990 ) -> io::Result<Self> {
991 let (cols, rows) = terminal::size()?;
992 let area = Rect::new(0, 0, cols as u32, rows as u32);
993
994 let mut raw = io::stdout();
995 let session = TerminalSessionGuard::enter(
996 TerminalSessionMode::Fullscreen,
997 &mut raw,
998 mouse,
999 kitty_keyboard,
1000 report_all_keys,
1001 )?;
1002
1003 Ok(Self {
1004 stdout: Sink::Stdout(BufWriter::with_capacity(65536, raw)),
1005 current: Buffer::empty(area),
1006 previous: Buffer::empty(area),
1007 cursor_visible: false,
1008 session,
1009 color_depth,
1010 theme_bg: None,
1011 kitty_mgr: KittyImageManager::new(),
1012 run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
1013 })
1014 }
1015
1016 pub fn size(&self) -> (u32, u32) {
1018 (self.current.area.width, self.current.area.height)
1019 }
1020
1021 pub fn buffer_mut(&mut self) -> &mut Buffer {
1023 &mut self.current
1024 }
1025
1026 pub fn flush(&mut self) -> io::Result<()> {
1030 if self.current.area.width < self.previous.area.width {
1031 execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
1032 }
1033
1034 let sync_guard = should_emit_synchronized_update();
1038 if sync_guard {
1039 queue!(self.stdout, BeginSynchronizedUpdate)?;
1040 }
1041 self.current.recompute_line_hashes();
1046 self.previous.recompute_line_hashes();
1047 flush_buffer_diff(
1048 &mut self.stdout,
1049 &self.current,
1050 &self.previous,
1051 self.color_depth,
1052 0,
1053 &mut self.run_buf,
1054 )?;
1055
1056 self.kitty_mgr
1059 .flush(&mut self.stdout, &self.current.kitty_placements, 0)?;
1060
1061 flush_raw_sequences(&mut self.stdout, &self.current, &self.previous, 0)?;
1063
1064 flush_sprixels(&mut self.stdout, &self.current, &self.previous, 0)?;
1066
1067 if sync_guard {
1068 queue!(self.stdout, EndSynchronizedUpdate)?;
1069 }
1070 flush_cursor(
1071 &mut self.stdout,
1072 &mut self.cursor_visible,
1073 self.current.cursor_pos(),
1074 0,
1075 None,
1076 )?;
1077
1078 self.stdout.flush()?;
1079
1080 std::mem::swap(&mut self.current, &mut self.previous);
1081 if let Some(bg) = self.theme_bg {
1082 self.current.reset_with_bg(bg);
1083 } else {
1084 self.current.reset();
1085 }
1086 Ok(())
1087 }
1088
1089 pub fn handle_resize(&mut self) -> io::Result<()> {
1092 let (cols, rows) = terminal::size()?;
1093 let area = Rect::new(0, 0, cols as u32, rows as u32);
1094 self.current.resize(area);
1095 self.previous.resize(area);
1096 execute!(
1097 self.stdout,
1098 terminal::Clear(terminal::ClearType::All),
1099 cursor::MoveTo(0, 0)
1100 )?;
1101 Ok(())
1102 }
1103}
1104
1105#[cfg(any(test, feature = "pty-test"))]
1106impl Terminal {
1107 pub(crate) fn with_sink(width: u32, height: u32, color_depth: ColorDepth) -> Self {
1121 let area = Rect::new(0, 0, width, height);
1122 Self {
1123 stdout: Sink::Capture(Vec::new()),
1124 current: Buffer::empty(area),
1125 previous: Buffer::empty(area),
1126 cursor_visible: false,
1127 session: TerminalSessionGuard {
1128 mode: TerminalSessionMode::Fullscreen,
1129 mouse_enabled: false,
1130 kitty_keyboard: false,
1131 report_all_keys: false,
1132 harness: true,
1133 },
1134 color_depth,
1135 theme_bg: None,
1136 kitty_mgr: KittyImageManager::new(),
1137 run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
1138 }
1139 }
1140
1141 pub(crate) fn take_sink_bytes(&mut self) -> Vec<u8> {
1146 match &mut self.stdout {
1147 Sink::Capture(v) => std::mem::take(v),
1148 Sink::Stdout(_) => panic!("take_sink_bytes called on a non-capture Terminal"),
1149 }
1150 }
1151}
1152
1153impl crate::Backend for Terminal {
1154 fn size(&self) -> (u32, u32) {
1155 Terminal::size(self)
1156 }
1157
1158 fn buffer_mut(&mut self) -> &mut Buffer {
1159 Terminal::buffer_mut(self)
1160 }
1161
1162 fn flush(&mut self) -> io::Result<()> {
1163 Terminal::flush(self)
1164 }
1165}
1166
1167impl InlineTerminal {
1168 pub fn new(
1174 height: u32,
1175 mouse: bool,
1176 kitty_keyboard: bool,
1177 report_all_keys: bool,
1178 color_depth: ColorDepth,
1179 ) -> io::Result<Self> {
1180 let (cols, _) = terminal::size()?;
1181 let area = Rect::new(0, 0, cols as u32, height);
1182
1183 let mut raw = io::stdout();
1184 let session = TerminalSessionGuard::enter(
1185 TerminalSessionMode::Inline,
1186 &mut raw,
1187 mouse,
1188 kitty_keyboard,
1189 report_all_keys,
1190 )?;
1191
1192 let (_, cursor_row) = match cursor::position() {
1193 Ok(pos) => pos,
1194 Err(err) => {
1195 session.restore(&mut raw, false);
1196 return Err(err);
1197 }
1198 };
1199 Ok(Self {
1200 stdout: Sink::Stdout(BufWriter::with_capacity(65536, raw)),
1201 current: Buffer::empty(area),
1202 previous: Buffer::empty(area),
1203 cursor_visible: false,
1204 session,
1205 height,
1206 start_row: cursor_row,
1207 reserved: false,
1208 color_depth,
1209 theme_bg: None,
1210 kitty_mgr: KittyImageManager::new(),
1211 run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
1212 })
1213 }
1214
1215 pub fn size(&self) -> (u32, u32) {
1217 (self.current.area.width, self.current.area.height)
1218 }
1219
1220 pub fn buffer_mut(&mut self) -> &mut Buffer {
1222 &mut self.current
1223 }
1224
1225 pub fn flush(&mut self) -> io::Result<()> {
1229 if self.current.area.width < self.previous.area.width {
1230 execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
1231 }
1232
1233 let sync_guard = should_emit_synchronized_update();
1236 if sync_guard {
1237 queue!(self.stdout, BeginSynchronizedUpdate)?;
1238 }
1239
1240 if !self.reserved {
1241 queue!(self.stdout, cursor::MoveToColumn(0))?;
1242 for _ in 0..self.height {
1243 queue!(self.stdout, Print("\n"))?;
1244 }
1245 self.reserved = true;
1246
1247 let (_, rows) = terminal::size()?;
1248 let bottom = self.start_row.saturating_add(sat_u16(self.height));
1249 if bottom > rows {
1250 self.start_row = rows.saturating_sub(sat_u16(self.height));
1251 }
1252 }
1253 let row_offset = self.start_row as u32;
1254 self.current.recompute_line_hashes();
1257 self.previous.recompute_line_hashes();
1258 flush_buffer_diff(
1259 &mut self.stdout,
1260 &self.current,
1261 &self.previous,
1262 self.color_depth,
1263 row_offset,
1264 &mut self.run_buf,
1265 )?;
1266
1267 self.kitty_mgr
1273 .flush(&mut self.stdout, &self.current.kitty_placements, row_offset)?;
1274
1275 flush_raw_sequences(&mut self.stdout, &self.current, &self.previous, row_offset)?;
1277
1278 flush_sprixels(&mut self.stdout, &self.current, &self.previous, row_offset)?;
1280
1281 if sync_guard {
1282 queue!(self.stdout, EndSynchronizedUpdate)?;
1283 }
1284 let fallback_row = row_offset + self.height.saturating_sub(1);
1285 flush_cursor(
1286 &mut self.stdout,
1287 &mut self.cursor_visible,
1288 self.current.cursor_pos(),
1289 row_offset,
1290 Some(fallback_row),
1291 )?;
1292
1293 self.stdout.flush()?;
1294
1295 std::mem::swap(&mut self.current, &mut self.previous);
1296 reset_current_buffer(&mut self.current, self.theme_bg);
1297 Ok(())
1298 }
1299
1300 pub fn handle_resize(&mut self) -> io::Result<()> {
1303 let (cols, _) = terminal::size()?;
1304 let area = Rect::new(0, 0, cols as u32, self.height);
1305 self.current.resize(area);
1306 self.previous.resize(area);
1307 execute!(
1308 self.stdout,
1309 terminal::Clear(terminal::ClearType::All),
1310 cursor::MoveTo(0, 0)
1311 )?;
1312 Ok(())
1313 }
1314}
1315
1316impl crate::Backend for InlineTerminal {
1317 fn size(&self) -> (u32, u32) {
1318 InlineTerminal::size(self)
1319 }
1320
1321 fn buffer_mut(&mut self) -> &mut Buffer {
1322 InlineTerminal::buffer_mut(self)
1323 }
1324
1325 fn flush(&mut self) -> io::Result<()> {
1326 InlineTerminal::flush(self)
1327 }
1328}
1329
1330impl Drop for Terminal {
1331 fn drop(&mut self) {
1332 let _ = self.kitty_mgr.delete_all(&mut self.stdout);
1334 let _ = self.stdout.flush();
1335 self.session.restore(&mut self.stdout, false);
1336 }
1337}
1338
1339impl Drop for InlineTerminal {
1340 fn drop(&mut self) {
1341 let _ = self.kitty_mgr.delete_all(&mut self.stdout);
1342 let _ = self.stdout.flush();
1343 self.session.restore(&mut self.stdout, self.reserved);
1344 }
1345}
1346
1347mod selection;
1348pub(crate) use selection::{apply_selection_overlay, extract_selection_text, SelectionState};
1349#[cfg(test)]
1350pub(crate) use selection::{find_innermost_rect, normalize_selection};
1351
1352#[non_exhaustive]
1354#[cfg(feature = "crossterm")]
1355#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1356pub enum ColorScheme {
1357 Dark,
1359 Light,
1361 Unknown,
1363}
1364
1365#[cfg(feature = "crossterm")]
1366fn read_osc_response(timeout: Duration) -> Option<String> {
1367 let deadline = Instant::now() + timeout;
1368 let mut stdin = io::stdin();
1369 let mut bytes = Vec::new();
1370 let mut buf = [0u8; 1];
1371
1372 while Instant::now() < deadline {
1373 if !crossterm::event::poll(Duration::from_millis(10)).ok()? {
1374 continue;
1375 }
1376
1377 let read = stdin.read(&mut buf).ok()?;
1378 if read == 0 {
1379 continue;
1380 }
1381
1382 bytes.push(buf[0]);
1383
1384 if buf[0] == b'\x07' {
1385 break;
1386 }
1387 let len = bytes.len();
1388 if len >= 2 && bytes[len - 2] == 0x1B && bytes[len - 1] == b'\\' {
1389 break;
1390 }
1391
1392 if bytes.len() >= 4096 {
1393 break;
1394 }
1395 }
1396
1397 if bytes.is_empty() {
1398 return None;
1399 }
1400
1401 String::from_utf8(bytes).ok()
1402}
1403
1404#[cfg(feature = "crossterm")]
1406pub fn detect_color_scheme() -> ColorScheme {
1407 let mut stdout = io::stdout();
1408 if write!(stdout, "\x1b]11;?\x07").is_err() {
1409 return ColorScheme::Unknown;
1410 }
1411 if stdout.flush().is_err() {
1412 return ColorScheme::Unknown;
1413 }
1414
1415 let Some(response) = read_osc_response(Duration::from_millis(100)) else {
1416 return ColorScheme::Unknown;
1417 };
1418
1419 parse_osc11_response(&response)
1420}
1421
1422#[cfg(feature = "crossterm")]
1423pub(crate) fn parse_osc11_response(response: &str) -> ColorScheme {
1424 let Some(rgb_pos) = response.find("rgb:") else {
1425 return ColorScheme::Unknown;
1426 };
1427
1428 let payload = &response[rgb_pos + 4..];
1429 let end = payload
1430 .find(['\x07', '\x1b', '\r', '\n', ' ', '\t'])
1431 .unwrap_or(payload.len());
1432 let rgb = &payload[..end];
1433
1434 let mut channels = rgb.split('/');
1435 let (Some(r), Some(g), Some(b), None) = (
1436 channels.next(),
1437 channels.next(),
1438 channels.next(),
1439 channels.next(),
1440 ) else {
1441 return ColorScheme::Unknown;
1442 };
1443
1444 fn parse_channel(channel: &str) -> Option<f64> {
1445 if channel.is_empty() || channel.len() > 4 {
1446 return None;
1447 }
1448 let value = u16::from_str_radix(channel, 16).ok()? as f64;
1449 let max = ((1u32 << (channel.len() * 4)) - 1) as f64;
1450 if max <= 0.0 {
1451 return None;
1452 }
1453 Some((value / max).clamp(0.0, 1.0))
1454 }
1455
1456 let (Some(r), Some(g), Some(b)) = (parse_channel(r), parse_channel(g), parse_channel(b)) else {
1457 return ColorScheme::Unknown;
1458 };
1459
1460 let luminance = 0.299 * r + 0.587 * g + 0.114 * b;
1461 if luminance < 0.5 {
1462 ColorScheme::Dark
1463 } else {
1464 ColorScheme::Light
1465 }
1466}
1467
1468pub(crate) fn base64_encode(input: &[u8]) -> String {
1469 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1470 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
1471 for chunk in input.chunks(3) {
1472 let b0 = chunk[0] as u32;
1473 let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
1474 let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
1475 let triple = (b0 << 16) | (b1 << 8) | b2;
1476 out.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
1477 out.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
1478 out.push(if chunk.len() > 1 {
1479 CHARS[((triple >> 6) & 0x3F) as usize] as char
1480 } else {
1481 '='
1482 });
1483 out.push(if chunk.len() > 2 {
1484 CHARS[(triple & 0x3F) as usize] as char
1485 } else {
1486 '='
1487 });
1488 }
1489 out
1490}
1491
1492pub(crate) fn copy_to_clipboard(w: &mut impl Write, text: &str) -> io::Result<()> {
1493 let encoded = base64_encode(text.as_bytes());
1494 write!(w, "\x1b]52;c;{encoded}\x1b\\")?;
1495 w.flush()
1496}
1497
1498#[cfg(feature = "crossterm")]
1499fn parse_osc52_response(response: &str) -> Option<String> {
1500 let osc_pos = response.find("]52;")?;
1501 let body = &response[osc_pos + 4..];
1502 let semicolon = body.find(';')?;
1503 let payload = &body[semicolon + 1..];
1504
1505 let end = payload
1506 .find("\x1b\\")
1507 .or_else(|| payload.find('\x07'))
1508 .unwrap_or(payload.len());
1509 let encoded = payload[..end].trim();
1510 if encoded.is_empty() || encoded == "?" {
1511 return None;
1512 }
1513
1514 base64_decode(encoded)
1515}
1516
1517#[cfg(feature = "crossterm")]
1550pub fn read_clipboard() -> Option<String> {
1551 let mut stdout = io::stdout();
1552 write!(stdout, "\x1b]52;c;?\x07").ok()?;
1553 stdout.flush().ok()?;
1554
1555 let response = read_osc_response(Duration::from_millis(200))?;
1556 parse_osc52_response(&response)
1557}
1558
1559#[cfg(feature = "crossterm")]
1560fn base64_decode(input: &str) -> Option<String> {
1561 let mut filtered: Vec<u8> = input
1562 .bytes()
1563 .filter(|b| !matches!(b, b' ' | b'\n' | b'\r' | b'\t'))
1564 .collect();
1565
1566 match filtered.len() % 4 {
1567 0 => {}
1568 2 => filtered.extend_from_slice(b"=="),
1569 3 => filtered.push(b'='),
1570 _ => return None,
1571 }
1572
1573 fn decode_val(b: u8) -> Option<u8> {
1574 match b {
1575 b'A'..=b'Z' => Some(b - b'A'),
1576 b'a'..=b'z' => Some(b - b'a' + 26),
1577 b'0'..=b'9' => Some(b - b'0' + 52),
1578 b'+' => Some(62),
1579 b'/' => Some(63),
1580 _ => None,
1581 }
1582 }
1583
1584 let mut out = Vec::with_capacity((filtered.len() / 4) * 3);
1585 for chunk in filtered.chunks_exact(4) {
1586 let p2 = chunk[2] == b'=';
1587 let p3 = chunk[3] == b'=';
1588 if p2 && !p3 {
1589 return None;
1590 }
1591
1592 let v0 = decode_val(chunk[0])? as u32;
1593 let v1 = decode_val(chunk[1])? as u32;
1594 let v2 = if p2 { 0 } else { decode_val(chunk[2])? as u32 };
1595 let v3 = if p3 { 0 } else { decode_val(chunk[3])? as u32 };
1596
1597 let triple = (v0 << 18) | (v1 << 12) | (v2 << 6) | v3;
1598 out.push(((triple >> 16) & 0xFF) as u8);
1599 if !p2 {
1600 out.push(((triple >> 8) & 0xFF) as u8);
1601 }
1602 if !p3 {
1603 out.push((triple & 0xFF) as u8);
1604 }
1605 }
1606
1607 String::from_utf8(out).ok()
1608}
1609
1610#[allow(clippy::too_many_arguments)]
1611#[allow(unused_assignments)]
1612fn flush_buffer_diff(
1613 stdout: &mut impl Write,
1614 current: &Buffer,
1615 previous: &Buffer,
1616 color_depth: ColorDepth,
1617 row_offset: u32,
1618 run_buf: &mut String,
1619) -> io::Result<()> {
1620 let mut last_style = Style::new();
1632 let mut first_style = true;
1633 let mut active_link: Option<&str> = None;
1634 let mut has_updates = false;
1635 let mut last_cursor: Option<(u32, u32)> = None;
1639
1640 run_buf.clear();
1646 let mut run_abs_y: u32 = 0;
1647 let mut run_style: Style = Style::new();
1648 let mut run_link: Option<&str> = None;
1649 let mut run_next_col: u32 = 0;
1650 let mut run_open = false;
1651
1652 macro_rules! flush_run {
1657 ($stdout:expr) => {
1658 if run_open {
1659 queue!($stdout, Print(&run_buf))?;
1660 last_cursor = Some((run_next_col, run_abs_y));
1661 run_buf.clear();
1662 run_open = false;
1663 }
1664 };
1665 }
1666
1667 for y in current.area.y..current.area.bottom() {
1668 if current.row_clean(y)
1677 && current.row_hash(y).is_some()
1678 && current.row_hash(y) == previous.row_hash(y)
1679 {
1680 continue;
1681 }
1682 for x in current.area.x..current.area.right() {
1683 let cell = current.get(x, y);
1684 let prev = previous.get(x, y);
1685 if cell == prev || cell.symbol.is_empty() {
1686 flush_run!(stdout);
1688 continue;
1689 }
1690
1691 let abs_y = row_offset + y;
1692 let cell_link = cell
1697 .hyperlink
1698 .as_deref()
1699 .filter(|u| crate::buffer::is_valid_osc8_url(u));
1700
1701 let extends = run_open
1703 && run_abs_y == abs_y
1704 && run_next_col == x
1705 && run_style == cell.style
1706 && run_link == cell_link;
1707
1708 if !extends {
1709 flush_run!(stdout);
1710
1711 has_updates = true;
1715
1716 let need_move = last_cursor.map_or(true, |(lx, ly)| lx != x || ly != abs_y);
1717 if need_move {
1718 queue!(stdout, cursor::MoveTo(sat_u16(x), sat_u16(abs_y)))?;
1719 }
1720
1721 if cell.style != last_style {
1722 if first_style {
1723 queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
1724 apply_style(stdout, &cell.style, color_depth)?;
1725 first_style = false;
1726 } else {
1727 apply_style_delta(stdout, &last_style, &cell.style, color_depth)?;
1728 }
1729 last_style = cell.style;
1730 }
1731
1732 if cell_link != active_link {
1733 if let Some(url) = cell_link {
1734 queue!(stdout, Print("\x1b]8;;"))?;
1739 queue!(stdout, Print(url))?;
1740 queue!(stdout, Print("\x07"))?;
1741 } else {
1742 queue!(stdout, Print("\x1b]8;;\x07"))?;
1743 }
1744 active_link = cell_link;
1745 }
1746
1747 run_open = true;
1748 run_abs_y = abs_y;
1749 run_style = cell.style;
1750 run_link = cell_link;
1751 }
1752
1753 run_buf.push_str(&cell.symbol);
1757 let char_width = UnicodeWidthStr::width(cell.symbol.as_str()).max(1) as u32;
1758 if char_width > 1 && cell.symbol.chars().any(|c| c == '\u{FE0F}') {
1759 run_buf.push(' ');
1763 }
1764 run_next_col = x + char_width;
1765 }
1766
1767 flush_run!(stdout);
1769 }
1770
1771 if has_updates {
1772 if active_link.is_some() {
1773 queue!(stdout, Print("\x1b]8;;\x07"))?;
1774 }
1775 queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
1776 }
1777
1778 Ok(())
1779}
1780
1781#[doc(hidden)]
1791pub fn __bench_flush_buffer_diff<W: Write>(
1792 w: &mut W,
1793 current: &Buffer,
1794 previous: &Buffer,
1795 color_depth: ColorDepth,
1796) -> io::Result<()> {
1797 let mut run_buf = String::with_capacity(RUN_BUF_INITIAL_CAPACITY);
1800 flush_buffer_diff(w, current, previous, color_depth, 0, &mut run_buf)
1801}
1802
1803#[doc(hidden)]
1812pub fn __bench_flush_buffer_diff_mut<W: Write>(
1813 w: &mut W,
1814 current: &mut Buffer,
1815 previous: &mut Buffer,
1816 color_depth: ColorDepth,
1817) -> io::Result<()> {
1818 let mut run_buf = String::with_capacity(RUN_BUF_INITIAL_CAPACITY);
1822 __bench_flush_buffer_diff_mut_with_buf(w, current, previous, color_depth, &mut run_buf)
1823}
1824
1825#[doc(hidden)]
1850pub fn __bench_flush_buffer_diff_mut_with_buf<W: Write>(
1851 w: &mut W,
1852 current: &mut Buffer,
1853 previous: &mut Buffer,
1854 color_depth: ColorDepth,
1855 run_buf: &mut String,
1856) -> io::Result<()> {
1857 current.recompute_line_hashes();
1858 previous.recompute_line_hashes();
1859 flush_buffer_diff(w, current, previous, color_depth, 0, run_buf)
1860}
1861
1862#[doc(hidden)]
1867pub struct __BenchKittyFixture {
1868 mgr: KittyImageManager,
1869 placements: Vec<KittyPlacement>,
1870}
1871
1872#[doc(hidden)]
1875pub fn __bench_new_kitty_fixture(n: usize) -> __BenchKittyFixture {
1876 let mut placements = Vec::with_capacity(n);
1877 for i in 0..n {
1878 let mut rgba = vec![0u8; 256];
1880 rgba[0] = i as u8;
1882 let content_hash = crate::buffer::hash_rgba(&rgba);
1883 placements.push(KittyPlacement {
1884 content_hash,
1885 rgba: std::sync::Arc::new(rgba),
1886 src_width: 8,
1887 src_height: 8,
1888 x: (i as u32) * 4,
1889 y: (i as u32) * 2,
1890 cols: 4,
1891 rows: 2,
1892 crop_y: 0,
1893 crop_h: 0,
1894 });
1895 }
1896 __BenchKittyFixture {
1897 mgr: KittyImageManager::new(),
1898 placements,
1899 }
1900}
1901
1902impl __BenchKittyFixture {
1903 #[doc(hidden)]
1907 pub fn rgba_strong_counts(&self) -> Vec<usize> {
1908 self.placements
1909 .iter()
1910 .map(|p| std::sync::Arc::strong_count(&p.rgba))
1911 .collect()
1912 }
1913
1914 #[doc(hidden)]
1917 pub fn flush_inline<W: Write>(&mut self, sink: &mut W, row_offset: u32) -> io::Result<()> {
1918 self.mgr.flush(sink, &self.placements, row_offset)
1919 }
1920
1921 #[doc(hidden)]
1923 pub fn len(&self) -> usize {
1924 self.placements.len()
1925 }
1926
1927 #[doc(hidden)]
1929 pub fn is_empty(&self) -> bool {
1930 self.placements.is_empty()
1931 }
1932}
1933
1934#[doc(hidden)]
1944pub fn __bench_flush_kitty<W: Write>(sink: &mut W, n: usize, row_offset: u32) -> io::Result<()> {
1945 let mut fixture = __bench_new_kitty_fixture(n);
1946 fixture.flush_inline(sink, row_offset)
1947}
1948
1949#[doc(hidden)]
1956pub struct __BenchSprixelFixture {
1957 current: Buffer,
1958 previous: Buffer,
1959}
1960
1961#[doc(hidden)]
1970pub fn __bench_new_sprixel_fixture(n: usize) -> __BenchSprixelFixture {
1971 use crate::buffer::{SprixelCell, SprixelPlacement};
1972
1973 let height = (n as u32 * 3).max(1);
1975 let area = Rect::new(0, 0, 8, height);
1976 let mut current = Buffer::empty(area);
1977 let mut previous = Buffer::empty(area);
1978
1979 for i in 0..n {
1980 let placement = SprixelPlacement {
1981 content_hash: 0x5000 + i as u64,
1982 seq: "<SIXEL>".to_string(),
1983 x: 0,
1984 y: i as u32 * 3,
1985 cols: 4,
1986 rows: 2,
1987 cells: vec![SprixelCell::Opaque; 8],
1988 };
1989 current.sprixels.push(placement.clone());
1990 previous.sprixels.push(placement);
1991 }
1992
1993 current.recompute_line_hashes();
1996 previous.recompute_line_hashes();
1997
1998 __BenchSprixelFixture { current, previous }
1999}
2000
2001#[allow(dead_code)]
2010impl __BenchSprixelFixture {
2011 #[doc(hidden)]
2015 pub fn flush<W: Write>(&self, sink: &mut W, row_offset: u32) -> io::Result<()> {
2016 flush_sprixels(sink, &self.current, &self.previous, row_offset)
2017 }
2018
2019 #[doc(hidden)]
2021 pub fn len(&self) -> usize {
2022 self.current.sprixels.len()
2023 }
2024
2025 #[doc(hidden)]
2027 pub fn is_empty(&self) -> bool {
2028 self.current.sprixels.is_empty()
2029 }
2030}
2031
2032#[doc(hidden)]
2042pub fn __bench_flush_sprixels<W: Write>(sink: &mut W, n: usize, row_offset: u32) -> io::Result<()> {
2043 let fixture = __bench_new_sprixel_fixture(n);
2044 if fixture.is_empty() {
2045 return Ok(());
2046 }
2047 debug_assert_eq!(fixture.len(), n);
2048 fixture.flush(sink, row_offset)
2049}
2050
2051fn flush_raw_sequences(
2052 stdout: &mut impl Write,
2053 current: &Buffer,
2054 previous: &Buffer,
2055 row_offset: u32,
2056) -> io::Result<()> {
2057 if current.raw_sequences == previous.raw_sequences {
2058 return Ok(());
2059 }
2060
2061 for (x, y, seq) in ¤t.raw_sequences {
2062 queue!(
2063 stdout,
2064 cursor::MoveTo(sat_u16(*x), sat_u16(row_offset + *y)),
2065 Print(seq)
2066 )?;
2067 }
2068
2069 Ok(())
2070}
2071
2072type SprixelKey = (u64, u32, u32, u32, u32);
2077
2078#[inline]
2080fn sprixel_key(p: &crate::buffer::SprixelPlacement) -> SprixelKey {
2081 (p.content_hash, p.x, p.y, p.cols, p.rows)
2082}
2083
2084fn sprixel_needs_reblit(
2107 placement: &crate::buffer::SprixelPlacement,
2108 current: &Buffer,
2109 previous: &Buffer,
2110 prev_keys: &std::collections::HashSet<SprixelKey>,
2111) -> bool {
2112 use crate::buffer::SprixelCell;
2113
2114 if !prev_keys.contains(&sprixel_key(placement)) {
2119 return true;
2120 }
2121
2122 for row in 0..placement.rows {
2126 let y = placement.y + row;
2127 if current.row_clean(y) && current.row_hash(y) == previous.row_hash(y) {
2131 continue;
2132 }
2133 for col in 0..placement.cols {
2134 let idx = (row * placement.cols + col) as usize;
2135 match placement.cells.get(idx) {
2136 Some(SprixelCell::Opaque) | Some(SprixelCell::Mixed) => {}
2137 _ => continue,
2140 }
2141 let x = placement.x + col;
2142 let (Some(cell), Some(prev)) = (current.try_get(x, y), previous.try_get(x, y)) else {
2147 continue;
2148 };
2149 if cell != prev && !cell.symbol.is_empty() {
2155 return true;
2156 }
2157 }
2158 }
2159
2160 false
2161}
2162
2163fn flush_sprixels(
2175 stdout: &mut impl Write,
2176 current: &Buffer,
2177 previous: &Buffer,
2178 row_offset: u32,
2179) -> io::Result<()> {
2180 if current.sprixels.is_empty() {
2183 return Ok(());
2184 }
2185
2186 let prev_keys: std::collections::HashSet<SprixelKey> =
2187 previous.sprixels.iter().map(sprixel_key).collect();
2188
2189 for placement in ¤t.sprixels {
2190 if sprixel_needs_reblit(placement, current, previous, &prev_keys) {
2191 queue!(
2192 stdout,
2193 cursor::MoveTo(sat_u16(placement.x), sat_u16(row_offset + placement.y)),
2194 Print(&placement.seq)
2195 )?;
2196 }
2197 }
2198 Ok(())
2199}
2200
2201fn flush_cursor(
2202 stdout: &mut impl Write,
2203 cursor_visible: &mut bool,
2204 cursor_pos: Option<(u32, u32)>,
2205 row_offset: u32,
2206 fallback_row: Option<u32>,
2207) -> io::Result<()> {
2208 match cursor_pos {
2209 Some((cx, cy)) => {
2210 if !*cursor_visible {
2211 queue!(stdout, cursor::Show)?;
2212 *cursor_visible = true;
2213 }
2214 queue!(
2215 stdout,
2216 cursor::MoveTo(sat_u16(cx), sat_u16(row_offset + cy))
2217 )?;
2218 }
2219 None => {
2220 if *cursor_visible {
2221 queue!(stdout, cursor::Hide)?;
2222 *cursor_visible = false;
2223 }
2224 if let Some(row) = fallback_row {
2225 queue!(stdout, cursor::MoveTo(0, sat_u16(row)))?;
2226 }
2227 }
2228 }
2229
2230 Ok(())
2231}
2232
2233fn apply_style_delta(
2234 w: &mut impl Write,
2235 old: &Style,
2236 new: &Style,
2237 depth: ColorDepth,
2238) -> io::Result<()> {
2239 if old.fg != new.fg {
2240 match new.fg {
2241 Some(fg) => queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?,
2242 None => queue!(w, SetForegroundColor(CtColor::Reset))?,
2243 }
2244 }
2245 if old.bg != new.bg {
2246 match new.bg {
2247 Some(bg) => queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?,
2248 None => queue!(w, SetBackgroundColor(CtColor::Reset))?,
2249 }
2250 }
2251 let removed = Modifiers(old.modifiers.0 & !new.modifiers.0);
2252 let added = Modifiers(new.modifiers.0 & !old.modifiers.0);
2253 if removed.contains(Modifiers::BOLD) || removed.contains(Modifiers::DIM) {
2254 queue!(w, SetAttribute(Attribute::NormalIntensity))?;
2255 if new.modifiers.contains(Modifiers::BOLD) {
2256 queue!(w, SetAttribute(Attribute::Bold))?;
2257 }
2258 if new.modifiers.contains(Modifiers::DIM) {
2259 queue!(w, SetAttribute(Attribute::Dim))?;
2260 }
2261 } else {
2262 if added.contains(Modifiers::BOLD) {
2263 queue!(w, SetAttribute(Attribute::Bold))?;
2264 }
2265 if added.contains(Modifiers::DIM) {
2266 queue!(w, SetAttribute(Attribute::Dim))?;
2267 }
2268 }
2269 if removed.contains(Modifiers::ITALIC) {
2270 queue!(w, SetAttribute(Attribute::NoItalic))?;
2271 }
2272 if added.contains(Modifiers::ITALIC) {
2273 queue!(w, SetAttribute(Attribute::Italic))?;
2274 }
2275 if removed.contains(Modifiers::UNDERLINE) {
2276 queue!(w, SetAttribute(Attribute::NoUnderline))?;
2277 }
2278 if added.contains(Modifiers::UNDERLINE) {
2279 queue!(w, SetAttribute(Attribute::Underlined))?;
2280 }
2281 if removed.contains(Modifiers::REVERSED) {
2282 queue!(w, SetAttribute(Attribute::NoReverse))?;
2283 }
2284 if added.contains(Modifiers::REVERSED) {
2285 queue!(w, SetAttribute(Attribute::Reverse))?;
2286 }
2287 if removed.contains(Modifiers::STRIKETHROUGH) {
2288 queue!(w, SetAttribute(Attribute::NotCrossedOut))?;
2289 }
2290 if added.contains(Modifiers::STRIKETHROUGH) {
2291 queue!(w, SetAttribute(Attribute::CrossedOut))?;
2292 }
2293 if removed.contains(Modifiers::BLINK) {
2294 queue!(w, SetAttribute(Attribute::NoBlink))?;
2295 }
2296 if added.contains(Modifiers::BLINK) {
2297 queue!(w, SetAttribute(Attribute::SlowBlink))?;
2298 }
2299 if removed.contains(Modifiers::OVERLINE) {
2300 queue!(w, SetAttribute(Attribute::NotOverLined))?;
2301 }
2302 if added.contains(Modifiers::OVERLINE) {
2303 queue!(w, SetAttribute(Attribute::OverLined))?;
2304 }
2305 if old.underline_style != new.underline_style {
2309 write!(w, "\x1b[4:{}m", underline_style_param(new.underline_style))?;
2310 }
2311 if old.underline_color != new.underline_color {
2312 emit_underline_color(w, new.underline_color, depth)?;
2313 }
2314 Ok(())
2315}
2316
2317fn underline_style_param(style: UnderlineStyle) -> u8 {
2319 match style {
2320 UnderlineStyle::Straight => 1,
2321 UnderlineStyle::Double => 2,
2322 UnderlineStyle::Curly => 3,
2323 UnderlineStyle::Dotted => 4,
2324 UnderlineStyle::Dashed => 5,
2325 }
2326}
2327
2328fn emit_underline_color(
2334 w: &mut impl Write,
2335 color: Option<Color>,
2336 depth: ColorDepth,
2337) -> io::Result<()> {
2338 match color {
2339 None => write!(w, "\x1b[59m"),
2340 Some(c) => match c.downsampled(depth) {
2341 Color::Reset => write!(w, "\x1b[59m"),
2342 Color::Rgb(r, g, b) => write!(w, "\x1b[58:2::{r}:{g}:{b}m"),
2343 Color::Indexed(i) => write!(w, "\x1b[58:5:{i}m"),
2344 named => {
2347 let (r, g, b) = named.to_rgb();
2348 write!(w, "\x1b[58:2::{r}:{g}:{b}m")
2349 }
2350 },
2351 }
2352}
2353
2354fn apply_style(w: &mut impl Write, style: &Style, depth: ColorDepth) -> io::Result<()> {
2355 if let Some(fg) = style.fg {
2356 queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?;
2357 }
2358 if let Some(bg) = style.bg {
2359 queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?;
2360 }
2361 let m = style.modifiers;
2362 if m.contains(Modifiers::BOLD) {
2363 queue!(w, SetAttribute(Attribute::Bold))?;
2364 }
2365 if m.contains(Modifiers::DIM) {
2366 queue!(w, SetAttribute(Attribute::Dim))?;
2367 }
2368 if m.contains(Modifiers::ITALIC) {
2369 queue!(w, SetAttribute(Attribute::Italic))?;
2370 }
2371 if m.contains(Modifiers::UNDERLINE) {
2372 queue!(w, SetAttribute(Attribute::Underlined))?;
2373 }
2374 if m.contains(Modifiers::REVERSED) {
2375 queue!(w, SetAttribute(Attribute::Reverse))?;
2376 }
2377 if m.contains(Modifiers::STRIKETHROUGH) {
2378 queue!(w, SetAttribute(Attribute::CrossedOut))?;
2379 }
2380 if m.contains(Modifiers::BLINK) {
2381 queue!(w, SetAttribute(Attribute::SlowBlink))?;
2382 }
2383 if m.contains(Modifiers::OVERLINE) {
2384 queue!(w, SetAttribute(Attribute::OverLined))?;
2385 }
2386 if style.underline_style != UnderlineStyle::Straight {
2387 write!(
2388 w,
2389 "\x1b[4:{}m",
2390 underline_style_param(style.underline_style)
2391 )?;
2392 }
2393 if style.underline_color.is_some() {
2394 emit_underline_color(w, style.underline_color, depth)?;
2395 }
2396 Ok(())
2397}
2398
2399fn to_crossterm_color(color: Color, depth: ColorDepth) -> CtColor {
2400 let color = color.downsampled(depth);
2401 match color {
2402 Color::Reset => CtColor::Reset,
2403 Color::Black => CtColor::Black,
2404 Color::Red => CtColor::DarkRed,
2405 Color::Green => CtColor::DarkGreen,
2406 Color::Yellow => CtColor::DarkYellow,
2407 Color::Blue => CtColor::DarkBlue,
2408 Color::Magenta => CtColor::DarkMagenta,
2409 Color::Cyan => CtColor::DarkCyan,
2410 Color::White => CtColor::White,
2411 Color::DarkGray => CtColor::DarkGrey,
2412 Color::LightRed => CtColor::Red,
2413 Color::LightGreen => CtColor::Green,
2414 Color::LightYellow => CtColor::Yellow,
2415 Color::LightBlue => CtColor::Blue,
2416 Color::LightMagenta => CtColor::Magenta,
2417 Color::LightCyan => CtColor::Cyan,
2418 Color::LightWhite => CtColor::White,
2419 Color::Rgb(r, g, b) => CtColor::Rgb { r, g, b },
2420 Color::Indexed(i) => CtColor::AnsiValue(i),
2421 }
2422}
2423
2424fn reset_current_buffer(buffer: &mut Buffer, theme_bg: Option<Color>) {
2425 if let Some(bg) = theme_bg {
2426 buffer.reset_with_bg(bg);
2427 } else {
2428 buffer.reset();
2429 }
2430}
2431
2432fn write_session_enter(stdout: &mut impl Write, session: &TerminalSessionGuard) -> io::Result<()> {
2433 match session.mode {
2434 TerminalSessionMode::Fullscreen => {
2435 execute!(
2436 stdout,
2437 terminal::EnterAlternateScreen,
2438 cursor::Hide,
2439 EnableBracketedPaste
2440 )?;
2441 }
2442 TerminalSessionMode::Inline => {
2443 execute!(stdout, cursor::Hide, EnableBracketedPaste)?;
2444 }
2445 }
2446
2447 execute!(stdout, EnableFocusChange)?;
2453 if session.mouse_enabled {
2454 execute!(stdout, EnableMouseCapture)?;
2455 }
2456 if session.kitty_keyboard {
2457 use crossterm::event::PushKeyboardEnhancementFlags;
2458 let _ = execute!(
2459 stdout,
2460 PushKeyboardEnhancementFlags(kitty_flags(session.report_all_keys))
2461 );
2462 }
2463
2464 Ok(())
2465}
2466
2467fn kitty_flags(report_all_keys: bool) -> crossterm::event::KeyboardEnhancementFlags {
2477 use crossterm::event::KeyboardEnhancementFlags;
2478 let mut flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
2479 | KeyboardEnhancementFlags::REPORT_EVENT_TYPES;
2480 if report_all_keys {
2481 flags |= KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES;
2482 }
2483 flags
2484}
2485
2486fn write_session_cleanup(
2487 stdout: &mut impl Write,
2488 mode: TerminalSessionMode,
2489 inline_reserved: bool,
2490) -> io::Result<()> {
2491 execute!(
2492 stdout,
2493 ResetColor,
2494 SetAttribute(Attribute::Reset),
2495 cursor::Show,
2496 DisableBracketedPaste
2497 )?;
2498
2499 match mode {
2500 TerminalSessionMode::Fullscreen => {
2501 execute!(stdout, terminal::LeaveAlternateScreen)?;
2502 }
2503 TerminalSessionMode::Inline => {
2504 if inline_reserved {
2505 execute!(
2506 stdout,
2507 cursor::MoveToColumn(0),
2508 cursor::MoveDown(1),
2509 cursor::MoveToColumn(0),
2510 Print("\n")
2511 )?;
2512 } else {
2513 execute!(stdout, Print("\n"))?;
2514 }
2515 }
2516 }
2517
2518 Ok(())
2519}
2520
2521#[cfg(unix)]
2538#[derive(Debug, Clone, Copy)]
2539pub(crate) struct SessionSnapshot {
2540 mode: TerminalSessionMode,
2541 mouse_enabled: bool,
2542 kitty_keyboard: bool,
2543 report_all_keys: bool,
2544}
2545
2546#[cfg(unix)]
2549pub(crate) static NEEDS_FULL_REDRAW: std::sync::atomic::AtomicBool =
2550 std::sync::atomic::AtomicBool::new(false);
2551
2552#[cfg(unix)]
2553impl Terminal {
2554 pub(crate) fn session_snapshot(&self) -> SessionSnapshot {
2557 SessionSnapshot {
2558 mode: self.session.mode,
2559 mouse_enabled: self.session.mouse_enabled,
2560 kitty_keyboard: self.session.kitty_keyboard,
2561 report_all_keys: self.session.report_all_keys,
2562 }
2563 }
2564}
2565
2566#[cfg(unix)]
2567impl InlineTerminal {
2568 pub(crate) fn session_snapshot(&self) -> SessionSnapshot {
2571 SessionSnapshot {
2572 mode: self.session.mode,
2573 mouse_enabled: self.session.mouse_enabled,
2574 kitty_keyboard: self.session.kitty_keyboard,
2575 report_all_keys: self.session.report_all_keys,
2576 }
2577 }
2578}
2579
2580#[cfg(unix)]
2588fn write_suspend_sequence(stdout: &mut impl Write, snapshot: &SessionSnapshot) -> io::Result<()> {
2589 if snapshot.kitty_keyboard {
2590 use crossterm::event::PopKeyboardEnhancementFlags;
2591 execute!(stdout, PopKeyboardEnhancementFlags)?;
2592 }
2593 if snapshot.mouse_enabled {
2594 execute!(stdout, DisableMouseCapture)?;
2595 }
2596 execute!(stdout, DisableFocusChange)?;
2597 write_session_cleanup(stdout, snapshot.mode, false)
2598}
2599
2600#[cfg(unix)]
2607pub(crate) fn suspend_to_shell(snapshot: &SessionSnapshot) {
2608 let mut out = io::stdout();
2609 let _ = write_suspend_sequence(&mut out, snapshot);
2610 let _ = terminal::disable_raw_mode();
2611 let _ = out.flush();
2612}
2613
2614#[cfg(unix)]
2621pub(crate) fn resume_from_shell(snapshot: &SessionSnapshot) {
2622 let mut out = io::stdout();
2623 let _ = terminal::enable_raw_mode();
2624 let guard = TerminalSessionGuard {
2625 mode: snapshot.mode,
2626 mouse_enabled: snapshot.mouse_enabled,
2627 kitty_keyboard: snapshot.kitty_keyboard,
2628 report_all_keys: snapshot.report_all_keys,
2629 harness: false,
2630 };
2631 let _ = write_session_enter(&mut out, &guard);
2632 let _ = out.flush();
2633 NEEDS_FULL_REDRAW.store(true, std::sync::atomic::Ordering::SeqCst);
2634}
2635
2636#[cfg(all(unix, test))]
2638fn test_snapshot(mode: TerminalSessionMode, mouse: bool, kitty: bool) -> SessionSnapshot {
2639 SessionSnapshot {
2640 mode,
2641 mouse_enabled: mouse,
2642 kitty_keyboard: kitty,
2643 report_all_keys: false,
2644 }
2645}
2646
2647#[cfg(all(unix, test))]
2650pub(crate) fn test_session_snapshot() -> SessionSnapshot {
2651 SessionSnapshot {
2652 mode: TerminalSessionMode::Fullscreen,
2653 mouse_enabled: false,
2654 kitty_keyboard: false,
2655 report_all_keys: false,
2656 }
2657}
2658
2659#[cfg(test)]
2660mod tests {
2661 #![allow(clippy::unwrap_used)]
2662 use super::*;
2663
2664 #[test]
2665 fn reset_current_buffer_applies_theme_background() {
2666 let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 1));
2667
2668 reset_current_buffer(&mut buffer, Some(Color::Rgb(10, 20, 30)));
2669 assert_eq!(buffer.get(0, 0).style.bg, Some(Color::Rgb(10, 20, 30)));
2670
2671 reset_current_buffer(&mut buffer, None);
2672 assert_eq!(buffer.get(0, 0).style.bg, None);
2673 }
2674
2675 #[test]
2676 fn fullscreen_session_enter_writes_alt_screen_sequence() {
2677 let session = TerminalSessionGuard {
2678 mode: TerminalSessionMode::Fullscreen,
2679 mouse_enabled: false,
2680 kitty_keyboard: false,
2681 report_all_keys: false,
2682 harness: false,
2683 };
2684 let mut out = Vec::new();
2685 write_session_enter(&mut out, &session).unwrap();
2686 let output = String::from_utf8(out).unwrap();
2687 assert!(output.contains("\u{1b}[?1049h"));
2688 assert!(output.contains("\u{1b}[?25l"));
2689 assert!(output.contains("\u{1b}[?2004h"));
2690 }
2691
2692 #[test]
2693 fn inline_session_enter_skips_alt_screen_sequence() {
2694 let session = TerminalSessionGuard {
2695 mode: TerminalSessionMode::Inline,
2696 mouse_enabled: false,
2697 kitty_keyboard: false,
2698 report_all_keys: false,
2699 harness: false,
2700 };
2701 let mut out = Vec::new();
2702 write_session_enter(&mut out, &session).unwrap();
2703 let output = String::from_utf8(out).unwrap();
2704 assert!(!output.contains("\u{1b}[?1049h"));
2705 assert!(output.contains("\u{1b}[?25l"));
2706 assert!(output.contains("\u{1b}[?2004h"));
2707 }
2708
2709 #[test]
2710 fn fullscreen_session_cleanup_leaves_alt_screen() {
2711 let mut out = Vec::new();
2712 write_session_cleanup(&mut out, TerminalSessionMode::Fullscreen, false).unwrap();
2713 let output = String::from_utf8(out).unwrap();
2714 assert!(output.contains("\u{1b}[?1049l"));
2715 assert!(output.contains("\u{1b}[?25h"));
2716 assert!(output.contains("\u{1b}[?2004l"));
2717 }
2718
2719 #[test]
2720 fn inline_session_cleanup_keeps_normal_screen() {
2721 let mut out = Vec::new();
2722 write_session_cleanup(&mut out, TerminalSessionMode::Inline, false).unwrap();
2723 let output = String::from_utf8(out).unwrap();
2724 assert!(!output.contains("\u{1b}[?1049l"));
2725 assert!(output.ends_with('\n'));
2726 assert!(output.contains("\u{1b}[?25h"));
2727 assert!(output.contains("\u{1b}[?2004l"));
2728 }
2729
2730 #[cfg(unix)]
2733 #[test]
2734 fn suspend_sequence_fullscreen_leaves_alt_screen() {
2735 let snapshot = test_snapshot(TerminalSessionMode::Fullscreen, false, false);
2736 let mut out = Vec::new();
2737 write_suspend_sequence(&mut out, &snapshot).unwrap();
2738 let output = String::from_utf8(out).unwrap();
2739 assert!(output.contains("\u{1b}[?1049l"), "leaves alt screen");
2740 assert!(output.contains("\u{1b}[?25h"), "shows cursor");
2741 assert!(output.contains("\u{1b}[?2004l"), "disables bracketed paste");
2742 }
2743
2744 #[cfg(unix)]
2745 #[test]
2746 fn suspend_sequence_inline_keeps_normal_screen() {
2747 let snapshot = test_snapshot(TerminalSessionMode::Inline, false, false);
2748 let mut out = Vec::new();
2749 write_suspend_sequence(&mut out, &snapshot).unwrap();
2750 let output = String::from_utf8(out).unwrap();
2751 assert!(
2752 !output.contains("\u{1b}[?1049l"),
2753 "inline must not leave alt screen"
2754 );
2755 assert!(output.contains("\u{1b}[?25h"), "shows cursor");
2756 assert!(output.contains("\u{1b}[?2004l"), "disables bracketed paste");
2757 }
2758
2759 #[cfg(unix)]
2760 #[test]
2761 fn suspend_sequence_disables_mouse_and_kitty_when_enabled() {
2762 let snapshot = test_snapshot(TerminalSessionMode::Fullscreen, true, true);
2763 let mut out = Vec::new();
2764 write_suspend_sequence(&mut out, &snapshot).unwrap();
2765 let output = String::from_utf8(out).unwrap();
2767 assert!(output.contains("\u{1b}[?1006l"), "disables SGR mouse mode");
2768 }
2769
2770 #[cfg(unix)]
2771 #[test]
2772 fn resume_sequence_fullscreen_round_trips_enter_and_flags_redraw() {
2773 let snapshot = test_snapshot(TerminalSessionMode::Fullscreen, false, false);
2774
2775 let guard = TerminalSessionGuard {
2777 mode: snapshot.mode,
2778 mouse_enabled: snapshot.mouse_enabled,
2779 kitty_keyboard: snapshot.kitty_keyboard,
2780 report_all_keys: snapshot.report_all_keys,
2781 harness: false,
2782 };
2783 let mut enter_bytes = Vec::new();
2784 write_session_enter(&mut enter_bytes, &guard).unwrap();
2785 let enter = String::from_utf8(enter_bytes).unwrap();
2786 assert!(enter.contains("\u{1b}[?1049h"));
2787 assert!(enter.contains("\u{1b}[?25l"));
2788 assert!(enter.contains("\u{1b}[?2004h"));
2789
2790 NEEDS_FULL_REDRAW.store(false, std::sync::atomic::Ordering::SeqCst);
2792 resume_from_shell(&snapshot);
2793 assert!(
2794 NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst),
2795 "resume must request a full redraw exactly once"
2796 );
2797 assert!(
2798 !NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst),
2799 "the redraw flag is consumed by the first swap (idempotent)"
2800 );
2801 }
2802
2803 #[cfg(unix)]
2804 #[test]
2805 fn needs_full_redraw_swaps_true_once() {
2806 NEEDS_FULL_REDRAW.store(true, std::sync::atomic::Ordering::SeqCst);
2807 assert!(NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst));
2808 assert!(!NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst));
2809 }
2810
2811 #[test]
2812 fn kitty_flags_base_set_excludes_report_all_keys() {
2813 use crossterm::event::KeyboardEnhancementFlags;
2814 let flags = kitty_flags(false);
2815 assert!(flags.contains(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES));
2816 assert!(flags.contains(KeyboardEnhancementFlags::REPORT_EVENT_TYPES));
2817 assert!(!flags.contains(KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES));
2818 }
2819
2820 #[test]
2821 fn kitty_flags_report_all_keys_sets_flag() {
2822 use crossterm::event::KeyboardEnhancementFlags;
2823 let flags = kitty_flags(true);
2824 assert!(flags.contains(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES));
2825 assert!(flags.contains(KeyboardEnhancementFlags::REPORT_EVENT_TYPES));
2826 assert!(flags.contains(KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES));
2827 }
2828
2829 #[test]
2830 fn base64_encode_empty() {
2831 assert_eq!(base64_encode(b""), "");
2832 }
2833
2834 #[test]
2835 fn base64_encode_hello() {
2836 assert_eq!(base64_encode(b"Hello"), "SGVsbG8=");
2837 }
2838
2839 #[test]
2840 fn base64_encode_padding() {
2841 assert_eq!(base64_encode(b"a"), "YQ==");
2842 assert_eq!(base64_encode(b"ab"), "YWI=");
2843 assert_eq!(base64_encode(b"abc"), "YWJj");
2844 }
2845
2846 #[test]
2847 fn base64_encode_unicode() {
2848 assert_eq!(base64_encode("한글".as_bytes()), "7ZWc6riA");
2849 }
2850
2851 #[cfg(feature = "crossterm")]
2852 #[test]
2853 fn parse_osc11_response_dark_and_light() {
2854 assert_eq!(
2855 parse_osc11_response("\x1b]11;rgb:0000/0000/0000\x1b\\"),
2856 ColorScheme::Dark
2857 );
2858 assert_eq!(
2859 parse_osc11_response("\x1b]11;rgb:ffff/ffff/ffff\x07"),
2860 ColorScheme::Light
2861 );
2862 }
2863
2864 #[test]
2867 fn blitter_support_default_is_conservative() {
2868 let b = BlitterSupport::default();
2869 assert!(b.half);
2870 assert!(b.quad);
2871 assert!(!b.sextant);
2872 }
2873
2874 #[test]
2875 fn capabilities_default_is_all_false_but_half_block() {
2876 let c = Capabilities::default();
2877 assert!(!c.truecolor);
2878 assert!(!c.sixel);
2879 assert!(!c.iterm2);
2880 assert!(!c.kitty_graphics);
2881 assert!(!c.kitty_keyboard);
2882 assert!(!c.sync_output);
2883 assert_eq!(c.best_blitter(), Blitter::HalfBlock);
2885 }
2886
2887 #[test]
2888 fn best_blitter_ladder_table() {
2889 let kitty = Capabilities {
2890 kitty_graphics: true,
2891 ..Default::default()
2892 };
2893 assert_eq!(kitty.best_blitter(), Blitter::Kitty);
2894
2895 let sixel = Capabilities {
2896 sixel: true,
2897 ..Default::default()
2898 };
2899 assert_eq!(sixel.best_blitter(), Blitter::Sixel);
2900
2901 let iterm2 = Capabilities {
2902 iterm2: true,
2903 ..Default::default()
2904 };
2905 assert_eq!(iterm2.best_blitter(), Blitter::Iterm2);
2906
2907 let sixel_and_iterm2 = Capabilities {
2909 sixel: true,
2910 iterm2: true,
2911 ..Default::default()
2912 };
2913 assert_eq!(sixel_and_iterm2.best_blitter(), Blitter::Sixel);
2914
2915 let sextant = Capabilities {
2916 blitters: BlitterSupport {
2917 sextant: true,
2918 ..Default::default()
2919 },
2920 ..Default::default()
2921 };
2922 assert_eq!(sextant.best_blitter(), Blitter::Sextant);
2923
2924 assert_eq!(Capabilities::default().best_blitter(), Blitter::HalfBlock);
2925 }
2926
2927 #[test]
2928 fn best_blitter_precedence_kitty_over_everything() {
2929 let all = Capabilities {
2930 kitty_graphics: true,
2931 sixel: true,
2932 blitters: BlitterSupport {
2933 sextant: true,
2934 ..Default::default()
2935 },
2936 ..Default::default()
2937 };
2938 assert_eq!(all.best_blitter(), Blitter::Kitty);
2939
2940 let sixel_and_sextant = Capabilities {
2941 sixel: true,
2942 blitters: BlitterSupport {
2943 sextant: true,
2944 ..Default::default()
2945 },
2946 ..Default::default()
2947 };
2948 assert_eq!(sixel_and_sextant.best_blitter(), Blitter::Sixel);
2949 }
2950
2951 #[test]
2952 fn best_blitter_never_picks_unsupported_protocol() {
2953 for kitty in [false, true] {
2956 for sixel in [false, true] {
2957 for iterm2 in [false, true] {
2958 for sextant in [false, true] {
2959 let caps = Capabilities {
2960 kitty_graphics: kitty,
2961 sixel,
2962 iterm2,
2963 blitters: BlitterSupport {
2964 sextant,
2965 ..Default::default()
2966 },
2967 ..Default::default()
2968 };
2969 match caps.best_blitter() {
2970 Blitter::Kitty => assert!(kitty),
2971 Blitter::Sixel => assert!(sixel && !kitty),
2972 Blitter::Iterm2 => assert!(iterm2 && !sixel && !kitty),
2973 Blitter::Sextant => {
2974 assert!(sextant && !iterm2 && !sixel && !kitty)
2975 }
2976 Blitter::HalfBlock => {
2977 assert!(!kitty && !sixel && !iterm2 && !sextant)
2978 }
2979 }
2980 }
2981 }
2982 }
2983 }
2984 }
2985
2986 #[cfg(feature = "crossterm")]
2987 #[test]
2988 fn parse_da1_attribute_4_sets_sixel() {
2989 let mut caps = Capabilities::default();
2990 parse_da1("\x1b[?62;4;6c", &mut caps);
2991 assert!(caps.sixel);
2992 }
2993
2994 #[cfg(feature = "crossterm")]
2995 #[test]
2996 fn parse_da1_without_4_leaves_sixel_false() {
2997 let mut caps = Capabilities::default();
2998 parse_da1("\x1b[?62;1;6c", &mut caps);
2999 assert!(!caps.sixel);
3000 }
3001
3002 #[cfg(feature = "crossterm")]
3003 #[test]
3004 fn parse_da1_ignores_da2_segment_in_same_string() {
3005 let mut caps = Capabilities::default();
3007 parse_da1("\x1b[?62;1c\x1b[>0;276;0c", &mut caps);
3008 assert!(!caps.sixel);
3009 }
3010
3011 #[cfg(feature = "crossterm")]
3012 #[test]
3013 fn parse_da2_no_panic_on_garbage() {
3014 let mut caps = Capabilities::default();
3015 parse_da2("\x1b[>99;1;0c", &mut caps);
3017 assert!(!caps.kitty_graphics);
3018 parse_da2("not a da2 reply", &mut caps);
3019 assert!(!caps.kitty_graphics);
3020 }
3021
3022 #[cfg(feature = "crossterm")]
3023 #[test]
3024 fn parse_da2_kitty_id_sets_kitty_graphics() {
3025 let mut caps = Capabilities::default();
3026 parse_da2("\x1b[>41;4000;0c", &mut caps);
3028 assert!(caps.kitty_graphics);
3029 }
3030
3031 #[cfg(feature = "crossterm")]
3032 #[test]
3033 fn parse_da2_identity_extracts_id_and_version() {
3034 assert_eq!(parse_da2_identity("\x1b[>0;276;0c"), Some((0, 276)));
3035 assert_eq!(parse_da2_identity("\x1b[>41;4000;0c"), Some((41, 4000)));
3036 assert_eq!(parse_da2_identity("no reply here"), None);
3037 }
3038
3039 #[cfg(feature = "crossterm")]
3040 #[test]
3041 fn parse_kitty_graphics_ack_ok_sets_flag() {
3042 let mut caps = Capabilities::default();
3043 parse_kitty_graphics_ack("\x1b_Gi=31;OK\x1b\\", &mut caps);
3044 assert!(caps.kitty_graphics);
3045 }
3046
3047 #[cfg(feature = "crossterm")]
3048 #[test]
3049 fn parse_kitty_graphics_ack_error_or_wrong_id_leaves_flag() {
3050 let mut caps = Capabilities::default();
3051 parse_kitty_graphics_ack("\x1b_Gi=31;ENOENT:bad\x1b\\", &mut caps);
3053 assert!(!caps.kitty_graphics);
3054 parse_kitty_graphics_ack("\x1b_Gi=99;OK\x1b\\", &mut caps);
3056 assert!(!caps.kitty_graphics);
3057 parse_kitty_graphics_ack("garbage", &mut caps);
3059 assert!(!caps.kitty_graphics);
3060 }
3061
3062 #[cfg(feature = "crossterm")]
3063 #[test]
3064 fn parse_decrpm_sync_output_recognized_states_are_supported() {
3065 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;1$y"), Some(true));
3068 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;2$y"), Some(true));
3069 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;3$y"), Some(true));
3070 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;4$y"), Some(true));
3071 }
3072
3073 #[cfg(feature = "crossterm")]
3074 #[test]
3075 fn parse_decrpm_sync_output_ps0_is_unsupported() {
3076 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;0$y"), Some(false));
3078 }
3079
3080 #[cfg(feature = "crossterm")]
3081 #[test]
3082 fn parse_decrpm_sync_output_garbage_is_none() {
3083 assert_eq!(parse_decrpm_sync_output("not a decrpm reply"), None);
3085 assert_eq!(parse_decrpm_sync_output("\x1b[?2004;1$y"), None);
3087 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;1"), None);
3089 assert_eq!(parse_decrpm_sync_output("\x1b[?2026;x$y"), None);
3091 }
3092
3093 #[test]
3094 fn sync_output_gate_defaults_to_emit() {
3095 assert!(should_emit_synchronized_update());
3100 }
3101
3102 #[cfg(feature = "crossterm")]
3103 #[test]
3104 fn parse_xtgettcap_tc_sets_truecolor() {
3105 let mut caps = Capabilities::default();
3106 parse_xtgettcap_truecolor("\x1bP1+r5463=\x1b\\", &mut caps);
3108 assert!(caps.truecolor);
3109 }
3110
3111 #[cfg(feature = "crossterm")]
3112 #[test]
3113 fn parse_xtgettcap_invalid_leaves_truecolor_false() {
3114 let mut caps = Capabilities::default();
3115 parse_xtgettcap_truecolor("\x1bP0+r5463\x1b\\", &mut caps);
3117 assert!(!caps.truecolor);
3118 parse_xtgettcap_truecolor("\x1bP1+r1234=\x1b\\", &mut caps);
3120 assert!(!caps.truecolor);
3121 }
3122
3123 #[cfg(feature = "crossterm")]
3124 #[test]
3125 fn base64_decode_round_trip_hello() {
3126 let encoded = base64_encode("hello".as_bytes());
3127 assert_eq!(base64_decode(&encoded), Some("hello".to_string()));
3128 }
3129
3130 #[cfg(feature = "crossterm")]
3131 #[test]
3132 fn color_scheme_equality() {
3133 assert_eq!(ColorScheme::Dark, ColorScheme::Dark);
3134 assert_ne!(ColorScheme::Dark, ColorScheme::Light);
3135 assert_eq!(ColorScheme::Unknown, ColorScheme::Unknown);
3136 }
3137
3138 fn pair(r: Rect) -> (Rect, Rect) {
3139 (r, r)
3140 }
3141
3142 #[test]
3143 fn find_innermost_rect_picks_smallest() {
3144 let rects = vec![
3145 pair(Rect::new(0, 0, 80, 24)),
3146 pair(Rect::new(5, 2, 30, 10)),
3147 pair(Rect::new(10, 4, 10, 5)),
3148 ];
3149 let result = find_innermost_rect(&rects, 12, 5);
3150 assert_eq!(result, Some(Rect::new(10, 4, 10, 5)));
3151 }
3152
3153 #[test]
3154 fn find_innermost_rect_no_match() {
3155 let rects = vec![pair(Rect::new(10, 10, 5, 5))];
3156 assert_eq!(find_innermost_rect(&rects, 0, 0), None);
3157 }
3158
3159 #[test]
3160 fn find_innermost_rect_empty() {
3161 assert_eq!(find_innermost_rect(&[], 5, 5), None);
3162 }
3163
3164 #[test]
3165 fn find_innermost_rect_returns_content_rect() {
3166 let rects = vec![
3167 (Rect::new(0, 0, 80, 24), Rect::new(1, 1, 78, 22)),
3168 (Rect::new(5, 2, 30, 10), Rect::new(6, 3, 28, 8)),
3169 ];
3170 let result = find_innermost_rect(&rects, 10, 5);
3171 assert_eq!(result, Some(Rect::new(6, 3, 28, 8)));
3172 }
3173
3174 #[test]
3175 fn normalize_selection_already_ordered() {
3176 let (s, e) = normalize_selection((2, 1), (5, 3));
3177 assert_eq!(s, (2, 1));
3178 assert_eq!(e, (5, 3));
3179 }
3180
3181 #[test]
3182 fn normalize_selection_reversed() {
3183 let (s, e) = normalize_selection((5, 3), (2, 1));
3184 assert_eq!(s, (2, 1));
3185 assert_eq!(e, (5, 3));
3186 }
3187
3188 #[test]
3189 fn normalize_selection_same_row() {
3190 let (s, e) = normalize_selection((10, 5), (3, 5));
3191 assert_eq!(s, (3, 5));
3192 assert_eq!(e, (10, 5));
3193 }
3194
3195 #[test]
3196 fn selection_state_mouse_down_finds_rect() {
3197 let hit_map = vec![pair(Rect::new(0, 0, 80, 24)), pair(Rect::new(5, 2, 20, 10))];
3198 let mut sel = SelectionState::default();
3199 sel.mouse_down(10, 5, &hit_map);
3200 assert_eq!(sel.anchor, Some((10, 5)));
3201 assert_eq!(sel.current, Some((10, 5)));
3202 assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 20, 10)));
3203 assert!(!sel.active);
3204 }
3205
3206 #[test]
3207 fn selection_state_drag_activates() {
3208 let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
3209 let mut sel = SelectionState {
3210 anchor: Some((10, 5)),
3211 current: Some((10, 5)),
3212 widget_rect: Some(Rect::new(0, 0, 80, 24)),
3213 ..Default::default()
3214 };
3215 sel.mouse_drag(10, 5, &hit_map);
3216 assert!(!sel.active, "no movement = not active");
3217 sel.mouse_drag(11, 5, &hit_map);
3218 assert!(!sel.active, "1 cell horizontal = not active yet");
3219 sel.mouse_drag(13, 5, &hit_map);
3220 assert!(sel.active, ">1 cell horizontal = active");
3221 }
3222
3223 #[test]
3224 fn selection_state_drag_vertical_activates() {
3225 let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
3226 let mut sel = SelectionState {
3227 anchor: Some((10, 5)),
3228 current: Some((10, 5)),
3229 widget_rect: Some(Rect::new(0, 0, 80, 24)),
3230 ..Default::default()
3231 };
3232 sel.mouse_drag(10, 6, &hit_map);
3233 assert!(sel.active, "any vertical movement = active");
3234 }
3235
3236 #[test]
3237 fn selection_state_drag_expands_widget_rect() {
3238 let hit_map = vec![
3239 pair(Rect::new(0, 0, 80, 24)),
3240 pair(Rect::new(5, 2, 30, 10)),
3241 pair(Rect::new(5, 2, 30, 3)),
3242 ];
3243 let mut sel = SelectionState {
3244 anchor: Some((10, 3)),
3245 current: Some((10, 3)),
3246 widget_rect: Some(Rect::new(5, 2, 30, 3)),
3247 ..Default::default()
3248 };
3249 sel.mouse_drag(10, 6, &hit_map);
3250 assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 30, 10)));
3251 }
3252
3253 #[test]
3254 fn selection_state_clear_resets() {
3255 let mut sel = SelectionState {
3256 anchor: Some((1, 2)),
3257 current: Some((3, 4)),
3258 widget_rect: Some(Rect::new(0, 0, 10, 10)),
3259 active: true,
3260 };
3261 sel.clear();
3262 assert_eq!(sel.anchor, None);
3263 assert_eq!(sel.current, None);
3264 assert_eq!(sel.widget_rect, None);
3265 assert!(!sel.active);
3266 }
3267
3268 #[test]
3269 fn extract_selection_text_single_line() {
3270 let area = Rect::new(0, 0, 20, 5);
3271 let mut buf = Buffer::empty(area);
3272 buf.set_string(0, 0, "Hello World", Style::default());
3273 let sel = SelectionState {
3274 anchor: Some((0, 0)),
3275 current: Some((4, 0)),
3276 widget_rect: Some(area),
3277 active: true,
3278 };
3279 let text = extract_selection_text(&buf, &sel, &[]);
3280 assert_eq!(text, "Hello");
3281 }
3282
3283 #[test]
3284 fn extract_selection_text_multi_line() {
3285 let area = Rect::new(0, 0, 20, 5);
3286 let mut buf = Buffer::empty(area);
3287 buf.set_string(0, 0, "Line one", Style::default());
3288 buf.set_string(0, 1, "Line two", Style::default());
3289 buf.set_string(0, 2, "Line three", Style::default());
3290 let sel = SelectionState {
3291 anchor: Some((5, 0)),
3292 current: Some((3, 2)),
3293 widget_rect: Some(area),
3294 active: true,
3295 };
3296 let text = extract_selection_text(&buf, &sel, &[]);
3297 assert_eq!(text, "one\nLine two\nLine");
3298 }
3299
3300 #[test]
3301 fn extract_selection_text_clamped_to_widget() {
3302 let area = Rect::new(0, 0, 40, 10);
3303 let widget = Rect::new(5, 2, 10, 3);
3304 let mut buf = Buffer::empty(area);
3305 buf.set_string(5, 2, "ABCDEFGHIJ", Style::default());
3306 buf.set_string(5, 3, "KLMNOPQRST", Style::default());
3307 let sel = SelectionState {
3308 anchor: Some((3, 1)),
3309 current: Some((20, 5)),
3310 widget_rect: Some(widget),
3311 active: true,
3312 };
3313 let text = extract_selection_text(&buf, &sel, &[]);
3314 assert_eq!(text, "ABCDEFGHIJ\nKLMNOPQRST");
3315 }
3316
3317 #[test]
3318 fn extract_selection_text_inactive_returns_empty() {
3319 let area = Rect::new(0, 0, 10, 5);
3320 let buf = Buffer::empty(area);
3321 let sel = SelectionState {
3322 anchor: Some((0, 0)),
3323 current: Some((5, 2)),
3324 widget_rect: Some(area),
3325 active: false,
3326 };
3327 assert_eq!(extract_selection_text(&buf, &sel, &[]), "");
3328 }
3329
3330 #[test]
3331 fn apply_selection_overlay_reverses_cells() {
3332 let area = Rect::new(0, 0, 10, 3);
3333 let mut buf = Buffer::empty(area);
3334 buf.set_string(0, 0, "ABCDE", Style::default());
3335 let sel = SelectionState {
3336 anchor: Some((1, 0)),
3337 current: Some((3, 0)),
3338 widget_rect: Some(area),
3339 active: true,
3340 };
3341 apply_selection_overlay(&mut buf, &sel, &[]);
3342 assert!(!buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED));
3343 assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
3344 assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
3345 assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
3346 assert!(!buf.get(4, 0).style.modifiers.contains(Modifiers::REVERSED));
3347 }
3348
3349 #[test]
3350 fn extract_selection_text_skips_border_cells() {
3351 let area = Rect::new(0, 0, 40, 5);
3356 let mut buf = Buffer::empty(area);
3357 buf.set_string(0, 0, "╭", Style::default());
3359 buf.set_string(0, 1, "│", Style::default());
3360 buf.set_string(0, 2, "│", Style::default());
3361 buf.set_string(0, 3, "│", Style::default());
3362 buf.set_string(0, 4, "╰", Style::default());
3363 buf.set_string(19, 0, "╮", Style::default());
3364 buf.set_string(19, 1, "│", Style::default());
3365 buf.set_string(19, 2, "│", Style::default());
3366 buf.set_string(19, 3, "│", Style::default());
3367 buf.set_string(19, 4, "╯", Style::default());
3368 buf.set_string(20, 0, "╭", Style::default());
3370 buf.set_string(20, 1, "│", Style::default());
3371 buf.set_string(20, 2, "│", Style::default());
3372 buf.set_string(20, 3, "│", Style::default());
3373 buf.set_string(20, 4, "╰", Style::default());
3374 buf.set_string(39, 0, "╮", Style::default());
3375 buf.set_string(39, 1, "│", Style::default());
3376 buf.set_string(39, 2, "│", Style::default());
3377 buf.set_string(39, 3, "│", Style::default());
3378 buf.set_string(39, 4, "╯", Style::default());
3379 buf.set_string(1, 1, "Hello Col1", Style::default());
3381 buf.set_string(1, 2, "Line2 Col1", Style::default());
3382 buf.set_string(21, 1, "Hello Col2", Style::default());
3384 buf.set_string(21, 2, "Line2 Col2", Style::default());
3385
3386 let content_map = vec![
3387 (Rect::new(0, 0, 20, 5), Rect::new(1, 1, 18, 3)),
3388 (Rect::new(20, 0, 20, 5), Rect::new(21, 1, 18, 3)),
3389 ];
3390
3391 let sel = SelectionState {
3393 anchor: Some((0, 1)),
3394 current: Some((39, 2)),
3395 widget_rect: Some(area),
3396 active: true,
3397 };
3398 let text = extract_selection_text(&buf, &sel, &content_map);
3399 assert!(!text.contains('│'), "Border char │ found in: {text}");
3401 assert!(!text.contains('╭'), "Border char ╭ found in: {text}");
3402 assert!(!text.contains('╮'), "Border char ╮ found in: {text}");
3403 assert!(
3405 text.contains("Hello Col1"),
3406 "Missing Col1 content in: {text}"
3407 );
3408 assert!(
3409 text.contains("Hello Col2"),
3410 "Missing Col2 content in: {text}"
3411 );
3412 assert!(text.contains("Line2 Col1"), "Missing Col1 line2 in: {text}");
3413 assert!(text.contains("Line2 Col2"), "Missing Col2 line2 in: {text}");
3414 }
3415
3416 #[test]
3417 fn apply_selection_overlay_skips_border_cells() {
3418 let area = Rect::new(0, 0, 20, 3);
3419 let mut buf = Buffer::empty(area);
3420 buf.set_string(0, 0, "│", Style::default());
3421 buf.set_string(1, 0, "ABC", Style::default());
3422 buf.set_string(19, 0, "│", Style::default());
3423
3424 let content_map = vec![(Rect::new(0, 0, 20, 3), Rect::new(1, 0, 18, 3))];
3425 let sel = SelectionState {
3426 anchor: Some((0, 0)),
3427 current: Some((19, 0)),
3428 widget_rect: Some(area),
3429 active: true,
3430 };
3431 apply_selection_overlay(&mut buf, &sel, &content_map);
3432 assert!(
3434 !buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED),
3435 "Left border cell should not be reversed"
3436 );
3437 assert!(
3438 !buf.get(19, 0).style.modifiers.contains(Modifiers::REVERSED),
3439 "Right border cell should not be reversed"
3440 );
3441 assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
3443 assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
3444 assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
3445 }
3446
3447 #[test]
3448 fn copy_to_clipboard_writes_osc52() {
3449 let mut output: Vec<u8> = Vec::new();
3450 copy_to_clipboard(&mut output, "test").unwrap();
3451 let s = String::from_utf8(output).unwrap();
3452 assert!(s.starts_with("\x1b]52;c;"));
3453 assert!(s.ends_with("\x1b\\"));
3454 assert!(s.contains(&base64_encode(b"test")));
3455 }
3456
3457 fn count_move_tos(s: &str) -> usize {
3459 let bytes = s.as_bytes();
3460 let mut count = 0;
3461 let mut i = 0;
3462 while i + 1 < bytes.len() {
3463 if bytes[i] == 0x1b && bytes[i + 1] == b'[' {
3464 let mut j = i + 2;
3466 while j < bytes.len() && !(0x40..=0x7e).contains(&bytes[j]) {
3467 j += 1;
3468 }
3469 if j < bytes.len() && bytes[j] == b'H' {
3470 count += 1;
3471 }
3472 i = j + 1;
3473 } else {
3474 i += 1;
3475 }
3476 }
3477 count
3478 }
3479
3480 #[test]
3481 fn flush_coalesces_consecutive_same_style_cells_into_one_run() {
3482 let area = Rect::new(0, 0, 20, 1);
3484 let mut current = Buffer::empty(area);
3485 let previous = Buffer::empty(area);
3486 let style = Style::new().fg(Color::Red);
3487 for x in 0..10u32 {
3488 let cell = current.get_mut(x, 0);
3489 cell.set_char('X');
3490 cell.set_style(style);
3491 }
3492
3493 let mut out: Vec<u8> = Vec::new();
3494 flush_buffer_diff(
3495 &mut out,
3496 ¤t,
3497 &previous,
3498 ColorDepth::TrueColor,
3499 0,
3500 &mut String::new(),
3501 )
3502 .unwrap();
3503 let s = String::from_utf8(out).unwrap();
3504
3505 assert_eq!(
3507 count_move_tos(&s),
3508 1,
3509 "expected 1 MoveTo for a coalesced run, got {} in {:?}",
3510 count_move_tos(&s),
3511 s
3512 );
3513 assert!(
3515 s.contains("XXXXXXXXXX"),
3516 "expected contiguous run 'XXXXXXXXXX' in {:?}",
3517 s
3518 );
3519 }
3520
3521 #[test]
3522 fn flush_breaks_run_on_style_change() {
3523 let area = Rect::new(0, 0, 20, 1);
3525 let mut current = Buffer::empty(area);
3526 let previous = Buffer::empty(area);
3527 let red = Style::new().fg(Color::Red);
3528 let blue = Style::new().fg(Color::Blue);
3529 for x in 0..5u32 {
3530 let cell = current.get_mut(x, 0);
3531 cell.set_char('R');
3532 cell.set_style(red);
3533 }
3534 for x in 5..10u32 {
3535 let cell = current.get_mut(x, 0);
3536 cell.set_char('B');
3537 cell.set_style(blue);
3538 }
3539
3540 let mut out: Vec<u8> = Vec::new();
3541 flush_buffer_diff(
3542 &mut out,
3543 ¤t,
3544 &previous,
3545 ColorDepth::TrueColor,
3546 0,
3547 &mut String::new(),
3548 )
3549 .unwrap();
3550 let s = String::from_utf8(out).unwrap();
3551
3552 let moves = count_move_tos(&s);
3556 assert!(
3557 moves <= 2,
3558 "expected at most 2 MoveTos across a style boundary, got {} in {:?}",
3559 moves,
3560 s
3561 );
3562 assert!(s.contains("RRRRR"), "missing 'RRRRR' run in {:?}", s);
3563 assert!(s.contains("BBBBB"), "missing 'BBBBB' run in {:?}", s);
3564 }
3565
3566 #[test]
3567 fn flush_breaks_run_on_column_gap() {
3568 let area = Rect::new(0, 0, 20, 1);
3570 let mut current = Buffer::empty(area);
3571 let previous = Buffer::empty(area);
3572 let style = Style::new().fg(Color::Green);
3573 for x in 0..3u32 {
3574 current.get_mut(x, 0).set_char('A').set_style(style);
3575 }
3576 for x in 6..9u32 {
3577 current.get_mut(x, 0).set_char('B').set_style(style);
3578 }
3579
3580 let mut out: Vec<u8> = Vec::new();
3581 flush_buffer_diff(
3582 &mut out,
3583 ¤t,
3584 &previous,
3585 ColorDepth::TrueColor,
3586 0,
3587 &mut String::new(),
3588 )
3589 .unwrap();
3590 let s = String::from_utf8(out).unwrap();
3591
3592 assert_eq!(
3594 count_move_tos(&s),
3595 2,
3596 "expected 2 MoveTos across a column gap, got {} in {:?}",
3597 count_move_tos(&s),
3598 s
3599 );
3600 assert!(s.contains("AAA"), "missing 'AAA' run in {:?}", s);
3601 assert!(s.contains("BBB"), "missing 'BBB' run in {:?}", s);
3602 }
3603
3604 #[test]
3608 fn bufwriter_output_identical_to_direct_write() {
3609 let area = Rect::new(0, 0, 5, 1);
3610 let mut current = Buffer::empty(area);
3611 let previous = Buffer::empty(area);
3612 let style = Style::new().fg(Color::Rgb(255, 128, 0));
3613 for x in 0..5u32 {
3614 current.get_mut(x, 0).set_char('X').set_style(style);
3615 }
3616
3617 let mut direct: Vec<u8> = Vec::new();
3618 flush_buffer_diff(
3619 &mut direct,
3620 ¤t,
3621 &previous,
3622 ColorDepth::TrueColor,
3623 0,
3624 &mut String::new(),
3625 )
3626 .unwrap();
3627
3628 let mut buffered: BufWriter<Vec<u8>> = BufWriter::with_capacity(65536, Vec::new());
3629 flush_buffer_diff(
3630 &mut buffered,
3631 ¤t,
3632 &previous,
3633 ColorDepth::TrueColor,
3634 0,
3635 &mut String::new(),
3636 )
3637 .unwrap();
3638 buffered.flush().unwrap();
3639 let via_buf = buffered.into_inner().unwrap();
3640
3641 assert_eq!(
3642 direct, via_buf,
3643 "BufWriter output must be byte-for-byte identical to direct write"
3644 );
3645 }
3646
3647 #[test]
3651 fn bufwriter_coalesces_writes_into_single_flush() {
3652 #[derive(Debug)]
3653 struct CountingWriter {
3654 buf: Vec<u8>,
3655 write_call_count: usize,
3656 }
3657 impl Write for CountingWriter {
3658 fn write(&mut self, data: &[u8]) -> io::Result<usize> {
3659 self.write_call_count += 1;
3660 self.buf.extend_from_slice(data);
3661 Ok(data.len())
3662 }
3663 fn flush(&mut self) -> io::Result<()> {
3664 Ok(())
3665 }
3666 }
3667
3668 let area = Rect::new(0, 0, 10, 1);
3669 let mut current = Buffer::empty(area);
3670 let previous = Buffer::empty(area);
3671 for x in 0..10u32 {
3673 let color = if x % 2 == 0 {
3674 Color::Rgb(255, 0, 0)
3675 } else {
3676 Color::Rgb(0, 255, 0)
3677 };
3678 current
3679 .get_mut(x, 0)
3680 .set_char('Z')
3681 .set_style(Style::new().fg(color));
3682 }
3683
3684 let sink = CountingWriter {
3685 buf: Vec::new(),
3686 write_call_count: 0,
3687 };
3688 let mut bw = BufWriter::with_capacity(65536, sink);
3689 flush_buffer_diff(
3690 &mut bw,
3691 ¤t,
3692 &previous,
3693 ColorDepth::TrueColor,
3694 0,
3695 &mut String::new(),
3696 )
3697 .unwrap();
3698 bw.flush().unwrap();
3699 let inner = bw.into_inner().unwrap();
3700
3701 assert_eq!(
3703 inner.write_call_count, 1,
3704 "expected 1 write syscall to sink, got {}",
3705 inner.write_call_count
3706 );
3707 }
3708
3709 #[test]
3715 fn flush_skips_unchanged_rows_when_hashes_match() {
3716 let area = Rect::new(0, 0, 20, 4);
3717 let mut current = Buffer::empty(area);
3718 let mut previous = Buffer::empty(area);
3719 for y in 0..4u32 {
3721 current.set_string(0, y, "identical-row-content", Style::new());
3722 previous.set_string(0, y, "identical-row-content", Style::new());
3723 }
3724 current.recompute_line_hashes();
3725 previous.recompute_line_hashes();
3726
3727 let mut out: Vec<u8> = Vec::new();
3728 flush_buffer_diff(
3729 &mut out,
3730 ¤t,
3731 &previous,
3732 ColorDepth::TrueColor,
3733 0,
3734 &mut String::new(),
3735 )
3736 .unwrap();
3737 assert!(
3738 out.is_empty(),
3739 "identical buffers must emit zero flush bytes; got {} bytes: {:?}",
3740 out.len(),
3741 out
3742 );
3743 }
3744
3745 #[test]
3749 fn flush_skips_only_matching_rows_in_mixed_diff() {
3750 let area = Rect::new(0, 0, 6, 3);
3751 let mut current = Buffer::empty(area);
3752 let mut previous = Buffer::empty(area);
3753 current.set_string(0, 0, "abcdef", Style::new());
3754 previous.set_string(0, 0, "abcdef", Style::new());
3755 current.set_string(0, 1, "xxxxxx", Style::new());
3756 previous.set_string(0, 1, "yyyyyy", Style::new());
3757 current.set_string(0, 2, "zzzzzz", Style::new());
3758 previous.set_string(0, 2, "zzzzzz", Style::new());
3759 current.recompute_line_hashes();
3760 previous.recompute_line_hashes();
3761
3762 let mut out: Vec<u8> = Vec::new();
3763 flush_buffer_diff(
3764 &mut out,
3765 ¤t,
3766 &previous,
3767 ColorDepth::TrueColor,
3768 0,
3769 &mut String::new(),
3770 )
3771 .unwrap();
3772 let s = String::from_utf8_lossy(&out);
3773 assert!(s.contains("xxxxxx"), "differing row must flush: {s:?}");
3776 assert!(
3777 !s.contains("abcdef"),
3778 "matching row 0 must not flush: {s:?}"
3779 );
3780 assert!(
3781 !s.contains("zzzzzz"),
3782 "matching row 2 must not flush: {s:?}"
3783 );
3784 }
3785
3786 fn delta_bytes(old: &Style, new: &Style) -> Vec<u8> {
3787 let mut out = Vec::new();
3788 apply_style_delta(&mut out, old, new, ColorDepth::TrueColor).unwrap();
3789 out
3790 }
3791
3792 fn contains_seq(haystack: &[u8], needle: &[u8]) -> bool {
3793 haystack.windows(needle.len()).any(|w| w == needle)
3794 }
3795
3796 #[test]
3797 fn apply_style_delta_emits_blink_set_and_reset() {
3798 let on = delta_bytes(&Style::new(), &Style::new().blink());
3799 assert!(contains_seq(&on, b"\x1b[5m"), "blink set: {on:?}");
3801 let off = delta_bytes(&Style::new().blink(), &Style::new());
3802 assert!(contains_seq(&off, b"\x1b[25m"), "blink reset: {off:?}");
3804 }
3805
3806 #[test]
3807 fn apply_style_delta_emits_overline_set_and_reset() {
3808 let on = delta_bytes(&Style::new(), &Style::new().overline());
3809 assert!(contains_seq(&on, b"\x1b[53m"), "overline set: {on:?}");
3811 let off = delta_bytes(&Style::new().overline(), &Style::new());
3812 assert!(contains_seq(&off, b"\x1b[55m"), "overline reset: {off:?}");
3814 }
3815
3816 #[test]
3817 fn apply_style_delta_emits_curly_underline_subparameter() {
3818 let out = delta_bytes(
3819 &Style::new(),
3820 &Style::new().underline_style(UnderlineStyle::Curly),
3821 );
3822 assert!(contains_seq(&out, b"\x1b[4:3m"), "curly underline: {out:?}");
3823 }
3824
3825 #[test]
3826 fn apply_style_delta_emits_underline_color_and_reset() {
3827 let set = delta_bytes(
3828 &Style::new(),
3829 &Style::new().underline_color(Color::Rgb(255, 0, 0)),
3830 );
3831 assert!(
3832 contains_seq(&set, b"\x1b[58:2::255:0:0m"),
3833 "underline color set: {set:?}"
3834 );
3835 let clear = delta_bytes(
3836 &Style::new().underline_color(Color::Rgb(255, 0, 0)),
3837 &Style::new(),
3838 );
3839 assert!(
3840 contains_seq(&clear, b"\x1b[59m"),
3841 "underline color reset: {clear:?}"
3842 );
3843 }
3844
3845 #[test]
3846 fn apply_style_delta_underline_color_indexed_uses_sgr_58_5() {
3847 let out = delta_bytes(
3848 &Style::new(),
3849 &Style::new().underline_color(Color::Indexed(42)),
3850 );
3851 assert!(
3852 contains_seq(&out, b"\x1b[58:5:42m"),
3853 "indexed underline: {out:?}"
3854 );
3855 }
3856
3857 #[test]
3858 fn apply_style_full_emits_blink_overline_and_underline() {
3859 let mut out = Vec::new();
3860 let style = Style::new()
3861 .blink()
3862 .overline()
3863 .underline_style(UnderlineStyle::Dotted)
3864 .underline_color(Color::Rgb(0, 0, 255));
3865 apply_style(&mut out, &style, ColorDepth::TrueColor).unwrap();
3866 assert!(contains_seq(&out, b"\x1b[5m"), "blink: {out:?}");
3867 assert!(contains_seq(&out, b"\x1b[53m"), "overline: {out:?}");
3868 assert!(
3869 contains_seq(&out, b"\x1b[4:4m"),
3870 "dotted underline: {out:?}"
3871 );
3872 assert!(
3873 contains_seq(&out, b"\x1b[58:2::0:0:255m"),
3874 "underline color: {out:?}"
3875 );
3876 }
3877 #[test]
3881 fn with_sink_captures_flush_bytes_and_drops_clean() {
3882 let mut term = Terminal::with_sink(10, 1, ColorDepth::TrueColor);
3883 term.buffer_mut()
3884 .set_string(0, 0, "Z", Style::new().fg(Color::Rgb(200, 50, 50)));
3885 term.flush().unwrap();
3886 let bytes = term.take_sink_bytes();
3887 let s = String::from_utf8_lossy(&bytes);
3888 assert!(s.contains("\u{1b}[38;2;200;50;50m"), "missing SGR: {s:?}");
3890 assert!(s.contains('Z'), "missing glyph: {s:?}");
3891 assert!(term.take_sink_bytes().is_empty());
3893 drop(term);
3895 }
3896
3897 #[test]
3902 fn reused_run_buf_byte_identical_across_frames() {
3903 let area = Rect::new(0, 0, 12, 2);
3904 let make_frame = || {
3906 let mut current = Buffer::empty(area);
3907 let previous = Buffer::empty(area);
3908 current.set_string(0, 0, "hello world", Style::new().fg(Color::Rgb(1, 2, 3)));
3909 current.set_string(0, 1, "second line", Style::new().fg(Color::Rgb(4, 5, 6)));
3910 (current, previous)
3911 };
3912
3913 let mut baseline: Vec<u8> = Vec::new();
3915 {
3916 let (mut a, mut b) = make_frame();
3917 __bench_flush_buffer_diff_mut_with_buf(
3918 &mut baseline,
3919 &mut a,
3920 &mut b,
3921 ColorDepth::TrueColor,
3922 &mut String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
3923 )
3924 .unwrap();
3925 }
3926
3927 let mut shared = String::with_capacity(RUN_BUF_INITIAL_CAPACITY);
3930 {
3931 let mut warm: Vec<u8> = Vec::new();
3932 let (mut a, mut b) = make_frame();
3933 __bench_flush_buffer_diff_mut_with_buf(
3934 &mut warm,
3935 &mut a,
3936 &mut b,
3937 ColorDepth::TrueColor,
3938 &mut shared,
3939 )
3940 .unwrap();
3941 }
3942 let cap_after_warm = shared.capacity();
3943
3944 let mut reused: Vec<u8> = Vec::new();
3945 let (mut current, mut previous) = make_frame();
3946 __bench_flush_buffer_diff_mut_with_buf(
3947 &mut reused,
3948 &mut current,
3949 &mut previous,
3950 ColorDepth::TrueColor,
3951 &mut shared,
3952 )
3953 .unwrap();
3954
3955 assert_eq!(
3956 baseline, reused,
3957 "reused run_buf must emit byte-identical output"
3958 );
3959 assert!(
3962 shared.capacity() >= cap_after_warm,
3963 "run_buf capacity must persist across frames"
3964 );
3965 }
3966
3967 #[test]
3971 fn osc8_hyperlink_emitted_verbatim_after_write_rewrite() {
3972 let area = Rect::new(0, 0, 8, 1);
3973 let mut current = Buffer::empty(area);
3974 let previous = Buffer::empty(area);
3975 let url = "https://example.com/x";
3976 current.set_string_linked(0, 0, "link", Style::new(), url);
3978
3979 let mut out: Vec<u8> = Vec::new();
3980 flush_buffer_diff(
3981 &mut out,
3982 ¤t,
3983 &previous,
3984 ColorDepth::TrueColor,
3985 0,
3986 &mut String::new(),
3987 )
3988 .unwrap();
3989
3990 let open = format!("\x1b]8;;{url}\x07");
3991 assert!(
3992 contains_seq(&out, open.as_bytes()),
3993 "OSC 8 open must appear verbatim: {:?}",
3994 String::from_utf8_lossy(&out)
3995 );
3996 assert!(
3997 contains_seq(&out, b"\x1b]8;;\x07"),
3998 "OSC 8 close must appear: {:?}",
3999 String::from_utf8_lossy(&out)
4000 );
4001 }
4002
4003 fn kitty_placements(n: usize) -> Vec<KittyPlacement> {
4005 (0..n)
4006 .map(|i| {
4007 let mut rgba = vec![0u8; 256];
4008 rgba[0] = i as u8;
4009 let content_hash = crate::buffer::hash_rgba(&rgba);
4010 KittyPlacement {
4011 content_hash,
4012 rgba: std::sync::Arc::new(rgba),
4013 src_width: 8,
4014 src_height: 8,
4015 x: (i as u32) * 4,
4016 y: (i as u32) * 2,
4017 cols: 4,
4018 rows: 2,
4019 crop_y: 0,
4020 crop_h: 0,
4021 }
4022 })
4023 .collect()
4024 }
4025
4026 #[test]
4032 fn kitty_flush_smallvec_dedup_matches_for_small_n() {
4033 for n in [0usize, 1, 5] {
4034 let placements = kitty_placements(n);
4035 let mut mgr = KittyImageManager::new();
4036
4037 let mut frame1: Vec<u8> = Vec::new();
4039 mgr.flush(&mut frame1, &placements, 0).unwrap();
4040 let s1 = String::from_utf8_lossy(&frame1);
4041 assert_eq!(
4043 s1.matches("a=t,").count(),
4044 n,
4045 "n={n}: expected {n} uploads in frame 1: {s1:?}"
4046 );
4047 assert_eq!(
4048 s1.matches("a=p,").count(),
4049 n,
4050 "n={n}: expected {n} placements in frame 1: {s1:?}"
4051 );
4052
4053 let mut frame2: Vec<u8> = Vec::new();
4055 mgr.flush(&mut frame2, &placements, 0).unwrap();
4056 assert!(
4057 frame2.is_empty(),
4058 "n={n}: identical frame must hit the kitty fast path, got {} bytes",
4059 frame2.len()
4060 );
4061
4062 let mut frame3: Vec<u8> = Vec::new();
4066 mgr.flush(&mut frame3, &[], 0).unwrap();
4067 let s3 = String::from_utf8_lossy(&frame3);
4068 assert_eq!(
4069 s3.matches("a=d,d=i,").count(),
4070 n,
4071 "n={n}: expected {n} placement deletes in frame 3: {s3:?}"
4072 );
4073 assert_eq!(
4074 s3.matches("a=d,d=I,").count(),
4075 n,
4076 "n={n}: expected {n} image-data deletes in frame 3: {s3:?}"
4077 );
4078 }
4079 }
4080
4081 use crate::buffer::{SprixelCell, SprixelPlacement};
4084
4085 fn make_sprixel(cells: Vec<SprixelCell>) -> SprixelPlacement {
4087 SprixelPlacement {
4088 content_hash: 0xABCD,
4089 seq: "<SIXEL>".to_string(),
4090 x: 1,
4091 y: 1,
4092 cols: 2,
4093 rows: 2,
4094 cells,
4095 }
4096 }
4097
4098 #[test]
4099 fn sprixel_no_text_change_emits_zero_bytes() {
4100 let area = Rect::new(0, 0, 10, 5);
4102 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
4103
4104 let mut current = Buffer::empty(area);
4105 current.sprixels.push(placement.clone());
4106 let mut previous = Buffer::empty(area);
4107 previous.sprixels.push(placement);
4108
4109 let mut out: Vec<u8> = Vec::new();
4110 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4111 assert!(out.is_empty(), "stable frame should emit no sprixel bytes");
4112 }
4113
4114 #[test]
4115 fn sprixel_first_frame_blits_once() {
4116 let area = Rect::new(0, 0, 10, 5);
4118 let mut current = Buffer::empty(area);
4119 current
4120 .sprixels
4121 .push(make_sprixel(vec![SprixelCell::Opaque; 4]));
4122 let previous = Buffer::empty(area);
4123
4124 let mut out: Vec<u8> = Vec::new();
4125 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4126 let s = String::from_utf8(out).unwrap();
4127 assert_eq!(s.matches("<SIXEL>").count(), 1);
4128 }
4129
4130 #[test]
4131 fn sprixel_text_in_opaque_cell_reblits_once() {
4132 let area = Rect::new(0, 0, 10, 5);
4134 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
4135
4136 let mut current = Buffer::empty(area);
4137 current.sprixels.push(placement.clone());
4138 current.set_char(1, 1, 'X', Style::new());
4140
4141 let mut previous = Buffer::empty(area);
4142 previous.sprixels.push(placement);
4143
4144 let mut out: Vec<u8> = Vec::new();
4145 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4146 let s = String::from_utf8(out).unwrap();
4147 assert_eq!(
4148 s.matches("<SIXEL>").count(),
4149 1,
4150 "opaque-cell text write must re-blit the graphic exactly once"
4151 );
4152 }
4153
4154 #[test]
4155 fn sprixel_text_in_transparent_cell_does_not_reblit() {
4156 let area = Rect::new(0, 0, 10, 5);
4159 let cells = vec![
4160 SprixelCell::Transparent, SprixelCell::Opaque, SprixelCell::Opaque, SprixelCell::Opaque, ];
4165 let placement = make_sprixel(cells);
4166
4167 let mut current = Buffer::empty(area);
4168 current.sprixels.push(placement.clone());
4169 current.set_char(1, 1, 'X', Style::new());
4170
4171 let mut previous = Buffer::empty(area);
4172 previous.sprixels.push(placement);
4173
4174 let mut out: Vec<u8> = Vec::new();
4175 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4176 assert!(
4177 out.is_empty(),
4178 "text in a transparent footprint cell must emit zero sprixel bytes"
4179 );
4180 }
4181
4182 #[test]
4183 fn sprixel_text_outside_footprint_does_not_reblit() {
4184 let area = Rect::new(0, 0, 10, 5);
4186 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
4187
4188 let mut current = Buffer::empty(area);
4189 current.sprixels.push(placement.clone());
4190 current.set_char(5, 0, 'Z', Style::new());
4192
4193 let mut previous = Buffer::empty(area);
4194 previous.sprixels.push(placement);
4195
4196 let mut out: Vec<u8> = Vec::new();
4197 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4198 assert!(
4199 out.is_empty(),
4200 "text outside the footprint must not re-blit the graphic"
4201 );
4202 }
4203
4204 #[test]
4205 fn sprixel_position_change_reblits() {
4206 let area = Rect::new(0, 0, 10, 5);
4208 let mut moved = make_sprixel(vec![SprixelCell::Opaque; 4]);
4209 let original = moved.clone();
4210 moved.x = 4;
4211
4212 let mut current = Buffer::empty(area);
4213 current.sprixels.push(moved);
4214 let mut previous = Buffer::empty(area);
4215 previous.sprixels.push(original);
4216
4217 let mut out: Vec<u8> = Vec::new();
4218 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4219 let s = String::from_utf8(out).unwrap();
4220 assert_eq!(s.matches("<SIXEL>").count(), 1);
4221 }
4222
4223 #[test]
4224 fn sprixel_content_change_reblits() {
4225 let area = Rect::new(0, 0, 10, 5);
4227 let mut recolored = make_sprixel(vec![SprixelCell::Opaque; 4]);
4228 let original = recolored.clone();
4229 recolored.content_hash = 0x1234;
4230 recolored.seq = "<SIXEL2>".to_string();
4231
4232 let mut current = Buffer::empty(area);
4233 current.sprixels.push(recolored);
4234 let mut previous = Buffer::empty(area);
4235 previous.sprixels.push(original);
4236
4237 let mut out: Vec<u8> = Vec::new();
4238 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4239 let s = String::from_utf8(out).unwrap();
4240 assert_eq!(s.matches("<SIXEL2>").count(), 1);
4241 }
4242
4243 #[test]
4244 fn sprixel_reblit_count_invariant_over_single_cell_writes() {
4245 let area = Rect::new(0, 0, 10, 5);
4249 for (idx, (col, row)) in [(0u32, 0u32), (1, 0), (0, 1), (1, 1)]
4250 .into_iter()
4251 .enumerate()
4252 {
4253 for state in [
4254 SprixelCell::Opaque,
4255 SprixelCell::Mixed,
4256 SprixelCell::Transparent,
4257 ] {
4258 let mut cells = vec![SprixelCell::Opaque; 4];
4259 cells[idx] = state;
4260 let placement = make_sprixel(cells);
4261
4262 let mut current = Buffer::empty(area);
4263 current.sprixels.push(placement.clone());
4264 current.set_char(1 + col, 1 + row, 'A', Style::new());
4265
4266 let mut previous = Buffer::empty(area);
4267 previous.sprixels.push(placement);
4268
4269 let mut out: Vec<u8> = Vec::new();
4270 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4271 let count = String::from_utf8(out).unwrap().matches("<SIXEL>").count();
4272 let expected = if matches!(state, SprixelCell::Transparent) {
4273 0
4274 } else {
4275 1
4276 };
4277 assert_eq!(
4278 count, expected,
4279 "cell ({col},{row}) state {state:?}: expected {expected} re-blits"
4280 );
4281 }
4282 }
4283 }
4284
4285 #[test]
4292 fn sprixel_unchanged_with_hashes_engaged_emits_zero_bytes() {
4293 let area = Rect::new(0, 0, 10, 5);
4298 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
4299
4300 let mut current = Buffer::empty(area);
4301 current.sprixels.push(placement.clone());
4302 let mut previous = Buffer::empty(area);
4303 previous.sprixels.push(placement);
4304
4305 current.recompute_line_hashes();
4307 previous.recompute_line_hashes();
4308 assert!(current.row_clean(1) && current.row_clean(2));
4311 assert_eq!(current.row_hash(1), previous.row_hash(1));
4312
4313 let mut out: Vec<u8> = Vec::new();
4314 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4315 assert!(
4316 out.is_empty(),
4317 "unchanged sprixel must not be re-blitted (per-row shortcut)"
4318 );
4319 }
4320
4321 #[test]
4322 fn sprixel_changed_text_with_hashes_engaged_reblits_once() {
4323 let area = Rect::new(0, 0, 10, 5);
4328 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
4329
4330 let mut current = Buffer::empty(area);
4331 current.sprixels.push(placement.clone());
4332 current.set_char(1, 1, 'X', Style::new());
4333 let mut previous = Buffer::empty(area);
4334 previous.sprixels.push(placement);
4335
4336 current.recompute_line_hashes();
4337 previous.recompute_line_hashes();
4338 assert_ne!(current.row_hash(1), previous.row_hash(1));
4340
4341 let mut out: Vec<u8> = Vec::new();
4342 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4343 let s = String::from_utf8(out).unwrap();
4344 assert_eq!(
4345 s.matches("<SIXEL>").count(),
4346 1,
4347 "annihilating text write must re-blit exactly once"
4348 );
4349 }
4350
4351 #[test]
4352 fn sprixel_changed_text_in_transparent_cell_with_hashes_does_not_reblit() {
4353 let area = Rect::new(0, 0, 10, 5);
4358 let cells = vec![
4359 SprixelCell::Transparent, SprixelCell::Opaque, SprixelCell::Opaque, SprixelCell::Opaque, ];
4364 let placement = make_sprixel(cells);
4365
4366 let mut current = Buffer::empty(area);
4367 current.sprixels.push(placement.clone());
4368 current.set_char(1, 1, 'X', Style::new());
4369 let mut previous = Buffer::empty(area);
4370 previous.sprixels.push(placement);
4371
4372 current.recompute_line_hashes();
4373 previous.recompute_line_hashes();
4374
4375 let mut out: Vec<u8> = Vec::new();
4376 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4377 assert!(
4378 out.is_empty(),
4379 "transparent-cell text write must not re-blit even with hashes engaged"
4380 );
4381 }
4382
4383 #[test]
4384 fn sprixel_key_matches_partial_eq_contract() {
4385 let base = make_sprixel(vec![SprixelCell::Opaque; 4]);
4389 assert_eq!(sprixel_key(&base), sprixel_key(&base.clone()));
4390
4391 let mut moved = base.clone();
4392 moved.x = 7;
4393 assert_ne!(sprixel_key(&base), sprixel_key(&moved));
4394
4395 let mut recolored = base.clone();
4396 recolored.content_hash = 0x9999;
4397 assert_ne!(sprixel_key(&base), sprixel_key(&recolored));
4398
4399 let mut annihilated = base.clone();
4401 annihilated.cells = vec![SprixelCell::Annihilated; 4];
4402 assert_eq!(sprixel_key(&base), sprixel_key(&annihilated));
4403 assert_eq!(base, annihilated);
4404 }
4405
4406 #[test]
4407 fn sprixel_multi_placement_only_changed_one_reblits() {
4408 let area = Rect::new(0, 0, 10, 9);
4412 let mut current = Buffer::empty(area);
4413 let mut previous = Buffer::empty(area);
4414 for i in 0..3u32 {
4415 let p = SprixelPlacement {
4416 content_hash: 0x100 + i as u64,
4417 seq: format!("<S{i}>"),
4418 x: 0,
4419 y: i * 3,
4420 cols: 2,
4421 rows: 2,
4422 cells: vec![SprixelCell::Opaque; 4],
4423 };
4424 current.sprixels.push(p.clone());
4425 previous.sprixels.push(p);
4426 }
4427 current.sprixels[1].x = 5;
4429
4430 current.recompute_line_hashes();
4431 previous.recompute_line_hashes();
4432
4433 let mut out: Vec<u8> = Vec::new();
4434 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
4435 let s = String::from_utf8(out).unwrap();
4436 assert_eq!(s.matches("<S0>").count(), 0);
4437 assert_eq!(
4438 s.matches("<S1>").count(),
4439 1,
4440 "only the moved sprixel reblits"
4441 );
4442 assert_eq!(s.matches("<S2>").count(), 0);
4443 }
4444
4445 #[test]
4446 fn bench_sprixel_fixture_steady_state_emits_nothing() {
4447 let fixture = __bench_new_sprixel_fixture(4);
4451 assert_eq!(fixture.len(), 4);
4452 assert!(!fixture.is_empty());
4453 let mut out: Vec<u8> = Vec::new();
4454 fixture.flush(&mut out, 0).unwrap();
4455 assert!(
4456 out.is_empty(),
4457 "steady-state bench fixture re-blits nothing"
4458 );
4459 }
4460}