Skip to main content

tess/
term_query.rs

1//! Terminal graphics capability + cell-pixel-size detection. The response
2//! PARSERS here are pure and unit-tested; the I/O round-trip in `detect` is
3//! verified manually (no PTY tests, per project policy).
4
5use std::io::{Read, Write};
6use std::time::Duration;
7
8/// What the terminal supports, plus its cell pixel size when reported.
9#[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)>, // (width, height) in pixels
14}
15
16/// Parse a batch of terminal query responses (raw bytes) into capabilities.
17/// Looks for: Kitty `ESC _ G ... ; OK ESC \`, a DA1 `ESC [ ? <attrs> c` whose
18/// attribute list contains `4` (Sixel), and a cell-size report
19/// `ESC [ 6 ; <h> ; <w> t`.
20pub 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        // `\x1b[?` with no terminating `c`: must not panic, no sixel detected.
96        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        // Malformed cell-size body: parse fails gracefully, cell_px stays None.
103        let g = parse_responses(b"\x1b[6;xx;yyt");
104        assert_eq!(g.cell_px, None);
105    }
106}
107
108/// Probe the terminal for graphics support. Writes the query sequences to
109/// `/dev/tty`, reads the response with a short deadline, and parses it. Falls
110/// back to environment heuristics when the round-trip yields nothing. Pure
111/// ASCII (no support) is represented by an all-false `TermGraphics`.
112pub 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    // Kitty graphics query (1x1, action=query), cell-size (CSI 16 t), then DA1
125    // (CSI c). DA1 always answers, so it bounds the read.
126    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    // Detached reader: we read on a thread and abandon it on timeout. A
132    // conformant terminal answers DA1 (`c`) within ms so the thread exits
133    // promptly; a terminal that answers only partially leaves the thread
134    // blocked on read until process exit. Acceptable for a one-shot startup
135    // probe.
136    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                    // DA1 terminates with 'c' after an ESC was seen; stop there.
145                    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
164/// Best-effort capability guess from environment variables, used when the
165/// active query times out (e.g. inside some terminal multiplexers).
166pub 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}