use crate::unicode::WidthMethod;
use std::env;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
pub enum ColorSupport {
#[default]
None,
Basic,
Extended,
TrueColor,
}
#[derive(Clone, Debug)]
pub struct Capabilities {
pub color: ColorSupport,
pub unicode: bool,
pub width_method: WidthMethod,
pub hyperlinks: bool,
pub sync_output: bool,
pub mouse: bool,
pub focus: bool,
pub bracketed_paste: bool,
pub kitty_keyboard: bool,
pub kitty_graphics: bool,
pub sgr_pixels: bool,
pub color_scheme_updates: bool,
pub explicit_width: bool,
pub scaled_text: bool,
pub sixel: bool,
pub explicit_cursor_positioning: bool,
pub term_name: Option<String>,
}
impl Default for Capabilities {
fn default() -> Self {
Self {
color: ColorSupport::Basic,
unicode: false,
width_method: WidthMethod::default(),
hyperlinks: false,
sync_output: false,
mouse: false,
focus: false,
bracketed_paste: false,
kitty_keyboard: false,
kitty_graphics: false,
sgr_pixels: false,
color_scheme_updates: false,
explicit_width: false,
scaled_text: false,
sixel: false,
explicit_cursor_positioning: false,
term_name: None,
}
}
}
impl Capabilities {
#[must_use]
pub fn detect() -> Self {
let term = env::var("TERM").unwrap_or_default();
let colorterm = env::var("COLORTERM").unwrap_or_default();
let term_program = env::var("TERM_PROGRAM").unwrap_or_default();
let kitty_window_id = env::var("KITTY_WINDOW_ID").ok();
let color = Self::detect_color(&term, &colorterm);
let unicode = Self::detect_unicode();
let kitty_present = kitty_window_id.is_some();
let hyperlinks = Self::detect_hyperlinks(&term, &term_program, kitty_present);
let sync_output = Self::detect_sync(&term, &term_program, kitty_present);
let kitty_keyboard = kitty_present;
let kitty_graphics = kitty_present;
let is_xterm_compatible = Self::is_xterm_compatible(&term);
Self {
color,
unicode,
width_method: WidthMethod::default(),
hyperlinks,
sync_output,
mouse: is_xterm_compatible,
focus: is_xterm_compatible,
bracketed_paste: is_xterm_compatible,
kitty_keyboard,
kitty_graphics,
sgr_pixels: false,
color_scheme_updates: false,
explicit_width: false,
scaled_text: false,
sixel: term.contains("sixel"),
explicit_cursor_positioning: is_xterm_compatible,
term_name: if term.is_empty() { None } else { Some(term) },
}
}
fn is_xterm_compatible(term: &str) -> bool {
if term.is_empty() {
return false;
}
let compatible_prefixes = [
"xterm", "screen", "tmux", "rxvt", "vt100", "vt102", "vt220", "linux",
];
let compatible_names = [
"alacritty",
"kitty",
"wezterm",
"ghostty",
"konsole",
"gnome",
"gnome-terminal",
"mate-terminal",
"xfce4-terminal",
];
let term_lower = term.to_lowercase();
if compatible_prefixes
.iter()
.any(|p| term_lower.starts_with(p))
{
return true;
}
compatible_names.iter().any(|n| term_lower.contains(n))
}
pub fn apply_query_response(&mut self, response: &str) {
if response.contains("[?u") {
self.kitty_keyboard = true;
}
if let Some((width, height)) = parse_pixel_resolution(response) {
if width > 0 && height > 0 {
self.explicit_width = true;
self.sgr_pixels = true;
}
}
let lower = response.to_lowercase();
if lower.contains("kitty") {
self.kitty_graphics = true;
self.kitty_keyboard = true;
} else if lower.contains("wezterm") || lower.contains("alacritty") {
self.sync_output = true;
}
}
fn detect_color(term: &str, colorterm: &str) -> ColorSupport {
if colorterm.eq_ignore_ascii_case("truecolor") || colorterm.eq_ignore_ascii_case("24bit") {
return ColorSupport::TrueColor;
}
if term.contains("256color") || term.contains("24bit") || term.contains("truecolor") {
return ColorSupport::TrueColor;
}
let truecolor_terms = [
"xterm-256color",
"screen-256color",
"tmux-256color",
"alacritty",
"kitty",
"wezterm",
"ghostty",
];
if truecolor_terms.iter().any(|t| term.contains(t)) {
return ColorSupport::TrueColor;
}
if term.contains("256") {
return ColorSupport::Extended;
}
if term.starts_with("xterm") || term.starts_with("screen") || term.starts_with("vt100") {
return ColorSupport::Basic;
}
if !term.is_empty() {
return ColorSupport::Basic;
}
ColorSupport::None
}
fn detect_unicode() -> bool {
let lang = env::var("LANG").unwrap_or_default();
let lc_all = env::var("LC_ALL").unwrap_or_default();
let lc_ctype = env::var("LC_CTYPE").unwrap_or_default();
lang.to_lowercase().contains("utf")
|| lc_all.to_lowercase().contains("utf")
|| lc_ctype.to_lowercase().contains("utf")
}
fn detect_hyperlinks(term: &str, term_program: &str, kitty_present: bool) -> bool {
if kitty_present {
return true;
}
let supported_programs = [
"iTerm.app",
"Apple_Terminal",
"WezTerm",
"Hyper",
"Alacritty",
"kitty",
"ghostty",
];
if supported_programs
.iter()
.any(|t| term_program.eq_ignore_ascii_case(t) || term_program.contains(t))
{
return true;
}
let term_lower = term.to_lowercase();
let supported_terms = ["kitty", "ghostty", "wezterm", "alacritty"];
supported_terms.iter().any(|t| term_lower.contains(t))
}
fn detect_sync(term: &str, term_program: &str, kitty_present: bool) -> bool {
if kitty_present {
return true;
}
let supported_programs = ["kitty", "Alacritty", "WezTerm", "ghostty"];
if supported_programs
.iter()
.any(|t| term_program.eq_ignore_ascii_case(t) || term_program.contains(t))
{
return true;
}
let term_lower = term.to_lowercase();
let supported_terms = ["kitty", "ghostty", "wezterm", "alacritty"];
supported_terms.iter().any(|t| term_lower.contains(t))
}
#[must_use]
pub fn has_true_color(&self) -> bool {
self.color >= ColorSupport::TrueColor
}
#[must_use]
pub fn has_256_colors(&self) -> bool {
self.color >= ColorSupport::Extended
}
}
fn parse_pixel_resolution(response: &str) -> Option<(u32, u32)> {
let start = response.find("[4;")?;
let payload = &response[start + 3..];
let end = payload.find('t')?;
let payload = &payload[..end];
let mut parts = payload.split(';');
let height = parts.next()?.parse::<u32>().ok()?;
let width = parts.next()?.parse::<u32>().ok()?;
Some((width, height))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_pixel_resolution() {
let response = "\x1b[4;900;1440t";
assert_eq!(parse_pixel_resolution(response), Some((1440, 900)));
}
#[test]
fn test_apply_query_response_flags() {
let mut caps = Capabilities {
kitty_keyboard: false,
explicit_width: false,
sgr_pixels: false,
..Capabilities::default()
};
caps.apply_query_response("\x1b[?u");
caps.apply_query_response("\x1b[4;900;1440t");
assert!(caps.kitty_keyboard);
assert!(caps.explicit_width);
assert!(caps.sgr_pixels);
}
#[test]
fn test_color_support_ordering() {
assert!(ColorSupport::TrueColor > ColorSupport::Extended);
assert!(ColorSupport::Extended > ColorSupport::Basic);
assert!(ColorSupport::Basic > ColorSupport::None);
}
#[test]
fn test_capabilities_default() {
let caps = Capabilities::default();
assert_eq!(
caps.color,
ColorSupport::Basic,
"Default should assume basic color, not TrueColor"
);
assert!(!caps.unicode, "Default should not assume Unicode support");
assert!(!caps.hyperlinks, "Default should disable hyperlinks");
assert!(!caps.sync_output, "Default should disable sync output");
assert!(!caps.mouse, "Default should disable mouse");
assert!(!caps.focus, "Default should disable focus events");
assert!(
!caps.bracketed_paste,
"Default should disable bracketed paste"
);
assert!(
!caps.explicit_cursor_positioning,
"Default should disable explicit cursor positioning"
);
}
#[test]
fn test_is_xterm_compatible() {
assert!(Capabilities::is_xterm_compatible("xterm"));
assert!(Capabilities::is_xterm_compatible("xterm-256color"));
assert!(Capabilities::is_xterm_compatible("screen"));
assert!(Capabilities::is_xterm_compatible("screen-256color"));
assert!(Capabilities::is_xterm_compatible("tmux-256color"));
assert!(Capabilities::is_xterm_compatible("rxvt-unicode"));
assert!(Capabilities::is_xterm_compatible("linux"));
assert!(Capabilities::is_xterm_compatible("alacritty"));
assert!(Capabilities::is_xterm_compatible("kitty"));
assert!(Capabilities::is_xterm_compatible("wezterm"));
assert!(Capabilities::is_xterm_compatible("ghostty"));
assert!(!Capabilities::is_xterm_compatible(""));
assert!(!Capabilities::is_xterm_compatible("dumb"));
assert!(!Capabilities::is_xterm_compatible("unknown"));
}
#[test]
fn test_hyperlinks_via_term_program() {
assert!(Capabilities::detect_hyperlinks(
"xterm-256color",
"WezTerm",
false
));
assert!(Capabilities::detect_hyperlinks(
"alacritty",
"Alacritty",
false
));
assert!(Capabilities::detect_hyperlinks(
"xterm-256color",
"kitty",
false
));
assert!(Capabilities::detect_hyperlinks(
"xterm-256color",
"ghostty",
false
));
assert!(Capabilities::detect_hyperlinks(
"xterm-256color",
"iTerm.app",
false
));
assert!(Capabilities::detect_hyperlinks(
"xterm-256color",
"Apple_Terminal",
false
));
assert!(Capabilities::detect_hyperlinks(
"xterm-256color",
"Hyper",
false
));
}
#[test]
fn test_hyperlinks_via_term() {
assert!(Capabilities::detect_hyperlinks("xterm-kitty", "", false));
assert!(Capabilities::detect_hyperlinks("kitty", "", false));
assert!(Capabilities::detect_hyperlinks("ghostty", "", false));
assert!(Capabilities::detect_hyperlinks("wezterm", "", false));
assert!(Capabilities::detect_hyperlinks("alacritty", "", false));
}
#[test]
fn test_hyperlinks_via_kitty_window_id() {
assert!(Capabilities::detect_hyperlinks("xterm-256color", "", true));
assert!(Capabilities::detect_hyperlinks("linux", "", true));
}
#[test]
fn test_hyperlinks_false_for_unknown() {
assert!(!Capabilities::detect_hyperlinks(
"xterm-256color",
"",
false
));
assert!(!Capabilities::detect_hyperlinks(
"screen-256color",
"",
false
));
assert!(!Capabilities::detect_hyperlinks("linux", "", false));
assert!(!Capabilities::detect_hyperlinks("vt100", "", false));
assert!(!Capabilities::detect_hyperlinks("", "", false));
}
#[test]
fn test_sync_via_term_program() {
assert!(Capabilities::detect_sync("xterm-256color", "kitty", false));
assert!(Capabilities::detect_sync(
"xterm-256color",
"Alacritty",
false
));
assert!(Capabilities::detect_sync(
"xterm-256color",
"WezTerm",
false
));
assert!(Capabilities::detect_sync(
"xterm-256color",
"ghostty",
false
));
}
#[test]
fn test_sync_via_term() {
assert!(Capabilities::detect_sync("xterm-kitty", "", false));
assert!(Capabilities::detect_sync("kitty", "", false));
assert!(Capabilities::detect_sync("ghostty", "", false));
assert!(Capabilities::detect_sync("wezterm", "", false));
assert!(Capabilities::detect_sync("alacritty", "", false));
}
#[test]
fn test_sync_via_kitty_window_id() {
assert!(Capabilities::detect_sync("xterm-256color", "", true));
assert!(Capabilities::detect_sync("linux", "", true));
}
#[test]
fn test_sync_false_for_unknown() {
assert!(!Capabilities::detect_sync("xterm-256color", "", false));
assert!(!Capabilities::detect_sync("screen-256color", "", false));
assert!(!Capabilities::detect_sync("tmux-256color", "", false));
assert!(!Capabilities::detect_sync("linux", "", false));
assert!(!Capabilities::detect_sync(
"xterm-256color",
"iTerm.app",
false
));
assert!(!Capabilities::detect_sync(
"xterm-256color",
"Apple_Terminal",
false
));
}
#[test]
fn test_case_insensitive_term_matching() {
assert!(Capabilities::detect_hyperlinks("KITTY", "", false));
assert!(Capabilities::detect_hyperlinks("Kitty", "", false));
assert!(Capabilities::detect_sync("ALACRITTY", "", false));
assert!(Capabilities::detect_sync("Alacritty", "", false));
}
}