use std::env;
use ratatui_image::picker::{Picker, ProtocolType};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ColorDepth {
NoColor,
Ansi16,
Ansi256,
TrueColor,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageProtocol {
Sixel,
KittyGraphics,
ITerm2,
Halfblocks,
}
#[derive(Debug, Clone)]
pub struct Capabilities {
pub color_depth: ColorDepth,
pub mouse: bool,
pub image_protocol: Option<ImageProtocol>,
pub image_picker: Option<Picker>,
pub halfblocks_picker: Option<Picker>,
pub unicode_full: bool,
pub keyboard_enhancement: bool,
}
impl Capabilities {
pub fn detect(kbd_enhancement: bool) -> Self {
let term = env::var("TERM").unwrap_or_default();
let color_depth = detect_color_depth(&term);
let mouse = detect_mouse(&term);
let unicode_full = detect_unicode_full();
let (image_protocol, image_picker) = detect_image_protocol();
let halfblocks_picker = image_picker
.as_ref()
.map(|p| halfblocks_from(p.font_size()));
Self {
color_depth,
mouse,
image_protocol,
image_picker,
halfblocks_picker,
unicode_full,
keyboard_enhancement: kbd_enhancement,
}
}
pub fn detect_color_depth_from_env() -> ColorDepth {
let term = env::var("TERM").unwrap_or_default();
detect_color_depth(&term)
}
pub fn env_only() -> Self {
let term = env::var("TERM").unwrap_or_default();
Self {
color_depth: detect_color_depth(&term),
mouse: detect_mouse(&term),
unicode_full: detect_unicode_full(),
..Self::minimal()
}
}
pub fn full_color(&self) -> bool {
self.color_depth == ColorDepth::TrueColor
}
pub fn minimal() -> Self {
Self {
color_depth: ColorDepth::Ansi16,
mouse: false,
image_protocol: None,
image_picker: None,
halfblocks_picker: None,
unicode_full: false,
keyboard_enhancement: false,
}
}
pub fn fingerprint(&self) -> String {
let term_program = env::var("TERM_PROGRAM").unwrap_or_default();
let term = env::var("TERM").unwrap_or_default();
let tmux = if env::var("TMUX").is_ok() { "tmux" } else { "" };
let color = match self.color_depth {
ColorDepth::NoColor => "none",
ColorDepth::Ansi16 => "16",
ColorDepth::Ansi256 => "256",
ColorDepth::TrueColor => "truecolor",
};
let image = match self.image_protocol {
None => "none",
Some(ImageProtocol::Sixel) => "sixel",
Some(ImageProtocol::KittyGraphics) => "kitty",
Some(ImageProtocol::ITerm2) => "iterm2",
Some(ImageProtocol::Halfblocks) => "halfblocks",
};
format!(
"{term_program}|{term}|{tmux}|{color}|{image}|mouse={}|kbd={}|unicode={}",
self.mouse, self.keyboard_enhancement, self.unicode_full,
)
}
}
impl Default for Capabilities {
fn default() -> Self {
Self::minimal()
}
}
fn detect_color_depth(term: &str) -> ColorDepth {
if term == "dumb" || term.is_empty() {
return ColorDepth::NoColor;
}
let colorterm = env::var("COLORTERM")
.unwrap_or_default()
.to_ascii_lowercase();
if colorterm == "truecolor" || colorterm == "24bit" {
return ColorDepth::TrueColor;
}
if term.contains("direct") {
return ColorDepth::TrueColor;
}
if env::var("KITTY_WINDOW_ID").is_ok() || env::var("WEZTERM_PANE").is_ok() {
return ColorDepth::TrueColor;
}
if let Ok(tp) = env::var("TERM_PROGRAM") {
match tp.as_str() {
"iTerm.app" | "WezTerm" | "ghostty" | "Ghostty" => return ColorDepth::TrueColor,
_ => {}
}
}
if term.contains("256color") {
return ColorDepth::Ansi256;
}
ColorDepth::Ansi16
}
fn detect_mouse(term: &str) -> bool {
if term == "dumb" || term == "linux" || term.is_empty() {
return false;
}
true
}
fn detect_unicode_full() -> bool {
for var in ["LC_ALL", "LC_CTYPE", "LANG"] {
if let Ok(v) = env::var(var) {
let upper = v.to_ascii_uppercase();
if upper.contains("UTF-8") || upper.contains("UTF8") {
return true;
}
}
}
false
}
fn halfblocks_from(font_size: ratatui_image::FontSize) -> Picker {
#[allow(deprecated)]
let mut picker = Picker::from_fontsize(font_size);
picker.set_protocol_type(ProtocolType::Halfblocks);
picker
}
fn is_iterm2_app() -> bool {
env::var("TERM_PROGRAM").is_ok_and(|v| v.contains("iTerm"))
|| env::var("LC_TERMINAL").is_ok_and(|v| v.contains("iTerm"))
}
fn iterm2_hint_is_trustworthy() -> bool {
is_iterm2_app() && env::var_os("TMUX").is_none()
}
fn resolve_protocol(probed: ProtocolType, iterm2: bool) -> ProtocolType {
match probed {
ProtocolType::Kitty if iterm2 => ProtocolType::Iterm2,
other => other,
}
}
fn detect_image_protocol() -> (Option<ImageProtocol>, Option<Picker>) {
let result = std::panic::catch_unwind(Picker::from_query_stdio);
let mut picker = match result {
Ok(Ok(p)) => p,
_ => return (None, None),
};
picker.set_protocol_type(resolve_protocol(
picker.protocol_type(),
iterm2_hint_is_trustworthy(),
));
let protocol = match picker.protocol_type() {
ProtocolType::Sixel => ImageProtocol::Sixel,
ProtocolType::Kitty => ImageProtocol::KittyGraphics,
ProtocolType::Iterm2 => ImageProtocol::ITerm2,
ProtocolType::Halfblocks => ImageProtocol::Halfblocks,
};
(Some(protocol), Some(picker))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_env::{env_lock, EnvGuard};
#[test]
fn halfblocks_picker_is_halfblocks_even_under_iterm2() {
let _lock = env_lock();
let _g1 = EnvGuard::set("TERM_PROGRAM", "iTerm.app");
let _g2 = EnvGuard::unset("TMUX");
let picker = halfblocks_from(ratatui_image::FontSize::new(7, 15));
assert_eq!(picker.protocol_type(), ProtocolType::Halfblocks);
}
#[test]
fn halfblocks_picker_keeps_the_probed_font_size() {
let _lock = env_lock();
let _g = EnvGuard::unset("TERM_PROGRAM");
let picker = halfblocks_from(ratatui_image::FontSize::new(7, 15));
assert_eq!(picker.font_size().width, 7);
assert_eq!(picker.font_size().height, 15);
}
#[test]
fn iterm2_app_detected_from_term_program_and_lc_terminal() {
let _lock = env_lock();
{
let _g1 = EnvGuard::set("TERM_PROGRAM", "iTerm.app");
let _g2 = EnvGuard::unset("LC_TERMINAL");
assert!(is_iterm2_app());
}
{
let _g1 = EnvGuard::unset("TERM_PROGRAM");
let _g2 = EnvGuard::set("LC_TERMINAL", "iTerm2");
assert!(is_iterm2_app());
}
{
let _g1 = EnvGuard::set("TERM_PROGRAM", "ghostty");
let _g2 = EnvGuard::unset("LC_TERMINAL");
assert!(!is_iterm2_app());
}
}
#[test]
fn kitty_probe_under_iterm2_is_pinned_to_iterm2() {
assert_eq!(
resolve_protocol(ProtocolType::Kitty, true),
ProtocolType::Iterm2
);
}
#[test]
fn resolve_protocol_leaves_every_other_probe_alone() {
for probed in [
ProtocolType::Kitty,
ProtocolType::Sixel,
ProtocolType::Iterm2,
ProtocolType::Halfblocks,
] {
assert_eq!(
resolve_protocol(probed, false),
probed,
"{probed:?} must survive without an iTerm2 hint"
);
}
for probed in [
ProtocolType::Sixel,
ProtocolType::Iterm2,
ProtocolType::Halfblocks,
] {
assert_eq!(
resolve_protocol(probed, true),
probed,
"{probed:?} must survive even under iTerm2"
);
}
}
#[test]
fn iterm2_hint_is_distrusted_inside_tmux() {
let _lock = env_lock();
let _g1 = EnvGuard::set("LC_TERMINAL", "iTerm2");
let _g2 = EnvGuard::unset("TERM_PROGRAM");
{
let _g3 = EnvGuard::unset("TMUX");
assert!(iterm2_hint_is_trustworthy(), "bare iTerm2 is trusted");
}
{
let _g3 = EnvGuard::set("TMUX", "/tmp/tmux-501/default,1234,0");
assert!(
!iterm2_hint_is_trustworthy(),
"a stale forwarded marker inside tmux must not pin Iterm2"
);
assert_eq!(
resolve_protocol(ProtocolType::Kitty, iterm2_hint_is_trustworthy()),
ProtocolType::Kitty,
"the live capability probe wins inside tmux"
);
}
}
#[test]
fn iterm2_hint_is_absent_without_an_iterm2_marker() {
let _lock = env_lock();
let _g1 = EnvGuard::set("TERM_PROGRAM", "ghostty");
let _g2 = EnvGuard::unset("LC_TERMINAL");
let _g3 = EnvGuard::unset("TMUX");
assert!(!iterm2_hint_is_trustworthy());
}
#[test]
fn color_depth_no_color_for_dumb_terminal() {
assert_eq!(detect_color_depth("dumb"), ColorDepth::NoColor);
assert_eq!(detect_color_depth(""), ColorDepth::NoColor);
}
#[test]
fn color_depth_truecolor_from_colorterm() {
let _lock = env_lock();
let _g1 = EnvGuard::set("COLORTERM", "truecolor");
let _g2 = EnvGuard::unset("KITTY_WINDOW_ID");
let _g3 = EnvGuard::unset("WEZTERM_PANE");
let _g4 = EnvGuard::unset("TERM_PROGRAM");
assert_eq!(detect_color_depth("xterm-256color"), ColorDepth::TrueColor);
}
#[test]
fn color_depth_256_from_term_suffix() {
let _lock = env_lock();
let _g1 = EnvGuard::unset("COLORTERM");
let _g2 = EnvGuard::unset("KITTY_WINDOW_ID");
let _g3 = EnvGuard::unset("WEZTERM_PANE");
let _g4 = EnvGuard::unset("TERM_PROGRAM");
assert_eq!(detect_color_depth("xterm-256color"), ColorDepth::Ansi256);
}
#[test]
fn color_depth_16_for_plain_xterm() {
let _lock = env_lock();
let _g1 = EnvGuard::unset("COLORTERM");
let _g2 = EnvGuard::unset("KITTY_WINDOW_ID");
let _g3 = EnvGuard::unset("WEZTERM_PANE");
let _g4 = EnvGuard::unset("TERM_PROGRAM");
assert_eq!(detect_color_depth("xterm"), ColorDepth::Ansi16);
}
#[test]
fn color_depth_truecolor_for_kitty_envvar() {
let _lock = env_lock();
let _g1 = EnvGuard::unset("COLORTERM");
let _g2 = EnvGuard::set("KITTY_WINDOW_ID", "1");
let _g3 = EnvGuard::unset("WEZTERM_PANE");
let _g4 = EnvGuard::unset("TERM_PROGRAM");
assert_eq!(detect_color_depth("xterm"), ColorDepth::TrueColor);
}
#[test]
fn color_depth_256_for_apple_terminal() {
let _lock = env_lock();
let _g1 = EnvGuard::unset("COLORTERM");
let _g2 = EnvGuard::unset("KITTY_WINDOW_ID");
let _g3 = EnvGuard::unset("WEZTERM_PANE");
let _g4 = EnvGuard::set("TERM_PROGRAM", "Apple_Terminal");
assert_eq!(detect_color_depth("xterm-256color"), ColorDepth::Ansi256);
}
#[test]
fn full_color_only_for_truecolor() {
let caps = Capabilities {
color_depth: ColorDepth::TrueColor,
..Capabilities::minimal()
};
assert!(caps.full_color());
for depth in [ColorDepth::Ansi256, ColorDepth::Ansi16, ColorDepth::NoColor] {
let caps = Capabilities {
color_depth: depth,
..Capabilities::minimal()
};
assert!(!caps.full_color(), "{depth:?} is not full color");
}
}
#[test]
fn mouse_false_for_dumb_and_linux() {
assert!(!detect_mouse("dumb"));
assert!(!detect_mouse("linux"));
assert!(!detect_mouse(""));
}
#[test]
fn mouse_true_for_modern_terminals() {
assert!(detect_mouse("xterm-256color"));
assert!(detect_mouse("alacritty"));
assert!(detect_mouse("tmux-256color"));
}
#[test]
fn unicode_full_from_lang() {
let _lock = env_lock();
let _g1 = EnvGuard::set("LANG", "en_US.UTF-8");
let _g2 = EnvGuard::unset("LC_ALL");
let _g3 = EnvGuard::unset("LC_CTYPE");
assert!(detect_unicode_full());
}
#[test]
fn unicode_false_for_c_locale() {
let _lock = env_lock();
let _g1 = EnvGuard::set("LANG", "C");
let _g2 = EnvGuard::unset("LC_ALL");
let _g3 = EnvGuard::unset("LC_CTYPE");
assert!(!detect_unicode_full());
}
#[test]
fn minimal_capabilities_are_conservative() {
let caps = Capabilities::minimal();
assert_eq!(caps.color_depth, ColorDepth::Ansi16);
assert!(!caps.mouse);
assert!(caps.image_protocol.is_none());
assert!(!caps.unicode_full);
assert!(!caps.keyboard_enhancement);
}
#[test]
fn fingerprint_is_stable_for_identical_caps() {
let _lock = env_lock();
let _g1 = EnvGuard::set("TERM_PROGRAM", "WezTerm");
let _g2 = EnvGuard::set("TERM", "xterm-256color");
let _g3 = EnvGuard::unset("TMUX");
let caps = Capabilities {
color_depth: ColorDepth::TrueColor,
mouse: true,
image_protocol: Some(ImageProtocol::KittyGraphics),
image_picker: None,
halfblocks_picker: None,
unicode_full: true,
keyboard_enhancement: true,
};
assert_eq!(caps.fingerprint(), caps.fingerprint());
}
#[test]
fn fingerprint_differs_when_a_capability_flips() {
let _lock = env_lock();
let _g1 = EnvGuard::set("TERM_PROGRAM", "WezTerm");
let _g2 = EnvGuard::set("TERM", "xterm-256color");
let _g3 = EnvGuard::unset("TMUX");
let base = Capabilities {
color_depth: ColorDepth::TrueColor,
mouse: true,
image_protocol: Some(ImageProtocol::KittyGraphics),
image_picker: None,
halfblocks_picker: None,
unicode_full: true,
keyboard_enhancement: true,
};
let mouseless = Capabilities {
mouse: false,
..base.clone()
};
let no_kbd = Capabilities {
keyboard_enhancement: false,
..base.clone()
};
let no_image = Capabilities {
image_protocol: None,
..base.clone()
};
assert_ne!(base.fingerprint(), mouseless.fingerprint());
assert_ne!(base.fingerprint(), no_kbd.fingerprint());
assert_ne!(base.fingerprint(), no_image.fingerprint());
}
#[test]
fn fingerprint_includes_tmux_marker() {
let _lock = env_lock();
let _g1 = EnvGuard::set("TERM_PROGRAM", "WezTerm");
let _g2 = EnvGuard::set("TERM", "tmux-256color");
let caps = Capabilities::minimal();
let _no_tmux = EnvGuard::unset("TMUX");
let outside = caps.fingerprint();
let _in_tmux = EnvGuard::set("TMUX", "/tmp/tmux-1000/default,123,0");
let inside = caps.fingerprint();
assert_ne!(outside, inside);
assert!(inside.contains("tmux"));
}
}