Skip to main content

guise/theme/
color.rs

1//! 24-bit RGB color with hex parsing and conversion into gpui's `Hsla`.
2//!
3//! Colors are stored as plain RGB triples (the Mantine/open-color palette is
4//! authored as hex). `hsla`/`alpha` bridge into the gpui rendering types at the
5//! point of use.
6
7use gpui::{Hsla, Rgba};
8
9/// An opaque 24-bit color.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub struct Color {
12    pub r: u8,
13    pub g: u8,
14    pub b: u8,
15}
16
17impl Color {
18    pub const fn new(r: u8, g: u8, b: u8) -> Self {
19        Color { r, g, b }
20    }
21
22    /// Parse a `#rrggbb` / `rrggbb` / `#rgb` / `rgb` hex string.
23    ///
24    /// Panics on malformed input — callers pass static palette literals, so a
25    /// bad value is a programming error that should fail loudly at startup.
26    pub const fn hex(s: &str) -> Self {
27        let bytes = s.as_bytes();
28        let hex = if !bytes.is_empty() && bytes[0] == b'#' {
29            split_at(bytes, 1)
30        } else {
31            bytes
32        };
33        match hex.len() {
34            3 => Color::new(
35                nibble(hex[0]) * 17,
36                nibble(hex[1]) * 17,
37                nibble(hex[2]) * 17,
38            ),
39            6 => Color::new(
40                nibble(hex[0]) * 16 + nibble(hex[1]),
41                nibble(hex[2]) * 16 + nibble(hex[3]),
42                nibble(hex[4]) * 16 + nibble(hex[5]),
43            ),
44            _ => panic!("guise: color hex must be 3 or 6 digits"),
45        }
46    }
47
48    /// Build an opaque `Color` from a gpui `Hsla`, dropping any alpha. Used so a
49    /// CSS color (which parses to `Hsla`) can populate the opaque theme fields.
50    pub fn from_hsla(c: Hsla) -> Color {
51        let (r, g, b) = hsl_to_rgb(c.h, c.s, c.l);
52        Color::new(r, g, b)
53    }
54
55    /// As an opaque gpui `Rgba`.
56    pub fn rgba(self) -> Rgba {
57        Rgba {
58            r: self.r as f32 / 255.0,
59            g: self.g as f32 / 255.0,
60            b: self.b as f32 / 255.0,
61            a: 1.0,
62        }
63    }
64
65    /// As an opaque gpui `Hsla`.
66    pub fn hsla(self) -> Hsla {
67        self.rgba().into()
68    }
69
70    /// As a gpui `Hsla` with the given alpha in `0.0..=1.0`.
71    pub fn alpha(self, a: f32) -> Hsla {
72        let mut hsla: Hsla = self.rgba().into();
73        hsla.a = a.clamp(0.0, 1.0);
74        hsla
75    }
76
77    /// Relative luminance in `0.0..=1.0` (Rec. 709 weights). Used to pick a
78    /// readable foreground (black vs. white) over a filled color.
79    pub fn luminance(self) -> f32 {
80        (0.2126 * self.r as f32 + 0.7152 * self.g as f32 + 0.0722 * self.b as f32) / 255.0
81    }
82
83    /// A foreground color (black or white) with adequate contrast over `self`.
84    pub fn contrasting(self) -> Color {
85        if self.luminance() > 0.55 {
86            Color::new(0, 0, 0)
87        } else {
88            Color::new(255, 255, 255)
89        }
90    }
91}
92
93impl From<Color> for Hsla {
94    fn from(c: Color) -> Hsla {
95        c.hsla()
96    }
97}
98
99/// HSL (h,s,l all in `0.0..=1.0`, the gpui convention) to 8-bit RGB.
100fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
101    let to_u8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
102    if s <= 0.0 {
103        let v = to_u8(l);
104        return (v, v, v);
105    }
106    let q = if l < 0.5 {
107        l * (1.0 + s)
108    } else {
109        l + s - l * s
110    };
111    let p = 2.0 * l - q;
112    let hue = |mut t: f32| {
113        if t < 0.0 {
114            t += 1.0;
115        }
116        if t > 1.0 {
117            t -= 1.0;
118        }
119        if t < 1.0 / 6.0 {
120            p + (q - p) * 6.0 * t
121        } else if t < 1.0 / 2.0 {
122            q
123        } else if t < 2.0 / 3.0 {
124            p + (q - p) * (2.0 / 3.0 - t) * 6.0
125        } else {
126            p
127        }
128    };
129    (
130        to_u8(hue(h + 1.0 / 3.0)),
131        to_u8(hue(h)),
132        to_u8(hue(h - 1.0 / 3.0)),
133    )
134}
135
136const fn split_at(bytes: &[u8], at: usize) -> &[u8] {
137    // const-friendly slice from `at` to end.
138    bytes.split_at(at).1
139}
140
141const fn nibble(b: u8) -> u8 {
142    match b {
143        b'0'..=b'9' => b - b'0',
144        b'a'..=b'f' => b - b'a' + 10,
145        b'A'..=b'F' => b - b'A' + 10,
146        _ => panic!("guise: invalid hex digit in color"),
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn parses_long_and_short_forms() {
156        assert_eq!(Color::hex("#228be6"), Color::new(0x22, 0x8b, 0xe6));
157        assert_eq!(Color::hex("228be6"), Color::new(0x22, 0x8b, 0xe6));
158        assert_eq!(Color::hex("#fff"), Color::new(255, 255, 255));
159        assert_eq!(Color::hex("000"), Color::new(0, 0, 0));
160    }
161
162    #[test]
163    fn contrasting_picks_readable_foreground() {
164        assert_eq!(Color::hex("#ffffff").contrasting(), Color::new(0, 0, 0));
165        assert_eq!(
166            Color::hex("#1864ab").contrasting(),
167            Color::new(255, 255, 255)
168        );
169    }
170}