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 compact_str::CompactString;
11
12use crate::errors::{Result, RichError};
13use crate::markup::py_repr_str;
14
15/// The kind of terminal a color system supports.
16///
17/// Mirrors `rich.color.ColorSystem`. Ordering matters: a color of a given
18/// [`ColorType`] can always be represented in an equal-or-higher system, so we
19/// only ever *downgrade*, never upgrade.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
21pub enum ColorSystem {
22    /// 3/4-bit, the 16 standard ANSI colors.
23    Standard,
24    /// 8-bit, the 256-color palette.
25    EightBit,
26    /// 24-bit truecolor.
27    Truecolor,
28    /// Legacy Windows console (16 colors, distinct SGR handling).
29    Windows,
30}
31
32/// The origin/representation of a [`Color`]. Mirrors `rich.color.ColorType`.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34pub enum ColorType {
35    /// The terminal's default foreground/background.
36    Default,
37    /// One of the 16 standard colors (number 0–15).
38    Standard,
39    /// A 256-palette color (number 0–255).
40    EightBit,
41    /// A 24-bit color carrying an explicit [`ColorTriplet`].
42    Truecolor,
43    /// A legacy Windows console color (number 0–15).
44    Windows,
45}
46
47/// An 8-bit-per-channel RGB triplet. Mirrors `rich.color_triplet.ColorTriplet`.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
49pub struct ColorTriplet {
50    pub red: u8,
51    pub green: u8,
52    pub blue: u8,
53}
54
55impl ColorTriplet {
56    pub const fn new(red: u8, green: u8, blue: u8) -> Self {
57        Self { red, green, blue }
58    }
59
60    /// `#rrggbb` hex string (matches `ColorTriplet.hex`).
61    ///
62    /// Upstream is `f"#{red:02x}{green:02x}{blue:02x}"`. Written digit by
63    /// digit rather than through `format!`, which was half of a frame that
64    /// builds a colour per cell (docs/PORTING.md, "Performance notes");
65    /// `hex_matches_format` pins it to the formatted string.
66    pub fn hex(&self) -> String {
67        self.hex_name().into()
68    }
69
70    /// [`hex`](Self::hex) as a colour's name: inline, no allocation.
71    pub(crate) fn hex_name(&self) -> CompactString {
72        const DIGITS: &[u8; 16] = b"0123456789abcdef";
73        // Built on the stack and copied in once: `CompactString::push` a
74        // character at a time costs more than the allocation it saves.
75        let mut hex = *b"#000000";
76        for (i, value) in [self.red, self.green, self.blue].into_iter().enumerate() {
77            hex[1 + 2 * i] = DIGITS[usize::from(value >> 4)];
78            hex[2 + 2 * i] = DIGITS[usize::from(value & 0xf)];
79        }
80        // ASCII digits and `#`.
81        CompactString::from_utf8(hex).unwrap_or_default()
82    }
83
84    /// `rgb(r,g,b)` (matches `ColorTriplet.rgb`).
85    pub fn rgb(&self) -> String {
86        format!("rgb({},{},{})", self.red, self.green, self.blue)
87    }
88
89    /// The components as floats from 0 to 1 (matches
90    /// `ColorTriplet.normalized`).
91    pub fn normalized(&self) -> (f64, f64, f64) {
92        (
93            self.red as f64 / 255.0,
94            self.green as f64 / 255.0,
95            self.blue as f64 / 255.0,
96        )
97    }
98}
99
100/// A parsed color. Mirrors `rich.color.Color`.
101#[derive(Debug, Clone, PartialEq, Eq, Hash)]
102pub struct Color {
103    /// The original name/spec the color was created from: a
104    /// [`CompactString`](crate::CompactString), kept inline up to 24 bytes,
105    /// so a colour's name (`#rrggbb`, `bright_red`) allocates nothing.
106    pub name: crate::CompactString,
107    pub kind: ColorType,
108    /// Palette index for `Standard`/`EightBit`/`Windows`.
109    pub number: Option<u8>,
110    /// Explicit RGB for `Truecolor`.
111    pub triplet: Option<ColorTriplet>,
112}
113
114impl Color {
115    /// Whether this is the terminal default color. Port of `Color.is_default`.
116    pub fn is_default(&self) -> bool {
117        self.kind == ColorType::Default
118    }
119
120    /// The terminal default color.
121    pub fn default_color() -> Self {
122        Color {
123            name: CompactString::const_new("default"),
124            kind: ColorType::Default,
125            number: None,
126            triplet: None,
127        }
128    }
129
130    /// A color from the `ANSI_COLOR_NAMES` table: numbers < 16 are standard SGR
131    /// colors, the rest are 8-bit palette colors. Mirrors `Color.parse`.
132    fn named(name: &str, number: u8) -> Self {
133        let kind = if number < 16 {
134            ColorType::Standard
135        } else {
136            ColorType::EightBit
137        };
138        Color {
139            name: name.into(),
140            kind,
141            number: Some(number),
142            triplet: None,
143        }
144    }
145
146    /// A color from an 8-bit ANSI number (0–15 standard, 16–255 palette).
147    /// Port of `Color.from_ansi`.
148    pub fn from_ansi(number: u8) -> Self {
149        Color {
150            name: compact_str::format_compact!("color({number})"),
151            kind: if number < 16 {
152                ColorType::Standard
153            } else {
154                ColorType::EightBit
155            },
156            number: Some(number),
157            triplet: None,
158        }
159    }
160
161    /// A truecolor from explicit RGB channels. Port of `Color.from_rgb`
162    /// (via `from_triplet`): the name is the `#rrggbb` hex.
163    pub fn from_rgb(red: u8, green: u8, blue: u8) -> Self {
164        let triplet = ColorTriplet::new(red, green, blue);
165        Color {
166            name: triplet.hex_name(),
167            kind: ColorType::Truecolor,
168            number: None,
169            triplet: Some(triplet),
170        }
171    }
172
173    /// Parse a color from a string spec. Port of `Color.parse`.
174    ///
175    /// Accepts a color name from `ANSI_COLOR_NAMES`, `default`, `#rrggbb`,
176    /// `color(N)` (one to three digits) and `rgb(r,g,b)`, case-insensitively
177    /// and ignoring surrounding whitespace. The errors carry upstream's
178    /// `ColorParseError` messages.
179    pub fn parse(color: &str) -> Result<Self> {
180        let original = color;
181        let color = color.to_lowercase();
182        let color = color.trim();
183        let invalid =
184            || RichError::ColorParse(format!("{} is not a valid color", py_repr_str(original)));
185
186        if color == "default" {
187            return Ok(Color::default_color());
188        }
189        if let Some(number) = crate::color_names::ansi_color_number(color) {
190            return Ok(Color::named(color, number));
191        }
192        // `RE_COLOR`: `^#([0-9a-f]{6})$`, `color\(([0-9]{1,3})\)$` or
193        // `rgb\(([\d\s,]+)\)$`.
194        if let Some(hex) = color.strip_prefix('#') {
195            if hex.len() != 6 || !hex.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
196                return Err(invalid());
197            }
198            return Ok(Color {
199                name: color.into(),
200                kind: ColorType::Truecolor,
201                number: None,
202                triplet: Some(parse_rgb_hex(hex)),
203            });
204        }
205        if let Some(digits) = color
206            .strip_prefix("color(")
207            .and_then(|s| s.strip_suffix(')'))
208        {
209            if digits.is_empty() || digits.len() > 3 || !digits.bytes().all(|b| b.is_ascii_digit())
210            {
211                return Err(invalid());
212            }
213            let number: u16 = digits.parse().map_err(|_| invalid())?;
214            if number > 255 {
215                return Err(RichError::ColorParse(format!(
216                    "color number must be <= 255 in {}",
217                    py_repr_str(color)
218                )));
219            }
220            return Ok(Color {
221                name: color.into(),
222                kind: if number < 16 {
223                    ColorType::Standard
224                } else {
225                    ColorType::EightBit
226                },
227                number: Some(number as u8),
228                triplet: None,
229            });
230        }
231        if let Some(inner) = color.strip_prefix("rgb(").and_then(|s| s.strip_suffix(')')) {
232            if inner.is_empty()
233                || !inner
234                    .chars()
235                    .all(|c| c.is_ascii_digit() || c == ',' || c.is_whitespace())
236            {
237                return Err(invalid());
238            }
239            let components: Vec<&str> = inner.split(',').collect();
240            if components.len() != 3 {
241                return Err(RichError::ColorParse(format!(
242                    "expected three components in {}",
243                    py_repr_str(original)
244                )));
245            }
246            let mut values = [0u8; 3];
247            for (slot, component) in values.iter_mut().zip(&components) {
248                // Python's `int()`, which ignores surrounding whitespace.
249                let digits = component.trim();
250                if digits.is_empty() || digits.contains(char::is_whitespace) {
251                    return Err(RichError::ColorParse(format!(
252                        "invalid literal for int() with base 10: {}",
253                        py_repr_str(component)
254                    )));
255                }
256                match digits.parse::<u16>() {
257                    Ok(value) if value <= 255 => *slot = value as u8,
258                    _ => {
259                        return Err(RichError::ColorParse(format!(
260                            "color components must be <= 255 in {}",
261                            py_repr_str(original)
262                        )));
263                    }
264                }
265            }
266            return Ok(Color {
267                name: color.into(),
268                kind: ColorType::Truecolor,
269                number: None,
270                triplet: Some(ColorTriplet::new(values[0], values[1], values[2])),
271            });
272        }
273        Err(invalid())
274    }
275
276    /// A truecolor from a triplet; the name is its `#rrggbb` hex. Port of
277    /// `Color.from_triplet`.
278    pub fn from_triplet(triplet: ColorTriplet) -> Self {
279        Color {
280            name: triplet.hex_name(),
281            kind: ColorType::Truecolor,
282            number: None,
283            triplet: Some(triplet),
284        }
285    }
286
287    /// The native color system of this color: standard for the default
288    /// color. Port of the `Color.system` property.
289    pub fn system(&self) -> ColorSystem {
290        match self.kind {
291            ColorType::Default | ColorType::Standard => ColorSystem::Standard,
292            ColorType::EightBit => ColorSystem::EightBit,
293            ColorType::Truecolor => ColorSystem::Truecolor,
294            ColorType::Windows => ColorSystem::Windows,
295        }
296    }
297
298    /// Whether the terminal defines the actual color: true for the default,
299    /// standard and Windows colors. Port of `Color.is_system_defined`.
300    pub fn is_system_defined(&self) -> bool {
301        !matches!(
302            self.system(),
303            ColorSystem::EightBit | ColorSystem::Truecolor
304        )
305    }
306
307    /// The RGB triplet this color shows as under `theme` (the default export
308    /// theme when `None`). A standard color takes the theme's ANSI color and
309    /// the default color the theme's foreground or background. Port of
310    /// `Color.get_truecolor(theme, foreground)`.
311    pub fn get_truecolor_for(
312        &self,
313        theme: Option<&crate::terminal_theme::TerminalTheme>,
314        foreground: bool,
315    ) -> ColorTriplet {
316        let theme = theme.unwrap_or(&crate::terminal_theme::DEFAULT_TERMINAL_THEME);
317        theme.resolve(self, foreground)
318    }
319
320    /// The text upstream prints for a color: `<color 'red' (standard)⬤ >`,
321    /// the dot in the color itself. Port of `Color.__rich__`.
322    pub fn rich_text(&self) -> crate::text::Text {
323        let kind = match self.kind {
324            ColorType::Default => "default",
325            ColorType::Standard => "standard",
326            ColorType::EightBit => "eight_bit",
327            ColorType::Truecolor => "truecolor",
328            ColorType::Windows => "windows",
329        };
330        let mut text =
331            crate::text::Text::new(format!("<color {} ({kind})", py_repr_str(&self.name)));
332        let dot = crate::style::Style::from_color(Some(self.clone()), None);
333        text.append("⬤", Some(dot.into()));
334        text.append(" >", None);
335        text
336    }
337
338    /// Resolve this color to an RGB triplet, using the palettes for indexed colors.
339    ///
340    /// Returns `None` only for [`ColorType::Default`].
341    pub fn get_truecolor(&self) -> Option<ColorTriplet> {
342        match self.kind {
343            ColorType::Default => None,
344            ColorType::Truecolor => self.triplet,
345            ColorType::Standard => self.number.map(|n| ANSI_BASE_PALETTE[n as usize]),
346            // Upstream resolves a Windows color through `WINDOWS_PALETTE`.
347            ColorType::Windows => self.number.map(|n| WINDOWS_PALETTE[(n & 15) as usize]),
348            ColorType::EightBit => self.number.map(eight_bit_triplet),
349        }
350    }
351
352    /// The SGR parameter list for this color (without the leading `\x1b[`).
353    ///
354    /// Port of `Color.get_ansi_codes`.
355    pub fn ansi_codes(&self, foreground: bool) -> Vec<String> {
356        match self.kind {
357            ColorType::Default => vec![if foreground { "39" } else { "49" }.to_string()],
358            ColorType::Windows | ColorType::Standard => {
359                let number = self.number.unwrap_or(0);
360                let (fore, back) = if number < 8 { (30, 40) } else { (82, 92) };
361                vec![(if foreground { fore } else { back } + number as u32).to_string()]
362            }
363            ColorType::EightBit => {
364                let number = self.number.unwrap_or(0);
365                vec![
366                    if foreground { "38" } else { "48" }.to_string(),
367                    "5".to_string(),
368                    number.to_string(),
369                ]
370            }
371            ColorType::Truecolor => {
372                let t = self.triplet.unwrap_or(ColorTriplet::new(0, 0, 0));
373                vec![
374                    if foreground { "38" } else { "48" }.to_string(),
375                    "2".to_string(),
376                    t.red.to_string(),
377                    t.green.to_string(),
378                    t.blue.to_string(),
379                ]
380            }
381        }
382    }
383
384    /// Return a version of this color representable in `system`.
385    ///
386    /// Port of `Color.downgrade`: colors are only ever converted *down* to a
387    /// smaller system, never up, and a Windows console matches against the
388    /// Windows 10 console palette ([`WINDOWS_PALETTE`]), not the standard one.
389    pub fn downgrade(&self, system: ColorSystem) -> Color {
390        let same_system = matches!(
391            (self.kind, system),
392            (ColorType::Standard, ColorSystem::Standard)
393                | (ColorType::EightBit, ColorSystem::EightBit)
394                | (ColorType::Truecolor, ColorSystem::Truecolor)
395                | (ColorType::Windows, ColorSystem::Windows)
396        );
397        if self.kind == ColorType::Default || same_system {
398            return self.clone();
399        }
400        let is_truecolor = self.kind == ColorType::Truecolor;
401        // Upstream's non-truecolor branches assume an 8-bit color and index
402        // `EIGHT_BIT_PALETTE` with the number, whatever the actual type.
403        let source_triplet = || {
404            if is_truecolor {
405                self.triplet
406            } else {
407                self.number.map(eight_bit_triplet)
408            }
409        };
410        match system {
411            ColorSystem::EightBit if is_truecolor => {
412                let Some(triplet) = self.triplet else {
413                    return self.clone();
414                };
415                Color {
416                    name: self.name.clone(),
417                    kind: ColorType::EightBit,
418                    number: Some(truecolor_to_eight_bit(triplet)),
419                    triplet: None,
420                }
421            }
422            ColorSystem::Standard => {
423                let Some(triplet) = source_triplet() else {
424                    return self.clone();
425                };
426                Color {
427                    name: self.name.clone(),
428                    kind: ColorType::Standard,
429                    number: Some(match_color(&STANDARD_PALETTE, triplet)),
430                    triplet: None,
431                }
432            }
433            ColorSystem::Windows => {
434                if !is_truecolor && let Some(number) = self.number.filter(|&number| number < 16) {
435                    return Color {
436                        name: self.name.clone(),
437                        kind: ColorType::Windows,
438                        number: Some(number),
439                        triplet: None,
440                    };
441                }
442                let Some(triplet) = source_triplet() else {
443                    return self.clone();
444                };
445                Color {
446                    name: self.name.clone(),
447                    kind: ColorType::Windows,
448                    number: Some(match_color(&WINDOWS_PALETTE, triplet)),
449                    triplet: None,
450                }
451            }
452            _ => self.clone(),
453        }
454    }
455}
456
457/// Six hex digits as a triplet. Port of `rich.color.parse_rgb_hex`.
458///
459/// # Panics
460///
461/// When `hex_color` is not six hex digits (upstream asserts its length).
462pub fn parse_rgb_hex(hex_color: &str) -> ColorTriplet {
463    assert_eq!(hex_color.len(), 6, "must be 6 characters");
464    let channel = |range: std::ops::Range<usize>| {
465        u8::from_str_radix(&hex_color[range], 16).expect("hex digits")
466    };
467    ColorTriplet::new(channel(0..2), channel(2..4), channel(4..6))
468}
469
470/// Blend two triplets, `cross_fade` of the way from `color1` to `color2`
471/// (truncating toward zero, as upstream's `int()` does). Port of
472/// `rich.color.blend_rgb` (upstream's default `cross_fade` is 0.5).
473pub fn blend_rgb(color1: ColorTriplet, color2: ColorTriplet, cross_fade: f64) -> ColorTriplet {
474    let mix = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * cross_fade) as u8;
475    ColorTriplet::new(
476        mix(color1.red, color2.red),
477        mix(color1.green, color2.green),
478        mix(color1.blue, color2.blue),
479    )
480}
481
482/// The canonical xterm RGB values for the 16 system colors — the first 16
483/// entries of the 256-color palette, and the ANSI set of
484/// [`DEFAULT_TERMINAL_THEME`](crate::terminal_theme::DEFAULT_TERMINAL_THEME).
485///
486/// Upstream keeps these values in `EIGHT_BIT_PALETTE[..16]` and in
487/// `DEFAULT_TERMINAL_THEME`; they are used to resolve a standard color to RGB.
488/// They are **not** the table used to match a truecolor down to a standard
489/// color — that is [`STANDARD_PALETTE`], which holds different values.
490pub const ANSI_BASE_PALETTE: [ColorTriplet; 16] = [
491    ColorTriplet::new(0, 0, 0),
492    ColorTriplet::new(128, 0, 0),
493    ColorTriplet::new(0, 128, 0),
494    ColorTriplet::new(128, 128, 0),
495    ColorTriplet::new(0, 0, 128),
496    ColorTriplet::new(128, 0, 128),
497    ColorTriplet::new(0, 128, 128),
498    ColorTriplet::new(192, 192, 192),
499    ColorTriplet::new(128, 128, 128),
500    ColorTriplet::new(255, 0, 0),
501    ColorTriplet::new(0, 255, 0),
502    ColorTriplet::new(255, 255, 0),
503    ColorTriplet::new(0, 0, 255),
504    ColorTriplet::new(255, 0, 255),
505    ColorTriplet::new(0, 255, 255),
506    ColorTriplet::new(255, 255, 255),
507];
508
509/// The palette a truecolor is matched *against* when downgrading to a standard
510/// color. Port of upstream's `rich._palettes.STANDARD_PALETTE`.
511///
512/// These are deliberately **not** [`ANSI_BASE_PALETTE`]'s values: upstream uses
513/// a 170/85-based table here, so e.g. `#ff8800` matches bright red (9) rather
514/// than the olive (3) that the 128-based table would pick.
515pub const STANDARD_PALETTE: [ColorTriplet; 16] = [
516    ColorTriplet::new(0, 0, 0),
517    ColorTriplet::new(170, 0, 0),
518    ColorTriplet::new(0, 170, 0),
519    ColorTriplet::new(170, 85, 0),
520    ColorTriplet::new(0, 0, 170),
521    ColorTriplet::new(170, 0, 170),
522    ColorTriplet::new(0, 170, 170),
523    ColorTriplet::new(170, 170, 170),
524    ColorTriplet::new(85, 85, 85),
525    ColorTriplet::new(255, 85, 85),
526    ColorTriplet::new(85, 255, 85),
527    ColorTriplet::new(255, 255, 85),
528    ColorTriplet::new(85, 85, 255),
529    ColorTriplet::new(255, 85, 255),
530    ColorTriplet::new(85, 255, 255),
531    ColorTriplet::new(255, 255, 255),
532];
533
534/// The Windows 10 console palette a truecolor or 8-bit color is matched
535/// against on a `windows` color system. Port of upstream's
536/// `rich._palettes.WINDOWS_PALETTE`.
537pub const WINDOWS_PALETTE: [ColorTriplet; 16] = [
538    ColorTriplet::new(12, 12, 12),
539    ColorTriplet::new(197, 15, 31),
540    ColorTriplet::new(19, 161, 14),
541    ColorTriplet::new(193, 156, 0),
542    ColorTriplet::new(0, 55, 218),
543    ColorTriplet::new(136, 23, 152),
544    ColorTriplet::new(58, 150, 221),
545    ColorTriplet::new(204, 204, 204),
546    ColorTriplet::new(118, 118, 118),
547    ColorTriplet::new(231, 72, 86),
548    ColorTriplet::new(22, 198, 12),
549    ColorTriplet::new(249, 241, 165),
550    ColorTriplet::new(59, 120, 255),
551    ColorTriplet::new(180, 0, 158),
552    ColorTriplet::new(97, 214, 214),
553    ColorTriplet::new(242, 242, 242),
554];
555
556/// The full 256-color palette, generated deterministically (16 system colors +
557/// the 6×6×6 cube + 24 grays), matching the xterm layout `rich` uses.
558static EIGHT_BIT_PALETTE: [ColorTriplet; 256] = build_eight_bit_palette();
559
560const fn build_eight_bit_palette() -> [ColorTriplet; 256] {
561    let mut palette = [ColorTriplet::new(0, 0, 0); 256];
562    // 0–15: standard colors.
563    let mut i = 0;
564    while i < 16 {
565        palette[i] = ANSI_BASE_PALETTE[i];
566        i += 1;
567    }
568    // 16–231: 6×6×6 color cube.
569    let levels = [0u8, 95, 135, 175, 215, 255];
570    let mut r = 0;
571    while r < 6 {
572        let mut g = 0;
573        while g < 6 {
574            let mut b = 0;
575            while b < 6 {
576                let index = 16 + 36 * r + 6 * g + b;
577                palette[index] = ColorTriplet::new(levels[r], levels[g], levels[b]);
578                b += 1;
579            }
580            g += 1;
581        }
582        r += 1;
583    }
584    // 232–255: grayscale ramp.
585    let mut n = 0;
586    while n < 24 {
587        let value = 8 + 10 * n as u8;
588        palette[232 + n] = ColorTriplet::new(value, value, value);
589        n += 1;
590    }
591    palette
592}
593
594fn eight_bit_triplet(number: u8) -> ColorTriplet {
595    EIGHT_BIT_PALETTE[number as usize]
596}
597
598/// Nearest-color search using the redmean distance.
599///
600/// Direct port of `rich.palette.Palette.match_color`.
601/// Lightness and saturation from `colorsys.rgb_to_hls`, for normalised
602/// (`0..=1`) components. Only the two values `downgrade` needs are returned.
603fn rgb_to_ls(red: f64, green: f64, blue: f64) -> (f64, f64) {
604    let max = red.max(green).max(blue);
605    let min = red.min(green).min(blue);
606    let lightness = (max + min) / 2.0;
607    if max == min {
608        return (lightness, 0.0);
609    }
610    let saturation = if lightness <= 0.5 {
611        (max - min) / (max + min)
612    } else {
613        (max - min) / (2.0 - max - min)
614    };
615    (lightness, saturation)
616}
617
618/// Reduce a truecolor triplet to an 8-bit palette index, exactly as upstream's
619/// `Color.downgrade` does for `ColorSystem.EIGHT_BIT`.
620///
621/// Note this is deliberately **not** a nearest-neighbour search over the 256
622/// palette: upstream maps into the 6×6×6 colour cube by formula (and into the
623/// greyscale ramp when saturation is under 15%), which picks different — and
624/// sometimes further — entries than nearest-match would. `#00ff00` becomes cube
625/// index 46, not the exact-matching standard bright-green 10.
626fn truecolor_to_eight_bit(color: ColorTriplet) -> u8 {
627    let (red, green, blue) = (
628        color.red as f64 / 255.0,
629        color.green as f64 / 255.0,
630        color.blue as f64 / 255.0,
631    );
632    let (lightness, saturation) = rgb_to_ls(red, green, blue);
633
634    // Under 15% saturation upstream treats the colour as greyscale.
635    if saturation < 0.15 {
636        // `round_ties_even` matches Python's banker's rounding; plain `round`
637        // would differ on exact .5 values (Python's round(2.5) == 2).
638        let gray = (lightness * 25.0).round_ties_even() as i64;
639        return match gray {
640            0 => 16,
641            25 => 231,
642            other => (231 + other) as u8,
643        };
644    }
645
646    // The cube axis is non-linear: the first step spans 0..95, the rest 40 each.
647    let axis = |component: u8| -> f64 {
648        let value = component as f64;
649        if value < 95.0 {
650            value / 95.0
651        } else {
652            1.0 + (value - 95.0) / 40.0
653        }
654    };
655    let six_red = axis(color.red).round_ties_even();
656    let six_green = axis(color.green).round_ties_even();
657    let six_blue = axis(color.blue).round_ties_even();
658    (16.0 + 36.0 * six_red + 6.0 * six_green + six_blue) as u8
659}
660
661fn match_color(palette: &[ColorTriplet], color: ColorTriplet) -> u8 {
662    let (red1, green1, blue1) = (color.red as i64, color.green as i64, color.blue as i64);
663    let mut best_index = 0usize;
664    let mut best_distance = i64::MAX;
665    for (index, candidate) in palette.iter().enumerate() {
666        let (red2, green2, blue2) = (
667            candidate.red as i64,
668            candidate.green as i64,
669            candidate.blue as i64,
670        );
671        let red_mean = (red1 + red2) / 2;
672        let red = red1 - red2;
673        let green = green1 - green2;
674        let blue = blue1 - blue2;
675        // Squared redmean distance (monotonic in the true distance, so the
676        // sqrt from upstream is unnecessary for an argmin).
677        let distance = (((512 + red_mean) * red * red) >> 8)
678            + 4 * green * green
679            + (((767 - red_mean) * blue * blue) >> 8);
680        if distance < best_distance {
681            best_distance = distance;
682            best_index = index;
683        }
684    }
685    best_index as u8
686}
687
688/// A palette of colors. Port of `rich.palette.Palette`.
689#[derive(Debug, Clone, PartialEq, Eq)]
690pub struct Palette {
691    colors: Vec<ColorTriplet>,
692}
693
694impl Palette {
695    /// A palette of `colors`, indexed from 0.
696    pub fn new(colors: Vec<ColorTriplet>) -> Self {
697        Palette { colors }
698    }
699
700    /// The color at `number` (`palette[number]`), if there is one.
701    pub fn get(&self, number: usize) -> Option<ColorTriplet> {
702        self.colors.get(number).copied()
703    }
704
705    /// The colors, in order.
706    pub fn colors(&self) -> &[ColorTriplet] {
707        &self.colors
708    }
709
710    /// How many colors the palette holds.
711    pub fn len(&self) -> usize {
712        self.colors.len()
713    }
714
715    /// Whether the palette holds no colors.
716    pub fn is_empty(&self) -> bool {
717        self.colors.is_empty()
718    }
719
720    /// The index of the color nearest to `color` by redmean distance, the
721    /// first on a tie. Port of `Palette.match`.
722    ///
723    /// # Panics
724    ///
725    /// On an empty palette, where upstream's `min` raises.
726    pub fn match_color(&self, color: ColorTriplet) -> usize {
727        assert!(!self.colors.is_empty(), "match on an empty palette");
728        match_color(&self.colors, color) as usize
729    }
730}
731
732impl std::ops::Index<usize> for Palette {
733    type Output = ColorTriplet;
734
735    fn index(&self, number: usize) -> &ColorTriplet {
736        &self.colors[number]
737    }
738}
739
740/// A palette prints as a table of its colors. Port of `Palette.__rich__`.
741impl crate::protocol::Renderable for Palette {
742    fn rich_render(
743        &self,
744        console: &crate::console::Console,
745        options: &crate::console::ConsoleOptions,
746    ) -> Vec<crate::segment::Segment> {
747        self.table().rich_render(console, options)
748    }
749
750    fn measure(
751        &self,
752        console: &crate::console::Console,
753        options: &crate::console::ConsoleOptions,
754    ) -> crate::measure::Measurement {
755        self.table().measure(console, options)
756    }
757}
758
759impl Palette {
760    /// The table `Palette.__rich__` returns.
761    fn table(&self) -> crate::table::Table {
762        use crate::table::{Cell, Table};
763        use crate::text::Text;
764        let mut table = Table::new()
765            .title("Palette")
766            .caption(format!("{} colors", self.colors.len()))
767            .highlight(true)
768            .caption_justify(crate::console::Justify::Right);
769        table.add_column("index");
770        table.add_column("RGB");
771        table.add_column("Color");
772        for (index, color) in self.colors.iter().enumerate() {
773            let swatch = crate::style::Style::from_color(None, Some(Color::from_triplet(*color)));
774            table.add_row_cells(vec![
775                Cell::Markup(index.to_string()),
776                Cell::Markup(format!("({}, {}, {})", color.red, color.green, color.blue)),
777                Cell::Text(Text::styled(" ".repeat(16), swatch)),
778            ]);
779        }
780        table
781    }
782}
783
784/// A color prints as its description and a dot in the color. Port of
785/// `Color.__rich__`.
786impl crate::protocol::Renderable for Color {
787    fn rich_render(
788        &self,
789        console: &crate::console::Console,
790        options: &crate::console::ConsoleOptions,
791    ) -> Vec<crate::segment::Segment> {
792        self.rich_text().rich_render(console, options)
793    }
794
795    fn measure(
796        &self,
797        console: &crate::console::Console,
798        options: &crate::console::ConsoleOptions,
799    ) -> crate::measure::Measurement {
800        self.rich_text().measure(console, options)
801    }
802
803    fn printed_text(&self) -> Option<crate::text::Text> {
804        Some(self.rich_text())
805    }
806}
807
808#[cfg(test)]
809mod tests {
810    use super::*;
811
812    /// The hand-written hex is upstream's `"#%02x%02x%02x"` for every
813    /// value of every channel.
814    #[test]
815    fn hex_matches_format() {
816        for value in 0..=255u8 {
817            let other = value.wrapping_mul(97).wrapping_add(13);
818            for (r, g, b) in [(value, other, 0), (other, value, 255), (0, other, value)] {
819                assert_eq!(
820                    ColorTriplet::new(r, g, b).hex(),
821                    format!("#{r:02x}{g:02x}{b:02x}")
822                );
823            }
824        }
825    }
826
827    /// A Windows console matches against `WINDOWS_PALETTE`: `#808080` is
828    /// bright black (90), not the standard palette's white (37); an 8-bit
829    /// color under 16 keeps its number.
830    #[test]
831    fn windows_downgrade_uses_the_windows_palette() {
832        let grey = Color::parse("#808080")
833            .unwrap()
834            .downgrade(ColorSystem::Windows);
835        assert_eq!((grey.kind, grey.number), (ColorType::Windows, Some(8)));
836        let low = Color::parse("color(9)")
837            .unwrap()
838            .downgrade(ColorSystem::Windows);
839        assert_eq!((low.kind, low.number), (ColorType::Windows, Some(9)));
840        assert!(Color::parse("grey").is_err());
841        assert!(Color::parse("gray").is_err());
842    }
843
844    #[test]
845    fn parses_standard_name() {
846        let c = Color::parse("red").unwrap();
847        assert_eq!(c.kind, ColorType::Standard);
848        assert_eq!(c.number, Some(1));
849        assert_eq!(c.ansi_codes(true), vec!["31"]);
850        assert_eq!(c.ansi_codes(false), vec!["41"]);
851    }
852
853    #[test]
854    fn extended_name_is_eight_bit() {
855        // orange1 (214) is beyond the 16 standard colors → 8-bit palette.
856        let c = Color::parse("orange1").unwrap();
857        assert_eq!(c.kind, ColorType::EightBit);
858        assert_eq!(c.number, Some(214));
859        assert_eq!(c.ansi_codes(true), vec!["38", "5", "214"]);
860    }
861
862    #[test]
863    fn bright_color_uses_high_intensity_sgr() {
864        let c = Color::parse("bright_red").unwrap();
865        assert_eq!(c.number, Some(9));
866        // 82 + 9 = 91
867        assert_eq!(c.ansi_codes(true), vec!["91"]);
868    }
869
870    #[test]
871    fn parses_hex_truecolor() {
872        let c = Color::parse("#ff8800").unwrap();
873        assert_eq!(c.kind, ColorType::Truecolor);
874        assert_eq!(c.triplet, Some(ColorTriplet::new(0xff, 0x88, 0x00)));
875        assert_eq!(c.ansi_codes(true), vec!["38", "2", "255", "136", "0"]);
876    }
877
878    /// Values captured from real rich 15.0.0 `Color.downgrade`.
879    ///
880    /// These previously asserted the *wrong* answers, because the matching used
881    /// the 128-based ANSI table rather than upstream's separate 170/85-based
882    /// `STANDARD_PALETTE` — `#ff0000` was said to give 9 (bright red) when
883    /// upstream gives 1 (maroon, the nearer entry under redmean distance).
884    #[test]
885    fn downgrade_to_standard_matches_upstream() {
886        let standard = |hex: &str| {
887            let down = Color::parse(hex).unwrap().downgrade(ColorSystem::Standard);
888            assert_eq!(down.kind, ColorType::Standard);
889            down.number
890        };
891        assert_eq!(standard("#ff0000"), Some(1));
892        assert_eq!(standard("#00ff00"), Some(2));
893        assert_eq!(standard("#0000ff"), Some(4));
894        assert_eq!(standard("#ffffff"), Some(15));
895        assert_eq!(standard("#808080"), Some(7));
896        assert_eq!(standard("#ff8800"), Some(9));
897    }
898
899    /// Upstream maps into the 6×6×6 cube (and the greyscale ramp) by formula,
900    /// *not* by nearest-neighbour over the 256 palette — so `#00ff00` becomes
901    /// cube index 46 rather than the exactly-matching system green 10.
902    #[test]
903    fn downgrade_to_eight_bit_matches_upstream() {
904        let eight_bit = |hex: &str| {
905            let down = Color::parse(hex).unwrap().downgrade(ColorSystem::EightBit);
906            assert_eq!(down.kind, ColorType::EightBit);
907            down.number
908        };
909        assert_eq!(eight_bit("#ff0000"), Some(196));
910        assert_eq!(eight_bit("#00ff00"), Some(46));
911        assert_eq!(eight_bit("#0000ff"), Some(21));
912        assert_eq!(eight_bit("#ff8800"), Some(208));
913        // Low saturation takes the greyscale-ramp branch.
914        assert_eq!(eight_bit("#ffffff"), Some(231));
915        assert_eq!(eight_bit("#808080"), Some(244));
916    }
917
918    #[test]
919    fn eight_bit_palette_cube_is_correct() {
920        // index 196 is the top of the cube red (5,0,0) -> 255,0,0
921        assert_eq!(eight_bit_triplet(196), ColorTriplet::new(255, 0, 0));
922    }
923}