1use std::io::{Read, Write};
6use std::time::Duration;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub struct TermGraphics {
11 pub kitty: bool,
12 pub sixel: bool,
13 pub cell_px: Option<(u16, u16)>, }
15
16pub fn parse_responses(buf: &[u8]) -> TermGraphics {
21 let s = String::from_utf8_lossy(buf);
22 let mut g = TermGraphics::default();
23
24 if s.contains("\x1b_G") && s.contains(";OK") {
25 g.kitty = true;
26 }
27
28 if let Some(start) = s.find("\x1b[?") {
29 if let Some(end_rel) = s[start..].find('c') {
30 let attrs = &s[start + 3..start + end_rel];
31 if attrs.split(';').any(|a| a == "4") {
32 g.sixel = true;
33 }
34 }
35 }
36
37 if let Some(start) = s.find("\x1b[6;") {
38 if let Some(end_rel) = s[start..].find('t') {
39 let body = &s[start + 4..start + end_rel];
40 let mut it = body.split(';');
41 if let (Some(h), Some(w)) = (it.next(), it.next()) {
42 if let (Ok(h), Ok(w)) = (h.parse::<u16>(), w.parse::<u16>()) {
43 g.cell_px = Some((w, h));
44 }
45 }
46 }
47 }
48 g
49}
50
51#[cfg(test)]
52mod tests {
53 use super::*;
54
55 #[test]
56 fn parses_kitty_ok() {
57 let g = parse_responses(b"\x1b_Gi=31;OK\x1b\\");
58 assert!(g.kitty);
59 assert!(!g.sixel);
60 }
61
62 #[test]
63 fn parses_da1_with_sixel() {
64 let g = parse_responses(b"\x1b[?62;4;9c");
65 assert!(g.sixel);
66 }
67
68 #[test]
69 fn da1_without_sixel_is_not_sixel() {
70 let g = parse_responses(b"\x1b[?62;9c");
71 assert!(!g.sixel);
72 }
73
74 #[test]
75 fn parses_cell_size() {
76 let g = parse_responses(b"\x1b[6;16;8t");
77 assert_eq!(g.cell_px, Some((8, 16)));
78 }
79
80 #[test]
81 fn garbage_yields_nothing() {
82 let g = parse_responses(b"random noise no escapes");
83 assert_eq!(g, TermGraphics::default());
84 }
85
86 #[test]
87 fn combined_response_parses_all() {
88 let g = parse_responses(b"\x1b_Gi=1;OK\x1b\\\x1b[6;16;8t\x1b[?62;4c");
89 assert!(g.kitty && g.sixel);
90 assert_eq!(g.cell_px, Some((8, 16)));
91 }
92
93 #[test]
94 fn truncated_da1_without_c_is_safe() {
95 let g = parse_responses(b"\x1b[?62;4");
97 assert!(!g.sixel);
98 }
99
100 #[test]
101 fn non_numeric_cell_size_is_ignored() {
102 let g = parse_responses(b"\x1b[6;xx;yyt");
104 assert_eq!(g.cell_px, None);
105 }
106}
107
108pub fn detect() -> TermGraphics {
113 if let Some(g) = query_tty(Duration::from_millis(120)) {
114 if g.kitty || g.sixel {
115 return merge_env(g);
116 }
117 }
118 env_fallback()
119}
120
121fn query_tty(timeout: Duration) -> Option<TermGraphics> {
122 use std::fs::OpenOptions;
123 let mut tty = OpenOptions::new().read(true).write(true).open("/dev/tty").ok()?;
124 let q = b"\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\\x1b[16t\x1b[c";
127 tty.write_all(q).ok()?;
128 tty.flush().ok()?;
129
130 let (tx, rx) = std::sync::mpsc::channel();
131 std::thread::spawn(move || {
137 let mut buf = Vec::new();
138 let mut byte = [0u8; 1];
139 loop {
140 match tty.read(&mut byte) {
141 Ok(0) => break,
142 Ok(_) => {
143 buf.push(byte[0]);
144 if byte[0] == b'c' && buf.contains(&0x1b) { break; }
146 if buf.len() > 4096 { break; }
147 }
148 Err(_) => break,
149 }
150 }
151 let _ = tx.send(buf);
152 });
153 let buf = rx.recv_timeout(timeout).ok()?;
154 Some(parse_responses(&buf))
155}
156
157fn merge_env(mut g: TermGraphics) -> TermGraphics {
158 let env = env_fallback();
159 g.kitty |= env.kitty;
160 g.sixel |= env.sixel;
161 g
162}
163
164pub fn env_fallback() -> TermGraphics {
167 let term = std::env::var("TERM").unwrap_or_default().to_lowercase();
168 let prog = std::env::var("TERM_PROGRAM").unwrap_or_default().to_lowercase();
169 let kitty = std::env::var("KITTY_WINDOW_ID").is_ok()
170 || term.contains("kitty")
171 || term.contains("wezterm")
172 || prog.contains("wezterm")
173 || prog.contains("iterm")
174 || prog.contains("ghostty");
175 let sixel = term.contains("foot")
176 || term.contains("mlterm")
177 || term.contains("vt340")
178 || term.contains("wezterm");
179 TermGraphics { kitty, sixel, cell_px: None }
180}