use crate::image::ImageSupport;
pub const DA1_REQUEST: &str = "\x1b[c";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Capabilities {
pub graphics: ImageSupport,
pub hyperlinks: bool,
pub clipboard: bool,
pub progress: bool,
pub truecolor: bool,
}
impl Capabilities {
pub fn from_env() -> Self {
let env = |k: &str| std::env::var(k).ok();
Self::from_env_parts(
env("TERM").as_deref(),
env("TERM_PROGRAM").as_deref(),
env("COLORTERM").as_deref(),
env("KITTY_WINDOW_ID").as_deref(),
env("GHOSTTY_RESOURCES_DIR").as_deref(),
)
}
pub fn from_env_parts(
term: Option<&str>,
term_program: Option<&str>,
colorterm: Option<&str>,
kitty_window_id: Option<&str>,
ghostty_resources_dir: Option<&str>,
) -> Self {
let graphics =
ImageSupport::detect_from(term, term_program, kitty_window_id, ghostty_resources_dir);
let program = term_program.unwrap_or_default().to_ascii_lowercase();
let term = term.unwrap_or_default();
let colorterm = colorterm.unwrap_or_default().to_ascii_lowercase();
let is = |p: &str| program == p;
let term_has = |s: &str| term.contains(s);
let modern = graphics != ImageSupport::None
|| is("wezterm")
|| is("ghostty")
|| is("iterm.app")
|| term_has("kitty")
|| term_has("foot")
|| term_has("contour")
|| term_has("alacritty");
Self {
graphics,
hyperlinks: modern,
clipboard: modern,
progress: is("ghostty") || is("wezterm") || is("konsole") || term_has("konsole"),
truecolor: modern || colorterm == "truecolor" || colorterm == "24bit",
}
}
pub fn query(timeout: std::time::Duration) -> Self {
let caps = Self::from_env();
match probe_device_attributes(timeout) {
Some(da) => caps.with_device_attributes(&da),
None => caps,
}
}
pub fn with_device_attributes(mut self, da: &DeviceAttributes) -> Self {
if da.sixel() && self.graphics == ImageSupport::None {
self.graphics = ImageSupport::Sixel;
}
self
}
pub fn supports_images(&self) -> bool {
self.graphics != ImageSupport::None
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DeviceAttributes {
codes: Vec<u16>,
}
impl DeviceAttributes {
pub fn parse(response: &[u8]) -> Option<Self> {
let start = response.windows(2).position(|w| w == b"\x1b[")? + 2;
let rest = &response[start..];
let rest = rest.strip_prefix(b"?").unwrap_or(rest);
let end = rest.iter().position(|&b| b == b'c')?;
let mut codes = Vec::new();
for part in rest[..end].split(|&b| b == b';') {
if let Ok(s) = std::str::from_utf8(part)
&& let Ok(n) = s.parse::<u16>()
{
codes.push(n);
}
}
(!codes.is_empty()).then_some(Self { codes })
}
pub fn has(&self, code: u16) -> bool {
self.codes.contains(&code)
}
pub fn sixel(&self) -> bool {
self.has(4)
}
}
#[cfg(unix)]
fn probe_device_attributes(timeout: std::time::Duration) -> Option<DeviceAttributes> {
use std::fs::File;
use std::io::{IsTerminal, Read, Write, stdin, stdout};
use std::mem::ManuallyDrop;
use std::os::fd::FromRawFd;
use std::sync::mpsc;
use std::thread;
if !stdin().is_terminal() || !stdout().is_terminal() {
return None;
}
{
let mut out = stdout();
out.write_all(DA1_REQUEST.as_bytes()).ok()?;
out.flush().ok()?;
}
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let mut input = ManuallyDrop::new(unsafe { File::from_raw_fd(0) });
let mut buf = Vec::new();
let mut byte = [0u8; 1];
while buf.len() < 64 {
match input.read(&mut byte) {
Ok(1) => {
buf.push(byte[0]);
if byte[0] == b'c' && buf.contains(&b'[') {
break;
}
}
_ => break,
}
}
let _ = tx.send(buf);
});
let buf = rx.recv_timeout(timeout).ok()?;
DeviceAttributes::parse(&buf)
}
#[cfg(not(unix))]
fn probe_device_attributes(_timeout: std::time::Duration) -> Option<DeviceAttributes> {
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn env_detects_kitty_graphics_and_truecolor() {
let c = Capabilities::from_env_parts(Some("xterm-kitty"), None, None, Some("1"), None);
assert_eq!(c.graphics, ImageSupport::Kitty);
assert!(c.supports_images());
assert!(
c.hyperlinks && c.clipboard,
"graphics terminals speak OSC 8/52"
);
assert!(c.truecolor);
}
#[test]
fn env_marks_ghostty_progress() {
let c = Capabilities::from_env_parts(None, Some("ghostty"), None, None, Some("/x"));
assert!(c.progress, "Ghostty drives OSC 9;4");
assert!(c.hyperlinks);
}
#[test]
fn env_is_conservative_for_unknown_terminals() {
let c = Capabilities::from_env_parts(
Some("xterm-256color"),
Some("Apple_Terminal"),
Some("truecolor"),
None,
None,
);
assert_eq!(c.graphics, ImageSupport::None);
assert!(!c.supports_images());
assert!(!c.hyperlinks && !c.clipboard && !c.progress);
assert!(c.truecolor, "COLORTERM=truecolor still detected");
}
#[test]
fn env_foot_gets_sixel_and_osc() {
let c = Capabilities::from_env_parts(Some("foot"), None, None, None, None);
assert_eq!(c.graphics, ImageSupport::Sixel);
assert!(c.hyperlinks && c.clipboard);
}
#[test]
fn parse_reads_da1_feature_codes() {
let da = DeviceAttributes::parse(b"\x1b[?62;1;4;22c").expect("parses");
assert!(da.has(62) && da.has(4) && da.has(22));
assert!(da.sixel());
}
#[test]
fn parse_finds_the_frame_amid_other_bytes() {
let da = DeviceAttributes::parse(b"junk\x1b[?1;2c").expect("parses");
assert!(da.has(1) && da.has(2));
assert!(!da.sixel());
}
#[test]
fn parse_rejects_non_da_input() {
assert!(DeviceAttributes::parse(b"no attributes here").is_none());
assert!(
DeviceAttributes::parse(b"\x1b[?c").is_none(),
"no numeric codes"
);
}
#[test]
fn device_attributes_upgrade_sixel_only_when_env_found_nothing() {
let sixel = DeviceAttributes::parse(b"\x1b[?4c").unwrap();
let none = Capabilities::from_env_parts(Some("xterm-256color"), None, None, None, None);
assert_eq!(none.graphics, ImageSupport::None);
assert_eq!(
none.with_device_attributes(&sixel).graphics,
ImageSupport::Sixel
);
let kitty = Capabilities::from_env_parts(Some("xterm-kitty"), None, None, None, None);
let no_sixel = DeviceAttributes::parse(b"\x1b[?1;2c").unwrap();
assert_eq!(
kitty.with_device_attributes(&no_sixel).graphics,
ImageSupport::Kitty
);
}
}