Skip to main content

rich/
color.rs

1//! Colors and color-system handling.
2//!
3//! Port of upstream `rich/color.py`, `rich/color_triplet.py` and the palette
4//! data from `rich/_palettes.py` / `rich/palette.py`.
5//!
6//! Parses the full `ANSI_COLOR_NAMES` table (see `color_names.rs`), `#rrggbb`
7//! hex, `rgb(r,g,b)`, and `color(N)` (0–255). Downgrade uses the redmean
8//! nearest-color search ported verbatim from `Palette.match_color`.
9
10use crate::errors::{Result, RichError};
11
12/// The kind of terminal a color system supports.
13///
14/// Mirrors `rich.color.ColorSystem`. Ordering matters: a color of a given
15/// [`ColorType`] can always be represented in an equal-or-higher system, so we
16/// only ever *downgrade*, never upgrade.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
18pub enum ColorSystem {
19    /// 3/4-bit, the 16 standard ANSI colors.
20    Standard,
21    /// 8-bit, the 256-color palette.
22    EightBit,
23    /// 24-bit truecolor.
24    Truecolor,
25    /// Legacy Windows console (16 colors, distinct SGR handling).
26    Windows,
27}
28
29/// The origin/representation of a [`Color`]. Mirrors `rich.color.ColorType`.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31pub enum ColorType {
32    /// The terminal's default foreground/background.
33    Default,
34    /// One of the 16 standard colors (number 0–15).
35    Standard,
36    /// A 256-palette color (number 0–255).
37    EightBit,
38    /// A 24-bit color carrying an explicit [`ColorTriplet`].
39    Truecolor,
40    /// A legacy Windows console color (number 0–15).
41    Windows,
42}
43
44/// An 8-bit-per-channel RGB triplet. Mirrors `rich.color_triplet.ColorTriplet`.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
46pub struct ColorTriplet {
47    pub red: u8,
48    pub green: u8,
49    pub blue: u8,
50}
51
52impl ColorTriplet {
53    pub const fn new(red: u8, green: u8, blue: u8) -> Self {
54        Self { red, green, blue }
55    }
56
57    /// `#rrggbb` hex string (matches `ColorTriplet.hex`).
58    pub fn hex(&self) -> String {
59        format!("#{:02x}{:02x}{:02x}", self.red, self.green, self.blue)
60    }
61}
62
63/// A parsed color. Mirrors `rich.color.Color`.
64#[derive(Debug, Clone, PartialEq, Eq, Hash)]
65pub struct Color {
66    /// The original name/spec the color was created from.
67    pub name: String,
68    pub kind: ColorType,
69    /// Palette index for `Standard`/`EightBit`/`Windows`.
70    pub number: Option<u8>,
71    /// Explicit RGB for `Truecolor`.
72    pub triplet: Option<ColorTriplet>,
73}
74
75impl Color {
76    /// Whether this is the terminal default color. Port of `Color.is_default`.
77    pub fn is_default(&self) -> bool {
78        self.kind == ColorType::Default
79    }
80
81    /// The terminal default color.
82    pub fn default_color() -> Self {
83        Color {
84            name: "default".to_string(),
85            kind: ColorType::Default,
86            number: None,
87            triplet: None,
88        }
89    }
90
91    /// A color from the `ANSI_COLOR_NAMES` table: numbers < 16 are standard SGR
92    /// colors, the rest are 8-bit palette colors. Mirrors `Color.parse`.
93    fn named(name: &str, number: u8) -> Self {
94        let kind = if number < 16 {
95            ColorType::Standard
96        } else {
97            ColorType::EightBit
98        };
99        Color {
100            name: name.to_string(),
101            kind,
102            number: Some(number),
103            triplet: None,
104        }
105    }
106
107    /// A color from an 8-bit ANSI number (0–15 standard, 16–255 palette).
108    /// Port of `Color.from_ansi`.
109    pub fn from_ansi(number: u8) -> Self {
110        Color {
111            name: format!("color({number})"),
112            kind: if number < 16 {
113                ColorType::Standard
114            } else {
115                ColorType::EightBit
116            },
117            number: Some(number),
118            triplet: None,
119        }
120    }
121
122    /// A truecolor from explicit RGB channels. Port of `Color.from_rgb`
123    /// (via `from_triplet`): the name is the `#rrggbb` hex.
124    pub fn from_rgb(red: u8, green: u8, blue: u8) -> Self {
125        let triplet = ColorTriplet::new(red, green, blue);
126        Color {
127            name: triplet.hex(),
128            kind: ColorType::Truecolor,
129            number: None,
130            triplet: Some(triplet),
131        }
132    }
133
134    /// Parse a color from a string spec.
135    ///
136    /// Accepts: a standard color name, `default`, `#rrggbb`, `rgb(r,g,b)`, and
137    /// `color(N)`. Mirrors the common paths of `Color.parse`.
138    pub fn parse(color: &str) -> Result<Self> {
139        let original = color.trim();
140        let lower = original.to_ascii_lowercase();
141
142        if lower == "default" {
143            return Ok(Color::default_color());
144        }
145        if let Some(number) = crate::color_names::ansi_color_number(&lower) {
146            return Ok(Color::named(&lower, number));
147        }
148        if let Some(hex) = lower.strip_prefix('#') {
149            let triplet = parse_hex(hex)
150                .ok_or_else(|| RichError::ColorParse(format!("invalid hex color {original:?}")))?;
151            return Ok(Color {
152                // Lowercased, matching upstream's `color.lower().strip()`. The
153                // name is what `Style::definition` prints, so keeping the
154                // original case would stop `on #FF0000` normalizing to
155                // `on #ff0000` — and then a mixed-case open/close tag pair would
156                // no longer match.
157                name: lower.clone(),
158                kind: ColorType::Truecolor,
159                number: None,
160                triplet: Some(triplet),
161            });
162        }
163        if let Some(inner) = lower.strip_prefix("rgb(").and_then(|s| s.strip_suffix(')')) {
164            let triplet = parse_rgb(inner)
165                .ok_or_else(|| RichError::ColorParse(format!("invalid rgb color {original:?}")))?;
166            return Ok(Color {
167                // Lowercased, matching upstream's `color.lower().strip()`. The
168                // name is what `Style::definition` prints, so keeping the
169                // original case would stop `on #FF0000` normalizing to
170                // `on #ff0000` — and then a mixed-case open/close tag pair would
171                // no longer match.
172                name: lower.clone(),
173                kind: ColorType::Truecolor,
174                number: None,
175                triplet: Some(triplet),
176            });
177        }
178        if let Some(inner) = lower
179            .strip_prefix("color(")
180            .and_then(|s| s.strip_suffix(')'))
181        {
182            let n: u16 = inner
183                .trim()
184                .parse()
185                .map_err(|_| RichError::ColorParse(format!("invalid color number {original:?}")))?;
186            if n > 255 {
187                return Err(RichError::ColorParse(format!(
188                    "color number must be <= 255, not {n}"
189                )));
190            }
191            // Numbers < 16 are standard SGR colors; the rest are 8-bit palette
192            // (matching `Color.parse`'s `color_8` branch).
193            return Ok(Color {
194                // Lowercased, matching upstream's `color.lower().strip()`. The
195                // name is what `Style::definition` prints, so keeping the
196                // original case would stop `on #FF0000` normalizing to
197                // `on #ff0000` — and then a mixed-case open/close tag pair would
198                // no longer match.
199                name: lower.clone(),
200                kind: if n < 16 {
201                    ColorType::Standard
202                } else {
203                    ColorType::EightBit
204                },
205                number: Some(n as u8),
206                triplet: None,
207            });
208        }
209        Err(RichError::ColorParse(format!(
210            "{original:?} is not a valid color"
211        )))
212    }
213
214    /// Resolve this color to an RGB triplet, using the palettes for indexed colors.
215    ///
216    /// Returns `None` only for [`ColorType::Default`].
217    pub fn get_truecolor(&self) -> Option<ColorTriplet> {
218        match self.kind {
219            ColorType::Default => None,
220            ColorType::Truecolor => self.triplet,
221            ColorType::Standard | ColorType::Windows => {
222                self.number.map(|n| ANSI_BASE_PALETTE[n as usize])
223            }
224            ColorType::EightBit => self.number.map(eight_bit_triplet),
225        }
226    }
227
228    /// The SGR parameter list for this color (without the leading `\x1b[`).
229    ///
230    /// Port of `Color.get_ansi_codes`.
231    pub fn ansi_codes(&self, foreground: bool) -> Vec<String> {
232        match self.kind {
233            ColorType::Default => vec![if foreground { "39" } else { "49" }.to_string()],
234            ColorType::Windows | ColorType::Standard => {
235                let number = self.number.unwrap_or(0);
236                let (fore, back) = if number < 8 { (30, 40) } else { (82, 92) };
237                vec![(if foreground { fore } else { back } + number as u32).to_string()]
238            }
239            ColorType::EightBit => {
240                let number = self.number.unwrap_or(0);
241                vec![
242                    if foreground { "38" } else { "48" }.to_string(),
243                    "5".to_string(),
244                    number.to_string(),
245                ]
246            }
247            ColorType::Truecolor => {
248                let t = self.triplet.unwrap_or(ColorTriplet::new(0, 0, 0));
249                vec![
250                    if foreground { "38" } else { "48" }.to_string(),
251                    "2".to_string(),
252                    t.red.to_string(),
253                    t.green.to_string(),
254                    t.blue.to_string(),
255                ]
256            }
257        }
258    }
259
260    /// Return a version of this color representable in `system`.
261    ///
262    /// Port of the reducing half of `Color.downgrade`: colors are only ever
263    /// converted *down* to a smaller system, never up.
264    pub fn downgrade(&self, system: ColorSystem) -> Color {
265        if self.kind == ColorType::Default {
266            return self.clone();
267        }
268        let target_rank = match system {
269            ColorSystem::Standard | ColorSystem::Windows => 0,
270            ColorSystem::EightBit => 1,
271            ColorSystem::Truecolor => 2,
272        };
273        let self_rank = match self.kind {
274            ColorType::Standard | ColorType::Windows => 0,
275            ColorType::EightBit => 1,
276            ColorType::Truecolor => 2,
277            ColorType::Default => return self.clone(),
278        };
279        if self_rank <= target_rank {
280            // Already representable; keep as-is (Windows/Standard are equivalent
281            // for our SGR purposes in this slice).
282            return self.clone();
283        }
284        let triplet = match self.get_truecolor() {
285            Some(t) => t,
286            None => return self.clone(),
287        };
288        match target_rank {
289            1 => Color {
290                name: self.name.clone(),
291                kind: ColorType::EightBit,
292                number: Some(truecolor_to_eight_bit(triplet)),
293                triplet: None,
294            },
295            _ => {
296                let number = match_color(&STANDARD_PALETTE, triplet);
297                Color {
298                    name: self.name.clone(),
299                    kind: ColorType::Standard,
300                    number: Some(number),
301                    triplet: None,
302                }
303            }
304        }
305    }
306}
307
308fn parse_hex(hex: &str) -> Option<ColorTriplet> {
309    if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
310        return None;
311    }
312    let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
313    let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
314    let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
315    Some(ColorTriplet::new(r, g, b))
316}
317
318fn parse_rgb(inner: &str) -> Option<ColorTriplet> {
319    let parts: Vec<&str> = inner.split(',').map(str::trim).collect();
320    if parts.len() != 3 {
321        return None;
322    }
323    let r = parts[0].parse().ok()?;
324    let g = parts[1].parse().ok()?;
325    let b = parts[2].parse().ok()?;
326    Some(ColorTriplet::new(r, g, b))
327}
328
329/// The canonical xterm RGB values for the 16 system colors — the first 16
330/// entries of the 256-color palette, and the ANSI set of
331/// [`DEFAULT_TERMINAL_THEME`](crate::terminal_theme::DEFAULT_TERMINAL_THEME).
332///
333/// Upstream keeps these values in `EIGHT_BIT_PALETTE[..16]` and in
334/// `DEFAULT_TERMINAL_THEME`; they are used to resolve a standard color to RGB.
335/// They are **not** the table used to match a truecolor down to a standard
336/// color — that is [`STANDARD_PALETTE`], which holds different values.
337pub const ANSI_BASE_PALETTE: [ColorTriplet; 16] = [
338    ColorTriplet::new(0, 0, 0),
339    ColorTriplet::new(128, 0, 0),
340    ColorTriplet::new(0, 128, 0),
341    ColorTriplet::new(128, 128, 0),
342    ColorTriplet::new(0, 0, 128),
343    ColorTriplet::new(128, 0, 128),
344    ColorTriplet::new(0, 128, 128),
345    ColorTriplet::new(192, 192, 192),
346    ColorTriplet::new(128, 128, 128),
347    ColorTriplet::new(255, 0, 0),
348    ColorTriplet::new(0, 255, 0),
349    ColorTriplet::new(255, 255, 0),
350    ColorTriplet::new(0, 0, 255),
351    ColorTriplet::new(255, 0, 255),
352    ColorTriplet::new(0, 255, 255),
353    ColorTriplet::new(255, 255, 255),
354];
355
356/// The palette a truecolor is matched *against* when downgrading to a standard
357/// color. Port of upstream's `rich._palettes.STANDARD_PALETTE`.
358///
359/// These are deliberately **not** [`ANSI_BASE_PALETTE`]'s values: upstream uses
360/// a 170/85-based table here, so e.g. `#ff8800` matches bright red (9) rather
361/// than the olive (3) that the 128-based table would pick.
362pub const STANDARD_PALETTE: [ColorTriplet; 16] = [
363    ColorTriplet::new(0, 0, 0),
364    ColorTriplet::new(170, 0, 0),
365    ColorTriplet::new(0, 170, 0),
366    ColorTriplet::new(170, 85, 0),
367    ColorTriplet::new(0, 0, 170),
368    ColorTriplet::new(170, 0, 170),
369    ColorTriplet::new(0, 170, 170),
370    ColorTriplet::new(170, 170, 170),
371    ColorTriplet::new(85, 85, 85),
372    ColorTriplet::new(255, 85, 85),
373    ColorTriplet::new(85, 255, 85),
374    ColorTriplet::new(255, 255, 85),
375    ColorTriplet::new(85, 85, 255),
376    ColorTriplet::new(255, 85, 255),
377    ColorTriplet::new(85, 255, 255),
378    ColorTriplet::new(255, 255, 255),
379];
380
381/// The full 256-color palette, generated deterministically (16 system colors +
382/// the 6×6×6 cube + 24 grays), matching the xterm layout `rich` uses.
383static EIGHT_BIT_PALETTE: [ColorTriplet; 256] = build_eight_bit_palette();
384
385const fn build_eight_bit_palette() -> [ColorTriplet; 256] {
386    let mut palette = [ColorTriplet::new(0, 0, 0); 256];
387    // 0–15: standard colors.
388    let mut i = 0;
389    while i < 16 {
390        palette[i] = ANSI_BASE_PALETTE[i];
391        i += 1;
392    }
393    // 16–231: 6×6×6 color cube.
394    let levels = [0u8, 95, 135, 175, 215, 255];
395    let mut r = 0;
396    while r < 6 {
397        let mut g = 0;
398        while g < 6 {
399            let mut b = 0;
400            while b < 6 {
401                let index = 16 + 36 * r + 6 * g + b;
402                palette[index] = ColorTriplet::new(levels[r], levels[g], levels[b]);
403                b += 1;
404            }
405            g += 1;
406        }
407        r += 1;
408    }
409    // 232–255: grayscale ramp.
410    let mut n = 0;
411    while n < 24 {
412        let value = 8 + 10 * n as u8;
413        palette[232 + n] = ColorTriplet::new(value, value, value);
414        n += 1;
415    }
416    palette
417}
418
419fn eight_bit_triplet(number: u8) -> ColorTriplet {
420    EIGHT_BIT_PALETTE[number as usize]
421}
422
423/// Nearest-color search using the redmean distance.
424///
425/// Direct port of `rich.palette.Palette.match_color`.
426/// Lightness and saturation from `colorsys.rgb_to_hls`, for normalised
427/// (`0..=1`) components. Only the two values `downgrade` needs are returned.
428fn rgb_to_ls(red: f64, green: f64, blue: f64) -> (f64, f64) {
429    let max = red.max(green).max(blue);
430    let min = red.min(green).min(blue);
431    let lightness = (max + min) / 2.0;
432    if max == min {
433        return (lightness, 0.0);
434    }
435    let saturation = if lightness <= 0.5 {
436        (max - min) / (max + min)
437    } else {
438        (max - min) / (2.0 - max - min)
439    };
440    (lightness, saturation)
441}
442
443/// Reduce a truecolor triplet to an 8-bit palette index, exactly as upstream's
444/// `Color.downgrade` does for `ColorSystem.EIGHT_BIT`.
445///
446/// Note this is deliberately **not** a nearest-neighbour search over the 256
447/// palette: upstream maps into the 6×6×6 colour cube by formula (and into the
448/// greyscale ramp when saturation is under 15%), which picks different — and
449/// sometimes further — entries than nearest-match would. `#00ff00` becomes cube
450/// index 46, not the exact-matching standard bright-green 10.
451fn truecolor_to_eight_bit(color: ColorTriplet) -> u8 {
452    let (red, green, blue) = (
453        color.red as f64 / 255.0,
454        color.green as f64 / 255.0,
455        color.blue as f64 / 255.0,
456    );
457    let (lightness, saturation) = rgb_to_ls(red, green, blue);
458
459    // Under 15% saturation upstream treats the colour as greyscale.
460    if saturation < 0.15 {
461        // `round_ties_even` matches Python's banker's rounding; plain `round`
462        // would differ on exact .5 values (Python's round(2.5) == 2).
463        let gray = (lightness * 25.0).round_ties_even() as i64;
464        return match gray {
465            0 => 16,
466            25 => 231,
467            other => (231 + other) as u8,
468        };
469    }
470
471    // The cube axis is non-linear: the first step spans 0..95, the rest 40 each.
472    let axis = |component: u8| -> f64 {
473        let value = component as f64;
474        if value < 95.0 {
475            value / 95.0
476        } else {
477            1.0 + (value - 95.0) / 40.0
478        }
479    };
480    let six_red = axis(color.red).round_ties_even();
481    let six_green = axis(color.green).round_ties_even();
482    let six_blue = axis(color.blue).round_ties_even();
483    (16.0 + 36.0 * six_red + 6.0 * six_green + six_blue) as u8
484}
485
486fn match_color(palette: &[ColorTriplet], color: ColorTriplet) -> u8 {
487    let (red1, green1, blue1) = (color.red as i64, color.green as i64, color.blue as i64);
488    let mut best_index = 0usize;
489    let mut best_distance = i64::MAX;
490    for (index, candidate) in palette.iter().enumerate() {
491        let (red2, green2, blue2) = (
492            candidate.red as i64,
493            candidate.green as i64,
494            candidate.blue as i64,
495        );
496        let red_mean = (red1 + red2) / 2;
497        let red = red1 - red2;
498        let green = green1 - green2;
499        let blue = blue1 - blue2;
500        // Squared redmean distance (monotonic in the true distance, so the
501        // sqrt from upstream is unnecessary for an argmin).
502        let distance = (((512 + red_mean) * red * red) >> 8)
503            + 4 * green * green
504            + (((767 - red_mean) * blue * blue) >> 8);
505        if distance < best_distance {
506            best_distance = distance;
507            best_index = index;
508        }
509    }
510    best_index as u8
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516
517    #[test]
518    fn parses_standard_name() {
519        let c = Color::parse("red").unwrap();
520        assert_eq!(c.kind, ColorType::Standard);
521        assert_eq!(c.number, Some(1));
522        assert_eq!(c.ansi_codes(true), vec!["31"]);
523        assert_eq!(c.ansi_codes(false), vec!["41"]);
524    }
525
526    #[test]
527    fn extended_name_is_eight_bit() {
528        // orange1 (214) is beyond the 16 standard colors → 8-bit palette.
529        let c = Color::parse("orange1").unwrap();
530        assert_eq!(c.kind, ColorType::EightBit);
531        assert_eq!(c.number, Some(214));
532        assert_eq!(c.ansi_codes(true), vec!["38", "5", "214"]);
533    }
534
535    #[test]
536    fn bright_color_uses_high_intensity_sgr() {
537        let c = Color::parse("bright_red").unwrap();
538        assert_eq!(c.number, Some(9));
539        // 82 + 9 = 91
540        assert_eq!(c.ansi_codes(true), vec!["91"]);
541    }
542
543    #[test]
544    fn parses_hex_truecolor() {
545        let c = Color::parse("#ff8800").unwrap();
546        assert_eq!(c.kind, ColorType::Truecolor);
547        assert_eq!(c.triplet, Some(ColorTriplet::new(0xff, 0x88, 0x00)));
548        assert_eq!(c.ansi_codes(true), vec!["38", "2", "255", "136", "0"]);
549    }
550
551    /// Values captured from real rich 15.0.0 `Color.downgrade`.
552    ///
553    /// These previously asserted the *wrong* answers, because the matching used
554    /// the 128-based ANSI table rather than upstream's separate 170/85-based
555    /// `STANDARD_PALETTE` — `#ff0000` was said to give 9 (bright red) when
556    /// upstream gives 1 (maroon, the nearer entry under redmean distance).
557    #[test]
558    fn downgrade_to_standard_matches_upstream() {
559        let standard = |hex: &str| {
560            let down = Color::parse(hex).unwrap().downgrade(ColorSystem::Standard);
561            assert_eq!(down.kind, ColorType::Standard);
562            down.number
563        };
564        assert_eq!(standard("#ff0000"), Some(1));
565        assert_eq!(standard("#00ff00"), Some(2));
566        assert_eq!(standard("#0000ff"), Some(4));
567        assert_eq!(standard("#ffffff"), Some(15));
568        assert_eq!(standard("#808080"), Some(7));
569        assert_eq!(standard("#ff8800"), Some(9));
570    }
571
572    /// Upstream maps into the 6×6×6 cube (and the greyscale ramp) by formula,
573    /// *not* by nearest-neighbour over the 256 palette — so `#00ff00` becomes
574    /// cube index 46 rather than the exactly-matching system green 10.
575    #[test]
576    fn downgrade_to_eight_bit_matches_upstream() {
577        let eight_bit = |hex: &str| {
578            let down = Color::parse(hex).unwrap().downgrade(ColorSystem::EightBit);
579            assert_eq!(down.kind, ColorType::EightBit);
580            down.number
581        };
582        assert_eq!(eight_bit("#ff0000"), Some(196));
583        assert_eq!(eight_bit("#00ff00"), Some(46));
584        assert_eq!(eight_bit("#0000ff"), Some(21));
585        assert_eq!(eight_bit("#ff8800"), Some(208));
586        // Low saturation takes the greyscale-ramp branch.
587        assert_eq!(eight_bit("#ffffff"), Some(231));
588        assert_eq!(eight_bit("#808080"), Some(244));
589    }
590
591    #[test]
592    fn eight_bit_palette_cube_is_correct() {
593        // index 196 is the top of the cube red (5,0,0) -> 255,0,0
594        assert_eq!(eight_bit_triplet(196), ColorTriplet::new(255, 0, 0));
595    }
596}