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 {
435                    if let Some(number) = self.number.filter(|&number| number < 16) {
436                        return Color {
437                            name: self.name.clone(),
438                            kind: ColorType::Windows,
439                            number: Some(number),
440                            triplet: None,
441                        };
442                    }
443                }
444                let Some(triplet) = source_triplet() else {
445                    return self.clone();
446                };
447                Color {
448                    name: self.name.clone(),
449                    kind: ColorType::Windows,
450                    number: Some(match_color(&WINDOWS_PALETTE, triplet)),
451                    triplet: None,
452                }
453            }
454            _ => self.clone(),
455        }
456    }
457}
458
459/// Six hex digits as a triplet. Port of `rich.color.parse_rgb_hex`.
460///
461/// # Panics
462///
463/// When `hex_color` is not six hex digits (upstream asserts its length).
464pub fn parse_rgb_hex(hex_color: &str) -> ColorTriplet {
465    assert_eq!(hex_color.len(), 6, "must be 6 characters");
466    let channel = |range: std::ops::Range<usize>| {
467        u8::from_str_radix(&hex_color[range], 16).expect("hex digits")
468    };
469    ColorTriplet::new(channel(0..2), channel(2..4), channel(4..6))
470}
471
472/// Blend two triplets, `cross_fade` of the way from `color1` to `color2`
473/// (truncating toward zero, as upstream's `int()` does). Port of
474/// `rich.color.blend_rgb` (upstream's default `cross_fade` is 0.5).
475pub fn blend_rgb(color1: ColorTriplet, color2: ColorTriplet, cross_fade: f64) -> ColorTriplet {
476    let mix = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * cross_fade) as u8;
477    ColorTriplet::new(
478        mix(color1.red, color2.red),
479        mix(color1.green, color2.green),
480        mix(color1.blue, color2.blue),
481    )
482}
483
484/// The canonical xterm RGB values for the 16 system colors — the first 16
485/// entries of the 256-color palette, and the ANSI set of
486/// [`DEFAULT_TERMINAL_THEME`](crate::terminal_theme::DEFAULT_TERMINAL_THEME).
487///
488/// Upstream keeps these values in `EIGHT_BIT_PALETTE[..16]` and in
489/// `DEFAULT_TERMINAL_THEME`; they are used to resolve a standard color to RGB.
490/// They are **not** the table used to match a truecolor down to a standard
491/// color — that is [`STANDARD_PALETTE`], which holds different values.
492pub const ANSI_BASE_PALETTE: [ColorTriplet; 16] = [
493    ColorTriplet::new(0, 0, 0),
494    ColorTriplet::new(128, 0, 0),
495    ColorTriplet::new(0, 128, 0),
496    ColorTriplet::new(128, 128, 0),
497    ColorTriplet::new(0, 0, 128),
498    ColorTriplet::new(128, 0, 128),
499    ColorTriplet::new(0, 128, 128),
500    ColorTriplet::new(192, 192, 192),
501    ColorTriplet::new(128, 128, 128),
502    ColorTriplet::new(255, 0, 0),
503    ColorTriplet::new(0, 255, 0),
504    ColorTriplet::new(255, 255, 0),
505    ColorTriplet::new(0, 0, 255),
506    ColorTriplet::new(255, 0, 255),
507    ColorTriplet::new(0, 255, 255),
508    ColorTriplet::new(255, 255, 255),
509];
510
511/// The palette a truecolor is matched *against* when downgrading to a standard
512/// color. Port of upstream's `rich._palettes.STANDARD_PALETTE`.
513///
514/// These are deliberately **not** [`ANSI_BASE_PALETTE`]'s values: upstream uses
515/// a 170/85-based table here, so e.g. `#ff8800` matches bright red (9) rather
516/// than the olive (3) that the 128-based table would pick.
517pub const STANDARD_PALETTE: [ColorTriplet; 16] = [
518    ColorTriplet::new(0, 0, 0),
519    ColorTriplet::new(170, 0, 0),
520    ColorTriplet::new(0, 170, 0),
521    ColorTriplet::new(170, 85, 0),
522    ColorTriplet::new(0, 0, 170),
523    ColorTriplet::new(170, 0, 170),
524    ColorTriplet::new(0, 170, 170),
525    ColorTriplet::new(170, 170, 170),
526    ColorTriplet::new(85, 85, 85),
527    ColorTriplet::new(255, 85, 85),
528    ColorTriplet::new(85, 255, 85),
529    ColorTriplet::new(255, 255, 85),
530    ColorTriplet::new(85, 85, 255),
531    ColorTriplet::new(255, 85, 255),
532    ColorTriplet::new(85, 255, 255),
533    ColorTriplet::new(255, 255, 255),
534];
535
536/// The Windows 10 console palette a truecolor or 8-bit color is matched
537/// against on a `windows` color system. Port of upstream's
538/// `rich._palettes.WINDOWS_PALETTE`.
539pub const WINDOWS_PALETTE: [ColorTriplet; 16] = [
540    ColorTriplet::new(12, 12, 12),
541    ColorTriplet::new(197, 15, 31),
542    ColorTriplet::new(19, 161, 14),
543    ColorTriplet::new(193, 156, 0),
544    ColorTriplet::new(0, 55, 218),
545    ColorTriplet::new(136, 23, 152),
546    ColorTriplet::new(58, 150, 221),
547    ColorTriplet::new(204, 204, 204),
548    ColorTriplet::new(118, 118, 118),
549    ColorTriplet::new(231, 72, 86),
550    ColorTriplet::new(22, 198, 12),
551    ColorTriplet::new(249, 241, 165),
552    ColorTriplet::new(59, 120, 255),
553    ColorTriplet::new(180, 0, 158),
554    ColorTriplet::new(97, 214, 214),
555    ColorTriplet::new(242, 242, 242),
556];
557
558/// The full 256-color palette, generated deterministically (16 system colors +
559/// the 6×6×6 cube + 24 grays), matching the xterm layout `rich` uses.
560static EIGHT_BIT_PALETTE: [ColorTriplet; 256] = build_eight_bit_palette();
561
562const fn build_eight_bit_palette() -> [ColorTriplet; 256] {
563    let mut palette = [ColorTriplet::new(0, 0, 0); 256];
564    // 0–15: standard colors.
565    let mut i = 0;
566    while i < 16 {
567        palette[i] = ANSI_BASE_PALETTE[i];
568        i += 1;
569    }
570    // 16–231: 6×6×6 color cube.
571    let levels = [0u8, 95, 135, 175, 215, 255];
572    let mut r = 0;
573    while r < 6 {
574        let mut g = 0;
575        while g < 6 {
576            let mut b = 0;
577            while b < 6 {
578                let index = 16 + 36 * r + 6 * g + b;
579                palette[index] = ColorTriplet::new(levels[r], levels[g], levels[b]);
580                b += 1;
581            }
582            g += 1;
583        }
584        r += 1;
585    }
586    // 232–255: grayscale ramp.
587    let mut n = 0;
588    while n < 24 {
589        let value = 8 + 10 * n as u8;
590        palette[232 + n] = ColorTriplet::new(value, value, value);
591        n += 1;
592    }
593    palette
594}
595
596fn eight_bit_triplet(number: u8) -> ColorTriplet {
597    EIGHT_BIT_PALETTE[number as usize]
598}
599
600/// Nearest-color search using the redmean distance.
601///
602/// Direct port of `rich.palette.Palette.match_color`.
603/// Lightness and saturation from `colorsys.rgb_to_hls`, for normalised
604/// (`0..=1`) components. Only the two values `downgrade` needs are returned.
605fn rgb_to_ls(red: f64, green: f64, blue: f64) -> (f64, f64) {
606    let max = red.max(green).max(blue);
607    let min = red.min(green).min(blue);
608    let lightness = (max + min) / 2.0;
609    if max == min {
610        return (lightness, 0.0);
611    }
612    let saturation = if lightness <= 0.5 {
613        (max - min) / (max + min)
614    } else {
615        (max - min) / (2.0 - max - min)
616    };
617    (lightness, saturation)
618}
619
620/// Reduce a truecolor triplet to an 8-bit palette index, exactly as upstream's
621/// `Color.downgrade` does for `ColorSystem.EIGHT_BIT`.
622///
623/// Note this is deliberately **not** a nearest-neighbour search over the 256
624/// palette: upstream maps into the 6×6×6 colour cube by formula (and into the
625/// greyscale ramp when saturation is under 15%), which picks different — and
626/// sometimes further — entries than nearest-match would. `#00ff00` becomes cube
627/// index 46, not the exact-matching standard bright-green 10.
628fn truecolor_to_eight_bit(color: ColorTriplet) -> u8 {
629    let (red, green, blue) = (
630        color.red as f64 / 255.0,
631        color.green as f64 / 255.0,
632        color.blue as f64 / 255.0,
633    );
634    let (lightness, saturation) = rgb_to_ls(red, green, blue);
635
636    // Under 15% saturation upstream treats the colour as greyscale.
637    if saturation < 0.15 {
638        // `round_ties_even` matches Python's banker's rounding; plain `round`
639        // would differ on exact .5 values (Python's round(2.5) == 2).
640        let gray = (lightness * 25.0).round_ties_even() as i64;
641        return match gray {
642            0 => 16,
643            25 => 231,
644            other => (231 + other) as u8,
645        };
646    }
647
648    // The cube axis is non-linear: the first step spans 0..95, the rest 40 each.
649    let axis = |component: u8| -> f64 {
650        let value = component as f64;
651        if value < 95.0 {
652            value / 95.0
653        } else {
654            1.0 + (value - 95.0) / 40.0
655        }
656    };
657    let six_red = axis(color.red).round_ties_even();
658    let six_green = axis(color.green).round_ties_even();
659    let six_blue = axis(color.blue).round_ties_even();
660    (16.0 + 36.0 * six_red + 6.0 * six_green + six_blue) as u8
661}
662
663fn match_color(palette: &[ColorTriplet], color: ColorTriplet) -> u8 {
664    let (red1, green1, blue1) = (color.red as i64, color.green as i64, color.blue as i64);
665    let mut best_index = 0usize;
666    let mut best_distance = i64::MAX;
667    for (index, candidate) in palette.iter().enumerate() {
668        let (red2, green2, blue2) = (
669            candidate.red as i64,
670            candidate.green as i64,
671            candidate.blue as i64,
672        );
673        let red_mean = (red1 + red2) / 2;
674        let red = red1 - red2;
675        let green = green1 - green2;
676        let blue = blue1 - blue2;
677        // Squared redmean distance (monotonic in the true distance, so the
678        // sqrt from upstream is unnecessary for an argmin).
679        let distance = (((512 + red_mean) * red * red) >> 8)
680            + 4 * green * green
681            + (((767 - red_mean) * blue * blue) >> 8);
682        if distance < best_distance {
683            best_distance = distance;
684            best_index = index;
685        }
686    }
687    best_index as u8
688}
689
690/// A palette of colors. Port of `rich.palette.Palette`.
691#[derive(Debug, Clone, PartialEq, Eq)]
692pub struct Palette {
693    colors: Vec<ColorTriplet>,
694}
695
696impl Palette {
697    /// A palette of `colors`, indexed from 0.
698    pub fn new(colors: Vec<ColorTriplet>) -> Self {
699        Palette { colors }
700    }
701
702    /// The color at `number` (`palette[number]`), if there is one.
703    pub fn get(&self, number: usize) -> Option<ColorTriplet> {
704        self.colors.get(number).copied()
705    }
706
707    /// The colors, in order.
708    pub fn colors(&self) -> &[ColorTriplet] {
709        &self.colors
710    }
711
712    /// How many colors the palette holds.
713    pub fn len(&self) -> usize {
714        self.colors.len()
715    }
716
717    /// Whether the palette holds no colors.
718    pub fn is_empty(&self) -> bool {
719        self.colors.is_empty()
720    }
721
722    /// The index of the color nearest to `color` by redmean distance, the
723    /// first on a tie. Port of `Palette.match`.
724    ///
725    /// # Panics
726    ///
727    /// On an empty palette, where upstream's `min` raises.
728    pub fn match_color(&self, color: ColorTriplet) -> usize {
729        assert!(!self.colors.is_empty(), "match on an empty palette");
730        match_color(&self.colors, color) as usize
731    }
732}
733
734impl std::ops::Index<usize> for Palette {
735    type Output = ColorTriplet;
736
737    fn index(&self, number: usize) -> &ColorTriplet {
738        &self.colors[number]
739    }
740}
741
742/// A palette prints as a table of its colors. Port of `Palette.__rich__`.
743impl crate::protocol::Renderable for Palette {
744    fn rich_render(
745        &self,
746        console: &crate::console::Console,
747        options: &crate::console::ConsoleOptions,
748    ) -> Vec<crate::segment::Segment> {
749        self.table().rich_render(console, options)
750    }
751
752    fn measure(
753        &self,
754        console: &crate::console::Console,
755        options: &crate::console::ConsoleOptions,
756    ) -> crate::measure::Measurement {
757        self.table().measure(console, options)
758    }
759}
760
761impl Palette {
762    /// The table `Palette.__rich__` returns.
763    fn table(&self) -> crate::table::Table {
764        use crate::table::{Cell, Table};
765        use crate::text::Text;
766        let mut table = Table::new()
767            .title("Palette")
768            .caption(format!("{} colors", self.colors.len()))
769            .highlight(true)
770            .caption_justify(crate::console::Justify::Right);
771        table.add_column("index");
772        table.add_column("RGB");
773        table.add_column("Color");
774        for (index, color) in self.colors.iter().enumerate() {
775            let swatch = crate::style::Style::from_color(None, Some(Color::from_triplet(*color)));
776            table.add_row_cells(vec![
777                Cell::Markup(index.to_string()),
778                Cell::Markup(format!("({}, {}, {})", color.red, color.green, color.blue)),
779                Cell::Text(Text::styled(" ".repeat(16), swatch)),
780            ]);
781        }
782        table
783    }
784}
785
786/// A color prints as its description and a dot in the color. Port of
787/// `Color.__rich__`.
788impl crate::protocol::Renderable for Color {
789    fn rich_render(
790        &self,
791        console: &crate::console::Console,
792        options: &crate::console::ConsoleOptions,
793    ) -> Vec<crate::segment::Segment> {
794        self.rich_text().rich_render(console, options)
795    }
796
797    fn measure(
798        &self,
799        console: &crate::console::Console,
800        options: &crate::console::ConsoleOptions,
801    ) -> crate::measure::Measurement {
802        self.rich_text().measure(console, options)
803    }
804
805    fn printed_text(&self) -> Option<crate::text::Text> {
806        Some(self.rich_text())
807    }
808}
809
810#[cfg(test)]
811mod tests {
812    use super::*;
813
814    /// The hand-written hex is upstream's `"#%02x%02x%02x"` for every
815    /// value of every channel.
816    #[test]
817    fn hex_matches_format() {
818        for value in 0..=255u8 {
819            let other = value.wrapping_mul(97).wrapping_add(13);
820            for (r, g, b) in [(value, other, 0), (other, value, 255), (0, other, value)] {
821                assert_eq!(
822                    ColorTriplet::new(r, g, b).hex(),
823                    format!("#{r:02x}{g:02x}{b:02x}")
824                );
825            }
826        }
827    }
828
829    /// A Windows console matches against `WINDOWS_PALETTE`: `#808080` is
830    /// bright black (90), not the standard palette's white (37); an 8-bit
831    /// color under 16 keeps its number.
832    #[test]
833    fn windows_downgrade_uses_the_windows_palette() {
834        let grey = Color::parse("#808080")
835            .unwrap()
836            .downgrade(ColorSystem::Windows);
837        assert_eq!((grey.kind, grey.number), (ColorType::Windows, Some(8)));
838        let low = Color::parse("color(9)")
839            .unwrap()
840            .downgrade(ColorSystem::Windows);
841        assert_eq!((low.kind, low.number), (ColorType::Windows, Some(9)));
842        assert!(Color::parse("grey").is_err());
843        assert!(Color::parse("gray").is_err());
844    }
845
846    #[test]
847    fn parses_standard_name() {
848        let c = Color::parse("red").unwrap();
849        assert_eq!(c.kind, ColorType::Standard);
850        assert_eq!(c.number, Some(1));
851        assert_eq!(c.ansi_codes(true), vec!["31"]);
852        assert_eq!(c.ansi_codes(false), vec!["41"]);
853    }
854
855    #[test]
856    fn extended_name_is_eight_bit() {
857        // orange1 (214) is beyond the 16 standard colors → 8-bit palette.
858        let c = Color::parse("orange1").unwrap();
859        assert_eq!(c.kind, ColorType::EightBit);
860        assert_eq!(c.number, Some(214));
861        assert_eq!(c.ansi_codes(true), vec!["38", "5", "214"]);
862    }
863
864    #[test]
865    fn bright_color_uses_high_intensity_sgr() {
866        let c = Color::parse("bright_red").unwrap();
867        assert_eq!(c.number, Some(9));
868        // 82 + 9 = 91
869        assert_eq!(c.ansi_codes(true), vec!["91"]);
870    }
871
872    #[test]
873    fn parses_hex_truecolor() {
874        let c = Color::parse("#ff8800").unwrap();
875        assert_eq!(c.kind, ColorType::Truecolor);
876        assert_eq!(c.triplet, Some(ColorTriplet::new(0xff, 0x88, 0x00)));
877        assert_eq!(c.ansi_codes(true), vec!["38", "2", "255", "136", "0"]);
878    }
879
880    /// Values captured from real rich 15.0.0 `Color.downgrade`.
881    ///
882    /// These previously asserted the *wrong* answers, because the matching used
883    /// the 128-based ANSI table rather than upstream's separate 170/85-based
884    /// `STANDARD_PALETTE` — `#ff0000` was said to give 9 (bright red) when
885    /// upstream gives 1 (maroon, the nearer entry under redmean distance).
886    #[test]
887    fn downgrade_to_standard_matches_upstream() {
888        let standard = |hex: &str| {
889            let down = Color::parse(hex).unwrap().downgrade(ColorSystem::Standard);
890            assert_eq!(down.kind, ColorType::Standard);
891            down.number
892        };
893        assert_eq!(standard("#ff0000"), Some(1));
894        assert_eq!(standard("#00ff00"), Some(2));
895        assert_eq!(standard("#0000ff"), Some(4));
896        assert_eq!(standard("#ffffff"), Some(15));
897        assert_eq!(standard("#808080"), Some(7));
898        assert_eq!(standard("#ff8800"), Some(9));
899    }
900
901    /// Upstream maps into the 6×6×6 cube (and the greyscale ramp) by formula,
902    /// *not* by nearest-neighbour over the 256 palette — so `#00ff00` becomes
903    /// cube index 46 rather than the exactly-matching system green 10.
904    #[test]
905    fn downgrade_to_eight_bit_matches_upstream() {
906        let eight_bit = |hex: &str| {
907            let down = Color::parse(hex).unwrap().downgrade(ColorSystem::EightBit);
908            assert_eq!(down.kind, ColorType::EightBit);
909            down.number
910        };
911        assert_eq!(eight_bit("#ff0000"), Some(196));
912        assert_eq!(eight_bit("#00ff00"), Some(46));
913        assert_eq!(eight_bit("#0000ff"), Some(21));
914        assert_eq!(eight_bit("#ff8800"), Some(208));
915        // Low saturation takes the greyscale-ramp branch.
916        assert_eq!(eight_bit("#ffffff"), Some(231));
917        assert_eq!(eight_bit("#808080"), Some(244));
918    }
919
920    #[test]
921    fn eight_bit_palette_cube_is_correct() {
922        // index 196 is the top of the cube red (5,0,0) -> 255,0,0
923        assert_eq!(eight_bit_triplet(196), ColorTriplet::new(255, 0, 0));
924    }
925}