1use std::sync::LazyLock;
24
25use crate::utils::env_flag_value_enabled;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
29pub enum BoxGlyphTier {
30 #[default]
32 Ascii,
33 Unicode,
35 RichUnicode,
37}
38
39impl BoxGlyphTier {
40 #[must_use]
42 pub fn supports_unicode(self) -> bool {
43 !matches!(self, Self::Ascii)
44 }
45}
46
47#[derive(Debug, Clone, Default, PartialEq, Eq)]
53pub struct TerminalEnv {
54 pub term: Option<String>,
56 pub term_program: Option<String>,
58 pub lang: Option<String>,
60 pub lc_all: Option<String>,
62 pub lc_ctype: Option<String>,
64 pub no_unicode: Option<String>,
66 pub no_rich_unicode: Option<String>,
68}
69
70impl TerminalEnv {
71 #[must_use]
73 pub fn capture() -> Self {
74 Self {
75 term: std::env::var("TERM").ok(),
76 term_program: std::env::var("TERM_PROGRAM").ok(),
77 lang: std::env::var("LANG").ok(),
78 lc_all: std::env::var("LC_ALL").ok(),
79 lc_ctype: std::env::var("LC_CTYPE").ok(),
80 no_unicode: std::env::var("VTCODE_NO_UNICODE").ok(),
81 no_rich_unicode: std::env::var("VTCODE_NO_RICH_UNICODE").ok(),
82 }
83 }
84
85 #[must_use]
91 pub fn no_unicode_requested(&self) -> bool {
92 env_flag_value_enabled(self.no_unicode.as_deref())
93 }
94
95 #[must_use]
97 pub fn no_rich_unicode_requested(&self) -> bool {
98 env_flag_value_enabled(self.no_rich_unicode.as_deref())
99 }
100
101 #[must_use]
108 pub fn supports_unicode_box_drawing(&self) -> bool {
109 if let Some(term) = self.term.as_deref() {
110 if term_supports_unicode(term) {
111 return true;
112 }
113 if term_lacks_unicode(term) {
114 return false;
115 }
116 }
117
118 [self.lang.as_deref(), self.lc_all.as_deref(), self.lc_ctype.as_deref()]
119 .iter()
120 .flatten()
121 .any(|locale| locale_is_utf8(locale))
122 }
123
124 #[must_use]
129 pub fn supports_rich_box_drawing(&self) -> bool {
130 const RICH_TERMINALS: [&str; 6] = ["ghostty", "wezterm", "iterm", "hyper", "kitty", "alacritty"];
131
132 let term_program = self.term_program.as_deref().unwrap_or_default();
133 let term = self.term.as_deref().unwrap_or_default();
134 RICH_TERMINALS.iter().any(|name| {
135 crate::formatting::contains_ignore_ascii_case(term_program, name)
136 || crate::formatting::contains_ignore_ascii_case(term, name)
137 })
138 }
139}
140
141#[must_use]
143pub fn box_glyph_tier() -> BoxGlyphTier {
144 *BOX_GLYPH_TIER
145}
146
147#[must_use]
149pub fn box_glyph_tier_for(env: &TerminalEnv) -> BoxGlyphTier {
150 if env.no_unicode_requested() {
151 return BoxGlyphTier::Ascii;
152 }
153 if !env.supports_unicode_box_drawing() {
154 return BoxGlyphTier::Ascii;
155 }
156 if env.no_rich_unicode_requested() {
157 return BoxGlyphTier::Unicode;
158 }
159 if env.supports_rich_box_drawing() {
160 return BoxGlyphTier::RichUnicode;
161 }
162 BoxGlyphTier::Unicode
163}
164
165static BOX_GLYPH_TIER: LazyLock<BoxGlyphTier> = LazyLock::new(|| box_glyph_tier_for(&TerminalEnv::capture()));
167
168fn term_supports_unicode(term: &str) -> bool {
170 let term = term.to_ascii_lowercase();
171 term.contains("utf")
172 || term.contains("unicode")
173 || term.contains("256color")
174 || term.contains("truecolor")
175 || term.contains("direct")
176 || term.contains("alacritty")
177 || term.contains("wezterm")
178 || term.contains("kitty")
179 || term.contains("iterm")
180 || term.contains("hyper")
181 || term.contains("ghostty")
182 || term.contains("konsole")
183 || term.contains("contour")
184 || term.contains("foot")
185}
186
187fn term_lacks_unicode(term: &str) -> bool {
189 let term = term.to_ascii_lowercase();
190 term == "dumb" || term == "emacs" || term == "ansi" || term == "cons25" || term == "basic" || term.starts_with("vt")
191}
192
193fn locale_is_utf8(locale: &str) -> bool {
195 locale.to_ascii_lowercase().contains("utf")
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201
202 fn env(term: Option<&str>, term_program: Option<&str>, lang: Option<&str>) -> TerminalEnv {
203 TerminalEnv {
204 term: term.map(str::to_owned),
205 term_program: term_program.map(str::to_owned),
206 lang: lang.map(str::to_owned),
207 ..TerminalEnv::default()
208 }
209 }
210
211 #[test]
212 fn ascii_is_the_fallback_without_evidence() {
213 assert_eq!(box_glyph_tier_for(&TerminalEnv::default()), BoxGlyphTier::Ascii);
214 assert_eq!(box_glyph_tier_for(&env(None, None, None)), BoxGlyphTier::Ascii);
215 }
216
217 #[test]
218 fn term_advertisement_earns_unicode_even_with_a_c_locale() {
219 let tier = box_glyph_tier_for(&env(Some("xterm-256color"), None, Some("C")));
220 assert_eq!(tier, BoxGlyphTier::Unicode);
221 }
222
223 #[test]
224 fn utf8_locale_earns_unicode_when_term_says_nothing() {
225 assert_eq!(box_glyph_tier_for(&env(None, None, Some("en_US.UTF-8"))), BoxGlyphTier::Unicode);
226 assert_eq!(
227 box_glyph_tier_for(&TerminalEnv {
228 lc_ctype: Some("en_US.UTF-8".to_owned()),
229 ..TerminalEnv::default()
230 }),
231 BoxGlyphTier::Unicode
232 );
233 }
234
235 #[test]
236 fn explicitly_non_utf8_locale_stays_ascii() {
237 assert_eq!(box_glyph_tier_for(&env(None, None, Some("C"))), BoxGlyphTier::Ascii);
238 }
239
240 #[test]
241 fn dumb_terminal_stays_ascii_even_with_a_utf8_locale() {
242 assert_eq!(box_glyph_tier_for(&env(Some("dumb"), None, Some("en_US.UTF-8"))), BoxGlyphTier::Ascii);
243 }
244
245 #[test]
246 fn term_program_identifies_rich_terminals_that_share_a_generic_term() {
247 for (program, term) in [
248 ("iTerm.app", "xterm-256color"),
249 ("WezTerm", "xterm-256color"),
250 ("Hyper.app", "xterm-256color"),
251 ("kitty", "xterm-kitty"),
252 ("ghostty", "xterm-ghostty"),
253 ("Alacritty", "alacritty"),
254 ] {
255 let tier = box_glyph_tier_for(&env(Some(term), Some(program), Some("en_US.UTF-8")));
256 assert_eq!(tier, BoxGlyphTier::RichUnicode, "{program} should reach the rich tier");
257 }
258 }
259
260 #[test]
261 fn plain_terminals_stay_on_the_unicode_tier() {
262 assert_eq!(box_glyph_tier_for(&env(Some("xterm-256color"), None, Some("en_US.UTF-8"))), BoxGlyphTier::Unicode);
263 assert_eq!(
264 box_glyph_tier_for(&env(Some("xterm-256color"), Some("Apple_Terminal"), Some("en_US.UTF-8"))),
265 BoxGlyphTier::Unicode
266 );
267 }
268
269 #[test]
270 fn opt_out_flags_use_canonical_flag_semantics() {
271 let base = env(Some("xterm-256color"), Some("WezTerm"), Some("en_US.UTF-8"));
272 assert_eq!(box_glyph_tier_for(&base), BoxGlyphTier::RichUnicode);
273
274 let no_unicode = TerminalEnv { no_unicode: Some("1".to_owned()), ..base.clone() };
275 assert_eq!(box_glyph_tier_for(&no_unicode), BoxGlyphTier::Ascii);
276
277 let no_rich = TerminalEnv {
278 no_rich_unicode: Some("1".to_owned()),
279 ..base.clone()
280 };
281 assert_eq!(box_glyph_tier_for(&no_rich), BoxGlyphTier::Unicode);
282 }
283
284 #[test]
285 fn falsy_opt_out_values_do_not_disable_unicode() {
286 for value in ["0", "false", "off", "no", ""] {
287 let disabled = TerminalEnv {
288 no_unicode: Some(value.to_owned()),
289 no_rich_unicode: Some(value.to_owned()),
290 ..env(Some("xterm-256color"), Some("WezTerm"), Some("en_US.UTF-8"))
291 };
292 assert_eq!(
293 box_glyph_tier_for(&disabled),
294 BoxGlyphTier::RichUnicode,
295 "VTCODE_NO_UNICODE={value:?} must not disable Unicode"
296 );
297 }
298 }
299
300 #[test]
301 fn tier_supports_unicode_matches_the_ladder() {
302 assert!(!BoxGlyphTier::Ascii.supports_unicode());
303 assert!(BoxGlyphTier::Unicode.supports_unicode());
304 assert!(BoxGlyphTier::RichUnicode.supports_unicode());
305 }
306}