Skip to main content

kitchen_ui/theme/
color.rs

1//! The colours: shared neutrals (plain greys: surfaces from the deepest, text, borders), status
2//! colours and the accent ([`super::Look::active_palette`]), resolved for the current look.
3//!
4//! Every function here reads the look set with [`super::set_look`], so code can ask for a colour
5//! without a context (`color::text_muted()`). [`Colors::resolve`] gives the same values for any
6//! look, for tests and for drawing a theme that is not the current one.
7//!
8//! Surfaces step clearly apart; borders are darker than the surfaces they divide in the dark
9//! theme (thin lines, as in Affinity); text passes WCAG AA (≥ 4.5:1) on every surface, marks
10//! ≥ 3:1 (designparser `wcag-contrast`). The tests check each pair.
11
12use gpui_kit::{Hsla, Rgba, rgb};
13
14use super::{Look, look};
15
16/// The shared neutrals as `(dark, light)` RGB pairs: plain greys (no tint), so any accent sits on
17/// them.
18pub mod neutrals {
19    /// Title bar, tool columns, tab strips, and the window background panes sit on.
20    pub const CHROME: (u32, u32) = (0x222222, 0xe4e4e4);
21    /// Panels and panes.
22    pub const PANEL: (u32, u32) = (0x2a2a2a, 0xf2f2f2);
23    /// Raised blocks inside panels, popovers, panel group headers.
24    pub const RAISED: (u32, u32) = (0x313131, 0xfafafa);
25    /// Controls: buttons, inputs, slider tracks.
26    pub const CONTROL: (u32, u32) = (0x3a3a3a, 0xe0e0e0);
27    /// A control or row under the pointer.
28    pub const HOVER: (u32, u32) = (0x454545, 0xd4d4d4);
29    /// Separators and outlines.
30    pub const BORDER: (u32, u32) = (0x171717, 0xc6c6c6);
31    /// Body text.
32    pub const TEXT: (u32, u32) = (0xe6e6e6, 0x1c1c1c);
33    /// Secondary text: labels, captions, hints.
34    pub const TEXT_MUTED: (u32, u32) = (0xb8b8b8, 0x555555);
35    /// Text of disabled controls only (no contrast requirement).
36    pub const TEXT_DISABLED: (u32, u32) = (0x6e6e6e, 0x9a9a9a);
37    /// Errors (text and fills).
38    pub const DANGER: (u32, u32) = (0xef6b6b, 0xb3261e);
39    /// Success (text and fills).
40    pub const SUCCESS: (u32, u32) = (0x4caf7a, 0x1e7a46);
41    /// Warnings (text and fills).
42    pub const WARNING: (u32, u32) = (0xe0a030, 0x8a5a00);
43}
44
45/// Every colour of one look, resolved.
46#[derive(Clone, Copy, Debug, PartialEq)]
47pub struct Colors {
48    /// Title bar, tool columns, tab strips; the window background between panes.
49    pub chrome: Hsla,
50    /// Panels and panes.
51    pub panel: Hsla,
52    /// Raised blocks inside panels, popovers.
53    pub raised: Hsla,
54    /// Controls: buttons, inputs, slider tracks.
55    pub control: Hsla,
56    /// A control or row under the pointer.
57    pub hover: Hsla,
58    /// Separators and outlines.
59    pub border: Hsla,
60    /// Body text.
61    pub text: Hsla,
62    /// Secondary text.
63    pub text_muted: Hsla,
64    /// Disabled text only.
65    pub text_disabled: Hsla,
66    /// The accent fill: primary buttons, selection backgrounds (at a low opacity).
67    pub accent: Hsla,
68    /// The accent fill under the pointer.
69    pub accent_hover: Hsla,
70    /// Text and icons on the accent fill.
71    pub on_accent: Hsla,
72    /// Thin accent marks and accent text: slider fills, focus rings, links, selected borders.
73    pub accent_mark: Hsla,
74    /// Errors.
75    pub danger: Hsla,
76    /// Success.
77    pub success: Hsla,
78    /// Warnings.
79    pub warning: Hsla,
80}
81
82impl Colors {
83    /// The colours of `look`.
84    pub fn resolve(look: &Look) -> Colors {
85        let dark = look.dark;
86        let n = |pair: (u32, u32)| rgb_hsla(if dark { pair.0 } else { pair.1 });
87        let a = look.active_palette().accent(dark);
88        Colors {
89            chrome: n(neutrals::CHROME),
90            panel: n(neutrals::PANEL),
91            raised: n(neutrals::RAISED),
92            control: n(neutrals::CONTROL),
93            hover: n(neutrals::HOVER),
94            border: n(neutrals::BORDER),
95            text: n(neutrals::TEXT),
96            text_muted: n(neutrals::TEXT_MUTED),
97            text_disabled: n(neutrals::TEXT_DISABLED),
98            accent: rgb_hsla(a.fill),
99            accent_hover: rgb_hsla(a.fill_hover),
100            on_accent: rgb_hsla(a.on_fill),
101            accent_mark: rgb_hsla(a.mark),
102            danger: n(neutrals::DANGER),
103            success: n(neutrals::SUCCESS),
104            warning: n(neutrals::WARNING),
105        }
106    }
107}
108
109/// The colours of the current look.
110pub fn colors() -> Colors {
111    Colors::resolve(&look())
112}
113
114/// Title bar, tool columns, tab strips; the window background between panes.
115pub fn chrome() -> Hsla {
116    colors().chrome
117}
118/// Panels and panes.
119pub fn panel() -> Hsla {
120    colors().panel
121}
122/// Raised blocks inside panels, popovers, panel group headers.
123pub fn raised() -> Hsla {
124    colors().raised
125}
126/// Controls: buttons, inputs, slider tracks.
127pub fn control() -> Hsla {
128    colors().control
129}
130/// A control or row under the pointer.
131pub fn hover() -> Hsla {
132    colors().hover
133}
134/// Separators and outlines.
135pub fn border() -> Hsla {
136    colors().border
137}
138/// Body text.
139pub fn text() -> Hsla {
140    colors().text
141}
142/// Secondary text: ≥ 4.5:1 on every surface in both themes.
143pub fn text_muted() -> Hsla {
144    colors().text_muted
145}
146/// Disabled text only.
147pub fn text_disabled() -> Hsla {
148    colors().text_disabled
149}
150/// The accent fill: selection, the active tool, primary actions ([`on_accent`] text on it).
151pub fn accent() -> Hsla {
152    colors().accent
153}
154/// The accent fill under the pointer.
155pub fn accent_hover() -> Hsla {
156    colors().accent_hover
157}
158/// Text and icons on the accent fill.
159pub fn on_accent() -> Hsla {
160    colors().on_accent
161}
162/// Thin accent marks and accent text (≥ 3:1 on panels and controls).
163pub fn accent_mark() -> Hsla {
164    colors().accent_mark
165}
166/// Errors.
167pub fn danger() -> Hsla {
168    colors().danger
169}
170/// Success.
171pub fn success() -> Hsla {
172    colors().success
173}
174/// Warnings.
175pub fn warning() -> Hsla {
176    colors().warning
177}
178
179/// A colour from `0xRRGGBB`.
180pub fn rgb_hsla(c: u32) -> Hsla {
181    rgb(c).into()
182}
183
184/// A colour from 0–255 RGB.
185pub fn rgb8(c: [u8; 3]) -> Hsla {
186    rgb_hsla(((c[0] as u32) << 16) | ((c[1] as u32) << 8) | c[2] as u32)
187}
188
189/// Relative luminance (WCAG), alpha ignored.
190pub fn luminance(c: Hsla) -> f32 {
191    let Rgba { r, g, b, .. } = c.to_rgb();
192    let lin = |v: f32| if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) };
193    0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
194}
195
196/// WCAG contrast ratio of two colours (1 to 21).
197pub fn contrast(a: Hsla, b: Hsla) -> f32 {
198    let (la, lb) = (luminance(a), luminance(b));
199    (la.max(lb) + 0.05) / (la.min(lb) + 0.05)
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::theme::Palette;
206
207    use crate::theme::Rgb;
208
209    /// Every app palette and every suggested accent, plus a few arbitrary picks, in both themes.
210    fn looks() -> Vec<Look> {
211        let mut accents: Vec<Option<Rgb>> = vec![None];
212        accents.extend(Rgb::SUGGESTED.map(Some));
213        accents.extend([0xfffacd, 0x0a1030, 0xff0000, 0xffffff, 0x000000].map(|c| Some(Rgb(c))));
214        let mut out = Vec::new();
215        for p in Palette::APPS {
216            for a in &accents {
217                for dark in [true, false] {
218                    out.push(Look::new(p).with_accent(*a).with_dark(dark));
219                }
220            }
221        }
222        out
223    }
224
225    #[test]
226    fn neutrals_are_plain_greys() {
227        use neutrals::*;
228        for (name, pair) in [("chrome", CHROME), ("panel", PANEL), ("raised", RAISED), ("control", CONTROL), ("hover", HOVER), ("border", BORDER), ("text", TEXT), ("text_muted", TEXT_MUTED), ("text_disabled", TEXT_DISABLED)] {
229            for c in [pair.0, pair.1] {
230                let (r, g, b) = (c >> 16 & 0xff, c >> 8 & 0xff, c & 0xff);
231                assert!(r == g && g == b, "{name}: #{c:06x} is tinted");
232            }
233        }
234    }
235
236    #[test]
237    fn text_passes_aa_on_every_surface() {
238        for l in looks() {
239            let c = Colors::resolve(&l);
240            for (sname, s) in [("chrome", c.chrome), ("panel", c.panel), ("raised", c.raised), ("control", c.control), ("hover", c.hover)] {
241                for (tname, t) in [("text", c.text), ("text_muted", c.text_muted)] {
242                    let r = contrast(t, s);
243                    assert!(r >= 4.5, "{} dark={}: {tname} on {sname} is {r:.2}:1", l.palette.name, l.dark);
244                }
245            }
246            for (name, t) in [("danger", c.danger), ("success", c.success), ("warning", c.warning)] {
247                let r = contrast(t, c.panel);
248                assert!(r >= 4.5, "dark={}: {name} on panel is {r:.2}:1", l.dark);
249            }
250        }
251    }
252
253    #[test]
254    fn accents_pass_contrast() {
255        for l in looks() {
256            let c = Colors::resolve(&l);
257            let name = format!("{} {:?} dark={}", l.palette.name, l.accent, l.dark);
258            for (fname, f) in [("fill", c.accent), ("fill_hover", c.accent_hover)] {
259                let r = contrast(c.on_accent, f);
260                assert!(r >= 4.5, "{name}: text on {fname} is {r:.2}:1");
261            }
262            // Accent text (links) on panels; marks on controls
263            for (sname, s, min) in [("panel", c.panel, 4.5), ("raised", c.raised, 4.5), ("control", c.control, 3.0), ("hover", c.hover, 3.0)] {
264                let r = contrast(c.accent_mark, s);
265                assert!(r >= min, "{name}: mark on {sname} is {r:.2}:1");
266            }
267        }
268    }
269
270    #[test]
271    fn surfaces_step_apart() {
272        for dark in [true, false] {
273            let c = Colors::resolve(&Look { dark, ..Look::new(Palette::LIME) });
274            let order = [c.chrome, c.panel, c.raised];
275            let l: Vec<f32> = order.iter().map(|c| luminance(*c)).collect();
276            assert!(l[0] < l[1] && l[1] < l[2], "dark={dark}: chrome < panel < raised in lightness");
277        }
278    }
279
280    #[test]
281    fn contrast_of_known_pairs() {
282        assert!((contrast(rgb_hsla(0x000000), rgb_hsla(0xffffff)) - 21.0).abs() < 0.01);
283        // The usual reference pairs: #767676 on white is 4.54:1 (the lightest grey passing AA),
284        // #777777 4.48:1 (fails). designparser `wcag-contrast` quotes 4.48 for #767676.
285        let r = contrast(rgb_hsla(0x767676), rgb_hsla(0xffffff));
286        assert!((r - 4.54).abs() < 0.01, "{r}");
287        let r = contrast(rgb_hsla(0x777777), rgb_hsla(0xffffff));
288        assert!((r - 4.48).abs() < 0.01, "{r}");
289        assert_eq!(rgb8([0x12, 0x34, 0x56]), rgb_hsla(0x123456));
290    }
291}