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 matches!(ColorDepth::detect(), ColorDepth::TrueColor) {
563 caps.truecolor = true;
564 }
565
566 if !caps.kitty_graphics && term_is_kitty_graphics_host() {
571 caps.kitty_graphics = true;
572 }
573
574 if term_is_iterm_host() {
578 caps.iterm2 = true;
579 }
580
581 caps
582}
583
584#[cfg(feature = "crossterm")]
589fn term_is_iterm_host() -> bool {
590 let term_program = std::env::var("TERM_PROGRAM")
591 .unwrap_or_default()
592 .to_ascii_lowercase();
593 matches!(
594 term_program.as_str(),
595 "iterm.app" | "wezterm" | "tabby" | "mintty"
596 )
597}
598
599#[cfg(feature = "crossterm")]
604fn term_is_kitty_graphics_host() -> bool {
605 let term = std::env::var("TERM")
606 .unwrap_or_default()
607 .to_ascii_lowercase();
608 let term_program = std::env::var("TERM_PROGRAM")
609 .unwrap_or_default()
610 .to_ascii_lowercase();
611 term.contains("kitty") || matches!(term_program.as_str(), "ghostty" | "wezterm" | "kitty")
613}
614
615#[cfg(feature = "crossterm")]
620fn read_da_response(timeout: Duration) -> Option<String> {
621 let deadline = Instant::now() + timeout;
622 let mut stdin = io::stdin();
623 let mut bytes = Vec::new();
624 let mut buf = [0u8; 1];
625 let mut terminators = 0usize;
626
627 while Instant::now() < deadline {
628 if !crossterm::event::poll(Duration::from_millis(10)).ok()? {
629 continue;
630 }
631 let read = stdin.read(&mut buf).ok()?;
632 if read == 0 {
633 continue;
634 }
635 bytes.push(buf[0]);
636 if buf[0] == b'c' {
640 terminators += 1;
641 if terminators >= 2 {
642 break;
643 }
644 }
645 if bytes.len() >= 4096 {
646 break;
647 }
648 }
649
650 if bytes.is_empty() {
651 return None;
652 }
653 String::from_utf8(bytes).ok()
654}
655
656#[cfg(feature = "crossterm")]
660fn parse_da1(response: &str, caps: &mut Capabilities) {
661 let mut search = response;
663 while let Some(pos) = search.find("\x1b[?") {
664 let body = &search[pos + 3..];
665 let Some(end) = body.find('c') else { break };
666 let attrs = &body[..end];
667 for attr in attrs.split(';') {
668 if attr.trim() == "4" {
669 caps.sixel = true;
670 }
671 }
672 search = &body[end + 1..];
673 }
674}
675
676#[cfg(feature = "crossterm")]
684fn parse_da2(response: &str, caps: &mut Capabilities) {
685 let Some((id, _ver)) = parse_da2_identity(response) else {
686 return;
687 };
688 const KITTY_GRAPHICS_DA2_ID: u32 = 41;
693 if id == KITTY_GRAPHICS_DA2_ID {
694 caps.kitty_graphics = true;
695 }
696}
697
698#[cfg(feature = "crossterm")]
700fn parse_da2_identity(response: &str) -> Option<(u32, u32)> {
701 let pos = response.find("\x1b[>")?;
702 let body = &response[pos + 3..];
703 let end = body.find('c')?;
704 let mut parts = body[..end].split(';');
705 let id = parts.next()?.trim().parse::<u32>().ok()?;
706 let ver = parts.next().and_then(|s| s.trim().parse::<u32>().ok());
707 Some((id, ver.unwrap_or(0)))
708}
709
710#[cfg(feature = "crossterm")]
714fn parse_kitty_graphics_ack(response: &str, caps: &mut Capabilities) {
715 if let Some(pos) = response.find("\x1b_G") {
718 let body = &response[pos + 3..];
719 let end = body.find("\x1b\\").unwrap_or(body.len());
720 let payload = &body[..end];
721 if payload.contains("i=31") && payload.contains("OK") {
722 caps.kitty_graphics = true;
723 }
724 }
725}
726
727#[cfg(feature = "crossterm")]
731fn parse_xtgettcap_truecolor(response: &str, caps: &mut Capabilities) {
732 if let Some(pos) = response.find("\x1bP1+r") {
734 let body = &response[pos + 5..];
735 if body
736 .to_ascii_lowercase()
737 .split([';', '\x1b'])
738 .any(|seg| seg.starts_with("5463"))
739 {
740 caps.truecolor = true;
741 }
742 }
743}
744
745fn split_base64(encoded: &str, chunk_size: usize) -> Vec<&str> {
746 let mut chunks = Vec::new();
747 let bytes = encoded.as_bytes();
748 let mut offset = 0;
749 while offset < bytes.len() {
750 let end = (offset + chunk_size).min(bytes.len());
751 chunks.push(&encoded[offset..end]);
752 offset = end;
753 }
754 if chunks.is_empty() {
755 chunks.push("");
756 }
757 chunks
758}
759
760pub(crate) struct Terminal {
761 stdout: Sink,
762 current: Buffer,
763 previous: Buffer,
764 cursor_visible: bool,
765 session: TerminalSessionGuard,
766 color_depth: ColorDepth,
767 pub(crate) theme_bg: Option<Color>,
768 kitty_mgr: KittyImageManager,
769 run_buf: String,
773}
774
775pub(crate) struct InlineTerminal {
776 stdout: Sink,
777 current: Buffer,
778 previous: Buffer,
779 cursor_visible: bool,
780 session: TerminalSessionGuard,
781 height: u32,
782 start_row: u16,
783 reserved: bool,
784 color_depth: ColorDepth,
785 pub(crate) theme_bg: Option<Color>,
786 kitty_mgr: KittyImageManager,
787 run_buf: String,
789}
790
791const RUN_BUF_INITIAL_CAPACITY: usize = 4096;
795
796#[derive(Debug, Clone, Copy, PartialEq, Eq)]
797enum TerminalSessionMode {
798 Fullscreen,
799 Inline,
800}
801
802#[derive(Debug, Clone, Copy)]
803struct TerminalSessionGuard {
804 mode: TerminalSessionMode,
805 mouse_enabled: bool,
806 kitty_keyboard: bool,
807 report_all_keys: bool,
808 harness: bool,
814}
815
816impl TerminalSessionGuard {
817 fn enter(
818 mode: TerminalSessionMode,
819 stdout: &mut impl Write,
820 mouse_enabled: bool,
821 kitty_keyboard: bool,
822 report_all_keys: bool,
823 ) -> io::Result<Self> {
824 let guard = Self {
825 mode,
826 mouse_enabled,
827 kitty_keyboard,
828 report_all_keys,
829 harness: false,
830 };
831
832 terminal::enable_raw_mode()?;
833 if let Err(err) = write_session_enter(stdout, &guard) {
834 guard.restore(stdout, false);
835 return Err(err);
836 }
837
838 let _ = capabilities();
845
846 Ok(guard)
847 }
848
849 fn restore(&self, stdout: &mut impl Write, inline_reserved: bool) {
850 if self.harness {
852 return;
853 }
854 if self.kitty_keyboard {
855 use crossterm::event::PopKeyboardEnhancementFlags;
856 let _ = execute!(stdout, PopKeyboardEnhancementFlags);
857 }
858 if self.mouse_enabled {
859 let _ = execute!(stdout, DisableMouseCapture);
860 }
861 let _ = execute!(stdout, DisableFocusChange);
862 let _ = write_session_cleanup(stdout, self.mode, inline_reserved);
863 let _ = terminal::disable_raw_mode();
864 }
865}
866
867impl Terminal {
868 pub fn new(
873 mouse: bool,
874 kitty_keyboard: bool,
875 report_all_keys: bool,
876 color_depth: ColorDepth,
877 ) -> io::Result<Self> {
878 let (cols, rows) = terminal::size()?;
879 let area = Rect::new(0, 0, cols as u32, rows as u32);
880
881 let mut raw = io::stdout();
882 let session = TerminalSessionGuard::enter(
883 TerminalSessionMode::Fullscreen,
884 &mut raw,
885 mouse,
886 kitty_keyboard,
887 report_all_keys,
888 )?;
889
890 Ok(Self {
891 stdout: Sink::Stdout(BufWriter::with_capacity(65536, raw)),
892 current: Buffer::empty(area),
893 previous: Buffer::empty(area),
894 cursor_visible: false,
895 session,
896 color_depth,
897 theme_bg: None,
898 kitty_mgr: KittyImageManager::new(),
899 run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
900 })
901 }
902
903 pub fn size(&self) -> (u32, u32) {
905 (self.current.area.width, self.current.area.height)
906 }
907
908 pub fn buffer_mut(&mut self) -> &mut Buffer {
910 &mut self.current
911 }
912
913 pub fn flush(&mut self) -> io::Result<()> {
917 if self.current.area.width < self.previous.area.width {
918 execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
919 }
920
921 queue!(self.stdout, BeginSynchronizedUpdate)?;
922 self.current.recompute_line_hashes();
927 self.previous.recompute_line_hashes();
928 flush_buffer_diff(
929 &mut self.stdout,
930 &self.current,
931 &self.previous,
932 self.color_depth,
933 0,
934 &mut self.run_buf,
935 )?;
936
937 self.kitty_mgr
940 .flush(&mut self.stdout, &self.current.kitty_placements, 0)?;
941
942 flush_raw_sequences(&mut self.stdout, &self.current, &self.previous, 0)?;
944
945 flush_sprixels(&mut self.stdout, &self.current, &self.previous, 0)?;
947
948 queue!(self.stdout, EndSynchronizedUpdate)?;
949 flush_cursor(
950 &mut self.stdout,
951 &mut self.cursor_visible,
952 self.current.cursor_pos(),
953 0,
954 None,
955 )?;
956
957 self.stdout.flush()?;
958
959 std::mem::swap(&mut self.current, &mut self.previous);
960 if let Some(bg) = self.theme_bg {
961 self.current.reset_with_bg(bg);
962 } else {
963 self.current.reset();
964 }
965 Ok(())
966 }
967
968 pub fn handle_resize(&mut self) -> io::Result<()> {
971 let (cols, rows) = terminal::size()?;
972 let area = Rect::new(0, 0, cols as u32, rows as u32);
973 self.current.resize(area);
974 self.previous.resize(area);
975 execute!(
976 self.stdout,
977 terminal::Clear(terminal::ClearType::All),
978 cursor::MoveTo(0, 0)
979 )?;
980 Ok(())
981 }
982}
983
984#[cfg(any(test, feature = "pty-test"))]
985impl Terminal {
986 pub(crate) fn with_sink(width: u32, height: u32, color_depth: ColorDepth) -> Self {
1000 let area = Rect::new(0, 0, width, height);
1001 Self {
1002 stdout: Sink::Capture(Vec::new()),
1003 current: Buffer::empty(area),
1004 previous: Buffer::empty(area),
1005 cursor_visible: false,
1006 session: TerminalSessionGuard {
1007 mode: TerminalSessionMode::Fullscreen,
1008 mouse_enabled: false,
1009 kitty_keyboard: false,
1010 report_all_keys: false,
1011 harness: true,
1012 },
1013 color_depth,
1014 theme_bg: None,
1015 kitty_mgr: KittyImageManager::new(),
1016 run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
1017 }
1018 }
1019
1020 pub(crate) fn take_sink_bytes(&mut self) -> Vec<u8> {
1025 match &mut self.stdout {
1026 Sink::Capture(v) => std::mem::take(v),
1027 Sink::Stdout(_) => panic!("take_sink_bytes called on a non-capture Terminal"),
1028 }
1029 }
1030}
1031
1032impl crate::Backend for Terminal {
1033 fn size(&self) -> (u32, u32) {
1034 Terminal::size(self)
1035 }
1036
1037 fn buffer_mut(&mut self) -> &mut Buffer {
1038 Terminal::buffer_mut(self)
1039 }
1040
1041 fn flush(&mut self) -> io::Result<()> {
1042 Terminal::flush(self)
1043 }
1044}
1045
1046impl InlineTerminal {
1047 pub fn new(
1053 height: u32,
1054 mouse: bool,
1055 kitty_keyboard: bool,
1056 report_all_keys: bool,
1057 color_depth: ColorDepth,
1058 ) -> io::Result<Self> {
1059 let (cols, _) = terminal::size()?;
1060 let area = Rect::new(0, 0, cols as u32, height);
1061
1062 let mut raw = io::stdout();
1063 let session = TerminalSessionGuard::enter(
1064 TerminalSessionMode::Inline,
1065 &mut raw,
1066 mouse,
1067 kitty_keyboard,
1068 report_all_keys,
1069 )?;
1070
1071 let (_, cursor_row) = match cursor::position() {
1072 Ok(pos) => pos,
1073 Err(err) => {
1074 session.restore(&mut raw, false);
1075 return Err(err);
1076 }
1077 };
1078 Ok(Self {
1079 stdout: Sink::Stdout(BufWriter::with_capacity(65536, raw)),
1080 current: Buffer::empty(area),
1081 previous: Buffer::empty(area),
1082 cursor_visible: false,
1083 session,
1084 height,
1085 start_row: cursor_row,
1086 reserved: false,
1087 color_depth,
1088 theme_bg: None,
1089 kitty_mgr: KittyImageManager::new(),
1090 run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
1091 })
1092 }
1093
1094 pub fn size(&self) -> (u32, u32) {
1096 (self.current.area.width, self.current.area.height)
1097 }
1098
1099 pub fn buffer_mut(&mut self) -> &mut Buffer {
1101 &mut self.current
1102 }
1103
1104 pub fn flush(&mut self) -> io::Result<()> {
1108 if self.current.area.width < self.previous.area.width {
1109 execute!(self.stdout, terminal::Clear(terminal::ClearType::All))?;
1110 }
1111
1112 queue!(self.stdout, BeginSynchronizedUpdate)?;
1113
1114 if !self.reserved {
1115 queue!(self.stdout, cursor::MoveToColumn(0))?;
1116 for _ in 0..self.height {
1117 queue!(self.stdout, Print("\n"))?;
1118 }
1119 self.reserved = true;
1120
1121 let (_, rows) = terminal::size()?;
1122 let bottom = self.start_row.saturating_add(sat_u16(self.height));
1123 if bottom > rows {
1124 self.start_row = rows.saturating_sub(sat_u16(self.height));
1125 }
1126 }
1127 let row_offset = self.start_row as u32;
1128 self.current.recompute_line_hashes();
1131 self.previous.recompute_line_hashes();
1132 flush_buffer_diff(
1133 &mut self.stdout,
1134 &self.current,
1135 &self.previous,
1136 self.color_depth,
1137 row_offset,
1138 &mut self.run_buf,
1139 )?;
1140
1141 self.kitty_mgr
1147 .flush(&mut self.stdout, &self.current.kitty_placements, row_offset)?;
1148
1149 flush_raw_sequences(&mut self.stdout, &self.current, &self.previous, row_offset)?;
1151
1152 flush_sprixels(&mut self.stdout, &self.current, &self.previous, row_offset)?;
1154
1155 queue!(self.stdout, EndSynchronizedUpdate)?;
1156 let fallback_row = row_offset + self.height.saturating_sub(1);
1157 flush_cursor(
1158 &mut self.stdout,
1159 &mut self.cursor_visible,
1160 self.current.cursor_pos(),
1161 row_offset,
1162 Some(fallback_row),
1163 )?;
1164
1165 self.stdout.flush()?;
1166
1167 std::mem::swap(&mut self.current, &mut self.previous);
1168 reset_current_buffer(&mut self.current, self.theme_bg);
1169 Ok(())
1170 }
1171
1172 pub fn handle_resize(&mut self) -> io::Result<()> {
1175 let (cols, _) = terminal::size()?;
1176 let area = Rect::new(0, 0, cols as u32, self.height);
1177 self.current.resize(area);
1178 self.previous.resize(area);
1179 execute!(
1180 self.stdout,
1181 terminal::Clear(terminal::ClearType::All),
1182 cursor::MoveTo(0, 0)
1183 )?;
1184 Ok(())
1185 }
1186}
1187
1188impl crate::Backend for InlineTerminal {
1189 fn size(&self) -> (u32, u32) {
1190 InlineTerminal::size(self)
1191 }
1192
1193 fn buffer_mut(&mut self) -> &mut Buffer {
1194 InlineTerminal::buffer_mut(self)
1195 }
1196
1197 fn flush(&mut self) -> io::Result<()> {
1198 InlineTerminal::flush(self)
1199 }
1200}
1201
1202impl Drop for Terminal {
1203 fn drop(&mut self) {
1204 let _ = self.kitty_mgr.delete_all(&mut self.stdout);
1206 let _ = self.stdout.flush();
1207 self.session.restore(&mut self.stdout, false);
1208 }
1209}
1210
1211impl Drop for InlineTerminal {
1212 fn drop(&mut self) {
1213 let _ = self.kitty_mgr.delete_all(&mut self.stdout);
1214 let _ = self.stdout.flush();
1215 self.session.restore(&mut self.stdout, self.reserved);
1216 }
1217}
1218
1219mod selection;
1220pub(crate) use selection::{apply_selection_overlay, extract_selection_text, SelectionState};
1221#[cfg(test)]
1222pub(crate) use selection::{find_innermost_rect, normalize_selection};
1223
1224#[non_exhaustive]
1226#[cfg(feature = "crossterm")]
1227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1228pub enum ColorScheme {
1229 Dark,
1231 Light,
1233 Unknown,
1235}
1236
1237#[cfg(feature = "crossterm")]
1238fn read_osc_response(timeout: Duration) -> Option<String> {
1239 let deadline = Instant::now() + timeout;
1240 let mut stdin = io::stdin();
1241 let mut bytes = Vec::new();
1242 let mut buf = [0u8; 1];
1243
1244 while Instant::now() < deadline {
1245 if !crossterm::event::poll(Duration::from_millis(10)).ok()? {
1246 continue;
1247 }
1248
1249 let read = stdin.read(&mut buf).ok()?;
1250 if read == 0 {
1251 continue;
1252 }
1253
1254 bytes.push(buf[0]);
1255
1256 if buf[0] == b'\x07' {
1257 break;
1258 }
1259 let len = bytes.len();
1260 if len >= 2 && bytes[len - 2] == 0x1B && bytes[len - 1] == b'\\' {
1261 break;
1262 }
1263
1264 if bytes.len() >= 4096 {
1265 break;
1266 }
1267 }
1268
1269 if bytes.is_empty() {
1270 return None;
1271 }
1272
1273 String::from_utf8(bytes).ok()
1274}
1275
1276#[cfg(feature = "crossterm")]
1278pub fn detect_color_scheme() -> ColorScheme {
1279 let mut stdout = io::stdout();
1280 if write!(stdout, "\x1b]11;?\x07").is_err() {
1281 return ColorScheme::Unknown;
1282 }
1283 if stdout.flush().is_err() {
1284 return ColorScheme::Unknown;
1285 }
1286
1287 let Some(response) = read_osc_response(Duration::from_millis(100)) else {
1288 return ColorScheme::Unknown;
1289 };
1290
1291 parse_osc11_response(&response)
1292}
1293
1294#[cfg(feature = "crossterm")]
1295pub(crate) fn parse_osc11_response(response: &str) -> ColorScheme {
1296 let Some(rgb_pos) = response.find("rgb:") else {
1297 return ColorScheme::Unknown;
1298 };
1299
1300 let payload = &response[rgb_pos + 4..];
1301 let end = payload
1302 .find(['\x07', '\x1b', '\r', '\n', ' ', '\t'])
1303 .unwrap_or(payload.len());
1304 let rgb = &payload[..end];
1305
1306 let mut channels = rgb.split('/');
1307 let (Some(r), Some(g), Some(b), None) = (
1308 channels.next(),
1309 channels.next(),
1310 channels.next(),
1311 channels.next(),
1312 ) else {
1313 return ColorScheme::Unknown;
1314 };
1315
1316 fn parse_channel(channel: &str) -> Option<f64> {
1317 if channel.is_empty() || channel.len() > 4 {
1318 return None;
1319 }
1320 let value = u16::from_str_radix(channel, 16).ok()? as f64;
1321 let max = ((1u32 << (channel.len() * 4)) - 1) as f64;
1322 if max <= 0.0 {
1323 return None;
1324 }
1325 Some((value / max).clamp(0.0, 1.0))
1326 }
1327
1328 let (Some(r), Some(g), Some(b)) = (parse_channel(r), parse_channel(g), parse_channel(b)) else {
1329 return ColorScheme::Unknown;
1330 };
1331
1332 let luminance = 0.299 * r + 0.587 * g + 0.114 * b;
1333 if luminance < 0.5 {
1334 ColorScheme::Dark
1335 } else {
1336 ColorScheme::Light
1337 }
1338}
1339
1340pub(crate) fn base64_encode(input: &[u8]) -> String {
1341 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1342 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
1343 for chunk in input.chunks(3) {
1344 let b0 = chunk[0] as u32;
1345 let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
1346 let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
1347 let triple = (b0 << 16) | (b1 << 8) | b2;
1348 out.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
1349 out.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
1350 out.push(if chunk.len() > 1 {
1351 CHARS[((triple >> 6) & 0x3F) as usize] as char
1352 } else {
1353 '='
1354 });
1355 out.push(if chunk.len() > 2 {
1356 CHARS[(triple & 0x3F) as usize] as char
1357 } else {
1358 '='
1359 });
1360 }
1361 out
1362}
1363
1364pub(crate) fn copy_to_clipboard(w: &mut impl Write, text: &str) -> io::Result<()> {
1365 let encoded = base64_encode(text.as_bytes());
1366 write!(w, "\x1b]52;c;{encoded}\x1b\\")?;
1367 w.flush()
1368}
1369
1370#[cfg(feature = "crossterm")]
1371fn parse_osc52_response(response: &str) -> Option<String> {
1372 let osc_pos = response.find("]52;")?;
1373 let body = &response[osc_pos + 4..];
1374 let semicolon = body.find(';')?;
1375 let payload = &body[semicolon + 1..];
1376
1377 let end = payload
1378 .find("\x1b\\")
1379 .or_else(|| payload.find('\x07'))
1380 .unwrap_or(payload.len());
1381 let encoded = payload[..end].trim();
1382 if encoded.is_empty() || encoded == "?" {
1383 return None;
1384 }
1385
1386 base64_decode(encoded)
1387}
1388
1389#[cfg(feature = "crossterm")]
1391pub fn read_clipboard() -> Option<String> {
1392 let mut stdout = io::stdout();
1393 write!(stdout, "\x1b]52;c;?\x07").ok()?;
1394 stdout.flush().ok()?;
1395
1396 let response = read_osc_response(Duration::from_millis(200))?;
1397 parse_osc52_response(&response)
1398}
1399
1400#[cfg(feature = "crossterm")]
1401fn base64_decode(input: &str) -> Option<String> {
1402 let mut filtered: Vec<u8> = input
1403 .bytes()
1404 .filter(|b| !matches!(b, b' ' | b'\n' | b'\r' | b'\t'))
1405 .collect();
1406
1407 match filtered.len() % 4 {
1408 0 => {}
1409 2 => filtered.extend_from_slice(b"=="),
1410 3 => filtered.push(b'='),
1411 _ => return None,
1412 }
1413
1414 fn decode_val(b: u8) -> Option<u8> {
1415 match b {
1416 b'A'..=b'Z' => Some(b - b'A'),
1417 b'a'..=b'z' => Some(b - b'a' + 26),
1418 b'0'..=b'9' => Some(b - b'0' + 52),
1419 b'+' => Some(62),
1420 b'/' => Some(63),
1421 _ => None,
1422 }
1423 }
1424
1425 let mut out = Vec::with_capacity((filtered.len() / 4) * 3);
1426 for chunk in filtered.chunks_exact(4) {
1427 let p2 = chunk[2] == b'=';
1428 let p3 = chunk[3] == b'=';
1429 if p2 && !p3 {
1430 return None;
1431 }
1432
1433 let v0 = decode_val(chunk[0])? as u32;
1434 let v1 = decode_val(chunk[1])? as u32;
1435 let v2 = if p2 { 0 } else { decode_val(chunk[2])? as u32 };
1436 let v3 = if p3 { 0 } else { decode_val(chunk[3])? as u32 };
1437
1438 let triple = (v0 << 18) | (v1 << 12) | (v2 << 6) | v3;
1439 out.push(((triple >> 16) & 0xFF) as u8);
1440 if !p2 {
1441 out.push(((triple >> 8) & 0xFF) as u8);
1442 }
1443 if !p3 {
1444 out.push((triple & 0xFF) as u8);
1445 }
1446 }
1447
1448 String::from_utf8(out).ok()
1449}
1450
1451#[allow(clippy::too_many_arguments)]
1452#[allow(unused_assignments)]
1453fn flush_buffer_diff(
1454 stdout: &mut impl Write,
1455 current: &Buffer,
1456 previous: &Buffer,
1457 color_depth: ColorDepth,
1458 row_offset: u32,
1459 run_buf: &mut String,
1460) -> io::Result<()> {
1461 let mut last_style = Style::new();
1473 let mut first_style = true;
1474 let mut active_link: Option<&str> = None;
1475 let mut has_updates = false;
1476 let mut last_cursor: Option<(u32, u32)> = None;
1480
1481 run_buf.clear();
1487 let mut run_abs_y: u32 = 0;
1488 let mut run_style: Style = Style::new();
1489 let mut run_link: Option<&str> = None;
1490 let mut run_next_col: u32 = 0;
1491 let mut run_open = false;
1492
1493 macro_rules! flush_run {
1498 ($stdout:expr) => {
1499 if run_open {
1500 queue!($stdout, Print(&run_buf))?;
1501 last_cursor = Some((run_next_col, run_abs_y));
1502 run_buf.clear();
1503 run_open = false;
1504 }
1505 };
1506 }
1507
1508 for y in current.area.y..current.area.bottom() {
1509 if current.row_clean(y)
1518 && current.row_hash(y).is_some()
1519 && current.row_hash(y) == previous.row_hash(y)
1520 {
1521 continue;
1522 }
1523 for x in current.area.x..current.area.right() {
1524 let cell = current.get(x, y);
1525 let prev = previous.get(x, y);
1526 if cell == prev || cell.symbol.is_empty() {
1527 flush_run!(stdout);
1529 continue;
1530 }
1531
1532 let abs_y = row_offset + y;
1533 let cell_link = cell
1538 .hyperlink
1539 .as_deref()
1540 .filter(|u| crate::buffer::is_valid_osc8_url(u));
1541
1542 let extends = run_open
1544 && run_abs_y == abs_y
1545 && run_next_col == x
1546 && run_style == cell.style
1547 && run_link == cell_link;
1548
1549 if !extends {
1550 flush_run!(stdout);
1551
1552 has_updates = true;
1556
1557 let need_move = last_cursor.map_or(true, |(lx, ly)| lx != x || ly != abs_y);
1558 if need_move {
1559 queue!(stdout, cursor::MoveTo(sat_u16(x), sat_u16(abs_y)))?;
1560 }
1561
1562 if cell.style != last_style {
1563 if first_style {
1564 queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
1565 apply_style(stdout, &cell.style, color_depth)?;
1566 first_style = false;
1567 } else {
1568 apply_style_delta(stdout, &last_style, &cell.style, color_depth)?;
1569 }
1570 last_style = cell.style;
1571 }
1572
1573 if cell_link != active_link {
1574 if let Some(url) = cell_link {
1575 queue!(stdout, Print("\x1b]8;;"))?;
1580 queue!(stdout, Print(url))?;
1581 queue!(stdout, Print("\x07"))?;
1582 } else {
1583 queue!(stdout, Print("\x1b]8;;\x07"))?;
1584 }
1585 active_link = cell_link;
1586 }
1587
1588 run_open = true;
1589 run_abs_y = abs_y;
1590 run_style = cell.style;
1591 run_link = cell_link;
1592 }
1593
1594 run_buf.push_str(&cell.symbol);
1598 let char_width = UnicodeWidthStr::width(cell.symbol.as_str()).max(1) as u32;
1599 if char_width > 1 && cell.symbol.chars().any(|c| c == '\u{FE0F}') {
1600 run_buf.push(' ');
1604 }
1605 run_next_col = x + char_width;
1606 }
1607
1608 flush_run!(stdout);
1610 }
1611
1612 if has_updates {
1613 if active_link.is_some() {
1614 queue!(stdout, Print("\x1b]8;;\x07"))?;
1615 }
1616 queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
1617 }
1618
1619 Ok(())
1620}
1621
1622#[doc(hidden)]
1632pub fn __bench_flush_buffer_diff<W: Write>(
1633 w: &mut W,
1634 current: &Buffer,
1635 previous: &Buffer,
1636 color_depth: ColorDepth,
1637) -> io::Result<()> {
1638 let mut run_buf = String::with_capacity(RUN_BUF_INITIAL_CAPACITY);
1641 flush_buffer_diff(w, current, previous, color_depth, 0, &mut run_buf)
1642}
1643
1644#[doc(hidden)]
1653pub fn __bench_flush_buffer_diff_mut<W: Write>(
1654 w: &mut W,
1655 current: &mut Buffer,
1656 previous: &mut Buffer,
1657 color_depth: ColorDepth,
1658) -> io::Result<()> {
1659 let mut run_buf = String::with_capacity(RUN_BUF_INITIAL_CAPACITY);
1663 __bench_flush_buffer_diff_mut_with_buf(w, current, previous, color_depth, &mut run_buf)
1664}
1665
1666#[doc(hidden)]
1691pub fn __bench_flush_buffer_diff_mut_with_buf<W: Write>(
1692 w: &mut W,
1693 current: &mut Buffer,
1694 previous: &mut Buffer,
1695 color_depth: ColorDepth,
1696 run_buf: &mut String,
1697) -> io::Result<()> {
1698 current.recompute_line_hashes();
1699 previous.recompute_line_hashes();
1700 flush_buffer_diff(w, current, previous, color_depth, 0, run_buf)
1701}
1702
1703#[doc(hidden)]
1708pub struct __BenchKittyFixture {
1709 mgr: KittyImageManager,
1710 placements: Vec<KittyPlacement>,
1711}
1712
1713#[doc(hidden)]
1716pub fn __bench_new_kitty_fixture(n: usize) -> __BenchKittyFixture {
1717 let mut placements = Vec::with_capacity(n);
1718 for i in 0..n {
1719 let mut rgba = vec![0u8; 256];
1721 rgba[0] = i as u8;
1723 let content_hash = crate::buffer::hash_rgba(&rgba);
1724 placements.push(KittyPlacement {
1725 content_hash,
1726 rgba: std::sync::Arc::new(rgba),
1727 src_width: 8,
1728 src_height: 8,
1729 x: (i as u32) * 4,
1730 y: (i as u32) * 2,
1731 cols: 4,
1732 rows: 2,
1733 crop_y: 0,
1734 crop_h: 0,
1735 });
1736 }
1737 __BenchKittyFixture {
1738 mgr: KittyImageManager::new(),
1739 placements,
1740 }
1741}
1742
1743impl __BenchKittyFixture {
1744 #[doc(hidden)]
1748 pub fn rgba_strong_counts(&self) -> Vec<usize> {
1749 self.placements
1750 .iter()
1751 .map(|p| std::sync::Arc::strong_count(&p.rgba))
1752 .collect()
1753 }
1754
1755 #[doc(hidden)]
1758 pub fn flush_inline<W: Write>(&mut self, sink: &mut W, row_offset: u32) -> io::Result<()> {
1759 self.mgr.flush(sink, &self.placements, row_offset)
1760 }
1761
1762 #[doc(hidden)]
1764 pub fn len(&self) -> usize {
1765 self.placements.len()
1766 }
1767
1768 #[doc(hidden)]
1770 pub fn is_empty(&self) -> bool {
1771 self.placements.is_empty()
1772 }
1773}
1774
1775fn flush_raw_sequences(
1776 stdout: &mut impl Write,
1777 current: &Buffer,
1778 previous: &Buffer,
1779 row_offset: u32,
1780) -> io::Result<()> {
1781 if current.raw_sequences == previous.raw_sequences {
1782 return Ok(());
1783 }
1784
1785 for (x, y, seq) in ¤t.raw_sequences {
1786 queue!(
1787 stdout,
1788 cursor::MoveTo(sat_u16(*x), sat_u16(row_offset + *y)),
1789 Print(seq)
1790 )?;
1791 }
1792
1793 Ok(())
1794}
1795
1796fn sprixel_needs_reblit(
1810 placement: &crate::buffer::SprixelPlacement,
1811 current: &Buffer,
1812 previous: &Buffer,
1813) -> bool {
1814 use crate::buffer::SprixelCell;
1815
1816 if !previous.sprixels.iter().any(|p| p == placement) {
1820 return true;
1821 }
1822
1823 for row in 0..placement.rows {
1827 for col in 0..placement.cols {
1828 let idx = (row * placement.cols + col) as usize;
1829 match placement.cells.get(idx) {
1830 Some(SprixelCell::Opaque) | Some(SprixelCell::Mixed) => {}
1831 _ => continue,
1834 }
1835 let x = placement.x + col;
1836 let y = placement.y + row;
1837 let (Some(cell), Some(prev)) = (current.try_get(x, y), previous.try_get(x, y)) else {
1842 continue;
1843 };
1844 if cell != prev && !cell.symbol.is_empty() {
1850 return true;
1851 }
1852 }
1853 }
1854
1855 false
1856}
1857
1858fn flush_sprixels(
1865 stdout: &mut impl Write,
1866 current: &Buffer,
1867 previous: &Buffer,
1868 row_offset: u32,
1869) -> io::Result<()> {
1870 for placement in ¤t.sprixels {
1871 if sprixel_needs_reblit(placement, current, previous) {
1872 queue!(
1873 stdout,
1874 cursor::MoveTo(sat_u16(placement.x), sat_u16(row_offset + placement.y)),
1875 Print(&placement.seq)
1876 )?;
1877 }
1878 }
1879 Ok(())
1880}
1881
1882fn flush_cursor(
1883 stdout: &mut impl Write,
1884 cursor_visible: &mut bool,
1885 cursor_pos: Option<(u32, u32)>,
1886 row_offset: u32,
1887 fallback_row: Option<u32>,
1888) -> io::Result<()> {
1889 match cursor_pos {
1890 Some((cx, cy)) => {
1891 if !*cursor_visible {
1892 queue!(stdout, cursor::Show)?;
1893 *cursor_visible = true;
1894 }
1895 queue!(
1896 stdout,
1897 cursor::MoveTo(sat_u16(cx), sat_u16(row_offset + cy))
1898 )?;
1899 }
1900 None => {
1901 if *cursor_visible {
1902 queue!(stdout, cursor::Hide)?;
1903 *cursor_visible = false;
1904 }
1905 if let Some(row) = fallback_row {
1906 queue!(stdout, cursor::MoveTo(0, sat_u16(row)))?;
1907 }
1908 }
1909 }
1910
1911 Ok(())
1912}
1913
1914fn apply_style_delta(
1915 w: &mut impl Write,
1916 old: &Style,
1917 new: &Style,
1918 depth: ColorDepth,
1919) -> io::Result<()> {
1920 if old.fg != new.fg {
1921 match new.fg {
1922 Some(fg) => queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?,
1923 None => queue!(w, SetForegroundColor(CtColor::Reset))?,
1924 }
1925 }
1926 if old.bg != new.bg {
1927 match new.bg {
1928 Some(bg) => queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?,
1929 None => queue!(w, SetBackgroundColor(CtColor::Reset))?,
1930 }
1931 }
1932 let removed = Modifiers(old.modifiers.0 & !new.modifiers.0);
1933 let added = Modifiers(new.modifiers.0 & !old.modifiers.0);
1934 if removed.contains(Modifiers::BOLD) || removed.contains(Modifiers::DIM) {
1935 queue!(w, SetAttribute(Attribute::NormalIntensity))?;
1936 if new.modifiers.contains(Modifiers::BOLD) {
1937 queue!(w, SetAttribute(Attribute::Bold))?;
1938 }
1939 if new.modifiers.contains(Modifiers::DIM) {
1940 queue!(w, SetAttribute(Attribute::Dim))?;
1941 }
1942 } else {
1943 if added.contains(Modifiers::BOLD) {
1944 queue!(w, SetAttribute(Attribute::Bold))?;
1945 }
1946 if added.contains(Modifiers::DIM) {
1947 queue!(w, SetAttribute(Attribute::Dim))?;
1948 }
1949 }
1950 if removed.contains(Modifiers::ITALIC) {
1951 queue!(w, SetAttribute(Attribute::NoItalic))?;
1952 }
1953 if added.contains(Modifiers::ITALIC) {
1954 queue!(w, SetAttribute(Attribute::Italic))?;
1955 }
1956 if removed.contains(Modifiers::UNDERLINE) {
1957 queue!(w, SetAttribute(Attribute::NoUnderline))?;
1958 }
1959 if added.contains(Modifiers::UNDERLINE) {
1960 queue!(w, SetAttribute(Attribute::Underlined))?;
1961 }
1962 if removed.contains(Modifiers::REVERSED) {
1963 queue!(w, SetAttribute(Attribute::NoReverse))?;
1964 }
1965 if added.contains(Modifiers::REVERSED) {
1966 queue!(w, SetAttribute(Attribute::Reverse))?;
1967 }
1968 if removed.contains(Modifiers::STRIKETHROUGH) {
1969 queue!(w, SetAttribute(Attribute::NotCrossedOut))?;
1970 }
1971 if added.contains(Modifiers::STRIKETHROUGH) {
1972 queue!(w, SetAttribute(Attribute::CrossedOut))?;
1973 }
1974 if removed.contains(Modifiers::BLINK) {
1975 queue!(w, SetAttribute(Attribute::NoBlink))?;
1976 }
1977 if added.contains(Modifiers::BLINK) {
1978 queue!(w, SetAttribute(Attribute::SlowBlink))?;
1979 }
1980 if removed.contains(Modifiers::OVERLINE) {
1981 queue!(w, SetAttribute(Attribute::NotOverLined))?;
1982 }
1983 if added.contains(Modifiers::OVERLINE) {
1984 queue!(w, SetAttribute(Attribute::OverLined))?;
1985 }
1986 if old.underline_style != new.underline_style {
1990 write!(w, "\x1b[4:{}m", underline_style_param(new.underline_style))?;
1991 }
1992 if old.underline_color != new.underline_color {
1993 emit_underline_color(w, new.underline_color, depth)?;
1994 }
1995 Ok(())
1996}
1997
1998fn underline_style_param(style: UnderlineStyle) -> u8 {
2000 match style {
2001 UnderlineStyle::Straight => 1,
2002 UnderlineStyle::Double => 2,
2003 UnderlineStyle::Curly => 3,
2004 UnderlineStyle::Dotted => 4,
2005 UnderlineStyle::Dashed => 5,
2006 }
2007}
2008
2009fn emit_underline_color(
2015 w: &mut impl Write,
2016 color: Option<Color>,
2017 depth: ColorDepth,
2018) -> io::Result<()> {
2019 match color {
2020 None => write!(w, "\x1b[59m"),
2021 Some(c) => match c.downsampled(depth) {
2022 Color::Reset => write!(w, "\x1b[59m"),
2023 Color::Rgb(r, g, b) => write!(w, "\x1b[58:2::{r}:{g}:{b}m"),
2024 Color::Indexed(i) => write!(w, "\x1b[58:5:{i}m"),
2025 named => {
2028 let (r, g, b) = named.to_rgb();
2029 write!(w, "\x1b[58:2::{r}:{g}:{b}m")
2030 }
2031 },
2032 }
2033}
2034
2035fn apply_style(w: &mut impl Write, style: &Style, depth: ColorDepth) -> io::Result<()> {
2036 if let Some(fg) = style.fg {
2037 queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?;
2038 }
2039 if let Some(bg) = style.bg {
2040 queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?;
2041 }
2042 let m = style.modifiers;
2043 if m.contains(Modifiers::BOLD) {
2044 queue!(w, SetAttribute(Attribute::Bold))?;
2045 }
2046 if m.contains(Modifiers::DIM) {
2047 queue!(w, SetAttribute(Attribute::Dim))?;
2048 }
2049 if m.contains(Modifiers::ITALIC) {
2050 queue!(w, SetAttribute(Attribute::Italic))?;
2051 }
2052 if m.contains(Modifiers::UNDERLINE) {
2053 queue!(w, SetAttribute(Attribute::Underlined))?;
2054 }
2055 if m.contains(Modifiers::REVERSED) {
2056 queue!(w, SetAttribute(Attribute::Reverse))?;
2057 }
2058 if m.contains(Modifiers::STRIKETHROUGH) {
2059 queue!(w, SetAttribute(Attribute::CrossedOut))?;
2060 }
2061 if m.contains(Modifiers::BLINK) {
2062 queue!(w, SetAttribute(Attribute::SlowBlink))?;
2063 }
2064 if m.contains(Modifiers::OVERLINE) {
2065 queue!(w, SetAttribute(Attribute::OverLined))?;
2066 }
2067 if style.underline_style != UnderlineStyle::Straight {
2068 write!(
2069 w,
2070 "\x1b[4:{}m",
2071 underline_style_param(style.underline_style)
2072 )?;
2073 }
2074 if style.underline_color.is_some() {
2075 emit_underline_color(w, style.underline_color, depth)?;
2076 }
2077 Ok(())
2078}
2079
2080fn to_crossterm_color(color: Color, depth: ColorDepth) -> CtColor {
2081 let color = color.downsampled(depth);
2082 match color {
2083 Color::Reset => CtColor::Reset,
2084 Color::Black => CtColor::Black,
2085 Color::Red => CtColor::DarkRed,
2086 Color::Green => CtColor::DarkGreen,
2087 Color::Yellow => CtColor::DarkYellow,
2088 Color::Blue => CtColor::DarkBlue,
2089 Color::Magenta => CtColor::DarkMagenta,
2090 Color::Cyan => CtColor::DarkCyan,
2091 Color::White => CtColor::White,
2092 Color::DarkGray => CtColor::DarkGrey,
2093 Color::LightRed => CtColor::Red,
2094 Color::LightGreen => CtColor::Green,
2095 Color::LightYellow => CtColor::Yellow,
2096 Color::LightBlue => CtColor::Blue,
2097 Color::LightMagenta => CtColor::Magenta,
2098 Color::LightCyan => CtColor::Cyan,
2099 Color::LightWhite => CtColor::White,
2100 Color::Rgb(r, g, b) => CtColor::Rgb { r, g, b },
2101 Color::Indexed(i) => CtColor::AnsiValue(i),
2102 }
2103}
2104
2105fn reset_current_buffer(buffer: &mut Buffer, theme_bg: Option<Color>) {
2106 if let Some(bg) = theme_bg {
2107 buffer.reset_with_bg(bg);
2108 } else {
2109 buffer.reset();
2110 }
2111}
2112
2113fn write_session_enter(stdout: &mut impl Write, session: &TerminalSessionGuard) -> io::Result<()> {
2114 match session.mode {
2115 TerminalSessionMode::Fullscreen => {
2116 execute!(
2117 stdout,
2118 terminal::EnterAlternateScreen,
2119 cursor::Hide,
2120 EnableBracketedPaste
2121 )?;
2122 }
2123 TerminalSessionMode::Inline => {
2124 execute!(stdout, cursor::Hide, EnableBracketedPaste)?;
2125 }
2126 }
2127
2128 execute!(stdout, EnableFocusChange)?;
2134 if session.mouse_enabled {
2135 execute!(stdout, EnableMouseCapture)?;
2136 }
2137 if session.kitty_keyboard {
2138 use crossterm::event::PushKeyboardEnhancementFlags;
2139 let _ = execute!(
2140 stdout,
2141 PushKeyboardEnhancementFlags(kitty_flags(session.report_all_keys))
2142 );
2143 }
2144
2145 Ok(())
2146}
2147
2148fn kitty_flags(report_all_keys: bool) -> crossterm::event::KeyboardEnhancementFlags {
2158 use crossterm::event::KeyboardEnhancementFlags;
2159 let mut flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
2160 | KeyboardEnhancementFlags::REPORT_EVENT_TYPES;
2161 if report_all_keys {
2162 flags |= KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES;
2163 }
2164 flags
2165}
2166
2167fn write_session_cleanup(
2168 stdout: &mut impl Write,
2169 mode: TerminalSessionMode,
2170 inline_reserved: bool,
2171) -> io::Result<()> {
2172 execute!(
2173 stdout,
2174 ResetColor,
2175 SetAttribute(Attribute::Reset),
2176 cursor::Show,
2177 DisableBracketedPaste
2178 )?;
2179
2180 match mode {
2181 TerminalSessionMode::Fullscreen => {
2182 execute!(stdout, terminal::LeaveAlternateScreen)?;
2183 }
2184 TerminalSessionMode::Inline => {
2185 if inline_reserved {
2186 execute!(
2187 stdout,
2188 cursor::MoveToColumn(0),
2189 cursor::MoveDown(1),
2190 cursor::MoveToColumn(0),
2191 Print("\n")
2192 )?;
2193 } else {
2194 execute!(stdout, Print("\n"))?;
2195 }
2196 }
2197 }
2198
2199 Ok(())
2200}
2201
2202#[cfg(unix)]
2219#[derive(Debug, Clone, Copy)]
2220pub(crate) struct SessionSnapshot {
2221 mode: TerminalSessionMode,
2222 mouse_enabled: bool,
2223 kitty_keyboard: bool,
2224 report_all_keys: bool,
2225}
2226
2227#[cfg(unix)]
2230pub(crate) static NEEDS_FULL_REDRAW: std::sync::atomic::AtomicBool =
2231 std::sync::atomic::AtomicBool::new(false);
2232
2233#[cfg(unix)]
2234impl Terminal {
2235 pub(crate) fn session_snapshot(&self) -> SessionSnapshot {
2238 SessionSnapshot {
2239 mode: self.session.mode,
2240 mouse_enabled: self.session.mouse_enabled,
2241 kitty_keyboard: self.session.kitty_keyboard,
2242 report_all_keys: self.session.report_all_keys,
2243 }
2244 }
2245}
2246
2247#[cfg(unix)]
2248impl InlineTerminal {
2249 pub(crate) fn session_snapshot(&self) -> SessionSnapshot {
2252 SessionSnapshot {
2253 mode: self.session.mode,
2254 mouse_enabled: self.session.mouse_enabled,
2255 kitty_keyboard: self.session.kitty_keyboard,
2256 report_all_keys: self.session.report_all_keys,
2257 }
2258 }
2259}
2260
2261#[cfg(unix)]
2269fn write_suspend_sequence(stdout: &mut impl Write, snapshot: &SessionSnapshot) -> io::Result<()> {
2270 if snapshot.kitty_keyboard {
2271 use crossterm::event::PopKeyboardEnhancementFlags;
2272 execute!(stdout, PopKeyboardEnhancementFlags)?;
2273 }
2274 if snapshot.mouse_enabled {
2275 execute!(stdout, DisableMouseCapture)?;
2276 }
2277 execute!(stdout, DisableFocusChange)?;
2278 write_session_cleanup(stdout, snapshot.mode, false)
2279}
2280
2281#[cfg(unix)]
2288pub(crate) fn suspend_to_shell(snapshot: &SessionSnapshot) {
2289 let mut out = io::stdout();
2290 let _ = write_suspend_sequence(&mut out, snapshot);
2291 let _ = terminal::disable_raw_mode();
2292 let _ = out.flush();
2293}
2294
2295#[cfg(unix)]
2302pub(crate) fn resume_from_shell(snapshot: &SessionSnapshot) {
2303 let mut out = io::stdout();
2304 let _ = terminal::enable_raw_mode();
2305 let guard = TerminalSessionGuard {
2306 mode: snapshot.mode,
2307 mouse_enabled: snapshot.mouse_enabled,
2308 kitty_keyboard: snapshot.kitty_keyboard,
2309 report_all_keys: snapshot.report_all_keys,
2310 harness: false,
2311 };
2312 let _ = write_session_enter(&mut out, &guard);
2313 let _ = out.flush();
2314 NEEDS_FULL_REDRAW.store(true, std::sync::atomic::Ordering::SeqCst);
2315}
2316
2317#[cfg(all(unix, test))]
2319fn test_snapshot(mode: TerminalSessionMode, mouse: bool, kitty: bool) -> SessionSnapshot {
2320 SessionSnapshot {
2321 mode,
2322 mouse_enabled: mouse,
2323 kitty_keyboard: kitty,
2324 report_all_keys: false,
2325 }
2326}
2327
2328#[cfg(all(unix, test))]
2331pub(crate) fn test_session_snapshot() -> SessionSnapshot {
2332 SessionSnapshot {
2333 mode: TerminalSessionMode::Fullscreen,
2334 mouse_enabled: false,
2335 kitty_keyboard: false,
2336 report_all_keys: false,
2337 }
2338}
2339
2340#[cfg(test)]
2341mod tests {
2342 #![allow(clippy::unwrap_used)]
2343 use super::*;
2344
2345 #[test]
2346 fn reset_current_buffer_applies_theme_background() {
2347 let mut buffer = Buffer::empty(Rect::new(0, 0, 2, 1));
2348
2349 reset_current_buffer(&mut buffer, Some(Color::Rgb(10, 20, 30)));
2350 assert_eq!(buffer.get(0, 0).style.bg, Some(Color::Rgb(10, 20, 30)));
2351
2352 reset_current_buffer(&mut buffer, None);
2353 assert_eq!(buffer.get(0, 0).style.bg, None);
2354 }
2355
2356 #[test]
2357 fn fullscreen_session_enter_writes_alt_screen_sequence() {
2358 let session = TerminalSessionGuard {
2359 mode: TerminalSessionMode::Fullscreen,
2360 mouse_enabled: false,
2361 kitty_keyboard: false,
2362 report_all_keys: false,
2363 harness: false,
2364 };
2365 let mut out = Vec::new();
2366 write_session_enter(&mut out, &session).unwrap();
2367 let output = String::from_utf8(out).unwrap();
2368 assert!(output.contains("\u{1b}[?1049h"));
2369 assert!(output.contains("\u{1b}[?25l"));
2370 assert!(output.contains("\u{1b}[?2004h"));
2371 }
2372
2373 #[test]
2374 fn inline_session_enter_skips_alt_screen_sequence() {
2375 let session = TerminalSessionGuard {
2376 mode: TerminalSessionMode::Inline,
2377 mouse_enabled: false,
2378 kitty_keyboard: false,
2379 report_all_keys: false,
2380 harness: false,
2381 };
2382 let mut out = Vec::new();
2383 write_session_enter(&mut out, &session).unwrap();
2384 let output = String::from_utf8(out).unwrap();
2385 assert!(!output.contains("\u{1b}[?1049h"));
2386 assert!(output.contains("\u{1b}[?25l"));
2387 assert!(output.contains("\u{1b}[?2004h"));
2388 }
2389
2390 #[test]
2391 fn fullscreen_session_cleanup_leaves_alt_screen() {
2392 let mut out = Vec::new();
2393 write_session_cleanup(&mut out, TerminalSessionMode::Fullscreen, false).unwrap();
2394 let output = String::from_utf8(out).unwrap();
2395 assert!(output.contains("\u{1b}[?1049l"));
2396 assert!(output.contains("\u{1b}[?25h"));
2397 assert!(output.contains("\u{1b}[?2004l"));
2398 }
2399
2400 #[test]
2401 fn inline_session_cleanup_keeps_normal_screen() {
2402 let mut out = Vec::new();
2403 write_session_cleanup(&mut out, TerminalSessionMode::Inline, false).unwrap();
2404 let output = String::from_utf8(out).unwrap();
2405 assert!(!output.contains("\u{1b}[?1049l"));
2406 assert!(output.ends_with('\n'));
2407 assert!(output.contains("\u{1b}[?25h"));
2408 assert!(output.contains("\u{1b}[?2004l"));
2409 }
2410
2411 #[cfg(unix)]
2414 #[test]
2415 fn suspend_sequence_fullscreen_leaves_alt_screen() {
2416 let snapshot = test_snapshot(TerminalSessionMode::Fullscreen, false, false);
2417 let mut out = Vec::new();
2418 write_suspend_sequence(&mut out, &snapshot).unwrap();
2419 let output = String::from_utf8(out).unwrap();
2420 assert!(output.contains("\u{1b}[?1049l"), "leaves alt screen");
2421 assert!(output.contains("\u{1b}[?25h"), "shows cursor");
2422 assert!(output.contains("\u{1b}[?2004l"), "disables bracketed paste");
2423 }
2424
2425 #[cfg(unix)]
2426 #[test]
2427 fn suspend_sequence_inline_keeps_normal_screen() {
2428 let snapshot = test_snapshot(TerminalSessionMode::Inline, false, false);
2429 let mut out = Vec::new();
2430 write_suspend_sequence(&mut out, &snapshot).unwrap();
2431 let output = String::from_utf8(out).unwrap();
2432 assert!(
2433 !output.contains("\u{1b}[?1049l"),
2434 "inline must not leave alt screen"
2435 );
2436 assert!(output.contains("\u{1b}[?25h"), "shows cursor");
2437 assert!(output.contains("\u{1b}[?2004l"), "disables bracketed paste");
2438 }
2439
2440 #[cfg(unix)]
2441 #[test]
2442 fn suspend_sequence_disables_mouse_and_kitty_when_enabled() {
2443 let snapshot = test_snapshot(TerminalSessionMode::Fullscreen, true, true);
2444 let mut out = Vec::new();
2445 write_suspend_sequence(&mut out, &snapshot).unwrap();
2446 let output = String::from_utf8(out).unwrap();
2448 assert!(output.contains("\u{1b}[?1006l"), "disables SGR mouse mode");
2449 }
2450
2451 #[cfg(unix)]
2452 #[test]
2453 fn resume_sequence_fullscreen_round_trips_enter_and_flags_redraw() {
2454 let snapshot = test_snapshot(TerminalSessionMode::Fullscreen, false, false);
2455
2456 let guard = TerminalSessionGuard {
2458 mode: snapshot.mode,
2459 mouse_enabled: snapshot.mouse_enabled,
2460 kitty_keyboard: snapshot.kitty_keyboard,
2461 report_all_keys: snapshot.report_all_keys,
2462 harness: false,
2463 };
2464 let mut enter_bytes = Vec::new();
2465 write_session_enter(&mut enter_bytes, &guard).unwrap();
2466 let enter = String::from_utf8(enter_bytes).unwrap();
2467 assert!(enter.contains("\u{1b}[?1049h"));
2468 assert!(enter.contains("\u{1b}[?25l"));
2469 assert!(enter.contains("\u{1b}[?2004h"));
2470
2471 NEEDS_FULL_REDRAW.store(false, std::sync::atomic::Ordering::SeqCst);
2473 resume_from_shell(&snapshot);
2474 assert!(
2475 NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst),
2476 "resume must request a full redraw exactly once"
2477 );
2478 assert!(
2479 !NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst),
2480 "the redraw flag is consumed by the first swap (idempotent)"
2481 );
2482 }
2483
2484 #[cfg(unix)]
2485 #[test]
2486 fn needs_full_redraw_swaps_true_once() {
2487 NEEDS_FULL_REDRAW.store(true, std::sync::atomic::Ordering::SeqCst);
2488 assert!(NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst));
2489 assert!(!NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst));
2490 }
2491
2492 #[test]
2493 fn kitty_flags_base_set_excludes_report_all_keys() {
2494 use crossterm::event::KeyboardEnhancementFlags;
2495 let flags = kitty_flags(false);
2496 assert!(flags.contains(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES));
2497 assert!(flags.contains(KeyboardEnhancementFlags::REPORT_EVENT_TYPES));
2498 assert!(!flags.contains(KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES));
2499 }
2500
2501 #[test]
2502 fn kitty_flags_report_all_keys_sets_flag() {
2503 use crossterm::event::KeyboardEnhancementFlags;
2504 let flags = kitty_flags(true);
2505 assert!(flags.contains(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES));
2506 assert!(flags.contains(KeyboardEnhancementFlags::REPORT_EVENT_TYPES));
2507 assert!(flags.contains(KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES));
2508 }
2509
2510 #[test]
2511 fn base64_encode_empty() {
2512 assert_eq!(base64_encode(b""), "");
2513 }
2514
2515 #[test]
2516 fn base64_encode_hello() {
2517 assert_eq!(base64_encode(b"Hello"), "SGVsbG8=");
2518 }
2519
2520 #[test]
2521 fn base64_encode_padding() {
2522 assert_eq!(base64_encode(b"a"), "YQ==");
2523 assert_eq!(base64_encode(b"ab"), "YWI=");
2524 assert_eq!(base64_encode(b"abc"), "YWJj");
2525 }
2526
2527 #[test]
2528 fn base64_encode_unicode() {
2529 assert_eq!(base64_encode("한글".as_bytes()), "7ZWc6riA");
2530 }
2531
2532 #[cfg(feature = "crossterm")]
2533 #[test]
2534 fn parse_osc11_response_dark_and_light() {
2535 assert_eq!(
2536 parse_osc11_response("\x1b]11;rgb:0000/0000/0000\x1b\\"),
2537 ColorScheme::Dark
2538 );
2539 assert_eq!(
2540 parse_osc11_response("\x1b]11;rgb:ffff/ffff/ffff\x07"),
2541 ColorScheme::Light
2542 );
2543 }
2544
2545 #[test]
2548 fn blitter_support_default_is_conservative() {
2549 let b = BlitterSupport::default();
2550 assert!(b.half);
2551 assert!(b.quad);
2552 assert!(!b.sextant);
2553 }
2554
2555 #[test]
2556 fn capabilities_default_is_all_false_but_half_block() {
2557 let c = Capabilities::default();
2558 assert!(!c.truecolor);
2559 assert!(!c.sixel);
2560 assert!(!c.iterm2);
2561 assert!(!c.kitty_graphics);
2562 assert!(!c.kitty_keyboard);
2563 assert!(!c.sync_output);
2564 assert_eq!(c.best_blitter(), Blitter::HalfBlock);
2566 }
2567
2568 #[test]
2569 fn best_blitter_ladder_table() {
2570 let kitty = Capabilities {
2571 kitty_graphics: true,
2572 ..Default::default()
2573 };
2574 assert_eq!(kitty.best_blitter(), Blitter::Kitty);
2575
2576 let sixel = Capabilities {
2577 sixel: true,
2578 ..Default::default()
2579 };
2580 assert_eq!(sixel.best_blitter(), Blitter::Sixel);
2581
2582 let iterm2 = Capabilities {
2583 iterm2: true,
2584 ..Default::default()
2585 };
2586 assert_eq!(iterm2.best_blitter(), Blitter::Iterm2);
2587
2588 let sixel_and_iterm2 = Capabilities {
2590 sixel: true,
2591 iterm2: true,
2592 ..Default::default()
2593 };
2594 assert_eq!(sixel_and_iterm2.best_blitter(), Blitter::Sixel);
2595
2596 let sextant = Capabilities {
2597 blitters: BlitterSupport {
2598 sextant: true,
2599 ..Default::default()
2600 },
2601 ..Default::default()
2602 };
2603 assert_eq!(sextant.best_blitter(), Blitter::Sextant);
2604
2605 assert_eq!(Capabilities::default().best_blitter(), Blitter::HalfBlock);
2606 }
2607
2608 #[test]
2609 fn best_blitter_precedence_kitty_over_everything() {
2610 let all = Capabilities {
2611 kitty_graphics: true,
2612 sixel: true,
2613 blitters: BlitterSupport {
2614 sextant: true,
2615 ..Default::default()
2616 },
2617 ..Default::default()
2618 };
2619 assert_eq!(all.best_blitter(), Blitter::Kitty);
2620
2621 let sixel_and_sextant = Capabilities {
2622 sixel: true,
2623 blitters: BlitterSupport {
2624 sextant: true,
2625 ..Default::default()
2626 },
2627 ..Default::default()
2628 };
2629 assert_eq!(sixel_and_sextant.best_blitter(), Blitter::Sixel);
2630 }
2631
2632 #[test]
2633 fn best_blitter_never_picks_unsupported_protocol() {
2634 for kitty in [false, true] {
2637 for sixel in [false, true] {
2638 for iterm2 in [false, true] {
2639 for sextant in [false, true] {
2640 let caps = Capabilities {
2641 kitty_graphics: kitty,
2642 sixel,
2643 iterm2,
2644 blitters: BlitterSupport {
2645 sextant,
2646 ..Default::default()
2647 },
2648 ..Default::default()
2649 };
2650 match caps.best_blitter() {
2651 Blitter::Kitty => assert!(kitty),
2652 Blitter::Sixel => assert!(sixel && !kitty),
2653 Blitter::Iterm2 => assert!(iterm2 && !sixel && !kitty),
2654 Blitter::Sextant => {
2655 assert!(sextant && !iterm2 && !sixel && !kitty)
2656 }
2657 Blitter::HalfBlock => {
2658 assert!(!kitty && !sixel && !iterm2 && !sextant)
2659 }
2660 }
2661 }
2662 }
2663 }
2664 }
2665 }
2666
2667 #[cfg(feature = "crossterm")]
2668 #[test]
2669 fn parse_da1_attribute_4_sets_sixel() {
2670 let mut caps = Capabilities::default();
2671 parse_da1("\x1b[?62;4;6c", &mut caps);
2672 assert!(caps.sixel);
2673 }
2674
2675 #[cfg(feature = "crossterm")]
2676 #[test]
2677 fn parse_da1_without_4_leaves_sixel_false() {
2678 let mut caps = Capabilities::default();
2679 parse_da1("\x1b[?62;1;6c", &mut caps);
2680 assert!(!caps.sixel);
2681 }
2682
2683 #[cfg(feature = "crossterm")]
2684 #[test]
2685 fn parse_da1_ignores_da2_segment_in_same_string() {
2686 let mut caps = Capabilities::default();
2688 parse_da1("\x1b[?62;1c\x1b[>0;276;0c", &mut caps);
2689 assert!(!caps.sixel);
2690 }
2691
2692 #[cfg(feature = "crossterm")]
2693 #[test]
2694 fn parse_da2_no_panic_on_garbage() {
2695 let mut caps = Capabilities::default();
2696 parse_da2("\x1b[>99;1;0c", &mut caps);
2698 assert!(!caps.kitty_graphics);
2699 parse_da2("not a da2 reply", &mut caps);
2700 assert!(!caps.kitty_graphics);
2701 }
2702
2703 #[cfg(feature = "crossterm")]
2704 #[test]
2705 fn parse_da2_kitty_id_sets_kitty_graphics() {
2706 let mut caps = Capabilities::default();
2707 parse_da2("\x1b[>41;4000;0c", &mut caps);
2709 assert!(caps.kitty_graphics);
2710 }
2711
2712 #[cfg(feature = "crossterm")]
2713 #[test]
2714 fn parse_da2_identity_extracts_id_and_version() {
2715 assert_eq!(parse_da2_identity("\x1b[>0;276;0c"), Some((0, 276)));
2716 assert_eq!(parse_da2_identity("\x1b[>41;4000;0c"), Some((41, 4000)));
2717 assert_eq!(parse_da2_identity("no reply here"), None);
2718 }
2719
2720 #[cfg(feature = "crossterm")]
2721 #[test]
2722 fn parse_kitty_graphics_ack_ok_sets_flag() {
2723 let mut caps = Capabilities::default();
2724 parse_kitty_graphics_ack("\x1b_Gi=31;OK\x1b\\", &mut caps);
2725 assert!(caps.kitty_graphics);
2726 }
2727
2728 #[cfg(feature = "crossterm")]
2729 #[test]
2730 fn parse_kitty_graphics_ack_error_or_wrong_id_leaves_flag() {
2731 let mut caps = Capabilities::default();
2732 parse_kitty_graphics_ack("\x1b_Gi=31;ENOENT:bad\x1b\\", &mut caps);
2734 assert!(!caps.kitty_graphics);
2735 parse_kitty_graphics_ack("\x1b_Gi=99;OK\x1b\\", &mut caps);
2737 assert!(!caps.kitty_graphics);
2738 parse_kitty_graphics_ack("garbage", &mut caps);
2740 assert!(!caps.kitty_graphics);
2741 }
2742
2743 #[cfg(feature = "crossterm")]
2744 #[test]
2745 fn parse_xtgettcap_tc_sets_truecolor() {
2746 let mut caps = Capabilities::default();
2747 parse_xtgettcap_truecolor("\x1bP1+r5463=\x1b\\", &mut caps);
2749 assert!(caps.truecolor);
2750 }
2751
2752 #[cfg(feature = "crossterm")]
2753 #[test]
2754 fn parse_xtgettcap_invalid_leaves_truecolor_false() {
2755 let mut caps = Capabilities::default();
2756 parse_xtgettcap_truecolor("\x1bP0+r5463\x1b\\", &mut caps);
2758 assert!(!caps.truecolor);
2759 parse_xtgettcap_truecolor("\x1bP1+r1234=\x1b\\", &mut caps);
2761 assert!(!caps.truecolor);
2762 }
2763
2764 #[cfg(feature = "crossterm")]
2765 #[test]
2766 fn base64_decode_round_trip_hello() {
2767 let encoded = base64_encode("hello".as_bytes());
2768 assert_eq!(base64_decode(&encoded), Some("hello".to_string()));
2769 }
2770
2771 #[cfg(feature = "crossterm")]
2772 #[test]
2773 fn color_scheme_equality() {
2774 assert_eq!(ColorScheme::Dark, ColorScheme::Dark);
2775 assert_ne!(ColorScheme::Dark, ColorScheme::Light);
2776 assert_eq!(ColorScheme::Unknown, ColorScheme::Unknown);
2777 }
2778
2779 fn pair(r: Rect) -> (Rect, Rect) {
2780 (r, r)
2781 }
2782
2783 #[test]
2784 fn find_innermost_rect_picks_smallest() {
2785 let rects = vec![
2786 pair(Rect::new(0, 0, 80, 24)),
2787 pair(Rect::new(5, 2, 30, 10)),
2788 pair(Rect::new(10, 4, 10, 5)),
2789 ];
2790 let result = find_innermost_rect(&rects, 12, 5);
2791 assert_eq!(result, Some(Rect::new(10, 4, 10, 5)));
2792 }
2793
2794 #[test]
2795 fn find_innermost_rect_no_match() {
2796 let rects = vec![pair(Rect::new(10, 10, 5, 5))];
2797 assert_eq!(find_innermost_rect(&rects, 0, 0), None);
2798 }
2799
2800 #[test]
2801 fn find_innermost_rect_empty() {
2802 assert_eq!(find_innermost_rect(&[], 5, 5), None);
2803 }
2804
2805 #[test]
2806 fn find_innermost_rect_returns_content_rect() {
2807 let rects = vec![
2808 (Rect::new(0, 0, 80, 24), Rect::new(1, 1, 78, 22)),
2809 (Rect::new(5, 2, 30, 10), Rect::new(6, 3, 28, 8)),
2810 ];
2811 let result = find_innermost_rect(&rects, 10, 5);
2812 assert_eq!(result, Some(Rect::new(6, 3, 28, 8)));
2813 }
2814
2815 #[test]
2816 fn normalize_selection_already_ordered() {
2817 let (s, e) = normalize_selection((2, 1), (5, 3));
2818 assert_eq!(s, (2, 1));
2819 assert_eq!(e, (5, 3));
2820 }
2821
2822 #[test]
2823 fn normalize_selection_reversed() {
2824 let (s, e) = normalize_selection((5, 3), (2, 1));
2825 assert_eq!(s, (2, 1));
2826 assert_eq!(e, (5, 3));
2827 }
2828
2829 #[test]
2830 fn normalize_selection_same_row() {
2831 let (s, e) = normalize_selection((10, 5), (3, 5));
2832 assert_eq!(s, (3, 5));
2833 assert_eq!(e, (10, 5));
2834 }
2835
2836 #[test]
2837 fn selection_state_mouse_down_finds_rect() {
2838 let hit_map = vec![pair(Rect::new(0, 0, 80, 24)), pair(Rect::new(5, 2, 20, 10))];
2839 let mut sel = SelectionState::default();
2840 sel.mouse_down(10, 5, &hit_map);
2841 assert_eq!(sel.anchor, Some((10, 5)));
2842 assert_eq!(sel.current, Some((10, 5)));
2843 assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 20, 10)));
2844 assert!(!sel.active);
2845 }
2846
2847 #[test]
2848 fn selection_state_drag_activates() {
2849 let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
2850 let mut sel = SelectionState {
2851 anchor: Some((10, 5)),
2852 current: Some((10, 5)),
2853 widget_rect: Some(Rect::new(0, 0, 80, 24)),
2854 ..Default::default()
2855 };
2856 sel.mouse_drag(10, 5, &hit_map);
2857 assert!(!sel.active, "no movement = not active");
2858 sel.mouse_drag(11, 5, &hit_map);
2859 assert!(!sel.active, "1 cell horizontal = not active yet");
2860 sel.mouse_drag(13, 5, &hit_map);
2861 assert!(sel.active, ">1 cell horizontal = active");
2862 }
2863
2864 #[test]
2865 fn selection_state_drag_vertical_activates() {
2866 let hit_map = vec![pair(Rect::new(0, 0, 80, 24))];
2867 let mut sel = SelectionState {
2868 anchor: Some((10, 5)),
2869 current: Some((10, 5)),
2870 widget_rect: Some(Rect::new(0, 0, 80, 24)),
2871 ..Default::default()
2872 };
2873 sel.mouse_drag(10, 6, &hit_map);
2874 assert!(sel.active, "any vertical movement = active");
2875 }
2876
2877 #[test]
2878 fn selection_state_drag_expands_widget_rect() {
2879 let hit_map = vec![
2880 pair(Rect::new(0, 0, 80, 24)),
2881 pair(Rect::new(5, 2, 30, 10)),
2882 pair(Rect::new(5, 2, 30, 3)),
2883 ];
2884 let mut sel = SelectionState {
2885 anchor: Some((10, 3)),
2886 current: Some((10, 3)),
2887 widget_rect: Some(Rect::new(5, 2, 30, 3)),
2888 ..Default::default()
2889 };
2890 sel.mouse_drag(10, 6, &hit_map);
2891 assert_eq!(sel.widget_rect, Some(Rect::new(5, 2, 30, 10)));
2892 }
2893
2894 #[test]
2895 fn selection_state_clear_resets() {
2896 let mut sel = SelectionState {
2897 anchor: Some((1, 2)),
2898 current: Some((3, 4)),
2899 widget_rect: Some(Rect::new(0, 0, 10, 10)),
2900 active: true,
2901 };
2902 sel.clear();
2903 assert_eq!(sel.anchor, None);
2904 assert_eq!(sel.current, None);
2905 assert_eq!(sel.widget_rect, None);
2906 assert!(!sel.active);
2907 }
2908
2909 #[test]
2910 fn extract_selection_text_single_line() {
2911 let area = Rect::new(0, 0, 20, 5);
2912 let mut buf = Buffer::empty(area);
2913 buf.set_string(0, 0, "Hello World", Style::default());
2914 let sel = SelectionState {
2915 anchor: Some((0, 0)),
2916 current: Some((4, 0)),
2917 widget_rect: Some(area),
2918 active: true,
2919 };
2920 let text = extract_selection_text(&buf, &sel, &[]);
2921 assert_eq!(text, "Hello");
2922 }
2923
2924 #[test]
2925 fn extract_selection_text_multi_line() {
2926 let area = Rect::new(0, 0, 20, 5);
2927 let mut buf = Buffer::empty(area);
2928 buf.set_string(0, 0, "Line one", Style::default());
2929 buf.set_string(0, 1, "Line two", Style::default());
2930 buf.set_string(0, 2, "Line three", Style::default());
2931 let sel = SelectionState {
2932 anchor: Some((5, 0)),
2933 current: Some((3, 2)),
2934 widget_rect: Some(area),
2935 active: true,
2936 };
2937 let text = extract_selection_text(&buf, &sel, &[]);
2938 assert_eq!(text, "one\nLine two\nLine");
2939 }
2940
2941 #[test]
2942 fn extract_selection_text_clamped_to_widget() {
2943 let area = Rect::new(0, 0, 40, 10);
2944 let widget = Rect::new(5, 2, 10, 3);
2945 let mut buf = Buffer::empty(area);
2946 buf.set_string(5, 2, "ABCDEFGHIJ", Style::default());
2947 buf.set_string(5, 3, "KLMNOPQRST", Style::default());
2948 let sel = SelectionState {
2949 anchor: Some((3, 1)),
2950 current: Some((20, 5)),
2951 widget_rect: Some(widget),
2952 active: true,
2953 };
2954 let text = extract_selection_text(&buf, &sel, &[]);
2955 assert_eq!(text, "ABCDEFGHIJ\nKLMNOPQRST");
2956 }
2957
2958 #[test]
2959 fn extract_selection_text_inactive_returns_empty() {
2960 let area = Rect::new(0, 0, 10, 5);
2961 let buf = Buffer::empty(area);
2962 let sel = SelectionState {
2963 anchor: Some((0, 0)),
2964 current: Some((5, 2)),
2965 widget_rect: Some(area),
2966 active: false,
2967 };
2968 assert_eq!(extract_selection_text(&buf, &sel, &[]), "");
2969 }
2970
2971 #[test]
2972 fn apply_selection_overlay_reverses_cells() {
2973 let area = Rect::new(0, 0, 10, 3);
2974 let mut buf = Buffer::empty(area);
2975 buf.set_string(0, 0, "ABCDE", Style::default());
2976 let sel = SelectionState {
2977 anchor: Some((1, 0)),
2978 current: Some((3, 0)),
2979 widget_rect: Some(area),
2980 active: true,
2981 };
2982 apply_selection_overlay(&mut buf, &sel, &[]);
2983 assert!(!buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED));
2984 assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
2985 assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
2986 assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
2987 assert!(!buf.get(4, 0).style.modifiers.contains(Modifiers::REVERSED));
2988 }
2989
2990 #[test]
2991 fn extract_selection_text_skips_border_cells() {
2992 let area = Rect::new(0, 0, 40, 5);
2997 let mut buf = Buffer::empty(area);
2998 buf.set_string(0, 0, "╭", Style::default());
3000 buf.set_string(0, 1, "│", Style::default());
3001 buf.set_string(0, 2, "│", Style::default());
3002 buf.set_string(0, 3, "│", Style::default());
3003 buf.set_string(0, 4, "╰", Style::default());
3004 buf.set_string(19, 0, "╮", Style::default());
3005 buf.set_string(19, 1, "│", Style::default());
3006 buf.set_string(19, 2, "│", Style::default());
3007 buf.set_string(19, 3, "│", Style::default());
3008 buf.set_string(19, 4, "╯", Style::default());
3009 buf.set_string(20, 0, "╭", Style::default());
3011 buf.set_string(20, 1, "│", Style::default());
3012 buf.set_string(20, 2, "│", Style::default());
3013 buf.set_string(20, 3, "│", Style::default());
3014 buf.set_string(20, 4, "╰", Style::default());
3015 buf.set_string(39, 0, "╮", Style::default());
3016 buf.set_string(39, 1, "│", Style::default());
3017 buf.set_string(39, 2, "│", Style::default());
3018 buf.set_string(39, 3, "│", Style::default());
3019 buf.set_string(39, 4, "╯", Style::default());
3020 buf.set_string(1, 1, "Hello Col1", Style::default());
3022 buf.set_string(1, 2, "Line2 Col1", Style::default());
3023 buf.set_string(21, 1, "Hello Col2", Style::default());
3025 buf.set_string(21, 2, "Line2 Col2", Style::default());
3026
3027 let content_map = vec![
3028 (Rect::new(0, 0, 20, 5), Rect::new(1, 1, 18, 3)),
3029 (Rect::new(20, 0, 20, 5), Rect::new(21, 1, 18, 3)),
3030 ];
3031
3032 let sel = SelectionState {
3034 anchor: Some((0, 1)),
3035 current: Some((39, 2)),
3036 widget_rect: Some(area),
3037 active: true,
3038 };
3039 let text = extract_selection_text(&buf, &sel, &content_map);
3040 assert!(!text.contains('│'), "Border char │ found in: {text}");
3042 assert!(!text.contains('╭'), "Border char ╭ found in: {text}");
3043 assert!(!text.contains('╮'), "Border char ╮ found in: {text}");
3044 assert!(
3046 text.contains("Hello Col1"),
3047 "Missing Col1 content in: {text}"
3048 );
3049 assert!(
3050 text.contains("Hello Col2"),
3051 "Missing Col2 content in: {text}"
3052 );
3053 assert!(text.contains("Line2 Col1"), "Missing Col1 line2 in: {text}");
3054 assert!(text.contains("Line2 Col2"), "Missing Col2 line2 in: {text}");
3055 }
3056
3057 #[test]
3058 fn apply_selection_overlay_skips_border_cells() {
3059 let area = Rect::new(0, 0, 20, 3);
3060 let mut buf = Buffer::empty(area);
3061 buf.set_string(0, 0, "│", Style::default());
3062 buf.set_string(1, 0, "ABC", Style::default());
3063 buf.set_string(19, 0, "│", Style::default());
3064
3065 let content_map = vec![(Rect::new(0, 0, 20, 3), Rect::new(1, 0, 18, 3))];
3066 let sel = SelectionState {
3067 anchor: Some((0, 0)),
3068 current: Some((19, 0)),
3069 widget_rect: Some(area),
3070 active: true,
3071 };
3072 apply_selection_overlay(&mut buf, &sel, &content_map);
3073 assert!(
3075 !buf.get(0, 0).style.modifiers.contains(Modifiers::REVERSED),
3076 "Left border cell should not be reversed"
3077 );
3078 assert!(
3079 !buf.get(19, 0).style.modifiers.contains(Modifiers::REVERSED),
3080 "Right border cell should not be reversed"
3081 );
3082 assert!(buf.get(1, 0).style.modifiers.contains(Modifiers::REVERSED));
3084 assert!(buf.get(2, 0).style.modifiers.contains(Modifiers::REVERSED));
3085 assert!(buf.get(3, 0).style.modifiers.contains(Modifiers::REVERSED));
3086 }
3087
3088 #[test]
3089 fn copy_to_clipboard_writes_osc52() {
3090 let mut output: Vec<u8> = Vec::new();
3091 copy_to_clipboard(&mut output, "test").unwrap();
3092 let s = String::from_utf8(output).unwrap();
3093 assert!(s.starts_with("\x1b]52;c;"));
3094 assert!(s.ends_with("\x1b\\"));
3095 assert!(s.contains(&base64_encode(b"test")));
3096 }
3097
3098 fn count_move_tos(s: &str) -> usize {
3100 let bytes = s.as_bytes();
3101 let mut count = 0;
3102 let mut i = 0;
3103 while i + 1 < bytes.len() {
3104 if bytes[i] == 0x1b && bytes[i + 1] == b'[' {
3105 let mut j = i + 2;
3107 while j < bytes.len() && !(0x40..=0x7e).contains(&bytes[j]) {
3108 j += 1;
3109 }
3110 if j < bytes.len() && bytes[j] == b'H' {
3111 count += 1;
3112 }
3113 i = j + 1;
3114 } else {
3115 i += 1;
3116 }
3117 }
3118 count
3119 }
3120
3121 #[test]
3122 fn flush_coalesces_consecutive_same_style_cells_into_one_run() {
3123 let area = Rect::new(0, 0, 20, 1);
3125 let mut current = Buffer::empty(area);
3126 let previous = Buffer::empty(area);
3127 let style = Style::new().fg(Color::Red);
3128 for x in 0..10u32 {
3129 let cell = current.get_mut(x, 0);
3130 cell.set_char('X');
3131 cell.set_style(style);
3132 }
3133
3134 let mut out: Vec<u8> = Vec::new();
3135 flush_buffer_diff(
3136 &mut out,
3137 ¤t,
3138 &previous,
3139 ColorDepth::TrueColor,
3140 0,
3141 &mut String::new(),
3142 )
3143 .unwrap();
3144 let s = String::from_utf8(out).unwrap();
3145
3146 assert_eq!(
3148 count_move_tos(&s),
3149 1,
3150 "expected 1 MoveTo for a coalesced run, got {} in {:?}",
3151 count_move_tos(&s),
3152 s
3153 );
3154 assert!(
3156 s.contains("XXXXXXXXXX"),
3157 "expected contiguous run 'XXXXXXXXXX' in {:?}",
3158 s
3159 );
3160 }
3161
3162 #[test]
3163 fn flush_breaks_run_on_style_change() {
3164 let area = Rect::new(0, 0, 20, 1);
3166 let mut current = Buffer::empty(area);
3167 let previous = Buffer::empty(area);
3168 let red = Style::new().fg(Color::Red);
3169 let blue = Style::new().fg(Color::Blue);
3170 for x in 0..5u32 {
3171 let cell = current.get_mut(x, 0);
3172 cell.set_char('R');
3173 cell.set_style(red);
3174 }
3175 for x in 5..10u32 {
3176 let cell = current.get_mut(x, 0);
3177 cell.set_char('B');
3178 cell.set_style(blue);
3179 }
3180
3181 let mut out: Vec<u8> = Vec::new();
3182 flush_buffer_diff(
3183 &mut out,
3184 ¤t,
3185 &previous,
3186 ColorDepth::TrueColor,
3187 0,
3188 &mut String::new(),
3189 )
3190 .unwrap();
3191 let s = String::from_utf8(out).unwrap();
3192
3193 let moves = count_move_tos(&s);
3197 assert!(
3198 moves <= 2,
3199 "expected at most 2 MoveTos across a style boundary, got {} in {:?}",
3200 moves,
3201 s
3202 );
3203 assert!(s.contains("RRRRR"), "missing 'RRRRR' run in {:?}", s);
3204 assert!(s.contains("BBBBB"), "missing 'BBBBB' run in {:?}", s);
3205 }
3206
3207 #[test]
3208 fn flush_breaks_run_on_column_gap() {
3209 let area = Rect::new(0, 0, 20, 1);
3211 let mut current = Buffer::empty(area);
3212 let previous = Buffer::empty(area);
3213 let style = Style::new().fg(Color::Green);
3214 for x in 0..3u32 {
3215 current.get_mut(x, 0).set_char('A').set_style(style);
3216 }
3217 for x in 6..9u32 {
3218 current.get_mut(x, 0).set_char('B').set_style(style);
3219 }
3220
3221 let mut out: Vec<u8> = Vec::new();
3222 flush_buffer_diff(
3223 &mut out,
3224 ¤t,
3225 &previous,
3226 ColorDepth::TrueColor,
3227 0,
3228 &mut String::new(),
3229 )
3230 .unwrap();
3231 let s = String::from_utf8(out).unwrap();
3232
3233 assert_eq!(
3235 count_move_tos(&s),
3236 2,
3237 "expected 2 MoveTos across a column gap, got {} in {:?}",
3238 count_move_tos(&s),
3239 s
3240 );
3241 assert!(s.contains("AAA"), "missing 'AAA' run in {:?}", s);
3242 assert!(s.contains("BBB"), "missing 'BBB' run in {:?}", s);
3243 }
3244
3245 #[test]
3249 fn bufwriter_output_identical_to_direct_write() {
3250 let area = Rect::new(0, 0, 5, 1);
3251 let mut current = Buffer::empty(area);
3252 let previous = Buffer::empty(area);
3253 let style = Style::new().fg(Color::Rgb(255, 128, 0));
3254 for x in 0..5u32 {
3255 current.get_mut(x, 0).set_char('X').set_style(style);
3256 }
3257
3258 let mut direct: Vec<u8> = Vec::new();
3259 flush_buffer_diff(
3260 &mut direct,
3261 ¤t,
3262 &previous,
3263 ColorDepth::TrueColor,
3264 0,
3265 &mut String::new(),
3266 )
3267 .unwrap();
3268
3269 let mut buffered: BufWriter<Vec<u8>> = BufWriter::with_capacity(65536, Vec::new());
3270 flush_buffer_diff(
3271 &mut buffered,
3272 ¤t,
3273 &previous,
3274 ColorDepth::TrueColor,
3275 0,
3276 &mut String::new(),
3277 )
3278 .unwrap();
3279 buffered.flush().unwrap();
3280 let via_buf = buffered.into_inner().unwrap();
3281
3282 assert_eq!(
3283 direct, via_buf,
3284 "BufWriter output must be byte-for-byte identical to direct write"
3285 );
3286 }
3287
3288 #[test]
3292 fn bufwriter_coalesces_writes_into_single_flush() {
3293 #[derive(Debug)]
3294 struct CountingWriter {
3295 buf: Vec<u8>,
3296 write_call_count: usize,
3297 }
3298 impl Write for CountingWriter {
3299 fn write(&mut self, data: &[u8]) -> io::Result<usize> {
3300 self.write_call_count += 1;
3301 self.buf.extend_from_slice(data);
3302 Ok(data.len())
3303 }
3304 fn flush(&mut self) -> io::Result<()> {
3305 Ok(())
3306 }
3307 }
3308
3309 let area = Rect::new(0, 0, 10, 1);
3310 let mut current = Buffer::empty(area);
3311 let previous = Buffer::empty(area);
3312 for x in 0..10u32 {
3314 let color = if x % 2 == 0 {
3315 Color::Rgb(255, 0, 0)
3316 } else {
3317 Color::Rgb(0, 255, 0)
3318 };
3319 current
3320 .get_mut(x, 0)
3321 .set_char('Z')
3322 .set_style(Style::new().fg(color));
3323 }
3324
3325 let sink = CountingWriter {
3326 buf: Vec::new(),
3327 write_call_count: 0,
3328 };
3329 let mut bw = BufWriter::with_capacity(65536, sink);
3330 flush_buffer_diff(
3331 &mut bw,
3332 ¤t,
3333 &previous,
3334 ColorDepth::TrueColor,
3335 0,
3336 &mut String::new(),
3337 )
3338 .unwrap();
3339 bw.flush().unwrap();
3340 let inner = bw.into_inner().unwrap();
3341
3342 assert_eq!(
3344 inner.write_call_count, 1,
3345 "expected 1 write syscall to sink, got {}",
3346 inner.write_call_count
3347 );
3348 }
3349
3350 #[test]
3356 fn flush_skips_unchanged_rows_when_hashes_match() {
3357 let area = Rect::new(0, 0, 20, 4);
3358 let mut current = Buffer::empty(area);
3359 let mut previous = Buffer::empty(area);
3360 for y in 0..4u32 {
3362 current.set_string(0, y, "identical-row-content", Style::new());
3363 previous.set_string(0, y, "identical-row-content", Style::new());
3364 }
3365 current.recompute_line_hashes();
3366 previous.recompute_line_hashes();
3367
3368 let mut out: Vec<u8> = Vec::new();
3369 flush_buffer_diff(
3370 &mut out,
3371 ¤t,
3372 &previous,
3373 ColorDepth::TrueColor,
3374 0,
3375 &mut String::new(),
3376 )
3377 .unwrap();
3378 assert!(
3379 out.is_empty(),
3380 "identical buffers must emit zero flush bytes; got {} bytes: {:?}",
3381 out.len(),
3382 out
3383 );
3384 }
3385
3386 #[test]
3390 fn flush_skips_only_matching_rows_in_mixed_diff() {
3391 let area = Rect::new(0, 0, 6, 3);
3392 let mut current = Buffer::empty(area);
3393 let mut previous = Buffer::empty(area);
3394 current.set_string(0, 0, "abcdef", Style::new());
3395 previous.set_string(0, 0, "abcdef", Style::new());
3396 current.set_string(0, 1, "xxxxxx", Style::new());
3397 previous.set_string(0, 1, "yyyyyy", Style::new());
3398 current.set_string(0, 2, "zzzzzz", Style::new());
3399 previous.set_string(0, 2, "zzzzzz", Style::new());
3400 current.recompute_line_hashes();
3401 previous.recompute_line_hashes();
3402
3403 let mut out: Vec<u8> = Vec::new();
3404 flush_buffer_diff(
3405 &mut out,
3406 ¤t,
3407 &previous,
3408 ColorDepth::TrueColor,
3409 0,
3410 &mut String::new(),
3411 )
3412 .unwrap();
3413 let s = String::from_utf8_lossy(&out);
3414 assert!(s.contains("xxxxxx"), "differing row must flush: {s:?}");
3417 assert!(
3418 !s.contains("abcdef"),
3419 "matching row 0 must not flush: {s:?}"
3420 );
3421 assert!(
3422 !s.contains("zzzzzz"),
3423 "matching row 2 must not flush: {s:?}"
3424 );
3425 }
3426
3427 fn delta_bytes(old: &Style, new: &Style) -> Vec<u8> {
3428 let mut out = Vec::new();
3429 apply_style_delta(&mut out, old, new, ColorDepth::TrueColor).unwrap();
3430 out
3431 }
3432
3433 fn contains_seq(haystack: &[u8], needle: &[u8]) -> bool {
3434 haystack.windows(needle.len()).any(|w| w == needle)
3435 }
3436
3437 #[test]
3438 fn apply_style_delta_emits_blink_set_and_reset() {
3439 let on = delta_bytes(&Style::new(), &Style::new().blink());
3440 assert!(contains_seq(&on, b"\x1b[5m"), "blink set: {on:?}");
3442 let off = delta_bytes(&Style::new().blink(), &Style::new());
3443 assert!(contains_seq(&off, b"\x1b[25m"), "blink reset: {off:?}");
3445 }
3446
3447 #[test]
3448 fn apply_style_delta_emits_overline_set_and_reset() {
3449 let on = delta_bytes(&Style::new(), &Style::new().overline());
3450 assert!(contains_seq(&on, b"\x1b[53m"), "overline set: {on:?}");
3452 let off = delta_bytes(&Style::new().overline(), &Style::new());
3453 assert!(contains_seq(&off, b"\x1b[55m"), "overline reset: {off:?}");
3455 }
3456
3457 #[test]
3458 fn apply_style_delta_emits_curly_underline_subparameter() {
3459 let out = delta_bytes(
3460 &Style::new(),
3461 &Style::new().underline_style(UnderlineStyle::Curly),
3462 );
3463 assert!(contains_seq(&out, b"\x1b[4:3m"), "curly underline: {out:?}");
3464 }
3465
3466 #[test]
3467 fn apply_style_delta_emits_underline_color_and_reset() {
3468 let set = delta_bytes(
3469 &Style::new(),
3470 &Style::new().underline_color(Color::Rgb(255, 0, 0)),
3471 );
3472 assert!(
3473 contains_seq(&set, b"\x1b[58:2::255:0:0m"),
3474 "underline color set: {set:?}"
3475 );
3476 let clear = delta_bytes(
3477 &Style::new().underline_color(Color::Rgb(255, 0, 0)),
3478 &Style::new(),
3479 );
3480 assert!(
3481 contains_seq(&clear, b"\x1b[59m"),
3482 "underline color reset: {clear:?}"
3483 );
3484 }
3485
3486 #[test]
3487 fn apply_style_delta_underline_color_indexed_uses_sgr_58_5() {
3488 let out = delta_bytes(
3489 &Style::new(),
3490 &Style::new().underline_color(Color::Indexed(42)),
3491 );
3492 assert!(
3493 contains_seq(&out, b"\x1b[58:5:42m"),
3494 "indexed underline: {out:?}"
3495 );
3496 }
3497
3498 #[test]
3499 fn apply_style_full_emits_blink_overline_and_underline() {
3500 let mut out = Vec::new();
3501 let style = Style::new()
3502 .blink()
3503 .overline()
3504 .underline_style(UnderlineStyle::Dotted)
3505 .underline_color(Color::Rgb(0, 0, 255));
3506 apply_style(&mut out, &style, ColorDepth::TrueColor).unwrap();
3507 assert!(contains_seq(&out, b"\x1b[5m"), "blink: {out:?}");
3508 assert!(contains_seq(&out, b"\x1b[53m"), "overline: {out:?}");
3509 assert!(
3510 contains_seq(&out, b"\x1b[4:4m"),
3511 "dotted underline: {out:?}"
3512 );
3513 assert!(
3514 contains_seq(&out, b"\x1b[58:2::0:0:255m"),
3515 "underline color: {out:?}"
3516 );
3517 }
3518 #[test]
3522 fn with_sink_captures_flush_bytes_and_drops_clean() {
3523 let mut term = Terminal::with_sink(10, 1, ColorDepth::TrueColor);
3524 term.buffer_mut()
3525 .set_string(0, 0, "Z", Style::new().fg(Color::Rgb(200, 50, 50)));
3526 term.flush().unwrap();
3527 let bytes = term.take_sink_bytes();
3528 let s = String::from_utf8_lossy(&bytes);
3529 assert!(s.contains("\u{1b}[38;2;200;50;50m"), "missing SGR: {s:?}");
3531 assert!(s.contains('Z'), "missing glyph: {s:?}");
3532 assert!(term.take_sink_bytes().is_empty());
3534 drop(term);
3536 }
3537
3538 #[test]
3543 fn reused_run_buf_byte_identical_across_frames() {
3544 let area = Rect::new(0, 0, 12, 2);
3545 let make_frame = || {
3547 let mut current = Buffer::empty(area);
3548 let previous = Buffer::empty(area);
3549 current.set_string(0, 0, "hello world", Style::new().fg(Color::Rgb(1, 2, 3)));
3550 current.set_string(0, 1, "second line", Style::new().fg(Color::Rgb(4, 5, 6)));
3551 (current, previous)
3552 };
3553
3554 let mut baseline: Vec<u8> = Vec::new();
3556 {
3557 let (mut a, mut b) = make_frame();
3558 __bench_flush_buffer_diff_mut_with_buf(
3559 &mut baseline,
3560 &mut a,
3561 &mut b,
3562 ColorDepth::TrueColor,
3563 &mut String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
3564 )
3565 .unwrap();
3566 }
3567
3568 let mut shared = String::with_capacity(RUN_BUF_INITIAL_CAPACITY);
3571 {
3572 let mut warm: Vec<u8> = Vec::new();
3573 let (mut a, mut b) = make_frame();
3574 __bench_flush_buffer_diff_mut_with_buf(
3575 &mut warm,
3576 &mut a,
3577 &mut b,
3578 ColorDepth::TrueColor,
3579 &mut shared,
3580 )
3581 .unwrap();
3582 }
3583 let cap_after_warm = shared.capacity();
3584
3585 let mut reused: Vec<u8> = Vec::new();
3586 let (mut current, mut previous) = make_frame();
3587 __bench_flush_buffer_diff_mut_with_buf(
3588 &mut reused,
3589 &mut current,
3590 &mut previous,
3591 ColorDepth::TrueColor,
3592 &mut shared,
3593 )
3594 .unwrap();
3595
3596 assert_eq!(
3597 baseline, reused,
3598 "reused run_buf must emit byte-identical output"
3599 );
3600 assert!(
3603 shared.capacity() >= cap_after_warm,
3604 "run_buf capacity must persist across frames"
3605 );
3606 }
3607
3608 #[test]
3612 fn osc8_hyperlink_emitted_verbatim_after_write_rewrite() {
3613 let area = Rect::new(0, 0, 8, 1);
3614 let mut current = Buffer::empty(area);
3615 let previous = Buffer::empty(area);
3616 let url = "https://example.com/x";
3617 current.set_string_linked(0, 0, "link", Style::new(), url);
3619
3620 let mut out: Vec<u8> = Vec::new();
3621 flush_buffer_diff(
3622 &mut out,
3623 ¤t,
3624 &previous,
3625 ColorDepth::TrueColor,
3626 0,
3627 &mut String::new(),
3628 )
3629 .unwrap();
3630
3631 let open = format!("\x1b]8;;{url}\x07");
3632 assert!(
3633 contains_seq(&out, open.as_bytes()),
3634 "OSC 8 open must appear verbatim: {:?}",
3635 String::from_utf8_lossy(&out)
3636 );
3637 assert!(
3638 contains_seq(&out, b"\x1b]8;;\x07"),
3639 "OSC 8 close must appear: {:?}",
3640 String::from_utf8_lossy(&out)
3641 );
3642 }
3643
3644 fn kitty_placements(n: usize) -> Vec<KittyPlacement> {
3646 (0..n)
3647 .map(|i| {
3648 let mut rgba = vec![0u8; 256];
3649 rgba[0] = i as u8;
3650 let content_hash = crate::buffer::hash_rgba(&rgba);
3651 KittyPlacement {
3652 content_hash,
3653 rgba: std::sync::Arc::new(rgba),
3654 src_width: 8,
3655 src_height: 8,
3656 x: (i as u32) * 4,
3657 y: (i as u32) * 2,
3658 cols: 4,
3659 rows: 2,
3660 crop_y: 0,
3661 crop_h: 0,
3662 }
3663 })
3664 .collect()
3665 }
3666
3667 #[test]
3673 fn kitty_flush_smallvec_dedup_matches_for_small_n() {
3674 for n in [0usize, 1, 5] {
3675 let placements = kitty_placements(n);
3676 let mut mgr = KittyImageManager::new();
3677
3678 let mut frame1: Vec<u8> = Vec::new();
3680 mgr.flush(&mut frame1, &placements, 0).unwrap();
3681 let s1 = String::from_utf8_lossy(&frame1);
3682 assert_eq!(
3684 s1.matches("a=t,").count(),
3685 n,
3686 "n={n}: expected {n} uploads in frame 1: {s1:?}"
3687 );
3688 assert_eq!(
3689 s1.matches("a=p,").count(),
3690 n,
3691 "n={n}: expected {n} placements in frame 1: {s1:?}"
3692 );
3693
3694 let mut frame2: Vec<u8> = Vec::new();
3696 mgr.flush(&mut frame2, &placements, 0).unwrap();
3697 assert!(
3698 frame2.is_empty(),
3699 "n={n}: identical frame must hit the kitty fast path, got {} bytes",
3700 frame2.len()
3701 );
3702
3703 let mut frame3: Vec<u8> = Vec::new();
3707 mgr.flush(&mut frame3, &[], 0).unwrap();
3708 let s3 = String::from_utf8_lossy(&frame3);
3709 assert_eq!(
3710 s3.matches("a=d,d=i,").count(),
3711 n,
3712 "n={n}: expected {n} placement deletes in frame 3: {s3:?}"
3713 );
3714 assert_eq!(
3715 s3.matches("a=d,d=I,").count(),
3716 n,
3717 "n={n}: expected {n} image-data deletes in frame 3: {s3:?}"
3718 );
3719 }
3720 }
3721
3722 use crate::buffer::{SprixelCell, SprixelPlacement};
3725
3726 fn make_sprixel(cells: Vec<SprixelCell>) -> SprixelPlacement {
3728 SprixelPlacement {
3729 content_hash: 0xABCD,
3730 seq: "<SIXEL>".to_string(),
3731 x: 1,
3732 y: 1,
3733 cols: 2,
3734 rows: 2,
3735 cells,
3736 }
3737 }
3738
3739 #[test]
3740 fn sprixel_no_text_change_emits_zero_bytes() {
3741 let area = Rect::new(0, 0, 10, 5);
3743 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
3744
3745 let mut current = Buffer::empty(area);
3746 current.sprixels.push(placement.clone());
3747 let mut previous = Buffer::empty(area);
3748 previous.sprixels.push(placement);
3749
3750 let mut out: Vec<u8> = Vec::new();
3751 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
3752 assert!(out.is_empty(), "stable frame should emit no sprixel bytes");
3753 }
3754
3755 #[test]
3756 fn sprixel_first_frame_blits_once() {
3757 let area = Rect::new(0, 0, 10, 5);
3759 let mut current = Buffer::empty(area);
3760 current
3761 .sprixels
3762 .push(make_sprixel(vec![SprixelCell::Opaque; 4]));
3763 let previous = Buffer::empty(area);
3764
3765 let mut out: Vec<u8> = Vec::new();
3766 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
3767 let s = String::from_utf8(out).unwrap();
3768 assert_eq!(s.matches("<SIXEL>").count(), 1);
3769 }
3770
3771 #[test]
3772 fn sprixel_text_in_opaque_cell_reblits_once() {
3773 let area = Rect::new(0, 0, 10, 5);
3775 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
3776
3777 let mut current = Buffer::empty(area);
3778 current.sprixels.push(placement.clone());
3779 current.set_char(1, 1, 'X', Style::new());
3781
3782 let mut previous = Buffer::empty(area);
3783 previous.sprixels.push(placement);
3784
3785 let mut out: Vec<u8> = Vec::new();
3786 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
3787 let s = String::from_utf8(out).unwrap();
3788 assert_eq!(
3789 s.matches("<SIXEL>").count(),
3790 1,
3791 "opaque-cell text write must re-blit the graphic exactly once"
3792 );
3793 }
3794
3795 #[test]
3796 fn sprixel_text_in_transparent_cell_does_not_reblit() {
3797 let area = Rect::new(0, 0, 10, 5);
3800 let cells = vec![
3801 SprixelCell::Transparent, SprixelCell::Opaque, SprixelCell::Opaque, SprixelCell::Opaque, ];
3806 let placement = make_sprixel(cells);
3807
3808 let mut current = Buffer::empty(area);
3809 current.sprixels.push(placement.clone());
3810 current.set_char(1, 1, 'X', Style::new());
3811
3812 let mut previous = Buffer::empty(area);
3813 previous.sprixels.push(placement);
3814
3815 let mut out: Vec<u8> = Vec::new();
3816 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
3817 assert!(
3818 out.is_empty(),
3819 "text in a transparent footprint cell must emit zero sprixel bytes"
3820 );
3821 }
3822
3823 #[test]
3824 fn sprixel_text_outside_footprint_does_not_reblit() {
3825 let area = Rect::new(0, 0, 10, 5);
3827 let placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
3828
3829 let mut current = Buffer::empty(area);
3830 current.sprixels.push(placement.clone());
3831 current.set_char(5, 0, 'Z', Style::new());
3833
3834 let mut previous = Buffer::empty(area);
3835 previous.sprixels.push(placement);
3836
3837 let mut out: Vec<u8> = Vec::new();
3838 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
3839 assert!(
3840 out.is_empty(),
3841 "text outside the footprint must not re-blit the graphic"
3842 );
3843 }
3844
3845 #[test]
3846 fn sprixel_position_change_reblits() {
3847 let area = Rect::new(0, 0, 10, 5);
3849 let mut moved = make_sprixel(vec![SprixelCell::Opaque; 4]);
3850 let original = moved.clone();
3851 moved.x = 4;
3852
3853 let mut current = Buffer::empty(area);
3854 current.sprixels.push(moved);
3855 let mut previous = Buffer::empty(area);
3856 previous.sprixels.push(original);
3857
3858 let mut out: Vec<u8> = Vec::new();
3859 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
3860 let s = String::from_utf8(out).unwrap();
3861 assert_eq!(s.matches("<SIXEL>").count(), 1);
3862 }
3863
3864 #[test]
3865 fn sprixel_content_change_reblits() {
3866 let area = Rect::new(0, 0, 10, 5);
3868 let mut recolored = make_sprixel(vec![SprixelCell::Opaque; 4]);
3869 let original = recolored.clone();
3870 recolored.content_hash = 0x1234;
3871 recolored.seq = "<SIXEL2>".to_string();
3872
3873 let mut current = Buffer::empty(area);
3874 current.sprixels.push(recolored);
3875 let mut previous = Buffer::empty(area);
3876 previous.sprixels.push(original);
3877
3878 let mut out: Vec<u8> = Vec::new();
3879 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
3880 let s = String::from_utf8(out).unwrap();
3881 assert_eq!(s.matches("<SIXEL2>").count(), 1);
3882 }
3883
3884 #[test]
3885 fn sprixel_reblit_count_invariant_over_single_cell_writes() {
3886 let area = Rect::new(0, 0, 10, 5);
3890 for (idx, (col, row)) in [(0u32, 0u32), (1, 0), (0, 1), (1, 1)]
3891 .into_iter()
3892 .enumerate()
3893 {
3894 for state in [
3895 SprixelCell::Opaque,
3896 SprixelCell::Mixed,
3897 SprixelCell::Transparent,
3898 ] {
3899 let mut cells = vec![SprixelCell::Opaque; 4];
3900 cells[idx] = state;
3901 let placement = make_sprixel(cells);
3902
3903 let mut current = Buffer::empty(area);
3904 current.sprixels.push(placement.clone());
3905 current.set_char(1 + col, 1 + row, 'A', Style::new());
3906
3907 let mut previous = Buffer::empty(area);
3908 previous.sprixels.push(placement);
3909
3910 let mut out: Vec<u8> = Vec::new();
3911 flush_sprixels(&mut out, ¤t, &previous, 0).unwrap();
3912 let count = String::from_utf8(out).unwrap().matches("<SIXEL>").count();
3913 let expected = if matches!(state, SprixelCell::Transparent) {
3914 0
3915 } else {
3916 1
3917 };
3918 assert_eq!(
3919 count, expected,
3920 "cell ({col},{row}) state {state:?}: expected {expected} re-blits"
3921 );
3922 }
3923 }
3924 }
3925}