use std::env;
use ratatui::symbols::border;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CapabilityMode {
#[default]
Auto,
On,
Off,
}
const ASCII_BORDER: border::Set<'static> = border::Set {
top_left: "+",
top_right: "+",
bottom_left: "+",
bottom_right: "+",
vertical_left: "|",
vertical_right: "|",
horizontal_top: "-",
horizontal_bottom: "-",
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct TerminalStyle {
unicode: bool,
color: bool,
}
impl Default for TerminalStyle {
fn default() -> Self {
Self {
unicode: true,
color: true,
}
}
}
impl TerminalStyle {
pub(crate) fn resolve(unicode: CapabilityMode, color: CapabilityMode) -> Self {
Self {
unicode: match unicode {
CapabilityMode::On => true,
CapabilityMode::Off => false,
CapabilityMode::Auto => environment_carries_unicode(|name: &str| env::var_os(name)),
},
color: match color {
CapabilityMode::On => true,
CapabilityMode::Off => false,
CapabilityMode::Auto => environment_shows_color(|name: &str| env::var_os(name)),
},
}
}
pub(crate) fn color(&self) -> bool {
self.color
}
pub(crate) fn border_set(&self) -> border::Set<'static> {
if self.unicode {
border::PLAIN
} else {
ASCII_BORDER
}
}
pub(crate) fn ellipsis(&self) -> &'static str {
if self.unicode {
"…"
} else {
"..."
}
}
pub(crate) fn hint_separator(&self) -> &'static str {
if self.unicode {
" │ "
} else {
" | "
}
}
pub(crate) fn section_rule(&self) -> &'static str {
if self.unicode {
"──"
} else {
"--"
}
}
pub(crate) fn elision_marker(&self) -> &'static str {
if self.unicode {
"⋯"
} else {
"..."
}
}
pub(crate) fn title_separator(&self) -> &'static str {
if self.unicode {
"·"
} else {
"-"
}
}
pub(crate) fn selection_symbol(&self) -> &'static str {
if self.unicode {
"❯ "
} else {
"> "
}
}
}
fn environment_carries_unicode(read: impl Fn(&str) -> Option<std::ffi::OsString>) -> bool {
let locale = ["LC_ALL", "LC_CTYPE", "LANG"]
.into_iter()
.find_map(&read)
.and_then(|value| value.into_string().ok());
let Some(locale) = locale.filter(|value| !value.is_empty()) else {
return true;
};
let lowered = locale.to_ascii_lowercase();
if lowered == "c" || lowered == "posix" || lowered.starts_with("c.") && !lowered.contains("utf")
{
return lowered.contains("utf");
}
match lowered.split_once('.') {
Some((_, charset)) => charset.contains("utf-8") || charset.contains("utf8"),
None => true,
}
}
fn environment_shows_color(read: impl Fn(&str) -> Option<std::ffi::OsString>) -> bool {
if read("NO_COLOR").is_some_and(|value| !value.is_empty()) {
return false;
}
!read("TERM").is_some_and(|value| value.eq_ignore_ascii_case("dumb"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::ffi::OsString;
fn environment<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<OsString> + 'a {
let map: HashMap<&str, &str> = pairs.iter().copied().collect();
move |name| map.get(name).map(OsString::from)
}
#[test]
fn an_absent_locale_is_not_evidence_against_unicode() {
assert!(environment_carries_unicode(environment(&[])));
}
#[test]
fn a_named_non_utf8_charset_falls_back() {
for locale in ["C", "POSIX", "en_US.ISO8859-1", "ja_JP.eucJP", "c"] {
assert!(
!environment_carries_unicode(environment(&[("LANG", locale)])),
"{locale} names an encoding that cannot carry box drawing"
);
}
for locale in ["en_US.UTF-8", "C.UTF-8", "en_GB.utf8", "en_US"] {
assert!(
environment_carries_unicode(environment(&[("LANG", locale)])),
"{locale} carries it"
);
}
}
#[test]
fn lc_all_outranks_lc_ctype_which_outranks_lang() {
assert!(!environment_carries_unicode(environment(&[
("LC_ALL", "C"),
("LC_CTYPE", "en_US.UTF-8"),
("LANG", "en_US.UTF-8"),
])));
assert!(!environment_carries_unicode(environment(&[
("LC_CTYPE", "C"),
("LANG", "en_US.UTF-8"),
])));
assert!(environment_carries_unicode(environment(&[
("LC_ALL", "en_US.UTF-8"),
("LANG", "C"),
])));
}
#[test]
fn no_color_is_honoured_whatever_its_value() {
for value in ["1", "0", "false", "yes"] {
assert!(!environment_shows_color(environment(&[(
"NO_COLOR", value
)])));
}
assert!(environment_shows_color(environment(&[("NO_COLOR", "")])));
assert!(environment_shows_color(environment(&[])));
}
#[test]
fn a_dumb_terminal_gets_no_color() {
assert!(!environment_shows_color(environment(&[("TERM", "dumb")])));
assert!(!environment_shows_color(environment(&[("TERM", "DUMB")])));
assert!(environment_shows_color(environment(&[(
"TERM",
"xterm-256color"
)])));
}
#[test]
fn an_explicit_setting_outranks_the_environment() {
let forced = TerminalStyle::resolve(CapabilityMode::On, CapabilityMode::On);
assert!(forced.unicode && forced.color());
let refused = TerminalStyle::resolve(CapabilityMode::Off, CapabilityMode::Off);
assert!(!refused.unicode && !refused.color());
}
#[test]
fn every_ascii_symbol_is_ascii_and_every_unicode_one_is_not_empty() {
let ascii = TerminalStyle {
unicode: false,
color: true,
};
let unicode = TerminalStyle::default();
let border = ascii.border_set();
for symbol in [
ascii.ellipsis(),
ascii.hint_separator(),
ascii.section_rule(),
ascii.elision_marker(),
ascii.title_separator(),
ascii.selection_symbol(),
border.top_left,
border.top_right,
border.bottom_left,
border.bottom_right,
border.vertical_left,
border.vertical_right,
border.horizontal_top,
border.horizontal_bottom,
] {
assert!(symbol.is_ascii(), "{symbol:?} is not ASCII");
assert!(!symbol.is_empty());
}
for symbol in [
unicode.ellipsis(),
unicode.hint_separator(),
unicode.section_rule(),
unicode.elision_marker(),
unicode.title_separator(),
unicode.selection_symbol(),
] {
assert!(!symbol.is_empty());
}
}
}