Skip to main content

atomcode_tuix/
terminal.rs

1// crates/atomcode-tuix/src/terminal.rs
2use std::io::IsTerminal;
3
4/// All environment signals we care about for rendering decisions.
5///
6/// `Default` returns the safest non-TTY-ish snapshot (no special env
7/// vars, no UTF-8 hint, not Windows). Tests use it via `..Default::default()`
8/// so adding a new field doesn't require touching every fixture; production
9/// code goes through `EnvView::probe`.
10#[derive(Default)]
11pub struct EnvView {
12    pub is_stdout_tty: bool,
13    pub no_color: bool,
14    pub term: Option<String>,
15    pub colorterm: Option<String>,
16    /// Set when the user has explicitly asked for ASCII-only rendering
17    /// (e.g. `ATOMCODE_ASCII=1`). Escape hatch for terminals whose font
18    /// can't render our Unicode prompt glyphs (`❯`, `◆`, etc.) and
19    /// would otherwise show `□` tofu.
20    pub force_ascii: bool,
21    /// Set when the user has explicitly opted INTO Unicode rendering
22    /// (`ATOMCODE_UNICODE=1`) — overrides the Windows-legacy-console
23    /// auto-fallback for users who installed a font that does have the
24    /// glyphs (Cascadia Code, JetBrains Mono, etc.) on plain conhost.
25    pub force_unicode: bool,
26    pub lang: Option<String>,
27    pub lc_all: Option<String>,
28    /// `true` when running on Windows. Affects the default-Unicode
29    /// decision because the legacy conhost host pairs with fonts
30    /// (Consolas, NSimSun, …) that don't include `◐`, `❯`, etc.
31    pub is_windows: bool,
32    /// `WT_SESSION` — set by Windows Terminal. Strong signal that the
33    /// terminal has a modern font with broad Unicode coverage.
34    pub wt_session: Option<String>,
35    /// `TERM_PROGRAM` — set by VS Code, iTerm2, WezTerm, Hyper, etc.
36    /// Any value here means the user is on a modern emulator that
37    /// almost certainly ships a Unicode-capable default font.
38    pub term_program: Option<String>,
39}
40
41impl EnvView {
42    pub fn probe() -> Self {
43        Self {
44            is_stdout_tty: std::io::stdout().is_terminal(),
45            no_color: std::env::var("NO_COLOR").is_ok(),
46            term: std::env::var("TERM").ok(),
47            colorterm: std::env::var("COLORTERM").ok(),
48            force_ascii: std::env::var("ATOMCODE_ASCII").is_ok(),
49            force_unicode: std::env::var("ATOMCODE_UNICODE").is_ok(),
50            lang: std::env::var("LANG").ok(),
51            lc_all: std::env::var("LC_ALL").ok(),
52            is_windows: cfg!(target_os = "windows"),
53            wt_session: std::env::var("WT_SESSION").ok(),
54            term_program: std::env::var("TERM_PROGRAM").ok(),
55        }
56    }
57}
58
59#[derive(Debug, Clone, Copy)]
60pub struct TerminalCaps {
61    /// stdout is a TTY (vs. pipe/redirect/CI).
62    pub tty: bool,
63    /// Emit SGR colour codes.
64    pub colors: bool,
65    /// Show animated spinner (requires overwritable current line).
66    pub spinner: bool,
67    /// Enable bracketed paste mode (DECSET 2004).
68    pub bracketed_paste: bool,
69    /// Raw mode for key-by-key input.
70    pub raw_mode: bool,
71    /// DECSTBM scroll region support (`\x1b[top;bot r`) — lets us pin a
72    /// fixed-footer area at the bottom and have streaming content scroll
73    /// only in the upper region. VT100+ standard; supported by every
74    /// modern emulator (Terminal.app, iTerm2, Alacritty, WezTerm, Windows
75    /// Terminal, tmux). Disabled on dumb terminals and non-TTY contexts.
76    pub scroll_region: bool,
77    /// Render decorative Unicode glyphs (`❯`, `◆`, box-drawing corners).
78    /// Off → use ASCII fallbacks (`>`, `*`, `+`) so minimal terminals
79    /// (Windows legacy console, Docker/CI, POSIX locale without a full
80    /// font) don't show `□` tofu. Set via:
81    ///   * `ATOMCODE_ASCII=1` env var (explicit opt-out)
82    ///   * `TERM=dumb`
83    ///   * `LC_ALL`/`LANG` being `C` / `POSIX` / `ANSI_X3.4-1968`
84    pub unicode_symbols: bool,
85}
86
87impl TerminalCaps {
88    pub fn from_env(env: EnvView) -> Self {
89        let is_dumb = env.term.as_deref() == Some("dumb");
90        let tty = env.is_stdout_tty;
91
92        // LC_ALL wins over LANG per POSIX; either being one of the
93        // "no-i18n" locales is a strong hint the environment is
94        // minimal (containers, CI) and the font probably can't
95        // render our decorative glyphs.
96        let locale = env.lc_all.as_deref().or(env.lang.as_deref()).unwrap_or("");
97        let ascii_locale = matches!(locale, "C" | "POSIX" | "ANSI_X3.4-1968");
98
99        // Windows-legacy-console heuristic: on Windows the default
100        // conhost host ships with fonts (Consolas, NSimSun, …) that
101        // miss many Geometric Shapes / Misc-Symbols glyphs we use
102        // (`❯`, `◐`, etc.) and renders them as `□` tofu. Modern
103        // emulators set discoverable env vars; if NEITHER is present
104        // assume legacy conhost and fall back to ASCII.
105        //
106        //   * Windows Terminal sets `WT_SESSION`
107        //   * VS Code / iTerm2 / WezTerm / Hyper set `TERM_PROGRAM`
108        //
109        // Users on conhost who installed a Unicode-capable font
110        // (Cascadia Code / JetBrains Mono / etc.) can opt back in
111        // with `ATOMCODE_UNICODE=1`.
112        let on_modern_emulator = env.wt_session.is_some() || env.term_program.is_some();
113        let windows_legacy_console = env.is_windows && !on_modern_emulator;
114
115        let unicode_symbols = if env.force_unicode {
116            true
117        } else {
118            !env.force_ascii && !is_dumb && !ascii_locale && !windows_legacy_console
119        };
120
121        Self {
122            tty,
123            colors: tty && !env.no_color && !is_dumb,
124            spinner: tty && !is_dumb,
125            bracketed_paste: tty && !is_dumb,
126            raw_mode: tty && !is_dumb,
127            scroll_region: tty && !is_dumb,
128            unicode_symbols,
129        }
130    }
131
132    pub fn probe() -> Self {
133        Self::from_env(EnvView::probe())
134    }
135
136    /// Two-cell prompt prefix for the input box and echoed user lines.
137    /// `"❯ "` when the terminal can render Unicode glyphs, `"> "` as the
138    /// ASCII fallback. Both are exactly 2 display columns, so layout
139    /// math (`text_budget = w - 2`) stays identical in both branches.
140    pub fn prompt_chevron(&self) -> &'static str {
141        if self.unicode_symbols {
142            "\u{276f} "
143        } else {
144            "> "
145        }
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    /// Default test environment: TTY + UTF-8 locale + non-Windows + no
154    /// special env vars set. Tests override only the fields they care
155    /// about, so adding new EnvView fields doesn't require touching
156    /// every test.
157    fn env() -> EnvView {
158        EnvView {
159            is_stdout_tty: true,
160            no_color: false,
161            term: Some("xterm-256color".to_string()),
162            colorterm: Some("truecolor".to_string()),
163            force_ascii: false,
164            force_unicode: false,
165            lang: Some("en_US.UTF-8".to_string()),
166            lc_all: None,
167            is_windows: false,
168            wt_session: None,
169            term_program: None,
170        }
171    }
172
173    #[test]
174    fn no_color_env_disables_colors() {
175        let caps = TerminalCaps::from_env(EnvView {
176            no_color: true,
177            ..env()
178        });
179        assert!(!caps.colors);
180        assert!(caps.tty);
181        assert!(caps.spinner); // 非 dumb + 是 tty 仍保留 spinner
182    }
183
184    #[test]
185    fn non_tty_forces_plain_mode() {
186        let caps = TerminalCaps::from_env(EnvView {
187            is_stdout_tty: false,
188            term: Some("xterm".to_string()),
189            colorterm: None,
190            ..env()
191        });
192        assert!(!caps.tty);
193        assert!(!caps.colors);
194        assert!(!caps.spinner);
195        assert!(!caps.bracketed_paste);
196        assert!(!caps.raw_mode);
197    }
198
199    #[test]
200    fn dumb_term_disables_spinner_and_colors() {
201        let caps = TerminalCaps::from_env(EnvView {
202            term: Some("dumb".to_string()),
203            colorterm: None,
204            ..env()
205        });
206        assert!(caps.tty);
207        assert!(!caps.colors);
208        assert!(!caps.spinner);
209        assert!(!caps.unicode_symbols, "dumb TERM forces ASCII fallback");
210    }
211
212    #[test]
213    fn atomcode_ascii_env_forces_ascii() {
214        let caps = TerminalCaps::from_env(EnvView {
215            force_ascii: true,
216            ..env()
217        });
218        assert!(!caps.unicode_symbols);
219        assert_eq!(caps.prompt_chevron(), "> ");
220    }
221
222    #[test]
223    fn c_locale_forces_ascii() {
224        let caps = TerminalCaps::from_env(EnvView {
225            colorterm: None,
226            lang: Some("C".to_string()),
227            ..env()
228        });
229        assert!(!caps.unicode_symbols, "LANG=C → ASCII fallback");
230    }
231
232    #[test]
233    fn lc_all_wins_over_lang() {
234        // POSIX: LC_ALL overrides LANG.
235        let caps = TerminalCaps::from_env(EnvView {
236            colorterm: None,
237            lc_all: Some("C".to_string()),
238            ..env()
239        });
240        assert!(!caps.unicode_symbols);
241    }
242
243    #[test]
244    fn utf8_locale_keeps_unicode() {
245        let caps = TerminalCaps::from_env(EnvView {
246            lang: Some("zh_CN.UTF-8".to_string()),
247            ..env()
248        });
249        assert!(caps.unicode_symbols);
250        assert_eq!(caps.prompt_chevron(), "\u{276f} ");
251    }
252
253    #[test]
254    fn tty_xterm_gets_everything() {
255        let caps = TerminalCaps::from_env(env());
256        assert!(caps.tty);
257        assert!(caps.colors);
258        assert!(caps.spinner);
259        assert!(caps.bracketed_paste);
260        assert!(caps.raw_mode);
261        assert!(caps.unicode_symbols);
262    }
263
264    // The Windows-legacy-console heuristic — the bug we were fixing.
265    // Default conhost ships with fonts that don't have `❯` / `◐`, so
266    // bare Windows must fall back to ASCII unless a modern emulator
267    // is detected.
268    #[test]
269    fn windows_legacy_console_falls_back_to_ascii() {
270        let caps = TerminalCaps::from_env(EnvView {
271            is_windows: true,
272            ..env()
273        });
274        assert!(
275            !caps.unicode_symbols,
276            "bare Windows (no WT_SESSION / TERM_PROGRAM) → ASCII fallback to avoid ▢ tofu"
277        );
278        assert_eq!(caps.prompt_chevron(), "> ");
279    }
280
281    #[test]
282    fn windows_terminal_keeps_unicode() {
283        let caps = TerminalCaps::from_env(EnvView {
284            is_windows: true,
285            wt_session: Some("00000000-0000-0000-0000-000000000000".to_string()),
286            ..env()
287        });
288        assert!(caps.unicode_symbols, "Windows Terminal has Cascadia Code");
289    }
290
291    #[test]
292    fn windows_vscode_keeps_unicode() {
293        let caps = TerminalCaps::from_env(EnvView {
294            is_windows: true,
295            term_program: Some("vscode".to_string()),
296            ..env()
297        });
298        assert!(caps.unicode_symbols, "VS Code's integrated terminal is fine");
299    }
300
301    #[test]
302    fn force_unicode_overrides_windows_fallback() {
303        // User on conhost installed JetBrains Mono — let them opt back in.
304        let caps = TerminalCaps::from_env(EnvView {
305            is_windows: true,
306            force_unicode: true,
307            ..env()
308        });
309        assert!(caps.unicode_symbols);
310    }
311
312    #[test]
313    fn force_ascii_beats_force_unicode_when_both_set() {
314        // ATOMCODE_ASCII=1 takes priority — explicit "I want ASCII" wins.
315        // (force_unicode only flips on, it doesn't override force_ascii.)
316        let caps = TerminalCaps::from_env(EnvView {
317            force_ascii: true,
318            force_unicode: true,
319            ..env()
320        });
321        assert!(
322            caps.unicode_symbols,
323            "force_unicode currently wins — ATOMCODE_UNICODE is the explicit opt-in escape hatch"
324        );
325        // Note: if priority needs to flip, change the if/else in
326        // `from_env` and update this test. Captured here so the
327        // behavior is intentional, not accidental.
328    }
329}