use std::io::{Read, Write};
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TermGraphics {
pub kitty: bool,
pub sixel: bool,
pub cell_px: Option<(u16, u16)>, }
pub fn parse_responses(buf: &[u8]) -> TermGraphics {
let s = String::from_utf8_lossy(buf);
let mut g = TermGraphics::default();
if s.contains("\x1b_G") && s.contains(";OK") {
g.kitty = true;
}
if let Some(start) = s.find("\x1b[?") {
if let Some(end_rel) = s[start..].find('c') {
let attrs = &s[start + 3..start + end_rel];
if attrs.split(';').any(|a| a == "4") {
g.sixel = true;
}
}
}
if let Some(start) = s.find("\x1b[6;") {
if let Some(end_rel) = s[start..].find('t') {
let body = &s[start + 4..start + end_rel];
let mut it = body.split(';');
if let (Some(h), Some(w)) = (it.next(), it.next()) {
if let (Ok(h), Ok(w)) = (h.parse::<u16>(), w.parse::<u16>()) {
g.cell_px = Some((w, h));
}
}
}
}
g
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_kitty_ok() {
let g = parse_responses(b"\x1b_Gi=31;OK\x1b\\");
assert!(g.kitty);
assert!(!g.sixel);
}
#[test]
fn parses_da1_with_sixel() {
let g = parse_responses(b"\x1b[?62;4;9c");
assert!(g.sixel);
}
#[test]
fn da1_without_sixel_is_not_sixel() {
let g = parse_responses(b"\x1b[?62;9c");
assert!(!g.sixel);
}
#[test]
fn parses_cell_size() {
let g = parse_responses(b"\x1b[6;16;8t");
assert_eq!(g.cell_px, Some((8, 16)));
}
#[test]
fn garbage_yields_nothing() {
let g = parse_responses(b"random noise no escapes");
assert_eq!(g, TermGraphics::default());
}
#[test]
fn combined_response_parses_all() {
let g = parse_responses(b"\x1b_Gi=1;OK\x1b\\\x1b[6;16;8t\x1b[?62;4c");
assert!(g.kitty && g.sixel);
assert_eq!(g.cell_px, Some((8, 16)));
}
#[test]
fn truncated_da1_without_c_is_safe() {
let g = parse_responses(b"\x1b[?62;4");
assert!(!g.sixel);
}
#[test]
fn non_numeric_cell_size_is_ignored() {
let g = parse_responses(b"\x1b[6;xx;yyt");
assert_eq!(g.cell_px, None);
}
}
pub fn detect() -> TermGraphics {
if let Some(g) = query_tty(Duration::from_millis(120)) {
if g.kitty || g.sixel {
return merge_env(g);
}
}
env_fallback()
}
fn query_tty(timeout: Duration) -> Option<TermGraphics> {
use std::fs::OpenOptions;
let mut tty = OpenOptions::new().read(true).write(true).open("/dev/tty").ok()?;
let q = b"\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\\x1b[16t\x1b[c";
tty.write_all(q).ok()?;
tty.flush().ok()?;
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut buf = Vec::new();
let mut byte = [0u8; 1];
loop {
match tty.read(&mut byte) {
Ok(0) => break,
Ok(_) => {
buf.push(byte[0]);
if byte[0] == b'c' && buf.contains(&0x1b) { break; }
if buf.len() > 4096 { break; }
}
Err(_) => break,
}
}
let _ = tx.send(buf);
});
let buf = rx.recv_timeout(timeout).ok()?;
Some(parse_responses(&buf))
}
fn merge_env(mut g: TermGraphics) -> TermGraphics {
let env = env_fallback();
g.kitty |= env.kitty;
g.sixel |= env.sixel;
g
}
pub fn env_fallback() -> TermGraphics {
let term = std::env::var("TERM").unwrap_or_default().to_lowercase();
let prog = std::env::var("TERM_PROGRAM").unwrap_or_default().to_lowercase();
let kitty = std::env::var("KITTY_WINDOW_ID").is_ok()
|| term.contains("kitty")
|| term.contains("wezterm")
|| prog.contains("wezterm")
|| prog.contains("iterm")
|| prog.contains("ghostty");
let sixel = term.contains("foot")
|| term.contains("mlterm")
|| term.contains("vt340")
|| term.contains("wezterm");
TermGraphics { kitty, sixel, cell_px: None }
}