Skip to main content

ftui_core/
glyph_policy.rs

1#![forbid(unsafe_code)]
2
3//! Glyph capability policy (Unicode/ASCII, emoji, and width calibration).
4//!
5//! This module centralizes glyph decisions so rendering and demos can
6//! consistently choose Unicode vs ASCII, emoji usage, and CJK width policy.
7//! Decisions are deterministic given environment variables and a terminal
8//! capability profile.
9
10use crate::terminal_capabilities::{TerminalCapabilities, TerminalProfile};
11use crate::text_width;
12use unicode_width::UnicodeWidthChar;
13
14/// Environment variable to override glyph mode (`unicode` or `ascii`).
15const ENV_GLYPH_MODE: &str = "FTUI_GLYPH_MODE";
16/// Environment variable to override emoji support (`1/0/true/false`).
17const ENV_GLYPH_EMOJI: &str = "FTUI_GLYPH_EMOJI";
18/// Legacy environment variable to disable emoji (`1/0/true/false`).
19const ENV_NO_EMOJI: &str = "FTUI_NO_EMOJI";
20/// Environment variable to override line drawing support (`1/0/true/false`).
21const ENV_GLYPH_LINE_DRAWING: &str = "FTUI_GLYPH_LINE_DRAWING";
22/// Environment variable to override Unicode arrow support (`1/0/true/false`).
23const ENV_GLYPH_ARROWS: &str = "FTUI_GLYPH_ARROWS";
24/// Environment variable to override double-width glyph support (`1/0/true/false`).
25const ENV_GLYPH_DOUBLE_WIDTH: &str = "FTUI_GLYPH_DOUBLE_WIDTH";
26
27/// Overall glyph rendering mode.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum GlyphMode {
30    /// Use Unicode glyphs (box drawing, symbols, arrows).
31    Unicode,
32    /// Use ASCII-only fallbacks.
33    Ascii,
34}
35
36impl GlyphMode {
37    fn parse(value: &str) -> Option<Self> {
38        match value.trim().to_ascii_lowercase().as_str() {
39            "unicode" | "uni" | "u" => Some(Self::Unicode),
40            "ascii" | "ansi" | "a" => Some(Self::Ascii),
41            _ => None,
42        }
43    }
44
45    #[must_use]
46    pub const fn as_str(self) -> &'static str {
47        match self {
48            Self::Unicode => "unicode",
49            Self::Ascii => "ascii",
50        }
51    }
52}
53
54/// Glyph capability policy.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub struct GlyphPolicy {
57    /// Overall glyph mode (Unicode vs ASCII).
58    pub mode: GlyphMode,
59    /// Whether emoji glyphs should be used.
60    pub emoji: bool,
61    /// Whether ambiguous-width glyphs should be treated as double-width.
62    pub cjk_width: bool,
63    /// Whether terminal supports double-width glyphs (CJK/emoji).
64    pub double_width: bool,
65    /// Whether terminal supports Unicode box-drawing characters.
66    pub unicode_box_drawing: bool,
67    /// Whether Unicode line drawing should be used.
68    pub unicode_line_drawing: bool,
69    /// Whether Unicode arrows/symbols should be used.
70    pub unicode_arrows: bool,
71}
72
73impl GlyphPolicy {
74    /// Detect policy using environment variables and detected terminal caps.
75    #[must_use]
76    pub fn detect() -> Self {
77        let caps = TerminalCapabilities::with_overrides();
78        Self::from_env_with(|key| std::env::var(key).ok(), &caps)
79    }
80
81    /// Detect policy using a custom environment lookup (for tests).
82    #[must_use]
83    pub fn from_env_with<F>(get_env: F, caps: &TerminalCapabilities) -> Self
84    where
85        F: Fn(&str) -> Option<String>,
86    {
87        let mode = detect_mode(&get_env, caps);
88        let (mut emoji, emoji_overridden) = detect_emoji(&get_env, caps, mode);
89        let double_width = detect_double_width(&get_env, caps);
90        let mut cjk_width = text_width::cjk_width_from_env(|key| get_env(key));
91        if !double_width {
92            cjk_width = false;
93        }
94        if !double_width && !emoji_overridden {
95            emoji = false;
96        }
97
98        let unicode_box_drawing = caps.unicode_box_drawing;
99        let mut unicode_line_drawing = mode == GlyphMode::Unicode && unicode_box_drawing;
100        if let Some(value) = env_override_bool(&get_env, ENV_GLYPH_LINE_DRAWING) {
101            unicode_line_drawing = value;
102        }
103        if mode == GlyphMode::Ascii {
104            unicode_line_drawing = false;
105        }
106        if unicode_line_drawing && !glyphs_fit_narrow(LINE_DRAWING_GLYPHS, cjk_width) {
107            unicode_line_drawing = false;
108        }
109
110        let mut unicode_arrows = mode == GlyphMode::Unicode;
111        if let Some(value) = env_override_bool(&get_env, ENV_GLYPH_ARROWS) {
112            unicode_arrows = value;
113        }
114        if mode == GlyphMode::Ascii {
115            unicode_arrows = false;
116        }
117        if unicode_arrows && !glyphs_fit_narrow(ARROW_GLYPHS, cjk_width) {
118            unicode_arrows = false;
119        }
120
121        Self {
122            mode,
123            emoji,
124            cjk_width,
125            double_width,
126            unicode_box_drawing,
127            unicode_line_drawing,
128            unicode_arrows,
129        }
130    }
131
132    /// Serialize policy to JSON (for diagnostics/evidence logs).
133    #[must_use]
134    pub fn to_json(&self) -> String {
135        format!(
136            concat!(
137                r#"{{"glyph_mode":"{}","emoji":{},"cjk_width":{},"double_width":{},"unicode_box_drawing":{},"unicode_line_drawing":{},"unicode_arrows":{}}}"#
138            ),
139            self.mode.as_str(),
140            self.emoji,
141            self.cjk_width,
142            self.double_width,
143            self.unicode_box_drawing,
144            self.unicode_line_drawing,
145            self.unicode_arrows
146        )
147    }
148}
149
150const LINE_DRAWING_GLYPHS: &[char] = &[
151    '─', '│', '┌', '┐', '└', '┘', '┬', '┴', '├', '┤', '┼', '╭', '╮', '╯', '╰',
152];
153const ARROW_GLYPHS: &[char] = &['→', '←', '↑', '↓', '↔', '↕', '⇢', '⇠', '⇡', '⇣'];
154
155fn detect_mode<F>(get_env: &F, caps: &TerminalCapabilities) -> GlyphMode
156where
157    F: Fn(&str) -> Option<String>,
158{
159    if let Some(value) = get_env(ENV_GLYPH_MODE)
160        && let Some(parsed) = GlyphMode::parse(&value)
161    {
162        return parsed;
163    }
164
165    if !caps.unicode_box_drawing {
166        return GlyphMode::Ascii;
167    }
168
169    match caps.profile() {
170        TerminalProfile::Dumb | TerminalProfile::Vt100 | TerminalProfile::LinuxConsole => {
171            GlyphMode::Ascii
172        }
173        _ => GlyphMode::Unicode,
174    }
175}
176
177fn detect_emoji<F>(get_env: &F, caps: &TerminalCapabilities, mode: GlyphMode) -> (bool, bool)
178where
179    F: Fn(&str) -> Option<String>,
180{
181    if mode == GlyphMode::Ascii {
182        return (false, false);
183    }
184
185    if let Some(value) = env_override_bool(get_env, ENV_GLYPH_EMOJI) {
186        return (value, true);
187    }
188
189    if let Some(value) = env_override_bool(get_env, ENV_NO_EMOJI) {
190        return (!value, true);
191    }
192
193    if !caps.unicode_emoji {
194        return (false, false);
195    }
196
197    // Default to true; users can explicitly disable.
198    (true, false)
199}
200
201fn detect_double_width<F>(get_env: &F, caps: &TerminalCapabilities) -> bool
202where
203    F: Fn(&str) -> Option<String>,
204{
205    if let Some(value) = env_override_bool(get_env, ENV_GLYPH_DOUBLE_WIDTH) {
206        return value;
207    }
208    caps.double_width
209}
210
211fn parse_bool(value: &str) -> Option<bool> {
212    match value.trim().to_ascii_lowercase().as_str() {
213        "1" | "true" | "yes" | "on" => Some(true),
214        "0" | "false" | "no" | "off" => Some(false),
215        _ => None,
216    }
217}
218
219fn env_override_bool<F>(get_env: &F, key: &str) -> Option<bool>
220where
221    F: Fn(&str) -> Option<String>,
222{
223    get_env(key).and_then(|value| parse_bool(&value))
224}
225
226fn glyph_width(ch: char, cjk_width: bool) -> usize {
227    if ch.is_ascii() {
228        return match ch {
229            '\t' | '\n' | '\r' => 1,
230            ' '..='~' => 1,
231            _ => 0,
232        };
233    }
234    if cjk_width {
235        ch.width_cjk().unwrap_or(0)
236    } else {
237        ch.width().unwrap_or(0)
238    }
239}
240
241fn glyphs_fit_narrow(glyphs: &[char], cjk_width: bool) -> bool {
242    glyphs
243        .iter()
244        .all(|&glyph| glyph_width(glyph, cjk_width) == 1)
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use std::collections::HashMap;
251
252    fn map_env(pairs: &[(&str, &str)]) -> HashMap<String, String> {
253        pairs
254            .iter()
255            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
256            .collect()
257    }
258
259    fn get_env<'a>(map: &'a HashMap<String, String>) -> impl Fn(&str) -> Option<String> + 'a {
260        move |key| map.get(key).cloned()
261    }
262
263    #[test]
264    fn glyph_mode_ascii_forces_ascii_policy() {
265        let env = map_env(&[(ENV_GLYPH_MODE, "ascii"), ("TERM", "xterm-256color")]);
266        let caps = TerminalCapabilities::modern();
267        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
268
269        assert_eq!(policy.mode, GlyphMode::Ascii);
270        assert!(!policy.unicode_line_drawing);
271        assert!(!policy.unicode_arrows);
272        assert!(!policy.emoji);
273    }
274
275    #[test]
276    fn emoji_override_disable() {
277        let env = map_env(&[(ENV_GLYPH_EMOJI, "0"), ("TERM", "wezterm")]);
278        let caps = TerminalCapabilities::modern();
279        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
280
281        assert!(!policy.emoji);
282    }
283
284    #[test]
285    fn legacy_no_emoji_override_disables() {
286        let env = map_env(&[(ENV_NO_EMOJI, "1"), ("TERM", "wezterm")]);
287        let caps = TerminalCapabilities::modern();
288        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
289
290        assert!(!policy.emoji);
291    }
292
293    #[test]
294    fn glyph_emoji_override_wins_over_legacy_no_emoji() {
295        let env = map_env(&[
296            (ENV_GLYPH_EMOJI, "1"),
297            (ENV_NO_EMOJI, "1"),
298            ("TERM", "wezterm"),
299        ]);
300        let caps = TerminalCapabilities::modern();
301        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
302
303        assert!(policy.emoji);
304    }
305
306    #[test]
307    fn emoji_default_true_for_modern_term() {
308        let env = map_env(&[("TERM", "xterm-256color")]);
309        let caps = TerminalCapabilities::modern();
310        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
311
312        assert!(policy.emoji);
313    }
314
315    #[test]
316    fn cjk_width_respects_env_override() {
317        let env = map_env(&[("FTUI_TEXT_CJK_WIDTH", "1")]);
318        let caps = TerminalCapabilities::modern();
319        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
320
321        assert!(policy.cjk_width);
322    }
323
324    #[test]
325    fn caps_disable_box_drawing_forces_ascii_mode() {
326        let env = map_env(&[]);
327        let mut caps = TerminalCapabilities::modern();
328        caps.unicode_box_drawing = false;
329        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
330
331        assert_eq!(policy.mode, GlyphMode::Ascii);
332        assert!(!policy.unicode_line_drawing);
333        assert!(!policy.unicode_arrows);
334    }
335
336    #[test]
337    fn caps_disable_emoji_disables_emoji_policy() {
338        let env = map_env(&[]);
339        let mut caps = TerminalCapabilities::modern();
340        caps.unicode_emoji = false;
341        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
342
343        assert!(!policy.emoji);
344    }
345
346    #[test]
347    fn line_drawing_env_override_disables_unicode_lines() {
348        let env = map_env(&[(ENV_GLYPH_LINE_DRAWING, "0")]);
349        let caps = TerminalCapabilities::modern();
350        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
351
352        assert!(!policy.unicode_line_drawing);
353    }
354
355    #[test]
356    fn arrows_env_override_disables_unicode_arrows() {
357        let env = map_env(&[(ENV_GLYPH_ARROWS, "0"), ("TERM", "wezterm")]);
358        let caps = TerminalCapabilities::modern();
359        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
360
361        assert!(!policy.unicode_arrows);
362    }
363
364    #[test]
365    fn ascii_mode_forces_arrows_off_even_if_override_true() {
366        let env = map_env(&[
367            (ENV_GLYPH_MODE, "ascii"),
368            (ENV_GLYPH_ARROWS, "1"),
369            ("TERM", "wezterm"),
370        ]);
371        let caps = TerminalCapabilities::modern();
372        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
373
374        assert_eq!(policy.mode, GlyphMode::Ascii);
375        assert!(!policy.unicode_arrows);
376    }
377
378    #[test]
379    fn emoji_env_override_true_ignores_caps_and_double_width() {
380        let env = map_env(&[(ENV_GLYPH_EMOJI, "1"), ("TERM", "dumb")]);
381        let mut caps = TerminalCapabilities::modern();
382        caps.unicode_emoji = false;
383        caps.double_width = false;
384        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
385
386        assert!(policy.emoji);
387    }
388
389    #[test]
390    fn policy_to_json_serializes_expected_flags() {
391        let env = map_env(&[
392            (ENV_GLYPH_MODE, "unicode"),
393            (ENV_GLYPH_EMOJI, "0"),
394            (ENV_GLYPH_LINE_DRAWING, "1"),
395            (ENV_GLYPH_ARROWS, "0"),
396            (ENV_GLYPH_DOUBLE_WIDTH, "1"),
397            ("FTUI_TEXT_CJK_WIDTH", "1"),
398        ]);
399        let caps = TerminalCapabilities::modern();
400        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
401
402        assert_eq!(
403            policy.to_json(),
404            r#"{"glyph_mode":"unicode","emoji":false,"cjk_width":true,"double_width":true,"unicode_box_drawing":true,"unicode_line_drawing":false,"unicode_arrows":false}"#
405        );
406    }
407
408    #[test]
409    fn glyph_double_width_env_overrides_cjk_width() {
410        let env = map_env(&[(ENV_GLYPH_DOUBLE_WIDTH, "0"), ("FTUI_TEXT_CJK_WIDTH", "1")]);
411        let caps = TerminalCapabilities::modern();
412        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
413
414        assert!(!policy.cjk_width);
415    }
416
417    #[test]
418    fn caps_double_width_false_disables_cjk_width() {
419        let env = map_env(&[("FTUI_TEXT_CJK_WIDTH", "1")]);
420        let mut caps = TerminalCapabilities::modern();
421        caps.double_width = false;
422        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
423
424        assert!(!policy.cjk_width);
425    }
426
427    #[test]
428    fn glyph_mode_parse_aliases() {
429        assert_eq!(GlyphMode::parse("uni"), Some(GlyphMode::Unicode));
430        assert_eq!(GlyphMode::parse("u"), Some(GlyphMode::Unicode));
431        assert_eq!(GlyphMode::parse("ansi"), Some(GlyphMode::Ascii));
432        assert_eq!(GlyphMode::parse("a"), Some(GlyphMode::Ascii));
433        assert_eq!(GlyphMode::parse("invalid"), None);
434    }
435
436    #[test]
437    fn glyph_mode_as_str_roundtrip() {
438        assert_eq!(GlyphMode::Unicode.as_str(), "unicode");
439        assert_eq!(GlyphMode::Ascii.as_str(), "ascii");
440        assert_eq!(
441            GlyphMode::parse(GlyphMode::Unicode.as_str()),
442            Some(GlyphMode::Unicode)
443        );
444    }
445
446    #[test]
447    fn parse_bool_truthy_and_falsy() {
448        assert_eq!(parse_bool("1"), Some(true));
449        assert_eq!(parse_bool("yes"), Some(true));
450        assert_eq!(parse_bool("on"), Some(true));
451        assert_eq!(parse_bool("0"), Some(false));
452        assert_eq!(parse_bool("no"), Some(false));
453        assert_eq!(parse_bool("off"), Some(false));
454        assert_eq!(parse_bool("garbage"), None);
455    }
456
457    #[test]
458    fn double_width_false_suppresses_emoji_without_explicit_override() {
459        let env = map_env(&[]);
460        let mut caps = TerminalCapabilities::modern();
461        caps.double_width = false;
462        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
463
464        assert!(!policy.emoji);
465    }
466
467    // ===== glyph_width =====
468
469    #[test]
470    fn glyph_width_ascii_printable() {
471        assert_eq!(glyph_width('a', false), 1);
472        assert_eq!(glyph_width('Z', false), 1);
473        assert_eq!(glyph_width(' ', false), 1);
474        assert_eq!(glyph_width('~', false), 1);
475    }
476
477    #[test]
478    fn glyph_width_ascii_control_chars() {
479        assert_eq!(glyph_width('\t', false), 1);
480        assert_eq!(glyph_width('\n', false), 1);
481        assert_eq!(glyph_width('\r', false), 1);
482        // NUL and other control chars are zero-width.
483        assert_eq!(glyph_width('\0', false), 0);
484        assert_eq!(glyph_width('\x01', false), 0);
485        assert_eq!(glyph_width('\x7f', false), 0); // DEL
486    }
487
488    #[test]
489    fn glyph_width_cjk_ideograph() {
490        // CJK Unified Ideograph (U+4E2D, 中) is double-width.
491        assert_eq!(glyph_width('中', false), 2);
492        assert_eq!(glyph_width('中', true), 2);
493    }
494
495    #[test]
496    fn glyph_width_box_drawing_non_cjk() {
497        // Box drawing chars should be single-width in non-CJK mode.
498        assert_eq!(glyph_width('─', false), 1);
499        assert_eq!(glyph_width('│', false), 1);
500        assert_eq!(glyph_width('┌', false), 1);
501    }
502
503    #[test]
504    fn glyph_width_arrow_non_cjk() {
505        // Arrow chars should be single-width in non-CJK mode.
506        assert_eq!(glyph_width('→', false), 1);
507        assert_eq!(glyph_width('←', false), 1);
508        assert_eq!(glyph_width('↑', false), 1);
509    }
510
511    // ===== glyphs_fit_narrow =====
512
513    #[test]
514    fn line_drawing_glyphs_fit_narrow_non_cjk() {
515        assert!(glyphs_fit_narrow(LINE_DRAWING_GLYPHS, false));
516    }
517
518    #[test]
519    fn arrow_glyphs_fit_narrow_non_cjk() {
520        assert!(glyphs_fit_narrow(ARROW_GLYPHS, false));
521    }
522
523    #[test]
524    fn glyphs_fit_narrow_rejects_wide_char() {
525        // A CJK ideograph is width 2, so a set containing it fails.
526        assert!(!glyphs_fit_narrow(&['中'], false));
527    }
528
529    #[test]
530    fn glyphs_fit_narrow_empty_set() {
531        assert!(glyphs_fit_narrow(&[], false));
532        assert!(glyphs_fit_narrow(&[], true));
533    }
534
535    // ===== detect_mode with terminal profiles =====
536
537    #[test]
538    fn dumb_terminal_defaults_to_ascii() {
539        let env = map_env(&[]);
540        let caps = TerminalCapabilities::dumb();
541        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
542
543        assert_eq!(policy.mode, GlyphMode::Ascii);
544    }
545
546    #[test]
547    fn mode_env_override_unicode_on_dumb_term() {
548        let env = map_env(&[(ENV_GLYPH_MODE, "unicode")]);
549        let caps = TerminalCapabilities::dumb();
550        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
551
552        assert_eq!(policy.mode, GlyphMode::Unicode);
553    }
554
555    // ===== env_override_bool edge cases =====
556
557    #[test]
558    fn env_override_bool_missing_key_returns_none() {
559        let env = map_env(&[]);
560        assert!(env_override_bool(&get_env(&env), ENV_GLYPH_EMOJI).is_none());
561    }
562
563    #[test]
564    fn env_override_bool_invalid_value_returns_none() {
565        let env = map_env(&[(ENV_GLYPH_EMOJI, "maybe")]);
566        assert!(env_override_bool(&get_env(&env), ENV_GLYPH_EMOJI).is_none());
567    }
568
569    // ===== Line drawing / arrows interaction with CJK width =====
570
571    #[test]
572    fn line_drawing_enabled_with_env_override() {
573        let env = map_env(&[(ENV_GLYPH_LINE_DRAWING, "1")]);
574        let caps = TerminalCapabilities::modern();
575        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
576
577        assert!(policy.unicode_line_drawing);
578    }
579
580    #[test]
581    fn arrows_enabled_with_env_override() {
582        let env = map_env(&[(ENV_GLYPH_ARROWS, "1")]);
583        let caps = TerminalCapabilities::modern();
584        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
585
586        assert!(policy.unicode_arrows);
587    }
588
589    #[test]
590    fn ascii_mode_forces_line_drawing_off_even_with_override() {
591        let env = map_env(&[(ENV_GLYPH_MODE, "ascii"), (ENV_GLYPH_LINE_DRAWING, "1")]);
592        let caps = TerminalCapabilities::modern();
593        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
594
595        assert_eq!(policy.mode, GlyphMode::Ascii);
596        assert!(!policy.unicode_line_drawing);
597    }
598
599    #[test]
600    fn double_width_env_override_true() {
601        let env = map_env(&[(ENV_GLYPH_DOUBLE_WIDTH, "1")]);
602        let mut caps = TerminalCapabilities::modern();
603        caps.double_width = false;
604        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
605
606        assert!(policy.double_width);
607    }
608
609    // ===== Default policy with modern caps =====
610
611    #[test]
612    fn default_modern_policy() {
613        let env = map_env(&[]);
614        let caps = TerminalCapabilities::modern();
615        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
616
617        assert_eq!(policy.mode, GlyphMode::Unicode);
618        assert!(policy.emoji);
619        assert!(policy.double_width);
620        assert!(policy.unicode_box_drawing);
621        assert!(policy.unicode_line_drawing);
622        assert!(policy.unicode_arrows);
623    }
624
625    // ===== GlyphMode::parse edge cases =====
626
627    #[test]
628    fn glyph_mode_parse_case_insensitive() {
629        assert_eq!(GlyphMode::parse("UNICODE"), Some(GlyphMode::Unicode));
630        assert_eq!(GlyphMode::parse("Unicode"), Some(GlyphMode::Unicode));
631        assert_eq!(GlyphMode::parse("ASCII"), Some(GlyphMode::Ascii));
632        assert_eq!(GlyphMode::parse("Ascii"), Some(GlyphMode::Ascii));
633    }
634
635    #[test]
636    fn glyph_mode_parse_whitespace_trimmed() {
637        assert_eq!(GlyphMode::parse(" unicode "), Some(GlyphMode::Unicode));
638        assert_eq!(GlyphMode::parse("\tascii\n"), Some(GlyphMode::Ascii));
639    }
640
641    #[test]
642    fn glyph_mode_parse_empty_returns_none() {
643        assert_eq!(GlyphMode::parse(""), None);
644    }
645
646    // ===== parse_bool edge cases =====
647
648    #[test]
649    fn parse_bool_case_insensitive() {
650        assert_eq!(parse_bool("TRUE"), Some(true));
651        assert_eq!(parse_bool("True"), Some(true));
652        assert_eq!(parse_bool("FALSE"), Some(false));
653        assert_eq!(parse_bool("False"), Some(false));
654        assert_eq!(parse_bool("YES"), Some(true));
655        assert_eq!(parse_bool("NO"), Some(false));
656    }
657
658    #[test]
659    fn parse_bool_whitespace_trimmed() {
660        assert_eq!(parse_bool(" true "), Some(true));
661        assert_eq!(parse_bool("\t0\n"), Some(false));
662    }
663
664    // ===== to_json =====
665
666    #[test]
667    fn to_json_all_true() {
668        let policy = GlyphPolicy {
669            mode: GlyphMode::Unicode,
670            emoji: true,
671            cjk_width: true,
672            double_width: true,
673            unicode_box_drawing: true,
674            unicode_line_drawing: true,
675            unicode_arrows: true,
676        };
677        let json = policy.to_json();
678        assert!(json.contains(r#""glyph_mode":"unicode""#));
679        assert!(json.contains(r#""emoji":true"#));
680        assert!(json.contains(r#""cjk_width":true"#));
681        assert!(json.contains(r#""double_width":true"#));
682        assert!(json.contains(r#""unicode_box_drawing":true"#));
683        assert!(json.contains(r#""unicode_line_drawing":true"#));
684        assert!(json.contains(r#""unicode_arrows":true"#));
685    }
686
687    #[test]
688    fn to_json_all_false_ascii() {
689        let policy = GlyphPolicy {
690            mode: GlyphMode::Ascii,
691            emoji: false,
692            cjk_width: false,
693            double_width: false,
694            unicode_box_drawing: false,
695            unicode_line_drawing: false,
696            unicode_arrows: false,
697        };
698        let json = policy.to_json();
699        assert!(json.contains(r#""glyph_mode":"ascii""#));
700        assert!(json.contains(r#""emoji":false"#));
701    }
702
703    // ===== detect_mode with additional terminal profiles =====
704
705    #[test]
706    fn vt100_terminal_defaults_to_ascii() {
707        let env = map_env(&[]);
708        let caps = TerminalCapabilities::vt100();
709        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
710
711        assert_eq!(policy.mode, GlyphMode::Ascii);
712        assert!(!policy.unicode_line_drawing);
713        assert!(!policy.unicode_arrows);
714    }
715
716    #[test]
717    fn linux_console_defaults_to_ascii_despite_box_drawing_caps() {
718        // LinuxConsole has unicode_box_drawing=true but the profile match
719        // still forces Ascii mode — a distinct code path from dumb/vt100.
720        let env = map_env(&[]);
721        let caps = TerminalCapabilities::linux_console();
722        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
723
724        assert!(caps.unicode_box_drawing);
725        assert_eq!(policy.mode, GlyphMode::Ascii);
726        assert!(!policy.unicode_line_drawing);
727        assert!(!policy.unicode_arrows);
728        assert!(!policy.emoji);
729    }
730
731    #[test]
732    fn mode_env_override_unicode_on_linux_console() {
733        let env = map_env(&[(ENV_GLYPH_MODE, "unicode")]);
734        let caps = TerminalCapabilities::linux_console();
735        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
736
737        assert_eq!(policy.mode, GlyphMode::Unicode);
738    }
739
740    // ===== detect_emoji edge cases =====
741
742    #[test]
743    fn legacy_no_emoji_false_enables_emoji() {
744        // NO_EMOJI=0 means "don't disable emoji" → emoji should be true.
745        let env = map_env(&[(ENV_NO_EMOJI, "0")]);
746        let caps = TerminalCapabilities::modern();
747        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
748
749        assert!(policy.emoji);
750    }
751
752    #[test]
753    fn emoji_disabled_in_ascii_mode_even_with_all_caps() {
754        let env = map_env(&[(ENV_GLYPH_MODE, "ascii")]);
755        let mut caps = TerminalCapabilities::modern();
756        caps.unicode_emoji = true;
757        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
758
759        assert_eq!(policy.mode, GlyphMode::Ascii);
760        assert!(!policy.emoji);
761    }
762
763    #[test]
764    fn emoji_override_true_in_ascii_mode_still_disabled() {
765        // Even GLYPH_EMOJI=1 cannot override ASCII mode's emoji suppression
766        // because detect_emoji returns (false, false) early for ASCII.
767        let env = map_env(&[(ENV_GLYPH_MODE, "ascii"), (ENV_GLYPH_EMOJI, "1")]);
768        let caps = TerminalCapabilities::modern();
769        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
770
771        assert!(!policy.emoji);
772    }
773
774    // ===== glyph_width with CJK mode =====
775
776    #[test]
777    fn glyph_width_box_drawing_cjk_mode() {
778        // Box-drawing characters are East Asian Ambiguous, so width_cjk()
779        // returns 2 for them.
780        assert_eq!(glyph_width('─', true), 2);
781        assert_eq!(glyph_width('│', true), 2);
782        assert_eq!(glyph_width('┌', true), 2);
783        assert_eq!(glyph_width('╭', true), 2);
784    }
785
786    #[test]
787    fn glyph_width_arrows_cjk_mode() {
788        // Standard arrows (U+2190-U+2199) are East Asian Ambiguous.
789        assert_eq!(glyph_width('→', true), 2);
790        assert_eq!(glyph_width('←', true), 2);
791        assert_eq!(glyph_width('↑', true), 2);
792        assert_eq!(glyph_width('↓', true), 2);
793    }
794
795    #[test]
796    fn glyph_width_combining_mark_zero_width() {
797        // Combining diacritical marks (e.g., U+0300 COMBINING GRAVE ACCENT)
798        // are zero-width.
799        assert_eq!(glyph_width('\u{0300}', false), 0);
800        assert_eq!(glyph_width('\u{0300}', true), 0);
801    }
802
803    #[test]
804    fn glyph_width_cjk_mode_does_not_affect_ascii() {
805        // CJK mode should not change ASCII character widths.
806        assert_eq!(glyph_width('a', true), 1);
807        assert_eq!(glyph_width('Z', true), 1);
808        assert_eq!(glyph_width('\0', true), 0);
809    }
810
811    // ===== glyphs_fit_narrow with CJK mode =====
812
813    #[test]
814    fn line_drawing_glyphs_wide_in_cjk_mode() {
815        // In CJK mode, box-drawing chars become double-width, so they
816        // no longer fit in a single cell.
817        assert!(!glyphs_fit_narrow(LINE_DRAWING_GLYPHS, true));
818    }
819
820    #[test]
821    fn arrow_glyphs_wide_in_cjk_mode() {
822        // In CJK mode, arrow chars become double-width.
823        assert!(!glyphs_fit_narrow(ARROW_GLYPHS, true));
824    }
825
826    // ===== CJK-width interaction disabling line drawing/arrows =====
827
828    #[test]
829    fn cjk_width_disables_line_drawing_in_unicode_mode() {
830        // When CJK width is enabled, box-drawing chars become wide,
831        // so unicode_line_drawing should be auto-disabled.
832        let env = map_env(&[("FTUI_TEXT_CJK_WIDTH", "1")]);
833        let caps = TerminalCapabilities::modern();
834        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
835
836        assert_eq!(policy.mode, GlyphMode::Unicode);
837        assert!(policy.cjk_width);
838        assert!(!policy.unicode_line_drawing);
839    }
840
841    #[test]
842    fn cjk_width_disables_arrows_in_unicode_mode() {
843        // When CJK width is enabled, arrow chars become wide,
844        // so unicode_arrows should be auto-disabled.
845        let env = map_env(&[("FTUI_TEXT_CJK_WIDTH", "1")]);
846        let caps = TerminalCapabilities::modern();
847        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
848
849        assert_eq!(policy.mode, GlyphMode::Unicode);
850        assert!(policy.cjk_width);
851        assert!(!policy.unicode_arrows);
852    }
853
854    #[test]
855    fn line_drawing_env_override_still_disabled_by_cjk_width() {
856        // Even with LINE_DRAWING=1, CJK width should still disable it
857        // because the glyphs don't fit narrow in CJK mode.
858        let env = map_env(&[(ENV_GLYPH_LINE_DRAWING, "1"), ("FTUI_TEXT_CJK_WIDTH", "1")]);
859        let caps = TerminalCapabilities::modern();
860        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
861
862        assert!(!policy.unicode_line_drawing);
863    }
864
865    #[test]
866    fn arrows_env_override_still_disabled_by_cjk_width() {
867        // Even with ARROWS=1, CJK width should still disable it.
868        let env = map_env(&[(ENV_GLYPH_ARROWS, "1"), ("FTUI_TEXT_CJK_WIDTH", "1")]);
869        let caps = TerminalCapabilities::modern();
870        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
871
872        assert!(!policy.unicode_arrows);
873    }
874
875    // ===== env_override_bool with valid values =====
876
877    #[test]
878    fn env_override_bool_truthy_values() {
879        for val in &["1", "true", "yes", "on", "TRUE", "YES", "ON"] {
880            let env = map_env(&[(ENV_GLYPH_EMOJI, val)]);
881            assert_eq!(
882                env_override_bool(&get_env(&env), ENV_GLYPH_EMOJI),
883                Some(true),
884                "expected Some(true) for {val:?}"
885            );
886        }
887    }
888
889    #[test]
890    fn env_override_bool_falsy_values() {
891        for val in &["0", "false", "no", "off", "FALSE", "NO", "OFF"] {
892            let env = map_env(&[(ENV_GLYPH_EMOJI, val)]);
893            assert_eq!(
894                env_override_bool(&get_env(&env), ENV_GLYPH_EMOJI),
895                Some(false),
896                "expected Some(false) for {val:?}"
897            );
898        }
899    }
900
901    // ===== Copy/Clone trait verification =====
902
903    #[test]
904    fn glyph_mode_is_copy() {
905        let mode = GlyphMode::Unicode;
906        let copy = mode;
907        assert_eq!(mode, copy);
908    }
909
910    #[test]
911    fn glyph_policy_is_copy() {
912        let policy = GlyphPolicy {
913            mode: GlyphMode::Unicode,
914            emoji: true,
915            cjk_width: false,
916            double_width: true,
917            unicode_box_drawing: true,
918            unicode_line_drawing: true,
919            unicode_arrows: true,
920        };
921        let copy = policy;
922        // Both original and copy should still be usable (Copy semantics).
923        assert_eq!(policy, copy);
924        assert_eq!(policy.mode, copy.mode);
925    }
926
927    // ===== to_json roundtrip completeness =====
928
929    #[test]
930    fn to_json_mixed_flags() {
931        let policy = GlyphPolicy {
932            mode: GlyphMode::Unicode,
933            emoji: false,
934            cjk_width: true,
935            double_width: true,
936            unicode_box_drawing: true,
937            unicode_line_drawing: false,
938            unicode_arrows: true,
939        };
940        let json = policy.to_json();
941        assert!(json.contains(r#""glyph_mode":"unicode""#));
942        assert!(json.contains(r#""emoji":false"#));
943        assert!(json.contains(r#""cjk_width":true"#));
944        assert!(json.contains(r#""double_width":true"#));
945        assert!(json.contains(r#""unicode_box_drawing":true"#));
946        assert!(json.contains(r#""unicode_line_drawing":false"#));
947        assert!(json.contains(r#""unicode_arrows":true"#));
948    }
949
950    // ===== Full policy snapshot for edge-case terminal profiles =====
951
952    #[test]
953    fn full_policy_vt100_all_defaults() {
954        let env = map_env(&[]);
955        let caps = TerminalCapabilities::vt100();
956        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
957
958        assert_eq!(policy.mode, GlyphMode::Ascii);
959        assert!(!policy.emoji);
960        assert!(!policy.cjk_width);
961        assert!(!policy.double_width);
962        assert!(!policy.unicode_box_drawing);
963        assert!(!policy.unicode_line_drawing);
964        assert!(!policy.unicode_arrows);
965    }
966
967    #[test]
968    fn full_policy_linux_console_all_defaults() {
969        let env = map_env(&[]);
970        let caps = TerminalCapabilities::linux_console();
971        let policy = GlyphPolicy::from_env_with(get_env(&env), &caps);
972
973        assert_eq!(policy.mode, GlyphMode::Ascii);
974        assert!(!policy.emoji);
975        assert!(!policy.cjk_width);
976        assert!(!policy.double_width);
977        assert!(policy.unicode_box_drawing); // LinuxConsole has box drawing
978        assert!(!policy.unicode_line_drawing); // But ASCII mode disables line drawing
979        assert!(!policy.unicode_arrows);
980    }
981}