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