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 => self.number.map(|n| ANSI_BASE_PALETTE[n as usize]),
222            // Upstream resolves a Windows color through `WINDOWS_PALETTE`.
223            ColorType::Windows => self.number.map(|n| WINDOWS_PALETTE[(n & 15) as usize]),
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 `Color.downgrade`: colors are only ever converted *down* to a
263    /// smaller system, never up, and a Windows console matches against the
264    /// Windows 10 console palette ([`WINDOWS_PALETTE`]), not the standard one.
265    pub fn downgrade(&self, system: ColorSystem) -> Color {
266        let same_system = matches!(
267            (self.kind, system),
268            (ColorType::Standard, ColorSystem::Standard)
269                | (ColorType::EightBit, ColorSystem::EightBit)
270                | (ColorType::Truecolor, ColorSystem::Truecolor)
271                | (ColorType::Windows, ColorSystem::Windows)
272        );
273        if self.kind == ColorType::Default || same_system {
274            return self.clone();
275        }
276        let is_truecolor = self.kind == ColorType::Truecolor;
277        // Upstream's non-truecolor branches assume an 8-bit color and index
278        // `EIGHT_BIT_PALETTE` with the number, whatever the actual type.
279        let source_triplet = || {
280            if is_truecolor {
281                self.triplet
282            } else {
283                self.number.map(eight_bit_triplet)
284            }
285        };
286        match system {
287            ColorSystem::EightBit if is_truecolor => {
288                let Some(triplet) = self.triplet else {
289                    return self.clone();
290                };
291                Color {
292                    name: self.name.clone(),
293                    kind: ColorType::EightBit,
294                    number: Some(truecolor_to_eight_bit(triplet)),
295                    triplet: None,
296                }
297            }
298            ColorSystem::Standard => {
299                let Some(triplet) = source_triplet() else {
300                    return self.clone();
301                };
302                Color {
303                    name: self.name.clone(),
304                    kind: ColorType::Standard,
305                    number: Some(match_color(&STANDARD_PALETTE, triplet)),
306                    triplet: None,
307                }
308            }
309            ColorSystem::Windows => {
310                if !is_truecolor {
311                    if let Some(number) = self.number.filter(|&number| number < 16) {
312                        return Color {
313                            name: self.name.clone(),
314                            kind: ColorType::Windows,
315                            number: Some(number),
316                            triplet: None,
317                        };
318                    }
319                }
320                let Some(triplet) = source_triplet() else {
321                    return self.clone();
322                };
323                Color {
324                    name: self.name.clone(),
325                    kind: ColorType::Windows,
326                    number: Some(match_color(&WINDOWS_PALETTE, triplet)),
327                    triplet: None,
328                }
329            }
330            _ => self.clone(),
331        }
332    }
333}
334
335fn parse_hex(hex: &str) -> Option<ColorTriplet> {
336    if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
337        return None;
338    }
339    let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
340    let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
341    let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
342    Some(ColorTriplet::new(r, g, b))
343}
344
345fn parse_rgb(inner: &str) -> Option<ColorTriplet> {
346    let parts: Vec<&str> = inner.split(',').map(str::trim).collect();
347    if parts.len() != 3 {
348        return None;
349    }
350    let r = parts[0].parse().ok()?;
351    let g = parts[1].parse().ok()?;
352    let b = parts[2].parse().ok()?;
353    Some(ColorTriplet::new(r, g, b))
354}
355
356/// The canonical xterm RGB values for the 16 system colors — the first 16
357/// entries of the 256-color palette, and the ANSI set of
358/// [`DEFAULT_TERMINAL_THEME`](crate::terminal_theme::DEFAULT_TERMINAL_THEME).
359///
360/// Upstream keeps these values in `EIGHT_BIT_PALETTE[..16]` and in
361/// `DEFAULT_TERMINAL_THEME`; they are used to resolve a standard color to RGB.
362/// They are **not** the table used to match a truecolor down to a standard
363/// color — that is [`STANDARD_PALETTE`], which holds different values.
364pub const ANSI_BASE_PALETTE: [ColorTriplet; 16] = [
365    ColorTriplet::new(0, 0, 0),
366    ColorTriplet::new(128, 0, 0),
367    ColorTriplet::new(0, 128, 0),
368    ColorTriplet::new(128, 128, 0),
369    ColorTriplet::new(0, 0, 128),
370    ColorTriplet::new(128, 0, 128),
371    ColorTriplet::new(0, 128, 128),
372    ColorTriplet::new(192, 192, 192),
373    ColorTriplet::new(128, 128, 128),
374    ColorTriplet::new(255, 0, 0),
375    ColorTriplet::new(0, 255, 0),
376    ColorTriplet::new(255, 255, 0),
377    ColorTriplet::new(0, 0, 255),
378    ColorTriplet::new(255, 0, 255),
379    ColorTriplet::new(0, 255, 255),
380    ColorTriplet::new(255, 255, 255),
381];
382
383/// The palette a truecolor is matched *against* when downgrading to a standard
384/// color. Port of upstream's `rich._palettes.STANDARD_PALETTE`.
385///
386/// These are deliberately **not** [`ANSI_BASE_PALETTE`]'s values: upstream uses
387/// a 170/85-based table here, so e.g. `#ff8800` matches bright red (9) rather
388/// than the olive (3) that the 128-based table would pick.
389pub const STANDARD_PALETTE: [ColorTriplet; 16] = [
390    ColorTriplet::new(0, 0, 0),
391    ColorTriplet::new(170, 0, 0),
392    ColorTriplet::new(0, 170, 0),
393    ColorTriplet::new(170, 85, 0),
394    ColorTriplet::new(0, 0, 170),
395    ColorTriplet::new(170, 0, 170),
396    ColorTriplet::new(0, 170, 170),
397    ColorTriplet::new(170, 170, 170),
398    ColorTriplet::new(85, 85, 85),
399    ColorTriplet::new(255, 85, 85),
400    ColorTriplet::new(85, 255, 85),
401    ColorTriplet::new(255, 255, 85),
402    ColorTriplet::new(85, 85, 255),
403    ColorTriplet::new(255, 85, 255),
404    ColorTriplet::new(85, 255, 255),
405    ColorTriplet::new(255, 255, 255),
406];
407
408/// The Windows 10 console palette a truecolor or 8-bit color is matched
409/// against on a `windows` color system. Port of upstream's
410/// `rich._palettes.WINDOWS_PALETTE`.
411pub const WINDOWS_PALETTE: [ColorTriplet; 16] = [
412    ColorTriplet::new(12, 12, 12),
413    ColorTriplet::new(197, 15, 31),
414    ColorTriplet::new(19, 161, 14),
415    ColorTriplet::new(193, 156, 0),
416    ColorTriplet::new(0, 55, 218),
417    ColorTriplet::new(136, 23, 152),
418    ColorTriplet::new(58, 150, 221),
419    ColorTriplet::new(204, 204, 204),
420    ColorTriplet::new(118, 118, 118),
421    ColorTriplet::new(231, 72, 86),
422    ColorTriplet::new(22, 198, 12),
423    ColorTriplet::new(249, 241, 165),
424    ColorTriplet::new(59, 120, 255),
425    ColorTriplet::new(180, 0, 158),
426    ColorTriplet::new(97, 214, 214),
427    ColorTriplet::new(242, 242, 242),
428];
429
430/// The full 256-color palette, generated deterministically (16 system colors +
431/// the 6×6×6 cube + 24 grays), matching the xterm layout `rich` uses.
432static EIGHT_BIT_PALETTE: [ColorTriplet; 256] = build_eight_bit_palette();
433
434const fn build_eight_bit_palette() -> [ColorTriplet; 256] {
435    let mut palette = [ColorTriplet::new(0, 0, 0); 256];
436    // 0–15: standard colors.
437    let mut i = 0;
438    while i < 16 {
439        palette[i] = ANSI_BASE_PALETTE[i];
440        i += 1;
441    }
442    // 16–231: 6×6×6 color cube.
443    let levels = [0u8, 95, 135, 175, 215, 255];
444    let mut r = 0;
445    while r < 6 {
446        let mut g = 0;
447        while g < 6 {
448            let mut b = 0;
449            while b < 6 {
450                let index = 16 + 36 * r + 6 * g + b;
451                palette[index] = ColorTriplet::new(levels[r], levels[g], levels[b]);
452                b += 1;
453            }
454            g += 1;
455        }
456        r += 1;
457    }
458    // 232–255: grayscale ramp.
459    let mut n = 0;
460    while n < 24 {
461        let value = 8 + 10 * n as u8;
462        palette[232 + n] = ColorTriplet::new(value, value, value);
463        n += 1;
464    }
465    palette
466}
467
468fn eight_bit_triplet(number: u8) -> ColorTriplet {
469    EIGHT_BIT_PALETTE[number as usize]
470}
471
472/// Nearest-color search using the redmean distance.
473///
474/// Direct port of `rich.palette.Palette.match_color`.
475/// Lightness and saturation from `colorsys.rgb_to_hls`, for normalised
476/// (`0..=1`) components. Only the two values `downgrade` needs are returned.
477fn rgb_to_ls(red: f64, green: f64, blue: f64) -> (f64, f64) {
478    let max = red.max(green).max(blue);
479    let min = red.min(green).min(blue);
480    let lightness = (max + min) / 2.0;
481    if max == min {
482        return (lightness, 0.0);
483    }
484    let saturation = if lightness <= 0.5 {
485        (max - min) / (max + min)
486    } else {
487        (max - min) / (2.0 - max - min)
488    };
489    (lightness, saturation)
490}
491
492/// Reduce a truecolor triplet to an 8-bit palette index, exactly as upstream's
493/// `Color.downgrade` does for `ColorSystem.EIGHT_BIT`.
494///
495/// Note this is deliberately **not** a nearest-neighbour search over the 256
496/// palette: upstream maps into the 6×6×6 colour cube by formula (and into the
497/// greyscale ramp when saturation is under 15%), which picks different — and
498/// sometimes further — entries than nearest-match would. `#00ff00` becomes cube
499/// index 46, not the exact-matching standard bright-green 10.
500fn truecolor_to_eight_bit(color: ColorTriplet) -> u8 {
501    let (red, green, blue) = (
502        color.red as f64 / 255.0,
503        color.green as f64 / 255.0,
504        color.blue as f64 / 255.0,
505    );
506    let (lightness, saturation) = rgb_to_ls(red, green, blue);
507
508    // Under 15% saturation upstream treats the colour as greyscale.
509    if saturation < 0.15 {
510        // `round_ties_even` matches Python's banker's rounding; plain `round`
511        // would differ on exact .5 values (Python's round(2.5) == 2).
512        let gray = (lightness * 25.0).round_ties_even() as i64;
513        return match gray {
514            0 => 16,
515            25 => 231,
516            other => (231 + other) as u8,
517        };
518    }
519
520    // The cube axis is non-linear: the first step spans 0..95, the rest 40 each.
521    let axis = |component: u8| -> f64 {
522        let value = component as f64;
523        if value < 95.0 {
524            value / 95.0
525        } else {
526            1.0 + (value - 95.0) / 40.0
527        }
528    };
529    let six_red = axis(color.red).round_ties_even();
530    let six_green = axis(color.green).round_ties_even();
531    let six_blue = axis(color.blue).round_ties_even();
532    (16.0 + 36.0 * six_red + 6.0 * six_green + six_blue) as u8
533}
534
535fn match_color(palette: &[ColorTriplet], color: ColorTriplet) -> u8 {
536    let (red1, green1, blue1) = (color.red as i64, color.green as i64, color.blue as i64);
537    let mut best_index = 0usize;
538    let mut best_distance = i64::MAX;
539    for (index, candidate) in palette.iter().enumerate() {
540        let (red2, green2, blue2) = (
541            candidate.red as i64,
542            candidate.green as i64,
543            candidate.blue as i64,
544        );
545        let red_mean = (red1 + red2) / 2;
546        let red = red1 - red2;
547        let green = green1 - green2;
548        let blue = blue1 - blue2;
549        // Squared redmean distance (monotonic in the true distance, so the
550        // sqrt from upstream is unnecessary for an argmin).
551        let distance = (((512 + red_mean) * red * red) >> 8)
552            + 4 * green * green
553            + (((767 - red_mean) * blue * blue) >> 8);
554        if distance < best_distance {
555            best_distance = distance;
556            best_index = index;
557        }
558    }
559    best_index as u8
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565
566    /// A Windows console matches against `WINDOWS_PALETTE`: `#808080` is
567    /// bright black (90), not the standard palette's white (37); an 8-bit
568    /// color under 16 keeps its number.
569    #[test]
570    fn windows_downgrade_uses_the_windows_palette() {
571        let grey = Color::parse("#808080")
572            .unwrap()
573            .downgrade(ColorSystem::Windows);
574        assert_eq!((grey.kind, grey.number), (ColorType::Windows, Some(8)));
575        let low = Color::parse("color(9)")
576            .unwrap()
577            .downgrade(ColorSystem::Windows);
578        assert_eq!((low.kind, low.number), (ColorType::Windows, Some(9)));
579        assert!(Color::parse("grey").is_err());
580        assert!(Color::parse("gray").is_err());
581    }
582
583    #[test]
584    fn parses_standard_name() {
585        let c = Color::parse("red").unwrap();
586        assert_eq!(c.kind, ColorType::Standard);
587        assert_eq!(c.number, Some(1));
588        assert_eq!(c.ansi_codes(true), vec!["31"]);
589        assert_eq!(c.ansi_codes(false), vec!["41"]);
590    }
591
592    #[test]
593    fn extended_name_is_eight_bit() {
594        // orange1 (214) is beyond the 16 standard colors → 8-bit palette.
595        let c = Color::parse("orange1").unwrap();
596        assert_eq!(c.kind, ColorType::EightBit);
597        assert_eq!(c.number, Some(214));
598        assert_eq!(c.ansi_codes(true), vec!["38", "5", "214"]);
599    }
600
601    #[test]
602    fn bright_color_uses_high_intensity_sgr() {
603        let c = Color::parse("bright_red").unwrap();
604        assert_eq!(c.number, Some(9));
605        // 82 + 9 = 91
606        assert_eq!(c.ansi_codes(true), vec!["91"]);
607    }
608
609    #[test]
610    fn parses_hex_truecolor() {
611        let c = Color::parse("#ff8800").unwrap();
612        assert_eq!(c.kind, ColorType::Truecolor);
613        assert_eq!(c.triplet, Some(ColorTriplet::new(0xff, 0x88, 0x00)));
614        assert_eq!(c.ansi_codes(true), vec!["38", "2", "255", "136", "0"]);
615    }
616
617    /// Values captured from real rich 15.0.0 `Color.downgrade`.
618    ///
619    /// These previously asserted the *wrong* answers, because the matching used
620    /// the 128-based ANSI table rather than upstream's separate 170/85-based
621    /// `STANDARD_PALETTE` — `#ff0000` was said to give 9 (bright red) when
622    /// upstream gives 1 (maroon, the nearer entry under redmean distance).
623    #[test]
624    fn downgrade_to_standard_matches_upstream() {
625        let standard = |hex: &str| {
626            let down = Color::parse(hex).unwrap().downgrade(ColorSystem::Standard);
627            assert_eq!(down.kind, ColorType::Standard);
628            down.number
629        };
630        assert_eq!(standard("#ff0000"), Some(1));
631        assert_eq!(standard("#00ff00"), Some(2));
632        assert_eq!(standard("#0000ff"), Some(4));
633        assert_eq!(standard("#ffffff"), Some(15));
634        assert_eq!(standard("#808080"), Some(7));
635        assert_eq!(standard("#ff8800"), Some(9));
636    }
637
638    /// Upstream maps into the 6×6×6 cube (and the greyscale ramp) by formula,
639    /// *not* by nearest-neighbour over the 256 palette — so `#00ff00` becomes
640    /// cube index 46 rather than the exactly-matching system green 10.
641    #[test]
642    fn downgrade_to_eight_bit_matches_upstream() {
643        let eight_bit = |hex: &str| {
644            let down = Color::parse(hex).unwrap().downgrade(ColorSystem::EightBit);
645            assert_eq!(down.kind, ColorType::EightBit);
646            down.number
647        };
648        assert_eq!(eight_bit("#ff0000"), Some(196));
649        assert_eq!(eight_bit("#00ff00"), Some(46));
650        assert_eq!(eight_bit("#0000ff"), Some(21));
651        assert_eq!(eight_bit("#ff8800"), Some(208));
652        // Low saturation takes the greyscale-ramp branch.
653        assert_eq!(eight_bit("#ffffff"), Some(231));
654        assert_eq!(eight_bit("#808080"), Some(244));
655    }
656
657    #[test]
658    fn eight_bit_palette_cube_is_correct() {
659        // index 196 is the top of the cube red (5,0,0) -> 255,0,0
660        assert_eq!(eight_bit_triplet(196), ColorTriplet::new(255, 0, 0));
661    }
662}