use crate::backend::{ColorDepth, SystemTheme};
pub fn detect_color_depth() -> ColorDepth {
detect_color_depth_from(|key| std::env::var(key).ok())
}
pub(crate) fn detect_color_depth_from(getenv: impl Fn(&str) -> Option<String>) -> ColorDepth {
if let Some(colorterm) = getenv("COLORTERM") {
let colorterm = colorterm.to_ascii_lowercase();
if colorterm.contains("truecolor") || colorterm.contains("24bit") {
return ColorDepth::TrueColor;
}
}
if let Some(term) = getenv("TERM") {
if term.contains("256color") {
return ColorDepth::Indexed256;
}
}
ColorDepth::Ansi16
}
pub fn detect_kitty_keyboard() -> bool {
detect_kitty_keyboard_from(|key| std::env::var(key).ok())
}
pub(crate) fn detect_kitty_keyboard_from(getenv: impl Fn(&str) -> Option<String>) -> bool {
if let Some(term) = getenv("TERM") {
let term = term.to_ascii_lowercase();
if term.contains("kitty") || term == "foot" || term == "foot-extra" {
return true;
}
}
if getenv("KITTY_WINDOW_ID").is_some() {
return true;
}
if getenv("TERM_PROGRAM").as_deref() == Some("WezTerm") {
return true;
}
false
}
pub(crate) fn probe_kitty_keyboard() -> bool {
if super::backend::stdin_hung_up() {
return detect_kitty_keyboard();
}
ratatui::crossterm::terminal::supports_keyboard_enhancement()
.unwrap_or_else(|_| detect_kitty_keyboard())
}
pub fn detect_sgr_pixel_mouse() -> bool {
detect_sgr_pixel_mouse_from(|key| std::env::var(key).ok())
}
pub(crate) fn detect_sgr_pixel_mouse_from(getenv: impl Fn(&str) -> Option<String>) -> bool {
if sgr_pixel_mouse_blocked_by_multiplexer_from(&getenv) {
return false;
}
let term = getenv("TERM").map(|t| t.to_ascii_lowercase());
if let Some(term) = &term {
if term.starts_with("foot") || term.starts_with("contour") {
return true;
}
}
if getenv("KITTY_WINDOW_ID").is_some() {
return true;
}
if getenv("TERM_PROGRAM").as_deref() == Some("WezTerm") {
return true;
}
false
}
pub(crate) fn sgr_pixel_mouse_blocked_by_multiplexer_from(
getenv: impl Fn(&str) -> Option<String>,
) -> bool {
if getenv("TMUX").is_some() {
return true;
}
if let Some(term) = getenv("TERM") {
let term = term.to_ascii_lowercase();
if term.starts_with("screen") || term.starts_with("tmux") {
return true;
}
}
false
}
pub(crate) fn sgr_pixel_mouse_blocked_by_multiplexer() -> bool {
sgr_pixel_mouse_blocked_by_multiplexer_from(|key| std::env::var(key).ok())
}
#[cfg_attr(not(unix), allow(dead_code))]
pub(crate) fn parse_decrqm_reply(buf: &[u8]) -> Option<u8> {
let text = std::str::from_utf8(buf).ok()?;
let body = text.strip_prefix("\x1b[?")?.strip_suffix("$y")?;
let mut parts = body.split(';');
parts.next()?; let ps = parts.next()?;
if parts.next().is_some() {
return None; }
ps.trim().parse::<u8>().ok()
}
pub(crate) fn decrqm_reply_supports_mode(ps: Option<u8>) -> bool {
matches!(ps, Some(1) | Some(3))
}
pub(crate) fn probe_sgr_pixel_mouse() -> bool {
decrqm_reply_supports_mode(query_sgr_pixel_decrqm())
}
#[cfg(unix)]
fn query_sgr_pixel_decrqm() -> Option<u8> {
use std::io::{Read, Write};
use std::os::unix::io::AsRawFd;
use std::time::{Duration, Instant};
let mut stdout = std::io::stdout();
stdout.write_all(b"\x1b[?1016$p").ok()?;
stdout.flush().ok()?;
let stdin = std::io::stdin();
let fd = stdin.as_raw_fd();
let mut handle = stdin.lock();
let deadline = Instant::now() + Duration::from_millis(2000);
let mut collected = Vec::with_capacity(16);
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return None;
}
let timeout_ms = i32::try_from(remaining.as_millis()).unwrap_or(i32::MAX);
super::backend::poll_fd_revents(fd, timeout_ms).revents()?;
let mut byte = [0u8; 1];
match handle.read(&mut byte) {
Ok(0) | Err(_) => return None,
Ok(_) => {
collected.push(byte[0]);
if byte[0] == b'y' || collected.len() >= 32 {
break;
}
}
}
}
parse_decrqm_reply(&collected)
}
#[cfg(not(unix))]
fn query_sgr_pixel_decrqm() -> Option<u8> {
None
}
pub fn detect_underline_color_support() -> bool {
detect_underline_color_support_from(cfg!(windows), |key| std::env::var(key).ok())
}
pub(crate) fn detect_underline_color_support_from(
is_windows: bool,
getenv: impl Fn(&str) -> Option<String>,
) -> bool {
if is_windows {
return false;
}
if getenv("WT_SESSION").is_some_and(|v| !v.is_empty()) {
return false;
}
getenv("TERM_PROGRAM").as_deref() != Some("Apple_Terminal")
}
pub fn detect_system_theme() -> Option<SystemTheme> {
detect_system_theme_from(|key| std::env::var(key).ok())
}
pub(crate) fn detect_system_theme_from(
getenv: impl Fn(&str) -> Option<String>,
) -> Option<SystemTheme> {
let colorfgbg = getenv("COLORFGBG")?;
let bg_index: u8 = colorfgbg.rsplit(';').next()?.trim().parse().ok()?;
Some(SystemTheme {
dark: !matches!(bg_index, 7 | 15),
accent: None,
high_contrast: false,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let map: HashMap<String, String> = pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
move |key| map.get(key).cloned()
}
#[test]
fn colorterm_truecolor_wins_regardless_of_term() {
assert_eq!(
detect_color_depth_from(env(&[("COLORTERM", "truecolor"), ("TERM", "xterm")])),
ColorDepth::TrueColor
);
}
#[test]
fn colorterm_24bit_also_counts() {
assert_eq!(
detect_color_depth_from(env(&[("COLORTERM", "24bit")])),
ColorDepth::TrueColor
);
}
#[test]
fn xterm_256color_with_no_colorterm_is_indexed() {
assert_eq!(
detect_color_depth_from(env(&[("TERM", "xterm-256color")])),
ColorDepth::Indexed256
);
}
#[test]
fn tmux_without_passthrough_is_indexed() {
assert_eq!(
detect_color_depth_from(env(&[("TERM", "screen-256color")])),
ColorDepth::Indexed256
);
assert_eq!(
detect_color_depth_from(env(&[("TERM", "tmux-256color")])),
ColorDepth::Indexed256
);
}
#[test]
fn plain_xterm_falls_back_to_ansi16() {
assert_eq!(
detect_color_depth_from(env(&[("TERM", "xterm")])),
ColorDepth::Ansi16
);
}
#[test]
fn nothing_set_falls_back_to_ansi16() {
assert_eq!(detect_color_depth_from(env(&[])), ColorDepth::Ansi16);
}
#[test]
fn empty_colorterm_does_not_falsely_claim_truecolor() {
assert_eq!(
detect_color_depth_from(env(&[("COLORTERM", ""), ("TERM", "xterm-256color")])),
ColorDepth::Indexed256
);
}
#[test]
fn xterm_kitty_term_is_detected() {
assert!(detect_kitty_keyboard_from(env(&[("TERM", "xterm-kitty")])));
}
#[test]
fn foot_term_is_detected() {
assert!(detect_kitty_keyboard_from(env(&[("TERM", "foot")])));
assert!(detect_kitty_keyboard_from(env(&[("TERM", "foot-extra")])));
}
#[test]
fn kitty_window_id_is_detected_even_under_a_rewritten_term() {
assert!(detect_kitty_keyboard_from(env(&[
("TERM", "screen-256color"),
("KITTY_WINDOW_ID", "1"),
])));
}
#[test]
fn wezterm_term_program_is_detected() {
assert!(detect_kitty_keyboard_from(env(&[(
"TERM_PROGRAM",
"WezTerm"
)])));
}
#[test]
fn plain_256color_term_is_not_detected() {
assert!(!detect_kitty_keyboard_from(env(&[(
"TERM",
"xterm-256color"
)])));
}
#[test]
fn nothing_set_is_not_detected() {
assert!(!detect_kitty_keyboard_from(env(&[])));
}
#[test]
fn term_program_match_is_exact_not_a_substring() {
assert!(!detect_kitty_keyboard_from(env(&[(
"TERM_PROGRAM",
"wezterm"
)])));
assert!(!detect_kitty_keyboard_from(env(&[(
"TERM_PROGRAM",
"NotWezTermAtAll"
)])));
}
#[test]
fn alacritty_term_is_not_guessed() {
assert!(!detect_kitty_keyboard_from(env(&[("TERM", "alacritty")])));
}
#[test]
fn kitty_window_id_is_detected_for_sgr_pixel_mouse() {
assert!(detect_sgr_pixel_mouse_from(env(&[(
"KITTY_WINDOW_ID",
"1"
)])));
}
#[test]
fn foot_term_is_detected_for_sgr_pixel_mouse() {
assert!(detect_sgr_pixel_mouse_from(env(&[("TERM", "foot")])));
assert!(detect_sgr_pixel_mouse_from(env(&[("TERM", "foot-extra")])));
}
#[test]
fn wezterm_term_program_is_detected_for_sgr_pixel_mouse() {
assert!(detect_sgr_pixel_mouse_from(env(&[(
"TERM_PROGRAM",
"WezTerm"
)])));
}
#[test]
fn contour_term_is_detected_for_sgr_pixel_mouse() {
assert!(detect_sgr_pixel_mouse_from(env(&[("TERM", "contour")])));
}
#[test]
fn tmux_env_var_hard_disables_sgr_pixel_mouse() {
assert!(!detect_sgr_pixel_mouse_from(env(&[(
"TMUX",
"/tmp/tmux-1000/default,1234,0"
)])));
}
#[test]
fn screen_term_is_not_detected() {
assert!(!detect_sgr_pixel_mouse_from(env(&[(
"TERM",
"screen-256color"
)])));
}
#[test]
fn tmux_term_is_not_detected() {
assert!(!detect_sgr_pixel_mouse_from(env(&[(
"TERM",
"tmux-256color"
)])));
}
#[test]
fn unset_term_is_not_detected_for_sgr_pixel_mouse() {
assert!(!detect_sgr_pixel_mouse_from(env(&[])));
}
#[test]
fn wezterm_term_program_under_tmux_is_not_detected() {
assert!(!detect_sgr_pixel_mouse_from(env(&[
("TERM_PROGRAM", "WezTerm"),
("TMUX", "/tmp/tmux-1000/default,1234,0"),
])));
}
#[test]
fn tmux_env_var_blocks_the_live_probe() {
assert!(sgr_pixel_mouse_blocked_by_multiplexer_from(env(&[(
"TMUX",
"/tmp/tmux-1000/default,1234,0"
)])));
}
#[test]
fn screen_term_blocks_the_live_probe() {
assert!(sgr_pixel_mouse_blocked_by_multiplexer_from(env(&[(
"TERM",
"screen-256color"
)])));
}
#[test]
fn tmux_term_blocks_the_live_probe() {
assert!(sgr_pixel_mouse_blocked_by_multiplexer_from(env(&[(
"TERM",
"tmux-256color"
)])));
}
#[test]
fn unrecognised_terminal_does_not_block_the_live_probe() {
assert!(!sgr_pixel_mouse_blocked_by_multiplexer_from(env(&[(
"TERM",
"xterm-256color"
)])));
assert!(!sgr_pixel_mouse_blocked_by_multiplexer_from(env(&[])));
}
#[test]
fn decrqm_reply_parses_each_ps_value() {
for ps in 0u8..=4 {
let reply = format!("\x1b[?1016;{ps}$y");
assert_eq!(
parse_decrqm_reply(reply.as_bytes()),
Some(ps),
"failed to parse Ps={ps}"
);
}
}
#[test]
fn decrqm_reply_truncated_before_terminator_fails_to_parse() {
assert_eq!(parse_decrqm_reply(b"\x1b[?1016;1"), None);
assert_eq!(parse_decrqm_reply(b""), None);
assert_eq!(parse_decrqm_reply(b"\x1b[?1016$y"), None); assert_eq!(parse_decrqm_reply(b"\x1b[?1016;abc$y"), None); }
#[test]
fn decrqm_ps_1_and_3_report_the_mode_supported() {
assert!(decrqm_reply_supports_mode(Some(1)));
assert!(decrqm_reply_supports_mode(Some(3)));
}
#[test]
fn decrqm_ps_0_2_4_report_the_mode_unsupported() {
for ps in [0u8, 2, 4] {
assert!(
!decrqm_reply_supports_mode(Some(ps)),
"Ps={ps} must resolve to unsupported"
);
}
}
#[test]
fn underline_color_unsupported_on_windows_regardless_of_env() {
assert!(!detect_underline_color_support_from(true, env(&[])));
assert!(!detect_underline_color_support_from(
true,
env(&[("TERM", "xterm-kitty")])
));
}
#[test]
fn underline_color_supported_off_windows_without_wt_session() {
assert!(detect_underline_color_support_from(false, env(&[])));
assert!(detect_underline_color_support_from(
false,
env(&[("WT_SESSION", "")])
));
}
#[test]
fn underline_color_unsupported_under_windows_terminal_wsl() {
assert!(!detect_underline_color_support_from(
false,
env(&[("WT_SESSION", "4c6d2a0e-1b2f-4a7e-9d3c-5f8e7a6b1c20")])
));
}
#[test]
fn underline_color_unsupported_in_apple_terminal() {
assert!(!detect_underline_color_support_from(
false,
env(&[("TERM_PROGRAM", "Apple_Terminal")])
));
assert!(detect_underline_color_support_from(
false,
env(&[("TERM_PROGRAM", "iTerm.app")])
));
}
#[test]
fn decrqm_timeout_reports_the_mode_unsupported() {
assert!(!decrqm_reply_supports_mode(None));
}
#[test]
fn no_colorfgbg_returns_none() {
assert_eq!(detect_system_theme_from(env(&[])), None);
}
#[test]
fn dark_background_index_zero_is_detected() {
let theme = detect_system_theme_from(env(&[("COLORFGBG", "15;0")]))
.expect("COLORFGBG should be parsed");
assert!(theme.dark);
assert_eq!(theme.accent, None);
assert!(!theme.high_contrast);
}
#[test]
fn light_background_index_seven_is_detected() {
let theme = detect_system_theme_from(env(&[("COLORFGBG", "0;7")]))
.expect("COLORFGBG should be parsed");
assert!(!theme.dark);
}
#[test]
fn light_background_index_fifteen_is_detected() {
let theme = detect_system_theme_from(env(&[("COLORFGBG", "0;15")]))
.expect("COLORFGBG should be parsed");
assert!(!theme.dark);
}
#[test]
fn other_background_indices_read_as_dark() {
for bg in [0, 1, 4, 8, 9, 14] {
let theme = detect_system_theme_from(env(&[("COLORFGBG", &format!("15;{bg}"))]))
.unwrap_or_else(|| panic!("COLORFGBG with bg={bg} should be parsed"));
assert!(theme.dark, "bg index {bg} should read as dark");
}
}
#[test]
fn malformed_colorfgbg_returns_none() {
assert_eq!(
detect_system_theme_from(env(&[("COLORFGBG", "not-a-number")])),
None
);
assert_eq!(detect_system_theme_from(env(&[("COLORFGBG", "")])), None);
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn parse_decrqm_reply_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..200)) {
let _ = parse_decrqm_reply(&bytes);
}
#[test]
fn parse_decrqm_reply_only_returns_a_digit_actually_present(
mode in 0u32..9999,
ps in 0u16..999, ) {
let reply = format!("\x1b[?{mode};{ps}$y");
let parsed = parse_decrqm_reply(reply.as_bytes());
match u8::try_from(ps) {
Ok(ps_u8) => prop_assert_eq!(parsed, Some(ps_u8)),
Err(_) => prop_assert_eq!(parsed, None), }
}
#[test]
fn decrqm_reply_supports_mode_matches_its_documented_cases(ps in any::<Option<u8>>()) {
prop_assert_eq!(
decrqm_reply_supports_mode(ps),
matches!(ps, Some(1) | Some(3))
);
}
}
}