Skip to main content

corundum_framework_gui/theme/
mod.rs

1//! The colours: light and dark, three contrast levels, an accent of the user's.
2//!
3//! A theme is a grid. Its rows are [`Tone`]s — lightness levels from the sunken
4//! background to strong text — and its columns are [`Swatch`]es, each a hue and a chroma.
5//! Every colour is one cell, so every colour, the accent and the status colours alike,
6//! takes its lightness from where it is used: switching light and dark, or the contrast,
7//! carries all of them with it.
8//!
9//! The lightness is OKLCH's, which follows perceived lightness: one tone gives about the
10//! same contrast whatever the hue.
11
12use std::sync::Arc;
13
14mod color;
15mod palette;
16mod picker;
17mod tint;
18mod ui;
19mod visuals;
20
21pub use palette::{Swatch, Theme, Tone};
22pub use picker::TintPicker;
23pub use tint::Tint;
24pub use ui::settings_ui;
25
26/// How far apart the text and the background are.
27#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29pub enum Contrast {
30    /// Body text at WCAG AA (4.5:1), quieter edges.
31    Low,
32    /// Body text at WCAG AAA (7:1).
33    #[default]
34    Medium,
35    /// Body text at 12:1 and up.
36    High,
37}
38
39/// What the user chose: what the app saves, and hands to [`install`] at start.
40#[derive(Clone, PartialEq, Debug, Default)]
41#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
42#[cfg_attr(feature = "serde", serde(default))]
43pub struct ThemeSettings {
44    /// Light, dark, or whichever the system uses.
45    pub preference: egui::ThemePreference,
46    pub contrast: Contrast,
47    /// The accent; `None` is the default, a blue.
48    pub accent: Option<Tint>,
49}
50
51impl ThemeSettings {
52    /// The accent, the default in place of `None`.
53    pub fn accent(&self) -> Tint {
54        self.accent.unwrap_or(DEFAULT_ACCENT)
55    }
56}
57
58/// The accent when the user has not picked one.
59pub(crate) const DEFAULT_ACCENT: Tint = Tint::Swatch(Swatch::Blue);
60
61/// The light and the dark theme, as [`install`] left them.
62#[derive(Clone)]
63struct Installed {
64    light: Arc<Theme>,
65    dark: Arc<Theme>,
66}
67
68fn key() -> egui::Id {
69    egui::Id::unique("corundum_theme")
70}
71
72/// Use `settings` in `ctx`: build the light and the dark theme, give egui both looks, and
73/// follow `settings.preference`. Call it at start and whenever the settings change.
74pub fn install(ctx: &egui::Context, settings: &ThemeSettings) {
75    let build = |dark| Arc::new(Theme::new(dark, settings.contrast, settings.accent()));
76    let installed = Installed {
77        light: build(false),
78        dark: build(true),
79    };
80    ctx.set_visuals_of(egui::Theme::Light, installed.light.visuals());
81    ctx.set_visuals_of(egui::Theme::Dark, installed.dark.visuals());
82    ctx.set_theme(settings.preference);
83    ctx.data_mut(|d| d.insert_temp(key(), installed));
84}
85
86/// Whether [`install`] has been called on `ctx`.
87pub fn is_installed(ctx: &egui::Context) -> bool {
88    ctx.data(|d| d.get_temp::<Installed>(key())).is_some()
89}
90
91/// The theme `ctx` draws with now: the dark or the light one, as egui's theme is. Before
92/// [`install`], the default settings' (egui's own look is left as it is).
93pub fn current(ctx: &egui::Context) -> Arc<Theme> {
94    let dark = ctx.theme() == egui::Theme::Dark;
95    match ctx.data(|d| d.get_temp::<Installed>(key())) {
96        Some(installed) if dark => installed.dark,
97        Some(installed) => installed.light,
98        None => Arc::new(Theme::new(dark, Contrast::default(), DEFAULT_ACCENT)),
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    fn same(a: &Theme, b: &Theme) -> bool {
107        a == b
108    }
109
110    #[test]
111    fn install_follows_the_egui_theme() {
112        let ctx = egui::Context::default();
113        let settings = ThemeSettings {
114            preference: egui::ThemePreference::Dark,
115            ..Default::default()
116        };
117        install(&ctx, &settings);
118        assert!(current(&ctx).is_dark());
119        assert_eq!(
120            ctx.global_style().visuals.panel_fill,
121            current(&ctx).color(Tone::Base, Swatch::Neutral)
122        );
123        ctx.set_theme(egui::Theme::Light);
124        assert!(!current(&ctx).is_dark());
125        assert_eq!(
126            ctx.global_style().visuals.panel_fill,
127            current(&ctx).color(Tone::Base, Swatch::Neutral)
128        );
129    }
130
131    #[test]
132    fn system_preference_still_gives_a_theme() {
133        let ctx = egui::Context::default();
134        install(&ctx, &ThemeSettings::default());
135        assert_eq!(current(&ctx).is_dark(), ctx.theme() == egui::Theme::Dark);
136    }
137
138    #[test]
139    fn reinstall_replaces_both() {
140        let ctx = egui::Context::default();
141        install(&ctx, &ThemeSettings::default());
142        let accent = Tint::Custom {
143            chroma: 0.1,
144            hue: 30.0,
145        };
146        install(
147            &ctx,
148            &ThemeSettings {
149                contrast: Contrast::High,
150                accent: Some(accent),
151                ..Default::default()
152            },
153        );
154        for (theme, dark) in [(egui::Theme::Dark, true), (egui::Theme::Light, false)] {
155            ctx.set_theme(theme);
156            assert!(same(
157                &current(&ctx),
158                &Theme::new(dark, Contrast::High, accent)
159            ));
160        }
161    }
162
163    #[test]
164    fn before_install_current_is_the_default() {
165        let ctx = egui::Context::default();
166        let visuals = ctx.global_style().visuals.clone();
167        assert!(!is_installed(&ctx));
168        let dark = ctx.theme() == egui::Theme::Dark;
169        assert!(same(
170            &current(&ctx),
171            &Theme::new(dark, Contrast::Medium, Tint::Swatch(Swatch::Blue))
172        ));
173        assert_eq!(ctx.global_style().visuals, visuals);
174    }
175
176    #[test]
177    fn none_accent_is_blue() {
178        let settings = ThemeSettings::default();
179        assert_eq!(settings.accent, None);
180        assert_eq!(settings.accent(), Tint::Swatch(Swatch::Blue));
181        let ctx = egui::Context::default();
182        install(&ctx, &settings);
183        let t = current(&ctx);
184        for tone in Tone::ALL {
185            assert_eq!(t.color(tone, Swatch::Accent), t.color(tone, Swatch::Blue));
186        }
187    }
188
189    #[cfg(feature = "serde")]
190    #[test]
191    fn settings_with_no_accent_round_trip() {
192        let settings = ThemeSettings::default();
193        let json = serde_json::to_string(&settings).unwrap();
194        let back: ThemeSettings = serde_json::from_str(&json).unwrap();
195        assert_eq!(back, settings);
196        assert_eq!(back.accent(), Tint::Swatch(Swatch::Blue));
197    }
198
199    #[cfg(feature = "serde")]
200    #[test]
201    fn settings_round_trip() {
202        let settings = ThemeSettings {
203            preference: egui::ThemePreference::Light,
204            contrast: Contrast::Low,
205            accent: Some(Tint::Custom {
206                chroma: 0.2,
207                hue: 140.0,
208            }),
209        };
210        let json = serde_json::to_string(&settings).unwrap();
211        assert_eq!(
212            serde_json::from_str::<ThemeSettings>(&json).unwrap(),
213            settings
214        );
215    }
216}