Skip to main content

uncurses/color/
mod.rs

1//! Terminal color values, palettes, and capability profiles.
2//!
3//! ## Color values
4//!
5//! [`Color`] covers three palette spaces in one enum: the sixteen named ANSI
6//! colors ([`Color::Green`], [`Color::BrightBlue`], and so on),
7//! [`Color::Indexed`] for the xterm 256-color palette, and [`Color::Rgb`] for
8//! 24-bit true color. All of them convert to RGB with [`Color::to_rgb`], which
9//! makes palette colors usable with true-color helpers such as
10//! [`Color::to_hex`] and [`Color::to_hsl`].
11//!
12//! ## Named colors
13//!
14//! The sixteen named variants are the standard ANSI palette: `Black` through
15//! `White` are normal intensity (palette indices `0..=7`) and `BrightBlack`
16//! through `BrightWhite` are the bright/high-intensity variants (`8..=15`).
17//! [`Color::named_index`] returns that `0..=15` index for a named color and
18//! `None` for [`Color::Indexed`]/[`Color::Rgb`]; [`Color::from_named`]
19//! goes the other way.
20//!
21//! ## Hex and HSL helpers
22//!
23//! [`Color::hex`] accepts optional-`#` three-, six-, or eight-digit hex input
24//! and returns [`Color::Rgb`]. Eight-digit input parses and ignores the final
25//! alpha byte because terminal colors carry no alpha channel. [`Color::hsl`]
26//! wraps hue into `0..360`, clamps saturation/lightness into `0.0..=1.0`, and
27//! rounds computed RGB channels to the nearest byte.
28//!
29//! [`Color::to_hex`] and [`Color::to_hsl`] first resolve named and indexed
30//! colors through the xterm palette. Gray HSL colors report hue `0.0` because
31//! the hue is undefined when saturation is zero.
32//!
33//! ## Profile downsampling
34//!
35//! [`Profile`] describes the color capability of an output stream. Converting a
36//! color through a profile preserves true color when possible, quantizes to the
37//! nearest supported palette for `Ansi256`/`Ansi`, and drops color entirely for
38//! `Ascii`/`Disabled`.
39//!
40//! ```text
41//! Color::Rgb / Indexed / named
42//!          │
43//!          ├─ Profile::TrueColor ──► original color
44//!          ├─ Profile::Ansi256   ──► nearest xterm index
45//!          ├─ Profile::Ansi      ──► nearest named color
46//!          └─ Ascii / Disabled   ──► None
47//! ```
48//!
49//! ```rust,ignore
50//! use uncurses::color::{Color, Profile};
51//!
52//! let orange = Color::Rgb(255, 128, 0);
53//! let green = Color::Green;
54//!
55//! assert_eq!(Profile::TrueColor.convert(orange), Some(orange));
56//! assert!(matches!(Profile::Ansi256.convert(orange), Some(Color::Indexed(_))));
57//! assert_eq!(Profile::Disabled.convert(green), None);
58//! ```
59
60mod convert;
61mod profile;
62
63pub use profile::Profile;
64
65/// A terminal color in one of the supported palette spaces.
66///
67/// The sixteen named variants are the standard ANSI palette. Use
68/// [`Color::Rgb`] when exact 24-bit color should be preserved on true-color
69/// terminals and [`Color::Indexed`] when targeting a specific xterm 256-color
70/// palette entry. A [`Profile`] can downsample any variant to the capability
71/// of a particular output stream.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
73pub enum Color {
74    /// Black (palette `0`, foreground `30`, background `40`).
75    Black,
76    /// Red (palette `1`, foreground `31`, background `41`).
77    Red,
78    /// Green (palette `2`, foreground `32`, background `42`).
79    Green,
80    /// Yellow (palette `3`, foreground `33`, background `43`).
81    Yellow,
82    /// Blue (palette `4`, foreground `34`, background `44`).
83    Blue,
84    /// Magenta (palette `5`, foreground `35`, background `45`).
85    Magenta,
86    /// Cyan (palette `6`, foreground `36`, background `46`).
87    Cyan,
88    /// White (palette `7`, foreground `37`, background `47`).
89    White,
90    /// Bright black (palette `8`, foreground `90`, background `100`).
91    BrightBlack,
92    /// Bright red (palette `9`, foreground `91`, background `101`).
93    BrightRed,
94    /// Bright green (palette `10`, foreground `92`, background `102`).
95    BrightGreen,
96    /// Bright yellow (palette `11`, foreground `93`, background `103`).
97    BrightYellow,
98    /// Bright blue (palette `12`, foreground `94`, background `104`).
99    BrightBlue,
100    /// Bright magenta (palette `13`, foreground `95`, background `105`).
101    BrightMagenta,
102    /// Bright cyan (palette `14`, foreground `96`, background `106`).
103    BrightCyan,
104    /// Bright white (palette `15`, foreground `97`, background `107`).
105    BrightWhite,
106    /// Extended xterm 256-color palette index (`0..=255`).
107    Indexed(u8),
108    /// 24-bit true color as red, green, and blue bytes.
109    Rgb(u8, u8, u8),
110}
111
112impl Color {
113    /// Construct a 24-bit RGB color.
114    ///
115    /// Returns [`Color::Rgb`] with the supplied red, green, and blue bytes.
116    /// This is a `const` convenience constructor for code that prefers named
117    /// parameters over the enum variant.
118    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
119        Self::Rgb(r, g, b)
120    }
121
122    /// Return the `0..=15` ANSI palette index for a named color.
123    ///
124    /// Returns `Some(0..=15)` for the sixteen named variants and `None` for
125    /// [`Color::Indexed`] and [`Color::Rgb`]. The index is also the xterm
126    /// 256-color palette index used when resolving a named color through
127    /// [`Color::to_rgb`].
128    pub const fn named_index(self) -> Option<u8> {
129        Some(match self {
130            Color::Black => 0,
131            Color::Red => 1,
132            Color::Green => 2,
133            Color::Yellow => 3,
134            Color::Blue => 4,
135            Color::Magenta => 5,
136            Color::Cyan => 6,
137            Color::White => 7,
138            Color::BrightBlack => 8,
139            Color::BrightRed => 9,
140            Color::BrightGreen => 10,
141            Color::BrightYellow => 11,
142            Color::BrightBlue => 12,
143            Color::BrightMagenta => 13,
144            Color::BrightCyan => 14,
145            Color::BrightWhite => 15,
146            Color::Indexed(_) | Color::Rgb(..) => return None,
147        })
148    }
149
150    /// Convert a `0..=15` ANSI palette index into the matching named color.
151    ///
152    /// Returns `Some` for values in `0..=15` and `None` for any higher value.
153    pub const fn from_named(v: u8) -> Option<Self> {
154        Some(match v {
155            0 => Color::Black,
156            1 => Color::Red,
157            2 => Color::Green,
158            3 => Color::Yellow,
159            4 => Color::Blue,
160            5 => Color::Magenta,
161            6 => Color::Cyan,
162            7 => Color::White,
163            8 => Color::BrightBlack,
164            9 => Color::BrightRed,
165            10 => Color::BrightGreen,
166            11 => Color::BrightYellow,
167            12 => Color::BrightBlue,
168            13 => Color::BrightMagenta,
169            14 => Color::BrightCyan,
170            15 => Color::BrightWhite,
171            _ => return None,
172        })
173    }
174
175    /// Return whether this is one of the sixteen named ANSI colors.
176    pub const fn is_named(self) -> bool {
177        self.named_index().is_some()
178    }
179
180    /// Return whether this is a bright/high-intensity named color.
181    ///
182    /// Bright colors are the named variants with palette indices `8..=15` and
183    /// encode as foreground SGR `90..=97` or background SGR `100..=107`.
184    /// [`Color::Indexed`] and [`Color::Rgb`] are never bright.
185    pub const fn is_named_bright(self) -> bool {
186        matches!(self.named_index(), Some(8..=15))
187    }
188
189    /// Convert this color to `(red, green, blue)` bytes.
190    ///
191    /// [`Color::Rgb`] returns its stored components unchanged. Named colors and
192    /// [`Color::Indexed`] are resolved through the xterm 256-color palette,
193    /// where named colors use their `0..=15` palette index.
194    pub fn to_rgb(self) -> (u8, u8, u8) {
195        match self {
196            Color::Rgb(r, g, b) => (r, g, b),
197            Color::Indexed(idx) => indexed_to_rgb(idx),
198            other => indexed_to_rgb(other.named_index().unwrap_or(0)),
199        }
200    }
201
202    /// Format this color as a lower-case `#rrggbb` hex string.
203    ///
204    /// Palette and indexed colors are resolved with [`Color::to_rgb`] first,
205    /// so the result is always the six-digit true-color form. The returned
206    /// string never includes alpha.
207    pub fn to_hex(self) -> String {
208        let (r, g, b) = self.to_rgb();
209        format!("#{r:02x}{g:02x}{b:02x}")
210    }
211
212    /// Convert this color to HSL components.
213    ///
214    /// Returns `(h, s, l)` with hue in degrees (`0.0..360.0`) and saturation
215    /// and lightness in `0.0..=1.0`. Palette and indexed colors are resolved to
216    /// RGB first. If the color is gray (`r == g == b`), saturation is `0.0` and
217    /// hue is reported as `0.0`.
218    pub fn to_hsl(self) -> (f32, f32, f32) {
219        let (r, g, b) = self.to_rgb();
220        let r = f32::from(r) / 255.0;
221        let g = f32::from(g) / 255.0;
222        let b = f32::from(b) / 255.0;
223        let max = r.max(g).max(b);
224        let min = r.min(g).min(b);
225        let delta = max - min;
226        let l = (max + min) / 2.0;
227        if delta == 0.0 {
228            return (0.0, 0.0, l);
229        }
230        let s = delta / (1.0 - (2.0 * l - 1.0).abs());
231        let h = if max == r {
232            60.0 * ((g - b) / delta).rem_euclid(6.0)
233        } else if max == g {
234            60.0 * ((b - r) / delta + 2.0)
235        } else {
236            60.0 * ((r - g) / delta + 4.0)
237        };
238        (h, s, l)
239    }
240
241    /// Parse a hex color string into [`Color::Rgb`].
242    ///
243    /// The leading `#` is optional. Accepted forms are:
244    ///
245    /// - `rgb` / `#rgb`: shorthand, each nibble is doubled (`f` becomes `ff`).
246    /// - `rrggbb` / `#rrggbb`: one byte per channel.
247    /// - `rrggbbaa` / `#rrggbbaa`: a trailing alpha byte is parsed for
248    ///   validity but ignored.
249    ///
250    /// Returns `None` for any other length or for non-hexadecimal input. The
251    /// function does not panic.
252    pub fn hex(s: &str) -> Option<Color> {
253        let s = s.strip_prefix('#').unwrap_or(s);
254        let bytes = s.as_bytes();
255        let (r, g, b) = match bytes.len() {
256            3 => {
257                let r = hex_nibble(bytes[0])?;
258                let g = hex_nibble(bytes[1])?;
259                let b = hex_nibble(bytes[2])?;
260                (r * 17, g * 17, b * 17)
261            }
262            6 | 8 => {
263                let r = hex_byte(bytes[0], bytes[1])?;
264                let g = hex_byte(bytes[2], bytes[3])?;
265                let b = hex_byte(bytes[4], bytes[5])?;
266                // For the 8-digit form, validate the trailing alpha pair so
267                // non-hex input is rejected, then discard it: terminal colors
268                // carry no alpha channel.
269                if bytes.len() == 8 {
270                    hex_byte(bytes[6], bytes[7])?;
271                }
272                (r, g, b)
273            }
274            _ => return None,
275        };
276        Some(Color::Rgb(r, g, b))
277    }
278
279    /// Construct [`Color::Rgb`] from HSL.
280    ///
281    /// `h` is hue in degrees and is wrapped into `0.0..360.0` with
282    /// `f32::rem_euclid`. `s` (saturation) and `l` (lightness) are clamped to
283    /// `0.0..=1.0`. The intermediate RGB values are rounded to the nearest byte
284    /// and clamped to `0..=255`.
285    pub fn hsl(h: f32, s: f32, l: f32) -> Color {
286        let h = h.rem_euclid(360.0);
287        let s = s.clamp(0.0, 1.0);
288        let l = l.clamp(0.0, 1.0);
289        let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
290        let hp = h / 60.0;
291        let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
292        let (r1, g1, b1) = match hp as u32 {
293            0 => (c, x, 0.0),
294            1 => (x, c, 0.0),
295            2 => (0.0, c, x),
296            3 => (0.0, x, c),
297            4 => (x, 0.0, c),
298            _ => (c, 0.0, x),
299        };
300        let m = l - c / 2.0;
301        let to = |v: f32| ((v + m) * 255.0).round().clamp(0.0, 255.0) as u8;
302        Color::Rgb(to(r1), to(g1), to(b1))
303    }
304}
305
306/// Parse one ASCII hex digit into its `0..=15` value.
307fn hex_nibble(b: u8) -> Option<u8> {
308    (b as char).to_digit(16).map(|d| d as u8)
309}
310
311/// Parse two ASCII hex digits into a byte.
312fn hex_byte(hi: u8, lo: u8) -> Option<u8> {
313    Some(hex_nibble(hi)? * 16 + hex_nibble(lo)?)
314}
315
316/// The standard xterm 256-color palette RGB values.
317const XTERM_COLORS: [(u8, u8, u8); 256] = {
318    let mut table = [(0u8, 0u8, 0u8); 256];
319
320    // 0-15: Standard colors (approximate)
321    table[0] = (0, 0, 0);
322    table[1] = (128, 0, 0);
323    table[2] = (0, 128, 0);
324    table[3] = (128, 128, 0);
325    table[4] = (0, 0, 128);
326    table[5] = (128, 0, 128);
327    table[6] = (0, 128, 128);
328    table[7] = (192, 192, 192);
329    table[8] = (128, 128, 128);
330    table[9] = (255, 0, 0);
331    table[10] = (0, 255, 0);
332    table[11] = (255, 255, 0);
333    table[12] = (0, 0, 255);
334    table[13] = (255, 0, 255);
335    table[14] = (0, 255, 255);
336    table[15] = (255, 255, 255);
337
338    // 16-231: 6x6x6 color cube
339    let cube_values: [u8; 6] = [0, 0x5f, 0x87, 0xaf, 0xd7, 0xff];
340    let mut i = 16usize;
341    let mut r = 0usize;
342    while r < 6 {
343        let mut g = 0usize;
344        while g < 6 {
345            let mut b = 0usize;
346            while b < 6 {
347                table[i] = (cube_values[r], cube_values[g], cube_values[b]);
348                i += 1;
349                b += 1;
350            }
351            g += 1;
352        }
353        r += 1;
354    }
355
356    // 232-255: Grayscale ramp
357    let mut i = 232usize;
358    while i < 256 {
359        let v = (8 + 10 * (i - 232)) as u8;
360        table[i] = (v, v, v);
361        i += 1;
362    }
363
364    table
365};
366
367/// Look up the RGB values for an xterm 256-color palette index.
368///
369/// Indices `0..=15` are the standard ANSI colors, `16..=231` are the 6×6×6
370/// color cube, and `232..=255` are the grayscale ramp. Every `u8` is a valid
371/// palette index, so this function does not fail or panic.
372pub(crate) fn indexed_to_rgb(idx: u8) -> (u8, u8, u8) {
373    XTERM_COLORS[idx as usize]
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn named_index_round_trips() {
382        assert_eq!(Color::Black.named_index(), Some(0));
383        assert_eq!(Color::BrightWhite.named_index(), Some(15));
384        assert_eq!(Color::Indexed(42).named_index(), None);
385        assert_eq!(Color::Rgb(1, 2, 3).named_index(), None);
386        assert_eq!(Color::from_named(0), Some(Color::Black));
387        assert_eq!(Color::from_named(15), Some(Color::BrightWhite));
388        assert_eq!(Color::from_named(16), None);
389        assert!(Color::Green.is_named());
390        assert!(!Color::Indexed(2).is_named());
391        assert!(Color::BrightRed.is_named_bright());
392        assert!(!Color::Red.is_named_bright());
393    }
394
395    #[test]
396    fn test_color_to_rgb() {
397        assert_eq!(Color::Rgb(255, 128, 0).to_rgb(), (255, 128, 0));
398        assert_eq!(Color::Black.to_rgb(), (0, 0, 0));
399    }
400
401    #[test]
402    fn test_xterm_cube_color() {
403        // Index 196 = pure red in the 6x6x6 cube (5,0,0)
404        assert_eq!(indexed_to_rgb(196), (255, 0, 0));
405    }
406
407    #[test]
408    fn test_xterm_grayscale() {
409        assert_eq!(indexed_to_rgb(232), (8, 8, 8));
410        assert_eq!(indexed_to_rgb(255), (238, 238, 238));
411    }
412
413    #[test]
414    fn test_hex_forms() {
415        assert_eq!(Color::hex("#fff"), Some(Color::Rgb(255, 255, 255)));
416        assert_eq!(Color::hex("fff"), Some(Color::Rgb(255, 255, 255)));
417        assert_eq!(Color::hex("#abc"), Some(Color::Rgb(0xaa, 0xbb, 0xcc)));
418        assert_eq!(Color::hex("#ff8800"), Some(Color::Rgb(255, 136, 0)));
419        // 8-digit form: the trailing alpha pair is parsed but ignored.
420        assert_eq!(Color::hex("#ff880042"), Some(Color::Rgb(255, 136, 0)));
421    }
422
423    #[test]
424    fn test_hex_rejects_bad_input() {
425        assert_eq!(Color::hex("#ff"), None); // wrong length
426        assert_eq!(Color::hex("#fffff"), None); // wrong length
427        assert_eq!(Color::hex("#gggggg"), None); // non-hex digits
428        assert_eq!(Color::hex("#ff8800zz"), None); // non-hex alpha pair
429        assert_eq!(Color::hex(""), None);
430    }
431
432    #[test]
433    fn test_hsl() {
434        assert_eq!(Color::hsl(0.0, 1.0, 0.5), Color::Rgb(255, 0, 0));
435        assert_eq!(Color::hsl(120.0, 1.0, 0.5), Color::Rgb(0, 255, 0));
436        assert_eq!(Color::hsl(240.0, 1.0, 0.5), Color::Rgb(0, 0, 255));
437        // Hue wraps and full lightness is white regardless of hue.
438        assert_eq!(Color::hsl(360.0, 1.0, 0.5), Color::Rgb(255, 0, 0));
439        assert_eq!(Color::hsl(123.0, 0.5, 1.0), Color::Rgb(255, 255, 255));
440        assert_eq!(Color::hsl(200.0, 0.7, 0.0), Color::Rgb(0, 0, 0));
441    }
442
443    #[test]
444    fn test_to_hex() {
445        assert_eq!(Color::Rgb(255, 136, 0).to_hex(), "#ff8800");
446        assert_eq!(Color::Rgb(0, 0, 0).to_hex(), "#000000");
447        assert_eq!(Color::Black.to_hex(), "#000000");
448        // Round-trips with `hex`.
449        assert_eq!(
450            Color::hex(&Color::Rgb(18, 52, 86).to_hex()),
451            Some(Color::Rgb(18, 52, 86))
452        );
453    }
454
455    #[test]
456    fn test_to_hsl() {
457        let approx = |a: f32, b: f32| (a - b).abs() < 0.01;
458        let (h, s, l) = Color::Rgb(255, 0, 0).to_hsl();
459        assert!(approx(h, 0.0) && approx(s, 1.0) && approx(l, 0.5));
460        let (h, s, l) = Color::Rgb(0, 255, 0).to_hsl();
461        assert!(approx(h, 120.0) && approx(s, 1.0) && approx(l, 0.5));
462        let (h, s, l) = Color::Rgb(0, 0, 255).to_hsl();
463        assert!(approx(h, 240.0) && approx(s, 1.0) && approx(l, 0.5));
464        // Gray: undefined hue reported as 0, zero saturation.
465        let (_, s, l) = Color::Rgb(128, 128, 128).to_hsl();
466        assert!(approx(s, 0.0) && approx(l, 128.0 / 255.0));
467        // hsl -> rgb -> hsl round-trip stays close.
468        let (h, s, l) = Color::hsl(210.0, 0.6, 0.45).to_hsl();
469        assert!(approx(h, 210.0) && approx(s, 0.6) && approx(l, 0.45));
470    }
471
472    #[test]
473    fn color_into_option() {
474        let c: Option<Color> = Color::Red.into();
475        assert_eq!(c, Some(Color::Red));
476    }
477}