Skip to main content

kitchen_ui/theme/
palette.rs

1//! Accent colours. The neutrals are shared ([`super::color`]); each app supplies a default
2//! [`Palette`] (dsp-kitchen [`Palette::LIME`], lumen [`Palette::EOSIN`]), and the user may pick any
3//! colour instead ([`Rgb`], stored in the app's preferences): [`Palette::from_rgb`] turns it into
4//! light and dark accents that pass WCAG contrast.
5
6use std::fmt;
7
8use gpui_kit::Hsla;
9use serde::{Deserialize, Deserializer, Serialize, Serializer};
10
11use super::color::{contrast, neutrals, rgb_hsla};
12
13/// A colour as `0xRRGGBB`: what the user picked as accent, stored as is in the app's preferences
14/// (as `"#rrggbb"` in JSON).
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
16pub struct Rgb(pub u32);
17
18impl Rgb {
19    /// Colours an accent picker offers besides the app's own (any other is a custom pick).
20    pub const SUGGESTED: [Rgb; 8] = [
21        Rgb(0xc6f26b), // lime
22        Rgb(0xd6457f), // eosin rose
23        Rgb(0x2b7f86), // teal
24        Rgb(0x3d65a8), // blue
25        Rgb(0x7451b8), // violet
26        Rgb(0xe0a030), // amber
27        Rgb(0xe5484d), // red
28        Rgb(0x8a8f98), // grey
29    ];
30
31    /// `"#c6f26b"`, `"c6f26b"` or `"#c6f"` as a colour; `None` for anything else.
32    pub fn parse(text: &str) -> Option<Rgb> {
33        let hex = text.trim().trim_start_matches('#');
34        let full = match hex.len() {
35            3 => hex.chars().flat_map(|c| [c, c]).collect(),
36            6 => hex.to_string(),
37            _ => return None,
38        };
39        u32::from_str_radix(&full, 16).ok().map(Rgb)
40    }
41
42    /// As a colour to draw.
43    pub fn hsla(self) -> Hsla {
44        rgb_hsla(self.0)
45    }
46}
47
48impl From<Hsla> for Rgb {
49    fn from(c: Hsla) -> Self {
50        Rgb(hsla_rgb(c))
51    }
52}
53
54impl fmt::Display for Rgb {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        write!(f, "#{:06x}", self.0 & 0xffffff)
57    }
58}
59
60impl Serialize for Rgb {
61    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
62        s.serialize_str(&self.to_string())
63    }
64}
65
66impl<'de> Deserialize<'de> for Rgb {
67    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
68        let text = String::deserialize(d)?;
69        Rgb::parse(&text).ok_or_else(|| serde::de::Error::custom(format!("{text:?} is not a colour (#rrggbb)")))
70    }
71}
72
73/// The accent colours of one theme (light or dark), as RGB.
74#[derive(Clone, Copy, Debug, PartialEq, Eq)]
75pub struct Accent {
76    /// Fills: primary buttons, the selected item's background (at a low opacity), switches on.
77    pub fill: u32,
78    /// The fill under the pointer.
79    pub fill_hover: u32,
80    /// Text and icons on the fill (≥ 4.5:1 on it and on its hover).
81    pub on_fill: u32,
82    /// Accent text and thin marks: links, focus rings, a slider's filled part, the selected item's
83    /// border (≥ 4.5:1 on panels as text, ≥ 3:1 on controls as a mark).
84    pub mark: u32,
85}
86
87/// An app's accent colours, one set per theme. Neutrals, spacing and type are shared.
88#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89pub struct Palette {
90    /// Shown in Settings and in the gallery.
91    pub name: &'static str,
92    /// Accent of the dark theme.
93    pub dark: Accent,
94    /// Accent of the light theme.
95    pub light: Accent,
96}
97
98impl Palette {
99    /// dsp-kitchen's lime (the neuro-kitchen site's palette): lime fills with near-black text on
100    /// them in both themes; in the light theme accent text and marks are a deep olive, since lime
101    /// on a light panel is barely visible.
102    pub const LIME: Palette = Palette {
103        name: "Lime",
104        dark: Accent { fill: 0xc6f26b, fill_hover: 0xe2faa8, on_fill: 0x0a0e17, mark: 0xc6f26b },
105        light: Accent { fill: 0xc6f26b, fill_hover: 0xb4e350, on_fill: 0x0a0e17, mark: 0x3f6212 },
106    };
107
108    /// lumen's eosin rose (the docs' palette): a light rose in the dark theme (near-black text on
109    /// it), a deep rose in the light theme (white text on it).
110    pub const EOSIN: Palette = Palette {
111        name: "Eosin",
112        dark: Accent { fill: 0xf28ab3, fill_hover: 0xf6a8c7, on_fill: 0x0a0e17, mark: 0xf28ab3 },
113        light: Accent { fill: 0xb0285f, fill_hover: 0x93214f, on_fill: 0xffffff, mark: 0xb0285f },
114    };
115
116    /// The apps' default palettes.
117    pub const APPS: [Palette; 2] = [Palette::LIME, Palette::EOSIN];
118
119    /// The accent of the dark (`true`) or light theme.
120    pub fn accent(&self, dark: bool) -> Accent {
121        if dark { self.dark } else { self.light }
122    }
123
124    /// The palette of a colour the user picked ([`Palette::from_rgb`], named "Custom").
125    pub fn custom(rgb: Rgb) -> Palette {
126        Palette::from_rgb("Custom", rgb.0)
127    }
128
129    /// A palette made from any colour, one accent per theme. The hue is kept; in the dark theme
130    /// the colour is first made 25 % less colourful, so it does not glare (designparser
131    /// `dark-mode-color-adaptation`). Then only its lightness moves (lighter in the dark theme,
132    /// darker in the light one) until:
133    ///
134    /// - the fill stands apart from the panel (≥ 2:1) and white or near-black text reads on it
135    ///   (≥ 4.5:1; whichever reads better is the text colour);
136    /// - the hover steps away from that text, so it reads at least as well;
137    /// - the mark reads as text on panels (≥ 4.5:1) and as a mark on controls (≥ 3:1).
138    pub fn from_rgb(name: &'static str, rgb: u32) -> Palette {
139        let base = rgb_hsla(rgb);
140        Palette { name, dark: derive(base, true), light: derive(base, false) }
141    }
142}
143
144/// One theme's accent from `base` ([`Palette::from_rgb`]).
145fn derive(base: Hsla, dark: bool) -> Accent {
146    let pick = |pair: (u32, u32)| rgb_hsla(if dark { pair.0 } else { pair.1 });
147    let (panel, raised, control, hover) = (pick(neutrals::PANEL), pick(neutrals::RAISED), pick(neutrals::CONTROL), pick(neutrals::HOVER));
148    // Lighter on dark panels, darker on light ones: the direction that always converges
149    let step = if dark { 0.01 } else { -0.01 };
150    let start = Hsla { s: if dark { base.s * 0.75 } else { base.s }, a: 1.0, ..base };
151    // Checked on the colour as stored (8-bit RGB), so rounding cannot undo a pass
152    let stored = |c: Hsla| rgb_hsla(hsla_rgb(c));
153    let reach = |ok: &dyn Fn(Hsla) -> bool| {
154        let mut c = start;
155        for _ in 0..100 {
156            if ok(stored(c)) {
157                break;
158            }
159            c.l = (c.l + step).clamp(0.0, 1.0);
160        }
161        stored(c)
162    };
163    let fill = reach(&|c| contrast(c, panel) >= 2.0 && contrast(best_text_on(c), c) >= 4.5);
164    let on_fill = best_text_on(fill);
165    let fill_hover = Hsla { l: (fill.l + if on_fill.l > 0.5 { -0.06 } else { 0.06 }).clamp(0.0, 1.0), ..fill };
166    let mark = reach(&|c| [panel, raised].iter().all(|b| contrast(c, *b) >= 4.5) && [control, hover].iter().all(|b| contrast(c, *b) >= 3.0));
167    Accent { fill: hsla_rgb(fill), fill_hover: hsla_rgb(fill_hover), on_fill: hsla_rgb(on_fill), mark: hsla_rgb(mark) }
168}
169
170/// White or near-black (`0x0a0e17`), whichever has more contrast on `c`.
171pub fn best_text_on(c: Hsla) -> Hsla {
172    let (white, ink) = (rgb_hsla(0xffffff), rgb_hsla(0x0a0e17));
173    if contrast(white, c) >= contrast(ink, c) { white } else { ink }
174}
175
176/// `c` as `0xRRGGBB` (alpha dropped).
177pub fn hsla_rgb(c: Hsla) -> u32 {
178    let r = c.to_rgb();
179    let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u32;
180    (b(r.r) << 16) | (b(r.g) << 8) | b(r.b)
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn rgb_reads_and_writes_hex() {
189        assert_eq!(Rgb::parse("#c6f26b"), Some(Rgb(0xc6f26b)));
190        assert_eq!(Rgb::parse(" C6F26B "), Some(Rgb(0xc6f26b)));
191        assert_eq!(Rgb::parse("#fa0"), Some(Rgb(0xffaa00)));
192        assert_eq!(Rgb::parse("#12345"), None);
193        assert_eq!(Rgb::parse("#zzzzzz"), None);
194        assert_eq!(serde_json::to_string(&Rgb(0x0a1030)).unwrap(), "\"#0a1030\"");
195        assert_eq!(serde_json::from_str::<Option<Rgb>>("\"#b0285f\"").unwrap(), Some(Rgb(0xb0285f)));
196        assert!(serde_json::from_str::<Rgb>("\"rose\"").is_err());
197    }
198
199    /// Any colour the user picks gives accents that pass in both themes (the pairs themselves are
200    /// checked for every look in `color::tests`); here also: hue kept, fill apart from the panel.
201    #[test]
202    fn arbitrary_colours_pass_in_both_themes() {
203        let picks = [("pale yellow", 0xfffacd), ("near-black navy", 0x0a1030), ("pure red", 0xff0000), ("white", 0xffffff), ("black", 0x000000), ("lime", 0xc6f26b), ("mid grey", 0x808080)];
204        for (name, rgb) in picks {
205            let p = Palette::from_rgb(name, rgb);
206            for dark in [true, false] {
207                let a = p.accent(dark);
208                let panel = rgb_hsla(if dark { neutrals::PANEL.0 } else { neutrals::PANEL.1 });
209                let (fill, mark) = (rgb_hsla(a.fill), rgb_hsla(a.mark));
210                assert!(contrast(fill, panel) >= 2.0, "{name} dark={dark}: fill on panel {:.2}:1", contrast(fill, panel));
211                assert!(contrast(rgb_hsla(a.on_fill), fill) >= 4.5, "{name} dark={dark}: text on fill");
212                assert!(contrast(rgb_hsla(a.on_fill), rgb_hsla(a.fill_hover)) >= 4.5, "{name} dark={dark}: text on hover");
213                assert!(contrast(mark, panel) >= 4.5, "{name} dark={dark}: mark on panel {:.2}:1", contrast(mark, panel));
214                // Hue kept (when the colour has one)
215                let src = rgb_hsla(rgb);
216                if src.s > 0.2 && (0.1..0.9).contains(&fill.l) {
217                    let d = (fill.h - src.h).abs().min(1.0 - (fill.h - src.h).abs());
218                    assert!(d < 0.02, "{name} dark={dark}: hue {} → {}", src.h, fill.h);
219                }
220            }
221        }
222    }
223
224    #[test]
225    fn dark_accents_are_less_colourful() {
226        let p = Palette::from_rgb("red", 0xff0000);
227        let (dark, light) = (rgb_hsla(p.dark.fill), rgb_hsla(p.light.fill));
228        assert!(dark.s < light.s, "dark {} vs light {}", dark.s, light.s);
229    }
230}