Skip to main content

rskit_cli/theme/
glyph.rs

1//! Semantic status glyphs with a pure-ASCII fallback.
2//!
3//! A small companion to [`crate::theme::color`]: where the palette resolves
4//! *color*, this resolves the small set of *symbols* CLIs use as status markers
5//! (a success check, an error cross, a bullet, an arrow…). Unicode glyphs render
6//! on modern UTF-8 terminals; a legacy or mis-encoded terminal falls back to a
7//! pure-ASCII stand-in so output never turns into replacement characters.
8//!
9//! Like [`Palette`](crate::theme::Palette), a [`Glyphs`] value resolves from a
10//! single boolean, so callers render identically regardless of capability, and
11//! it exposes an env-free constructor ([`Glyphs::new`]) for deterministic tests
12//! alongside the environment-driven [`Glyphs::from_env`].
13
14/// The locale environment variables consulted, in precedence order, to decide
15/// whether the terminal encoding is UTF-8.
16pub const UTF8_LOCALE_ENVS: [&str; 3] = ["LC_ALL", "LC_CTYPE", "LANG"];
17
18/// Whether the process locale advertises a UTF-8 encoding.
19///
20/// Consults [`UTF8_LOCALE_ENVS`] in order and reports `true` when any is set to
21/// a value naming UTF-8 (case-insensitive, `utf-8` or `utf8`). When none is set
22/// the result is `false`, so the ASCII fallback is the safe default.
23#[must_use]
24pub fn unicode_env_enabled() -> bool {
25    UTF8_LOCALE_ENVS.iter().any(|key| {
26        std::env::var(key).is_ok_and(|value| {
27            let value = value.to_ascii_lowercase();
28            value.contains("utf-8") || value.contains("utf8")
29        })
30    })
31}
32
33/// A resolved set of semantic status glyphs.
34///
35/// When Unicode is disabled every accessor returns its ASCII fallback, so the
36/// same rendering code stays byte-clean on terminals that cannot display the
37/// Unicode symbols. Construct it from a resolved boolean via [`Glyphs::new`], or
38/// from the process locale via [`Glyphs::from_env`].
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct Glyphs {
41    unicode: bool,
42}
43
44impl Glyphs {
45    /// A glyph set with Unicode explicitly enabled or disabled.
46    #[must_use]
47    pub const fn new(unicode: bool) -> Self {
48        Self { unicode }
49    }
50
51    /// Resolve a glyph set from the process locale via [`unicode_env_enabled`].
52    #[must_use]
53    pub fn from_env() -> Self {
54        Self::new(unicode_env_enabled())
55    }
56
57    /// Whether this set emits Unicode glyphs.
58    #[must_use]
59    pub const fn unicode(self) -> bool {
60        self.unicode
61    }
62
63    /// Success / completed — `✓` (ASCII `v`).
64    #[must_use]
65    pub const fn success(self) -> &'static str {
66        self.pick("✓", "v")
67    }
68
69    /// Failure / error — `✗` (ASCII `x`).
70    #[must_use]
71    pub const fn error(self) -> &'static str {
72        self.pick("✗", "x")
73    }
74
75    /// Warning / attention — `⚠` (ASCII `!`).
76    #[must_use]
77    pub const fn warning(self) -> &'static str {
78        self.pick("⚠", "!")
79    }
80
81    /// Informational — `ℹ` (ASCII `i`).
82    #[must_use]
83    pub const fn info(self) -> &'static str {
84        self.pick("ℹ", "i")
85    }
86
87    /// List bullet — `•` (ASCII `*`).
88    #[must_use]
89    pub const fn bullet(self) -> &'static str {
90        self.pick("•", "*")
91    }
92
93    /// Progression arrow — `→` (ASCII `->`).
94    #[must_use]
95    pub const fn arrow(self) -> &'static str {
96        self.pick("→", "->")
97    }
98
99    /// Selection pointer — `❯` (ASCII `>`).
100    #[must_use]
101    pub const fn pointer(self) -> &'static str {
102        self.pick("❯", ">")
103    }
104
105    /// Inline answer/input marker — `»` (ASCII `>`).
106    #[must_use]
107    pub const fn answer(self) -> &'static str {
108        self.pick("»", ">")
109    }
110
111    /// Selected radio option — `◉` (ASCII `(*)`).
112    #[must_use]
113    pub const fn radio_on(self) -> &'static str {
114        self.pick("◉", "(*)")
115    }
116
117    /// Unselected radio option — `○` (ASCII `( )`).
118    #[must_use]
119    pub const fn radio_off(self) -> &'static str {
120        self.pick("○", "( )")
121    }
122
123    /// Upward navigation arrow — `↑` (ASCII `^`).
124    #[must_use]
125    pub const fn arrow_up(self) -> &'static str {
126        self.pick("↑", "^")
127    }
128
129    /// Downward navigation arrow — `↓` (ASCII `v`).
130    #[must_use]
131    pub const fn arrow_down(self) -> &'static str {
132        self.pick("↓", "v")
133    }
134
135    /// Truncation ellipsis — `…` (ASCII `...`).
136    #[must_use]
137    pub const fn ellipsis(self) -> &'static str {
138        self.pick("…", "...")
139    }
140
141    /// Choose the Unicode glyph when enabled, else the ASCII fallback.
142    const fn pick(self, unicode: &'static str, ascii: &'static str) -> &'static str {
143        if self.unicode { unicode } else { ascii }
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::{Glyphs, unicode_env_enabled};
150
151    #[test]
152    fn env_probe_and_from_env_agree_and_are_callable() {
153        // `unicode_env_enabled` reads the process locale; `from_env` must mirror
154        // it. Both are exercised here without mutating the (forbidden-unsafe)
155        // environment, so the result tracks whatever locale the runner exposes.
156        assert_eq!(Glyphs::from_env().unicode(), unicode_env_enabled());
157    }
158
159    #[test]
160    fn unicode_set_emits_symbols() {
161        let glyphs = Glyphs::new(true);
162        assert!(glyphs.unicode());
163        assert_eq!(glyphs.success(), "✓");
164        assert_eq!(glyphs.error(), "✗");
165        assert_eq!(glyphs.warning(), "⚠");
166        assert_eq!(glyphs.info(), "ℹ");
167        assert_eq!(glyphs.bullet(), "•");
168        assert_eq!(glyphs.arrow(), "→");
169        assert_eq!(glyphs.pointer(), "❯");
170        assert_eq!(glyphs.answer(), "»");
171        assert_eq!(glyphs.radio_on(), "◉");
172        assert_eq!(glyphs.radio_off(), "○");
173        assert_eq!(glyphs.arrow_up(), "↑");
174        assert_eq!(glyphs.arrow_down(), "↓");
175        assert_eq!(glyphs.ellipsis(), "…");
176    }
177
178    #[test]
179    fn ascii_fallback_is_byte_clean() {
180        let glyphs = Glyphs::new(false);
181        assert!(!glyphs.unicode());
182        for symbol in [
183            glyphs.success(),
184            glyphs.error(),
185            glyphs.warning(),
186            glyphs.info(),
187            glyphs.bullet(),
188            glyphs.arrow(),
189            glyphs.pointer(),
190            glyphs.answer(),
191            glyphs.radio_on(),
192            glyphs.radio_off(),
193            glyphs.arrow_up(),
194            glyphs.arrow_down(),
195            glyphs.ellipsis(),
196        ] {
197            assert!(symbol.is_ascii(), "fallback must be ASCII: {symbol:?}");
198        }
199    }
200}