Skip to main content

uncurses/color/
profile.rs

1//! Terminal color profile detection and color downsampling.
2//!
3//! ## Detection inputs
4//!
5//! Detection combines TTY state with environment variables and `TERM`
6//! heuristics:
7//!
8//! * Non-TTY output is [`Profile::Disabled`] unless `TTY_FORCE` or
9//!   `CLICOLOR_FORCE` is set.
10//! * `NO_COLOR` clamps a TTY to [`Profile::Ascii`]: colors are disabled, but
11//!   text decoration may still be emitted.
12//! * `CLICOLOR_FORCE` forces at least [`Profile::Ansi`] and can still be
13//!   upgraded by other environment evidence.
14//! * `CLICOLOR` bumps a non-dumb TTY to at least [`Profile::Ansi`].
15//! * `COLORTERM=truecolor|24bit|yes|true` upgrades to [`Profile::TrueColor`],
16//!   except inside `screen`.
17//! * `TERM=dumb` starts as [`Profile::Disabled`]; `*-256color` upgrades to
18//!   [`Profile::Ansi256`]; `*-direct` upgrades to [`Profile::TrueColor`];
19//!   selected known true-color terminal names are recognized by substring.
20//! * `WT_SESSION`, `GOOGLE_CLOUD_SHELL`, and `CI` upgrade to
21//!   [`Profile::TrueColor`].
22//!
23//! ## Downsampling
24//!
25//! [`Profile::convert`] maps any [`Color`] into the best representation this
26//! profile should emit:
27//!
28//! ```text
29//! TrueColor ─────────► Some(original Color)
30//! Ansi256   ─────────► Some(nearest Color::Indexed(_))
31//! Ansi      ─────────► Some(nearest named Color)
32//! Ascii     ─┐
33//! Disabled  ─┴──────► None
34//! ```
35
36use super::Color;
37use crate::terminal::{Env, ProcessEnv};
38
39/// Terminal color capability profile.
40///
41/// Profiles are ordered by increasing capability:
42/// `Disabled < Ascii < Ansi < Ansi256 < TrueColor`. Use this ordering when
43/// clamping or choosing the maximum capability discovered from multiple
44/// sources.
45#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
46pub enum Profile {
47    /// No styling output at all.
48    ///
49    /// Used for non-TTY output and terminals that should not receive escape
50    /// sequences. Color conversion returns `None`; callers that convert whole
51    /// styles generally drop colors, attributes, underline state, and links.
52    Disabled,
53    /// ASCII/no-color profile where text decoration is still allowed.
54    ///
55    /// Color conversion returns `None`, but higher-level style conversion may
56    /// preserve non-color SGR attributes such as bold or underline.
57    Ascii,
58    /// Standard 16-color ANSI palette.
59    ///
60    /// Color conversion returns the nearest named [`Color`](super::Color)
61    /// using weighted RGB distance against the xterm palette entries `0..=15`.
62    Ansi,
63    /// xterm 256-color palette.
64    ///
65    /// Color conversion returns the nearest
66    /// [`Color::Indexed`](super::Color::Indexed), choosing between the 6×6×6
67    /// color cube and grayscale ramp by weighted RGB distance.
68    Ansi256,
69    /// 24-bit true color.
70    ///
71    /// Color conversion returns the original [`Color`] unchanged.
72    #[default]
73    TrueColor,
74}
75
76impl Profile {
77    /// Downsample a color to fit this profile.
78    ///
79    /// Returns `Some(color)` when this profile supports color output and
80    /// `None` for [`Profile::Disabled`] or [`Profile::Ascii`]. `TrueColor`
81    /// preserves the original value; `Ansi256` and `Ansi` resolve the input to
82    /// RGB and quantize to the nearest supported palette.
83    pub fn convert(self, color: Color) -> Option<Color> {
84        use super::convert::*;
85        match self {
86            Profile::Disabled | Profile::Ascii => None,
87            Profile::Ansi => Some(rgb_to_ansi16(color.to_rgb())),
88            Profile::Ansi256 => Some(rgb_to_ansi256(color.to_rgb())),
89            Profile::TrueColor => Some(color),
90        }
91    }
92
93    /// Detect the color profile from the current process environment.
94    ///
95    /// This assumes the output stream is a TTY. For explicit TTY state or
96    /// deterministic tests, use [`Profile::detect_from`].
97    pub fn detect() -> Self {
98        Self::detect_from(&ProcessEnv, true)
99    }
100
101    /// Detect the color profile from an explicit environment.
102    ///
103    /// `is_tty` should be `true` if the output stream is a terminal. A false
104    /// value clamps to [`Profile::Disabled`] unless `TTY_FORCE` makes the
105    /// stream act like a TTY or `CLICOLOR_FORCE` forces color. `TERM=dumb` and,
106    /// on non-Windows platforms, an empty `TERM` start as disabled before other
107    /// forcing/upgrading rules are applied.
108    pub fn detect_from(env: &dyn Env, is_tty: bool) -> Self {
109        let is_tty = is_tty || is_truthy(env, "TTY_FORCE");
110        let term = env.get("TERM").unwrap_or_default();
111
112        // `env_color_profile` is responsible for translating the
113        // environment to a profile, including the empty-or-`dumb` TERM
114        // case: on Unix that means Disabled, on Windows it falls back
115        // to a platform-specific probe (e.g. WT_SESSION → TrueColor)
116        // because Windows shells routinely leave TERM unset. The only
117        // unconditional clamp here is non-TTY output.
118        let envp = env_color_profile(env, &term);
119        let mut p = if !is_tty { Profile::Disabled } else { envp };
120
121        // NO_COLOR: clamp to Ascii (decoration still allowed). The spec is
122        // presence-based, not boolean: any non-empty value disables color,
123        // including `0` and `false`. `has` is exactly that test.
124        if env.has("NO_COLOR") && is_tty {
125            if p > Profile::Ascii {
126                p = Profile::Ascii;
127            }
128            return p;
129        }
130
131        // CLICOLOR_FORCE: at least Ansi, take max of env-derived.
132        if is_truthy(env, "CLICOLOR_FORCE") {
133            if p < Profile::Ansi {
134                p = Profile::Ansi;
135            }
136            if envp > p {
137                p = envp;
138            }
139            return p;
140        }
141
142        let is_dumb = term.is_empty() || term == DUMB_TERM;
143        // CLICOLOR: bump non-dumb TTY to at least Ansi.
144        if is_truthy(env, "CLICOLOR") && is_tty && !is_dumb && p < Profile::Ansi {
145            p = Profile::Ansi;
146        }
147
148        p
149    }
150}
151
152const DUMB_TERM: &str = "dumb";
153
154/// Return whether an environment variable reads as a truthy boolean.
155///
156/// Accepts `1`, `t`, `T`, `TRUE`, `true`, and `True` — the values Go's
157/// `strconv.ParseBool` reads as true. Anything else, including an empty or
158/// absent value, is false.
159fn is_truthy(env: &dyn Env, key: &str) -> bool {
160    matches!(
161        env.get(key).as_deref().unwrap_or_default(),
162        "1" | "t" | "T" | "TRUE" | "true" | "True"
163    )
164}
165
166/// Environment-driven profile inference. Knows nothing about TTY-ness.
167fn env_color_profile(env: &dyn Env, term: &str) -> Profile {
168    let mut p = if term == DUMB_TERM {
169        // An explicit `dumb` terminal opts out of styling everywhere.
170        Profile::Disabled
171    } else if term.is_empty() {
172        // On Windows, the lack of TERM is normal — Windows Terminal and
173        // cmd.exe don't set it. Defer to a Windows-specific fallback when
174        // we know we're on Windows; otherwise treat as Disabled.
175        #[cfg(windows)]
176        {
177            windows_color_profile(env).unwrap_or(Profile::Disabled)
178        }
179        #[cfg(not(windows))]
180        {
181            let _ = env;
182            Profile::Disabled
183        }
184    } else {
185        Profile::Ansi
186    };
187
188    // Known-good terminals: full TrueColor.
189    if KNOWN_TRUECOLOR_TERMS.iter().any(|t| term.contains(t)) {
190        return Profile::TrueColor;
191    }
192
193    if term.starts_with("tmux") || term.starts_with("screen") {
194        if p < Profile::Ansi256 {
195            p = Profile::Ansi256;
196        }
197    } else if term.starts_with("xterm") && p < Profile::Ansi {
198        p = Profile::Ansi;
199    }
200
201    // Windows Terminal session variable — set even when TERM isn't.
202    if env.has("WT_SESSION") {
203        return Profile::TrueColor;
204    }
205
206    if is_truthy(env, "GOOGLE_CLOUD_SHELL") {
207        return Profile::TrueColor;
208    }
209
210    // CI runners advertise themselves with CI=true and render ANSI
211    // color in their logs even though TERM is usually unset or `dumb`.
212    if is_truthy(env, "CI") {
213        return Profile::TrueColor;
214    }
215
216    // COLORTERM upgrades to TrueColor, except inside screen which
217    // doesn't propagate it. Modern tmux (3.2+) forwards COLORTERM to
218    // its panes, so we honour it there.
219    if colorterm_says_truecolor(env) && !term.starts_with("screen") {
220        return Profile::TrueColor;
221    }
222
223    if term.ends_with("256color") && p < Profile::Ansi256 {
224        p = Profile::Ansi256;
225    }
226
227    if term.ends_with("direct") {
228        return Profile::TrueColor;
229    }
230
231    p
232}
233
234/// Terminals known to support TrueColor regardless of `TERM` suffix.
235const KNOWN_TRUECOLOR_TERMS: &[&str] = &[
236    "alacritty",
237    "contour",
238    "foot",
239    "ghostty",
240    "kitty",
241    "rio",
242    "st",
243    "wezterm",
244];
245
246fn colorterm_says_truecolor(env: &dyn Env) -> bool {
247    let v = env
248        .get("COLORTERM")
249        .unwrap_or_default()
250        .to_ascii_lowercase();
251    matches!(v.as_str(), "truecolor" | "24bit" | "yes" | "true")
252}
253
254#[cfg(windows)]
255fn windows_color_profile(env: &dyn Env) -> Option<Profile> {
256    // Windows 10+ conhost and Windows Terminal both support virtual
257    // terminal sequences. WT_SESSION pins TrueColor; otherwise assume
258    // ANSI256 (conhost's legacy floor) — TrueColor support arrived in
259    // build 14931 and is universally available on supported Windows
260    // versions, but we stay conservative without further probing.
261    if env.has("WT_SESSION") {
262        return Some(Profile::TrueColor);
263    }
264    Some(Profile::Ansi256)
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use crate::terminal::EnvList;
271
272    fn env(pairs: &[(&str, &str)]) -> EnvList {
273        EnvList::from_pairs(pairs.iter().map(|(k, v)| (*k, *v)))
274    }
275
276    #[test]
277    fn default_is_truecolor() {
278        assert_eq!(Profile::default(), Profile::TrueColor);
279    }
280
281    #[test]
282    fn ordering_is_capability_ascending() {
283        assert!(Profile::Disabled < Profile::Ascii);
284        assert!(Profile::Ascii < Profile::Ansi);
285        assert!(Profile::Ansi < Profile::Ansi256);
286        assert!(Profile::Ansi256 < Profile::TrueColor);
287    }
288
289    #[test]
290    fn no_tty_clamps_to_notty() {
291        let e = env(&[("TERM", "xterm-256color")]);
292        assert_eq!(Profile::detect_from(&e, false), Profile::Disabled);
293    }
294
295    #[test]
296    fn dumb_term_is_notty() {
297        let e = env(&[("TERM", "dumb")]);
298        assert_eq!(Profile::detect_from(&e, true), Profile::Disabled);
299    }
300
301    #[test]
302    fn no_color_clamps_to_ascii() {
303        let e = env(&[("TERM", "xterm-256color"), ("NO_COLOR", "1")]);
304        assert_eq!(Profile::detect_from(&e, true), Profile::Ascii);
305    }
306
307    /// NO_COLOR is presence-based: any non-empty value disables color,
308    /// however little it reads as a boolean. Only an absent or empty value
309    /// leaves color on.
310    #[test]
311    fn no_color_clamps_for_any_non_empty_value() {
312        for v in ["1", "0", "false", "no", "yes", "please", " "] {
313            let e = env(&[("TERM", "xterm-256color"), ("NO_COLOR", v)]);
314            assert_eq!(
315                Profile::detect_from(&e, true),
316                Profile::Ascii,
317                "NO_COLOR={v:?} should disable color"
318            );
319        }
320        for e in [
321            env(&[("TERM", "xterm-256color"), ("NO_COLOR", "")]),
322            env(&[("TERM", "xterm-256color")]),
323        ] {
324            assert!(Profile::detect_from(&e, true) > Profile::Ascii);
325        }
326    }
327
328    #[test]
329    fn no_color_does_not_apply_off_tty() {
330        // off-tty + no_color: still Disabled (no_color clamp only applies when isatty).
331        let e = env(&[("TERM", "xterm-256color"), ("NO_COLOR", "1")]);
332        assert_eq!(Profile::detect_from(&e, false), Profile::Disabled);
333    }
334
335    #[test]
336    fn clicolor_force_overrides_notty() {
337        let e = env(&[("TERM", "dumb"), ("CLICOLOR_FORCE", "1")]);
338        // CLICOLOR_FORCE guarantees at least Ansi; the platform/env floor may
339        // be higher (e.g. Ansi256 on Windows conhost).
340        assert!(Profile::detect_from(&e, true) >= Profile::Ansi);
341    }
342
343    #[test]
344    fn clicolor_bumps_to_ansi_on_tty() {
345        // No TERM at all on a unix TTY would normally be Disabled.
346        let e = env(&[("CLICOLOR", "1"), ("TERM", "screen")]);
347        let p = Profile::detect_from(&e, true);
348        assert!(p >= Profile::Ansi);
349    }
350
351    #[test]
352    fn colorterm_truecolor_upgrades() {
353        let e = env(&[("TERM", "xterm"), ("COLORTERM", "truecolor")]);
354        assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
355    }
356
357    #[test]
358    fn colorterm_24bit_upgrades() {
359        let e = env(&[("TERM", "xterm"), ("COLORTERM", "24bit")]);
360        assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
361    }
362
363    #[test]
364    fn colorterm_does_not_upgrade_inside_screen() {
365        let e = env(&[("TERM", "screen-256color"), ("COLORTERM", "truecolor")]);
366        // screen does not forward COLORTERM-derived TrueColor.
367        let p = Profile::detect_from(&e, true);
368        assert!(p < Profile::TrueColor);
369    }
370
371    #[test]
372    fn colorterm_upgrades_inside_tmux() {
373        let e = env(&[("TERM", "tmux-256color"), ("COLORTERM", "truecolor")]);
374        // Modern tmux forwards COLORTERM, so the upgrade applies.
375        assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
376    }
377
378    #[test]
379    fn known_terminal_is_truecolor() {
380        for name in [
381            "alacritty",
382            "wezterm",
383            "ghostty",
384            "kitty-direct",
385            "xterm-kitty",
386        ] {
387            let e = env(&[("TERM", name)]);
388            assert_eq!(
389                Profile::detect_from(&e, true),
390                Profile::TrueColor,
391                "TERM={name} should detect as TrueColor",
392            );
393        }
394    }
395
396    #[test]
397    fn term_256color_suffix_is_ansi256() {
398        let e = env(&[("TERM", "xterm-256color")]);
399        assert_eq!(Profile::detect_from(&e, true), Profile::Ansi256);
400    }
401
402    #[test]
403    fn term_direct_suffix_is_truecolor() {
404        let e = env(&[("TERM", "tmux-direct")]);
405        assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
406    }
407
408    #[test]
409    fn screen_floor_is_ansi256() {
410        let e = env(&[("TERM", "screen")]);
411        assert_eq!(Profile::detect_from(&e, true), Profile::Ansi256);
412    }
413
414    #[test]
415    fn tmux_floor_is_ansi256() {
416        let e = env(&[("TERM", "tmux")]);
417        assert_eq!(Profile::detect_from(&e, true), Profile::Ansi256);
418    }
419
420    #[test]
421    fn wt_session_implies_truecolor() {
422        let e = env(&[("TERM", "xterm"), ("WT_SESSION", "1234")]);
423        assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
424    }
425
426    #[cfg(windows)]
427    #[test]
428    fn wt_session_without_term_is_truecolor_on_windows() {
429        // Windows shells (PowerShell, cmd, Windows Terminal) routinely
430        // leave TERM unset. WT_SESSION must still pin TrueColor.
431        let e = env(&[("WT_SESSION", "1234")]);
432        assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
433    }
434
435    #[cfg(windows)]
436    #[test]
437    fn empty_term_falls_back_to_ansi256_on_windows() {
438        // Without WT_SESSION, conhost still supports VT sequences on
439        // supported Windows versions; floor is Ansi256.
440        let e = env(&[]);
441        assert_eq!(Profile::detect_from(&e, true), Profile::Ansi256);
442    }
443
444    #[test]
445    fn google_cloud_shell_implies_truecolor() {
446        let e = env(&[("GOOGLE_CLOUD_SHELL", "true"), ("TERM", "xterm")]);
447        assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
448    }
449
450    #[test]
451    fn ci_implies_truecolor() {
452        let e = env(&[("CI", "true"), ("TERM", "dumb")]);
453        assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
454    }
455
456    #[test]
457    fn xterm_plain_is_ansi() {
458        let e = env(&[("TERM", "xterm")]);
459        assert_eq!(Profile::detect_from(&e, true), Profile::Ansi);
460    }
461
462    #[test]
463    fn env_bool_parses_truthy_values() {
464        let cases = [
465            ("1", true),
466            ("0", false),
467            ("true", true),
468            ("True", true),
469            ("TRUE", true),
470            ("t", true),
471            ("T", true),
472            ("false", false),
473            ("False", false),
474            ("FALSE", false),
475            ("", false),
476            ("yes", false),
477            ("garbage", false),
478        ];
479        for (v, want) in cases {
480            let e = env(&[("X", v)]);
481            assert_eq!(is_truthy(&e, "X"), want, "bool({v:?})");
482        }
483    }
484
485    #[test]
486    fn convert_to_each_profile() {
487        let red = Color::Rgb(255, 0, 0);
488        assert!(Profile::Disabled.convert(red).is_none());
489        assert!(Profile::Ascii.convert(red).is_none());
490        assert!(Profile::Ansi.convert(red).is_some());
491        assert!(Profile::Ansi256.convert(red).is_some());
492        assert_eq!(Profile::TrueColor.convert(red), Some(red));
493    }
494}