Skip to main content

corundum_framework_gui/theme/
palette.rs

1//! The grid: [`Tone`]s by [`Swatch`]es, and [`Theme`], its colours worked out.
2
3use egui::Color32;
4
5use super::color::{Oklch, to_color32};
6use super::{Contrast, Tint};
7
8/// A lightness level, from the background side to the text side.
9#[derive(Clone, Copy, PartialEq, Eq, Debug)]
10pub enum Tone {
11    /// Text edit backgrounds.
12    Sunken,
13    /// Panels and windows.
14    Base,
15    /// Inactive buttons, code, tinted bands.
16    Raised,
17    /// Hovered widgets.
18    Hover,
19    /// Pressed and selected widgets.
20    Pressed,
21    /// Separators and outlines.
22    Border,
23    /// Weak text.
24    Muted,
25    /// Body text.
26    Text,
27    /// The text of hovered and active widgets.
28    Strong,
29}
30
31impl Tone {
32    /// Every tone, background side first.
33    pub const ALL: [Tone; 9] = [
34        Tone::Sunken,
35        Tone::Base,
36        Tone::Raised,
37        Tone::Hover,
38        Tone::Pressed,
39        Tone::Border,
40        Tone::Muted,
41        Tone::Text,
42        Tone::Strong,
43    ];
44
45    /// How much of a swatch's chroma the tone keeps: fills stay quiet, text takes it all.
46    fn chroma_scale(self) -> f32 {
47        match self {
48            Tone::Sunken | Tone::Base => 0.2,
49            Tone::Raised => 0.35,
50            Tone::Hover => 0.45,
51            Tone::Pressed => 0.6,
52            Tone::Border => 0.4,
53            Tone::Muted | Tone::Text | Tone::Strong => 1.0,
54        }
55    }
56}
57
58/// A hue and a chroma. Red to Purple line up with Base16's base08 to base0E.
59#[derive(Clone, Copy, PartialEq, Eq, Debug)]
60#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
61pub enum Swatch {
62    /// Greys.
63    Neutral,
64    /// The user's accent ([`ThemeSettings::accent`](super::ThemeSettings::accent)).
65    Accent,
66    Red,
67    Orange,
68    Yellow,
69    Green,
70    Cyan,
71    Blue,
72    Purple,
73}
74
75impl Swatch {
76    /// Every swatch.
77    pub const ALL: [Swatch; 9] = [
78        Swatch::Neutral,
79        Swatch::Accent,
80        Swatch::Red,
81        Swatch::Orange,
82        Swatch::Yellow,
83        Swatch::Green,
84        Swatch::Cyan,
85        Swatch::Blue,
86        Swatch::Purple,
87    ];
88    /// Something went well: a service running, a device online.
89    pub const SUCCESS: Swatch = Swatch::Green;
90    /// Something needs a look.
91    pub const WARNING: Swatch = Swatch::Orange;
92    /// Something failed.
93    pub const DANGER: Swatch = Swatch::Red;
94    /// Something to know.
95    pub const INFO: Swatch = Swatch::Blue;
96
97    /// OKLCH (chroma, hue); the accent's here is the default's, Blue's.
98    pub(crate) fn chroma_hue(self) -> (f32, f32) {
99        match self {
100            Swatch::Neutral => (0.0, 0.0),
101            Swatch::Accent | Swatch::Blue => (0.15, 255.0),
102            Swatch::Red => (0.17, 25.0),
103            Swatch::Orange => (0.15, 55.0),
104            Swatch::Yellow => (0.14, 95.0),
105            Swatch::Green => (0.14, 145.0),
106            Swatch::Cyan => (0.12, 200.0),
107            Swatch::Purple => (0.15, 305.0),
108        }
109    }
110}
111
112/// `tint`'s OKLCH (chroma, hue), with `accent`'s for the accent swatch: hue modulo 360,
113/// chroma within `[0, MAX_CHROMA]`, and a value that is not a number read as the default
114/// accent's.
115fn resolve(tint: Tint, accent: (f32, f32)) -> (f32, f32) {
116    let (chroma, hue) = match tint {
117        Tint::Swatch(Swatch::Accent) => return accent,
118        Tint::Swatch(s) => return s.chroma_hue(),
119        Tint::Custom { chroma, hue } => (chroma, hue),
120    };
121    if !(chroma.is_finite() && hue.is_finite()) {
122        return Swatch::Blue.chroma_hue();
123    }
124    (chroma.clamp(0.0, Tint::MAX_CHROMA), hue.rem_euclid(360.0))
125}
126
127/// Each tone's OKLCH lightness, in [`Tone::ALL`]'s order.
128fn ladder(dark: bool, contrast: Contrast) -> [f32; 9] {
129    match (dark, contrast) {
130        (true, Contrast::Low) => [0.18, 0.23, 0.29, 0.34, 0.39, 0.42, 0.66, 0.80, 0.88],
131        (true, Contrast::Medium) => [0.16, 0.21, 0.27, 0.32, 0.38, 0.44, 0.72, 0.88, 0.96],
132        (true, Contrast::High) => [0.10, 0.14, 0.22, 0.28, 0.34, 0.50, 0.82, 0.96, 0.995],
133        (false, Contrast::Low) => [0.985, 0.96, 0.91, 0.87, 0.83, 0.78, 0.58, 0.42, 0.32],
134        (false, Contrast::Medium) => [0.995, 0.97, 0.92, 0.88, 0.84, 0.78, 0.50, 0.30, 0.20],
135        (false, Contrast::High) => [1.0, 0.99, 0.93, 0.89, 0.85, 0.70, 0.40, 0.16, 0.06],
136    }
137}
138
139/// The colour of (chroma, hue) at `tone` of `ladder`.
140fn cell(ladder: [f32; 9], tone: Tone, (c, h): (f32, f32)) -> Color32 {
141    to_color32(Oklch {
142        l: ladder[tone as usize],
143        c: c * tone.chroma_scale(),
144        h,
145    })
146}
147
148/// The colours of one theme: every [`Tone`] of every [`Swatch`], worked out when built.
149#[derive(Clone, PartialEq, Debug)]
150pub struct Theme {
151    dark: bool,
152    ladder: [f32; 9],
153    /// The accent's (chroma, hue).
154    accent: (f32, f32),
155    cells: [[Color32; 9]; 9],
156}
157
158impl Theme {
159    /// The dark or the light theme at `contrast`, with `accent`.
160    /// The accent [`Swatch::Accent`] as `accent` is read as the default, Blue.
161    pub fn new(dark: bool, contrast: Contrast, accent: Tint) -> Theme {
162        let ladder = ladder(dark, contrast);
163        let accent = resolve(accent, Swatch::Blue.chroma_hue());
164        let cells = std::array::from_fn(|t| {
165            std::array::from_fn(|s| {
166                cell(
167                    ladder,
168                    Tone::ALL[t],
169                    resolve(Tint::Swatch(Swatch::ALL[s]), accent),
170                )
171            })
172        });
173        Theme {
174            dark,
175            ladder,
176            accent,
177            cells,
178        }
179    }
180
181    /// `tint`'s OKLCH (chroma, hue) as this theme reads it: the accent's for the accent
182    /// swatch, a custom tint brought into range.
183    pub(crate) fn chroma_hue(&self, tint: Tint) -> (f32, f32) {
184        resolve(tint, self.accent)
185    }
186
187    /// `tint` at `tone`: a swatch's cell, or a custom tint worked out the way the cells are.
188    pub fn tint(&self, tone: Tone, tint: Tint) -> Color32 {
189        match tint {
190            Tint::Swatch(s) => self.color(tone, s),
191            Tint::Custom { .. } => cell(self.ladder, tone, resolve(tint, self.accent)),
192        }
193    }
194
195    /// Whether this is the dark theme.
196    pub fn is_dark(&self) -> bool {
197        self.dark
198    }
199
200    /// `swatch` at `tone`.
201    pub fn color(&self, tone: Tone, swatch: Swatch) -> Color32 {
202        self.cells[tone as usize][swatch as usize]
203    }
204
205    /// `swatch` as text: at [`Tone::Text`].
206    pub fn text(&self, swatch: Swatch) -> Color32 {
207        self.color(Tone::Text, swatch)
208    }
209
210    /// `swatch` as a fill: at [`Tone::Raised`].
211    pub fn fill(&self, swatch: Swatch) -> Color32 {
212        self.color(Tone::Raised, swatch)
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::theme::color::{contrast_ratio, from_color32};
220    use crate::theme::{Contrast, Tint};
221
222    const CONTRASTS: [Contrast; 3] = [Contrast::Low, Contrast::Medium, Contrast::High];
223
224    /// (`Text` on `Base`, `Muted` and swatch text on `Base`) for `contrast`.
225    fn targets(contrast: Contrast) -> (f32, f32) {
226        match contrast {
227            Contrast::Low => (4.5, 3.0),
228            Contrast::Medium => (7.0, 4.5),
229            Contrast::High => (12.0, 7.0),
230        }
231    }
232
233    fn themes(accent: Tint) -> impl Iterator<Item = (Theme, Contrast)> {
234        [false, true]
235            .into_iter()
236            .flat_map(move |dark| CONTRASTS.map(|c| (Theme::new(dark, c, accent), c)))
237    }
238
239    fn on_base(t: &Theme, c: egui::Color32) -> f32 {
240        contrast_ratio(c, t.color(Tone::Base, Swatch::Neutral))
241    }
242
243    #[test]
244    fn every_ladder_meets_its_targets() {
245        for (t, contrast) in themes(Tint::Swatch(Swatch::Blue)) {
246            let (text, muted) = targets(contrast);
247            let name = format!("dark {} {contrast:?}", t.is_dark());
248            let r = on_base(&t, t.color(Tone::Text, Swatch::Neutral));
249            assert!(r >= text, "{name}: text {r}");
250            let r = on_base(&t, t.color(Tone::Muted, Swatch::Neutral));
251            assert!(r >= muted, "{name}: muted {r}");
252            for s in Swatch::ALL {
253                let r = on_base(&t, t.text(s));
254                assert!(r >= muted, "{name}: {s:?} {r}");
255            }
256        }
257    }
258
259    #[test]
260    fn ladders_are_monotonic() {
261        for (t, contrast) in themes(Tint::Swatch(Swatch::Blue)) {
262            let l: Vec<f32> = Tone::ALL
263                .iter()
264                .map(|&tone| from_color32(t.color(tone, Swatch::Neutral)).l)
265                .collect();
266            for w in l.windows(2) {
267                let rising = w[1] > w[0];
268                assert_eq!(
269                    rising,
270                    t.is_dark(),
271                    "dark {} {contrast:?}: {l:?}",
272                    t.is_dark()
273                );
274            }
275        }
276    }
277
278    #[test]
279    fn accent_extremes_meet_the_swatch_target() {
280        for hue in (0..360).step_by(10) {
281            let accent = Tint::Custom {
282                chroma: Tint::MAX_CHROMA,
283                hue: hue as f32,
284            };
285            for (t, contrast) in themes(accent) {
286                let r = on_base(&t, t.text(Swatch::Accent));
287                assert!(
288                    r >= targets(contrast).1,
289                    "hue {hue}, dark {} {contrast:?}: {r}",
290                    t.is_dark()
291                );
292            }
293        }
294    }
295
296    #[test]
297    fn grey_accent_is_neutral() {
298        for hue in [0.0, 123.0, 300.0] {
299            let t = Theme::new(true, Contrast::Medium, Tint::Custom { chroma: 0.0, hue });
300            for tone in Tone::ALL {
301                assert_eq!(
302                    t.color(tone, Swatch::Accent),
303                    t.color(tone, Swatch::Neutral)
304                );
305            }
306        }
307    }
308
309    #[test]
310    fn hue_wraps() {
311        for (hue, same) in [(360.0, 0.0), (-30.0, 330.0), (720.0, 0.0)] {
312            let a = Theme::new(false, Contrast::Medium, Tint::Custom { chroma: 0.15, hue });
313            let b = Theme::new(
314                false,
315                Contrast::Medium,
316                Tint::Custom {
317                    chroma: 0.15,
318                    hue: same,
319                },
320            );
321            for tone in Tone::ALL {
322                assert_eq!(
323                    a.color(tone, Swatch::Accent),
324                    b.color(tone, Swatch::Accent),
325                    "{hue}"
326                );
327            }
328        }
329    }
330
331    #[test]
332    fn aliases() {
333        assert_eq!(Swatch::SUCCESS, Swatch::Green);
334        assert_eq!(Swatch::WARNING, Swatch::Orange);
335        assert_eq!(Swatch::DANGER, Swatch::Red);
336        assert_eq!(Swatch::INFO, Swatch::Blue);
337    }
338
339    fn medium(dark: bool) -> Theme {
340        Theme::new(dark, Contrast::Medium, Tint::Swatch(Swatch::Blue))
341    }
342
343    #[test]
344    fn tint_of_a_swatch_is_its_cell() {
345        for dark in [true, false] {
346            let t = medium(dark);
347            for tone in Tone::ALL {
348                for s in Swatch::ALL {
349                    assert_eq!(t.tint(tone, Tint::Swatch(s)), t.color(tone, s));
350                }
351            }
352        }
353    }
354
355    #[test]
356    fn custom_hue_wraps_and_grey_is_neutral() {
357        let t = medium(true);
358        let custom = |chroma, hue| Tint::Custom { chroma, hue };
359        for tone in Tone::ALL {
360            assert_eq!(
361                t.tint(tone, custom(0.15, -30.0)),
362                t.tint(tone, custom(0.15, 330.0))
363            );
364            assert_eq!(
365                t.tint(tone, custom(0.0, 77.0)),
366                t.color(tone, Swatch::Neutral)
367            );
368        }
369    }
370
371    #[test]
372    fn non_finite_reads_as_default_accent() {
373        let t = medium(false);
374        for tint in [
375            Tint::Custom {
376                chroma: f32::NAN,
377                hue: 10.0,
378            },
379            Tint::Custom {
380                chroma: 0.1,
381                hue: f32::INFINITY,
382            },
383        ] {
384            for tone in Tone::ALL {
385                assert_eq!(t.tint(tone, tint), t.tint(tone, Tint::Swatch(Swatch::Blue)));
386            }
387        }
388        let nan_accent = Theme::new(
389            false,
390            Contrast::Medium,
391            Tint::Custom {
392                chroma: f32::NAN,
393                hue: 0.0,
394            },
395        );
396        assert_eq!(
397            nan_accent.color(Tone::Text, Swatch::Accent),
398            t.color(Tone::Text, Swatch::Blue)
399        );
400    }
401
402    #[test]
403    fn accent_swatch_accent_is_blue() {
404        let t = Theme::new(true, Contrast::Medium, Tint::Swatch(Swatch::Accent));
405        for tone in Tone::ALL {
406            assert_eq!(t.color(tone, Swatch::Accent), t.color(tone, Swatch::Blue));
407        }
408    }
409}