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    /// Background of an added line in a diff: [`Colors::success`] mixed into the panel.
81    pub diff_added: Hsla,
82    /// Background of a removed line in a diff: [`Colors::danger`] mixed into the panel.
83    pub diff_removed: Hsla,
84}
85
86/// How much of the status colour a diff line's background takes (the rest is the panel).
87pub const DIFF_TINT: f32 = 0.16;
88
89impl Colors {
90    /// The colours of `look`.
91    pub fn resolve(look: &Look) -> Colors {
92        let dark = look.dark;
93        let n = |pair: (u32, u32)| rgb_hsla(if dark { pair.0 } else { pair.1 });
94        let a = look.active_palette().accent(dark);
95        Colors {
96            chrome: n(neutrals::CHROME),
97            panel: n(neutrals::PANEL),
98            raised: n(neutrals::RAISED),
99            control: n(neutrals::CONTROL),
100            hover: n(neutrals::HOVER),
101            border: n(neutrals::BORDER),
102            text: n(neutrals::TEXT),
103            text_muted: n(neutrals::TEXT_MUTED),
104            text_disabled: n(neutrals::TEXT_DISABLED),
105            accent: rgb_hsla(a.fill),
106            accent_hover: rgb_hsla(a.fill_hover),
107            on_accent: rgb_hsla(a.on_fill),
108            accent_mark: rgb_hsla(a.mark),
109            danger: n(neutrals::DANGER),
110            success: n(neutrals::SUCCESS),
111            warning: n(neutrals::WARNING),
112            diff_added: mix(n(neutrals::PANEL), n(neutrals::SUCCESS), DIFF_TINT),
113            diff_removed: mix(n(neutrals::PANEL), n(neutrals::DANGER), DIFF_TINT),
114        }
115    }
116}
117
118/// The colours of the current look.
119pub fn colors() -> Colors {
120    Colors::resolve(&look())
121}
122
123/// Title bar, tool columns, tab strips; the window background between panes.
124pub fn chrome() -> Hsla {
125    colors().chrome
126}
127/// Panels and panes.
128pub fn panel() -> Hsla {
129    colors().panel
130}
131/// Raised blocks inside panels, popovers, panel group headers.
132pub fn raised() -> Hsla {
133    colors().raised
134}
135/// Controls: buttons, inputs, slider tracks.
136pub fn control() -> Hsla {
137    colors().control
138}
139/// A control or row under the pointer.
140pub fn hover() -> Hsla {
141    colors().hover
142}
143/// Separators and outlines.
144pub fn border() -> Hsla {
145    colors().border
146}
147/// Body text.
148pub fn text() -> Hsla {
149    colors().text
150}
151/// Secondary text: ≥ 4.5:1 on every surface in both themes.
152pub fn text_muted() -> Hsla {
153    colors().text_muted
154}
155/// Disabled text only.
156pub fn text_disabled() -> Hsla {
157    colors().text_disabled
158}
159/// The accent fill: selection, the active tool, primary actions ([`on_accent`] text on it).
160pub fn accent() -> Hsla {
161    colors().accent
162}
163/// The accent fill under the pointer.
164pub fn accent_hover() -> Hsla {
165    colors().accent_hover
166}
167/// Text and icons on the accent fill.
168pub fn on_accent() -> Hsla {
169    colors().on_accent
170}
171/// Thin accent marks and accent text (≥ 3:1 on panels and controls).
172pub fn accent_mark() -> Hsla {
173    colors().accent_mark
174}
175/// Errors.
176pub fn danger() -> Hsla {
177    colors().danger
178}
179/// Success.
180pub fn success() -> Hsla {
181    colors().success
182}
183/// Warnings.
184pub fn warning() -> Hsla {
185    colors().warning
186}
187
188/// Background of an added line in a diff (text, muted text and the success mark pass on it).
189pub fn diff_added() -> Hsla {
190    colors().diff_added
191}
192/// Background of a removed line in a diff (text, muted text and the danger mark pass on it).
193pub fn diff_removed() -> Hsla {
194    colors().diff_removed
195}
196
197/// `a` with `t` (0–1) of `b` mixed in, in sRGB, opaque.
198pub fn mix(a: Hsla, b: Hsla, t: f32) -> Hsla {
199    let (a, b) = (a.to_rgb(), b.to_rgb());
200    let m = |x: f32, y: f32| x + (y - x) * t;
201    Rgba { r: m(a.r, b.r), g: m(a.g, b.g), b: m(a.b, b.b), a: 1.0 }.into()
202}
203
204/// A colour from `0xRRGGBB`.
205pub fn rgb_hsla(c: u32) -> Hsla {
206    rgb(c).into()
207}
208
209/// A colour from 0–255 RGB.
210pub fn rgb8(c: [u8; 3]) -> Hsla {
211    rgb_hsla(((c[0] as u32) << 16) | ((c[1] as u32) << 8) | c[2] as u32)
212}
213
214/// Relative luminance (WCAG), alpha ignored.
215pub fn luminance(c: Hsla) -> f32 {
216    let Rgba { r, g, b, .. } = c.to_rgb();
217    let lin = |v: f32| if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) };
218    0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
219}
220
221/// WCAG contrast ratio of two colours (1 to 21).
222pub fn contrast(a: Hsla, b: Hsla) -> f32 {
223    let (la, lb) = (luminance(a), luminance(b));
224    (la.max(lb) + 0.05) / (la.min(lb) + 0.05)
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use crate::theme::Palette;
231
232    use crate::theme::Rgb;
233
234    /// Every app palette and every suggested accent, plus a few arbitrary picks, in both themes.
235    fn looks() -> Vec<Look> {
236        let mut accents: Vec<Option<Rgb>> = vec![None];
237        accents.extend(Rgb::SUGGESTED.map(Some));
238        accents.extend([0xfffacd, 0x0a1030, 0xff0000, 0xffffff, 0x000000].map(|c| Some(Rgb(c))));
239        let mut out = Vec::new();
240        for p in Palette::APPS {
241            for a in &accents {
242                for dark in [true, false] {
243                    out.push(Look::new(p).with_accent(*a).with_dark(dark));
244                }
245            }
246        }
247        out
248    }
249
250    #[test]
251    fn neutrals_are_plain_greys() {
252        use neutrals::*;
253        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)] {
254            for c in [pair.0, pair.1] {
255                let (r, g, b) = (c >> 16 & 0xff, c >> 8 & 0xff, c & 0xff);
256                assert!(r == g && g == b, "{name}: #{c:06x} is tinted");
257            }
258        }
259    }
260
261    #[test]
262    fn text_passes_aa_on_every_surface() {
263        for l in looks() {
264            let c = Colors::resolve(&l);
265            for (sname, s) in [("chrome", c.chrome), ("panel", c.panel), ("raised", c.raised), ("control", c.control), ("hover", c.hover)] {
266                for (tname, t) in [("text", c.text), ("text_muted", c.text_muted)] {
267                    let r = contrast(t, s);
268                    assert!(r >= 4.5, "{} dark={}: {tname} on {sname} is {r:.2}:1", l.palette.name, l.dark);
269                }
270            }
271            for (name, t) in [("danger", c.danger), ("success", c.success), ("warning", c.warning)] {
272                let r = contrast(t, c.panel);
273                assert!(r >= 4.5, "dark={}: {name} on panel is {r:.2}:1", l.dark);
274            }
275        }
276    }
277
278    #[test]
279    fn accents_pass_contrast() {
280        for l in looks() {
281            let c = Colors::resolve(&l);
282            let name = format!("{} {:?} dark={}", l.palette.name, l.accent, l.dark);
283            for (fname, f) in [("fill", c.accent), ("fill_hover", c.accent_hover)] {
284                let r = contrast(c.on_accent, f);
285                assert!(r >= 4.5, "{name}: text on {fname} is {r:.2}:1");
286            }
287            // Accent text (links) on panels; marks on controls
288            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)] {
289                let r = contrast(c.accent_mark, s);
290                assert!(r >= min, "{name}: mark on {sname} is {r:.2}:1");
291            }
292        }
293    }
294
295    #[test]
296    fn diff_lines_pass_contrast() {
297        for dark in [true, false] {
298            let c = Colors::resolve(&Look::new(Palette::LIME).with_dark(dark));
299            for (bname, bg, mark) in [("added", c.diff_added, c.success), ("removed", c.diff_removed, c.danger)] {
300                // The line's text and its line numbers
301                for (tname, t) in [("text", c.text), ("text_muted", c.text_muted)] {
302                    let r = contrast(t, bg);
303                    assert!(r >= 4.5, "dark={dark}: {tname} on the {bname} line is {r:.2}:1");
304                }
305                // The + / − sign is a mark
306                let r = contrast(mark, bg);
307                assert!(r >= 3.0, "dark={dark}: the sign on the {bname} line is {r:.2}:1");
308                // The tint is visible against the panel
309                assert!(bg != c.panel, "dark={dark}: the {bname} line is not tinted");
310            }
311        }
312    }
313
314    #[test]
315    fn surfaces_step_apart() {
316        for dark in [true, false] {
317            let c = Colors::resolve(&Look { dark, ..Look::new(Palette::LIME) });
318            let order = [c.chrome, c.panel, c.raised];
319            let l: Vec<f32> = order.iter().map(|c| luminance(*c)).collect();
320            assert!(l[0] < l[1] && l[1] < l[2], "dark={dark}: chrome < panel < raised in lightness");
321        }
322    }
323
324    #[test]
325    fn contrast_of_known_pairs() {
326        assert!((contrast(rgb_hsla(0x000000), rgb_hsla(0xffffff)) - 21.0).abs() < 0.01);
327        // The usual reference pairs: #767676 on white is 4.54:1 (the lightest grey passing AA),
328        // #777777 4.48:1 (fails). designparser `wcag-contrast` quotes 4.48 for #767676.
329        let r = contrast(rgb_hsla(0x767676), rgb_hsla(0xffffff));
330        assert!((r - 4.54).abs() < 0.01, "{r}");
331        let r = contrast(rgb_hsla(0x777777), rgb_hsla(0xffffff));
332        assert!((r - 4.48).abs() < 0.01, "{r}");
333        assert_eq!(rgb8([0x12, 0x34, 0x56]), rgb_hsla(0x123456));
334    }
335}