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;
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        let env = Env::from_process();
99        Self::detect_from(&env, true)
100    }
101
102    /// Detect the color profile from an explicit environment snapshot.
103    ///
104    /// `is_tty` should be `true` if the output stream is a terminal. A false
105    /// value clamps to [`Profile::Disabled`] unless `TTY_FORCE` makes the
106    /// stream act like a TTY or `CLICOLOR_FORCE` forces color. `TERM=dumb` and,
107    /// on non-Windows platforms, an empty `TERM` start as disabled before other
108    /// forcing/upgrading rules are applied.
109    pub fn detect_from(env: &Env, is_tty: bool) -> Self {
110        let is_tty = is_tty || env.is_truthy("TTY_FORCE");
111        let term = env.get("TERM").unwrap_or_default();
112
113        // `env_color_profile` is responsible for translating the
114        // environment to a profile, including the empty-or-`dumb` TERM
115        // case: on Unix that means Disabled, on Windows it falls back
116        // to a platform-specific probe (e.g. WT_SESSION → TrueColor)
117        // because Windows shells routinely leave TERM unset. The only
118        // unconditional clamp here is non-TTY output.
119        let envp = env_color_profile(env, &term);
120        let mut p = if !is_tty { Profile::Disabled } else { envp };
121
122        // NO_COLOR: clamp to Ascii (decoration still allowed).
123        if env.is_truthy("NO_COLOR") && is_tty {
124            if p > Profile::Ascii {
125                p = Profile::Ascii;
126            }
127            return p;
128        }
129
130        // CLICOLOR_FORCE: at least Ansi, take max of env-derived.
131        if env.is_truthy("CLICOLOR_FORCE") {
132            if p < Profile::Ansi {
133                p = Profile::Ansi;
134            }
135            if envp > p {
136                p = envp;
137            }
138            return p;
139        }
140
141        let is_dumb = term.is_empty() || term == DUMB_TERM;
142        // CLICOLOR: bump non-dumb TTY to at least Ansi.
143        if env.is_truthy("CLICOLOR") && is_tty && !is_dumb && p < Profile::Ansi {
144            p = Profile::Ansi;
145        }
146
147        p
148    }
149}
150
151const DUMB_TERM: &str = "dumb";
152
153/// Environment-driven profile inference. Knows nothing about TTY-ness.
154fn env_color_profile(env: &Env, term: &str) -> Profile {
155    let mut p = if term == DUMB_TERM {
156        // An explicit `dumb` terminal opts out of styling everywhere.
157        Profile::Disabled
158    } else if term.is_empty() {
159        // On Windows, the lack of TERM is normal — Windows Terminal and
160        // cmd.exe don't set it. Defer to a Windows-specific fallback when
161        // we know we're on Windows; otherwise treat as Disabled.
162        #[cfg(windows)]
163        {
164            windows_color_profile(env).unwrap_or(Profile::Disabled)
165        }
166        #[cfg(not(windows))]
167        {
168            let _ = env;
169            Profile::Disabled
170        }
171    } else {
172        Profile::Ansi
173    };
174
175    // Known-good terminals: full TrueColor.
176    if KNOWN_TRUECOLOR_TERMS.iter().any(|t| term.contains(t)) {
177        return Profile::TrueColor;
178    }
179
180    if term.starts_with("tmux") || term.starts_with("screen") {
181        if p < Profile::Ansi256 {
182            p = Profile::Ansi256;
183        }
184    } else if term.starts_with("xterm") && p < Profile::Ansi {
185        p = Profile::Ansi;
186    }
187
188    // Windows Terminal session variable — set even when TERM isn't.
189    if env.has("WT_SESSION") {
190        return Profile::TrueColor;
191    }
192
193    if env.is_truthy("GOOGLE_CLOUD_SHELL") {
194        return Profile::TrueColor;
195    }
196
197    // CI runners advertise themselves with CI=true and render ANSI
198    // color in their logs even though TERM is usually unset or `dumb`.
199    if env.is_truthy("CI") {
200        return Profile::TrueColor;
201    }
202
203    // COLORTERM upgrades to TrueColor, except inside screen which
204    // doesn't propagate it. Modern tmux (3.2+) forwards COLORTERM to
205    // its panes, so we honour it there.
206    if colorterm_says_truecolor(env) && !term.starts_with("screen") {
207        return Profile::TrueColor;
208    }
209
210    if term.ends_with("256color") && p < Profile::Ansi256 {
211        p = Profile::Ansi256;
212    }
213
214    if term.ends_with("direct") {
215        return Profile::TrueColor;
216    }
217
218    p
219}
220
221/// Terminals known to support TrueColor regardless of `TERM` suffix.
222const KNOWN_TRUECOLOR_TERMS: &[&str] = &[
223    "alacritty",
224    "contour",
225    "foot",
226    "ghostty",
227    "kitty",
228    "rio",
229    "st",
230    "wezterm",
231];
232
233fn colorterm_says_truecolor(env: &Env) -> bool {
234    let v = env
235        .get("COLORTERM")
236        .unwrap_or_default()
237        .to_ascii_lowercase();
238    matches!(v.as_str(), "truecolor" | "24bit" | "yes" | "true")
239}
240
241#[cfg(windows)]
242fn windows_color_profile(env: &Env) -> Option<Profile> {
243    // Windows 10+ conhost and Windows Terminal both support virtual
244    // terminal sequences. WT_SESSION pins TrueColor; otherwise assume
245    // ANSI256 (conhost's legacy floor) — TrueColor support arrived in
246    // build 14931 and is universally available on supported Windows
247    // versions, but we stay conservative without further probing.
248    if env.has("WT_SESSION") {
249        return Some(Profile::TrueColor);
250    }
251    Some(Profile::Ansi256)
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    fn env(pairs: &[(&str, &str)]) -> Env {
259        Env::from_pairs(pairs.iter().map(|(k, v)| (*k, *v)))
260    }
261
262    #[test]
263    fn default_is_truecolor() {
264        assert_eq!(Profile::default(), Profile::TrueColor);
265    }
266
267    #[test]
268    fn ordering_is_capability_ascending() {
269        assert!(Profile::Disabled < Profile::Ascii);
270        assert!(Profile::Ascii < Profile::Ansi);
271        assert!(Profile::Ansi < Profile::Ansi256);
272        assert!(Profile::Ansi256 < Profile::TrueColor);
273    }
274
275    #[test]
276    fn no_tty_clamps_to_notty() {
277        let e = env(&[("TERM", "xterm-256color")]);
278        assert_eq!(Profile::detect_from(&e, false), Profile::Disabled);
279    }
280
281    #[test]
282    fn dumb_term_is_notty() {
283        let e = env(&[("TERM", "dumb")]);
284        assert_eq!(Profile::detect_from(&e, true), Profile::Disabled);
285    }
286
287    #[test]
288    fn no_color_clamps_to_ascii() {
289        let e = env(&[("TERM", "xterm-256color"), ("NO_COLOR", "1")]);
290        assert_eq!(Profile::detect_from(&e, true), Profile::Ascii);
291    }
292
293    #[test]
294    fn no_color_does_not_apply_off_tty() {
295        // off-tty + no_color: still Disabled (no_color clamp only applies when isatty).
296        let e = env(&[("TERM", "xterm-256color"), ("NO_COLOR", "1")]);
297        assert_eq!(Profile::detect_from(&e, false), Profile::Disabled);
298    }
299
300    #[test]
301    fn clicolor_force_overrides_notty() {
302        let e = env(&[("TERM", "dumb"), ("CLICOLOR_FORCE", "1")]);
303        // CLICOLOR_FORCE guarantees at least Ansi; the platform/env floor may
304        // be higher (e.g. Ansi256 on Windows conhost).
305        assert!(Profile::detect_from(&e, true) >= Profile::Ansi);
306    }
307
308    #[test]
309    fn clicolor_bumps_to_ansi_on_tty() {
310        // No TERM at all on a unix TTY would normally be Disabled.
311        let e = env(&[("CLICOLOR", "1"), ("TERM", "screen")]);
312        let p = Profile::detect_from(&e, true);
313        assert!(p >= Profile::Ansi);
314    }
315
316    #[test]
317    fn colorterm_truecolor_upgrades() {
318        let e = env(&[("TERM", "xterm"), ("COLORTERM", "truecolor")]);
319        assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
320    }
321
322    #[test]
323    fn colorterm_24bit_upgrades() {
324        let e = env(&[("TERM", "xterm"), ("COLORTERM", "24bit")]);
325        assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
326    }
327
328    #[test]
329    fn colorterm_does_not_upgrade_inside_screen() {
330        let e = env(&[("TERM", "screen-256color"), ("COLORTERM", "truecolor")]);
331        // screen does not forward COLORTERM-derived TrueColor.
332        let p = Profile::detect_from(&e, true);
333        assert!(p < Profile::TrueColor);
334    }
335
336    #[test]
337    fn colorterm_upgrades_inside_tmux() {
338        let e = env(&[("TERM", "tmux-256color"), ("COLORTERM", "truecolor")]);
339        // Modern tmux forwards COLORTERM, so the upgrade applies.
340        assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
341    }
342
343    #[test]
344    fn known_terminal_is_truecolor() {
345        for name in [
346            "alacritty",
347            "wezterm",
348            "ghostty",
349            "kitty-direct",
350            "xterm-kitty",
351        ] {
352            let e = env(&[("TERM", name)]);
353            assert_eq!(
354                Profile::detect_from(&e, true),
355                Profile::TrueColor,
356                "TERM={name} should detect as TrueColor",
357            );
358        }
359    }
360
361    #[test]
362    fn term_256color_suffix_is_ansi256() {
363        let e = env(&[("TERM", "xterm-256color")]);
364        assert_eq!(Profile::detect_from(&e, true), Profile::Ansi256);
365    }
366
367    #[test]
368    fn term_direct_suffix_is_truecolor() {
369        let e = env(&[("TERM", "tmux-direct")]);
370        assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
371    }
372
373    #[test]
374    fn screen_floor_is_ansi256() {
375        let e = env(&[("TERM", "screen")]);
376        assert_eq!(Profile::detect_from(&e, true), Profile::Ansi256);
377    }
378
379    #[test]
380    fn tmux_floor_is_ansi256() {
381        let e = env(&[("TERM", "tmux")]);
382        assert_eq!(Profile::detect_from(&e, true), Profile::Ansi256);
383    }
384
385    #[test]
386    fn wt_session_implies_truecolor() {
387        let e = env(&[("TERM", "xterm"), ("WT_SESSION", "1234")]);
388        assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
389    }
390
391    #[cfg(windows)]
392    #[test]
393    fn wt_session_without_term_is_truecolor_on_windows() {
394        // Windows shells (PowerShell, cmd, Windows Terminal) routinely
395        // leave TERM unset. WT_SESSION must still pin TrueColor.
396        let e = env(&[("WT_SESSION", "1234")]);
397        assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
398    }
399
400    #[cfg(windows)]
401    #[test]
402    fn empty_term_falls_back_to_ansi256_on_windows() {
403        // Without WT_SESSION, conhost still supports VT sequences on
404        // supported Windows versions; floor is Ansi256.
405        let e = env(&[]);
406        assert_eq!(Profile::detect_from(&e, true), Profile::Ansi256);
407    }
408
409    #[test]
410    fn google_cloud_shell_implies_truecolor() {
411        let e = env(&[("GOOGLE_CLOUD_SHELL", "true"), ("TERM", "xterm")]);
412        assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
413    }
414
415    #[test]
416    fn ci_implies_truecolor() {
417        let e = env(&[("CI", "true"), ("TERM", "dumb")]);
418        assert_eq!(Profile::detect_from(&e, true), Profile::TrueColor);
419    }
420
421    #[test]
422    fn xterm_plain_is_ansi() {
423        let e = env(&[("TERM", "xterm")]);
424        assert_eq!(Profile::detect_from(&e, true), Profile::Ansi);
425    }
426
427    #[test]
428    fn env_bool_parses_truthy_values() {
429        let cases = [
430            ("1", true),
431            ("0", false),
432            ("true", true),
433            ("True", true),
434            ("TRUE", true),
435            ("t", true),
436            ("T", true),
437            ("false", false),
438            ("False", false),
439            ("FALSE", false),
440            ("", false),
441            ("yes", false),
442            ("garbage", false),
443        ];
444        for (v, want) in cases {
445            let e = env(&[("X", v)]);
446            assert_eq!(e.is_truthy("X"), want, "bool({v:?})");
447        }
448    }
449
450    #[test]
451    fn convert_to_each_profile() {
452        let red = Color::Rgb(255, 0, 0);
453        assert!(Profile::Disabled.convert(red).is_none());
454        assert!(Profile::Ascii.convert(red).is_none());
455        assert!(Profile::Ansi.convert(red).is_some());
456        assert!(Profile::Ansi256.convert(red).is_some());
457        assert_eq!(Profile::TrueColor.convert(red), Some(red));
458    }
459}