use std::sync::LazyLock;
use crate::utils::env_flag_value_enabled;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum BoxGlyphTier {
#[default]
Ascii,
Unicode,
RichUnicode,
}
impl BoxGlyphTier {
#[must_use]
pub fn supports_unicode(self) -> bool {
!matches!(self, Self::Ascii)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TerminalEnv {
pub term: Option<String>,
pub term_program: Option<String>,
pub lang: Option<String>,
pub lc_all: Option<String>,
pub lc_ctype: Option<String>,
pub no_unicode: Option<String>,
pub no_rich_unicode: Option<String>,
}
impl TerminalEnv {
#[must_use]
pub fn capture() -> Self {
Self {
term: std::env::var("TERM").ok(),
term_program: std::env::var("TERM_PROGRAM").ok(),
lang: std::env::var("LANG").ok(),
lc_all: std::env::var("LC_ALL").ok(),
lc_ctype: std::env::var("LC_CTYPE").ok(),
no_unicode: std::env::var("VTCODE_NO_UNICODE").ok(),
no_rich_unicode: std::env::var("VTCODE_NO_RICH_UNICODE").ok(),
}
}
#[must_use]
pub fn no_unicode_requested(&self) -> bool {
env_flag_value_enabled(self.no_unicode.as_deref())
}
#[must_use]
pub fn no_rich_unicode_requested(&self) -> bool {
env_flag_value_enabled(self.no_rich_unicode.as_deref())
}
#[must_use]
pub fn supports_unicode_box_drawing(&self) -> bool {
if let Some(term) = self.term.as_deref() {
if term_supports_unicode(term) {
return true;
}
if term_lacks_unicode(term) {
return false;
}
}
[self.lang.as_deref(), self.lc_all.as_deref(), self.lc_ctype.as_deref()]
.iter()
.flatten()
.any(|locale| locale_is_utf8(locale))
}
#[must_use]
pub fn supports_rich_box_drawing(&self) -> bool {
const RICH_TERMINALS: [&str; 6] = ["ghostty", "wezterm", "iterm", "hyper", "kitty", "alacritty"];
let term_program = self.term_program.as_deref().unwrap_or_default();
let term = self.term.as_deref().unwrap_or_default();
RICH_TERMINALS.iter().any(|name| {
crate::formatting::contains_ignore_ascii_case(term_program, name)
|| crate::formatting::contains_ignore_ascii_case(term, name)
})
}
}
#[must_use]
pub fn box_glyph_tier() -> BoxGlyphTier {
*BOX_GLYPH_TIER
}
#[must_use]
pub fn box_glyph_tier_for(env: &TerminalEnv) -> BoxGlyphTier {
if env.no_unicode_requested() {
return BoxGlyphTier::Ascii;
}
if !env.supports_unicode_box_drawing() {
return BoxGlyphTier::Ascii;
}
if env.no_rich_unicode_requested() {
return BoxGlyphTier::Unicode;
}
if env.supports_rich_box_drawing() {
return BoxGlyphTier::RichUnicode;
}
BoxGlyphTier::Unicode
}
static BOX_GLYPH_TIER: LazyLock<BoxGlyphTier> = LazyLock::new(|| box_glyph_tier_for(&TerminalEnv::capture()));
fn term_supports_unicode(term: &str) -> bool {
let term = term.to_ascii_lowercase();
term.contains("utf")
|| term.contains("unicode")
|| term.contains("256color")
|| term.contains("truecolor")
|| term.contains("direct")
|| term.contains("alacritty")
|| term.contains("wezterm")
|| term.contains("kitty")
|| term.contains("iterm")
|| term.contains("hyper")
|| term.contains("ghostty")
|| term.contains("konsole")
|| term.contains("contour")
|| term.contains("foot")
}
fn term_lacks_unicode(term: &str) -> bool {
let term = term.to_ascii_lowercase();
term == "dumb" || term == "emacs" || term == "ansi" || term == "cons25" || term == "basic" || term.starts_with("vt")
}
fn locale_is_utf8(locale: &str) -> bool {
locale.to_ascii_lowercase().contains("utf")
}
#[cfg(test)]
mod tests {
use super::*;
fn env(term: Option<&str>, term_program: Option<&str>, lang: Option<&str>) -> TerminalEnv {
TerminalEnv {
term: term.map(str::to_owned),
term_program: term_program.map(str::to_owned),
lang: lang.map(str::to_owned),
..TerminalEnv::default()
}
}
#[test]
fn ascii_is_the_fallback_without_evidence() {
assert_eq!(box_glyph_tier_for(&TerminalEnv::default()), BoxGlyphTier::Ascii);
assert_eq!(box_glyph_tier_for(&env(None, None, None)), BoxGlyphTier::Ascii);
}
#[test]
fn term_advertisement_earns_unicode_even_with_a_c_locale() {
let tier = box_glyph_tier_for(&env(Some("xterm-256color"), None, Some("C")));
assert_eq!(tier, BoxGlyphTier::Unicode);
}
#[test]
fn utf8_locale_earns_unicode_when_term_says_nothing() {
assert_eq!(box_glyph_tier_for(&env(None, None, Some("en_US.UTF-8"))), BoxGlyphTier::Unicode);
assert_eq!(
box_glyph_tier_for(&TerminalEnv {
lc_ctype: Some("en_US.UTF-8".to_owned()),
..TerminalEnv::default()
}),
BoxGlyphTier::Unicode
);
}
#[test]
fn explicitly_non_utf8_locale_stays_ascii() {
assert_eq!(box_glyph_tier_for(&env(None, None, Some("C"))), BoxGlyphTier::Ascii);
}
#[test]
fn dumb_terminal_stays_ascii_even_with_a_utf8_locale() {
assert_eq!(box_glyph_tier_for(&env(Some("dumb"), None, Some("en_US.UTF-8"))), BoxGlyphTier::Ascii);
}
#[test]
fn term_program_identifies_rich_terminals_that_share_a_generic_term() {
for (program, term) in [
("iTerm.app", "xterm-256color"),
("WezTerm", "xterm-256color"),
("Hyper.app", "xterm-256color"),
("kitty", "xterm-kitty"),
("ghostty", "xterm-ghostty"),
("Alacritty", "alacritty"),
] {
let tier = box_glyph_tier_for(&env(Some(term), Some(program), Some("en_US.UTF-8")));
assert_eq!(tier, BoxGlyphTier::RichUnicode, "{program} should reach the rich tier");
}
}
#[test]
fn plain_terminals_stay_on_the_unicode_tier() {
assert_eq!(box_glyph_tier_for(&env(Some("xterm-256color"), None, Some("en_US.UTF-8"))), BoxGlyphTier::Unicode);
assert_eq!(
box_glyph_tier_for(&env(Some("xterm-256color"), Some("Apple_Terminal"), Some("en_US.UTF-8"))),
BoxGlyphTier::Unicode
);
}
#[test]
fn opt_out_flags_use_canonical_flag_semantics() {
let base = env(Some("xterm-256color"), Some("WezTerm"), Some("en_US.UTF-8"));
assert_eq!(box_glyph_tier_for(&base), BoxGlyphTier::RichUnicode);
let no_unicode = TerminalEnv { no_unicode: Some("1".to_owned()), ..base.clone() };
assert_eq!(box_glyph_tier_for(&no_unicode), BoxGlyphTier::Ascii);
let no_rich = TerminalEnv {
no_rich_unicode: Some("1".to_owned()),
..base.clone()
};
assert_eq!(box_glyph_tier_for(&no_rich), BoxGlyphTier::Unicode);
}
#[test]
fn falsy_opt_out_values_do_not_disable_unicode() {
for value in ["0", "false", "off", "no", ""] {
let disabled = TerminalEnv {
no_unicode: Some(value.to_owned()),
no_rich_unicode: Some(value.to_owned()),
..env(Some("xterm-256color"), Some("WezTerm"), Some("en_US.UTF-8"))
};
assert_eq!(
box_glyph_tier_for(&disabled),
BoxGlyphTier::RichUnicode,
"VTCODE_NO_UNICODE={value:?} must not disable Unicode"
);
}
}
#[test]
fn tier_supports_unicode_matches_the_ladder() {
assert!(!BoxGlyphTier::Ascii.supports_unicode());
assert!(BoxGlyphTier::Unicode.supports_unicode());
assert!(BoxGlyphTier::RichUnicode.supports_unicode());
}
}