1use std::f64::consts::TAU;
6
7use eframe::egui::{self, Color32, CornerRadius, FontFamily, FontId, Stroke, TextStyle};
8use serde::{Deserialize, Serialize};
9
10pub const TEXT_CONTRAST_MIN: f32 = 4.5;
12pub const NON_TEXT_CONTRAST_MIN: f32 = 3.0;
14
15pub const BREATH_PERIOD_SECS: f64 = 2.4;
17
18pub const CARD_RADIUS: u8 = 10;
20pub const CONTROL_RADIUS: u8 = 5;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum ThemeChoice {
27 #[default]
28 Dark,
29 Light,
30 System,
31}
32
33impl ThemeChoice {
34 pub const ALL: [ThemeChoice; 3] = [ThemeChoice::Dark, ThemeChoice::Light, ThemeChoice::System];
35
36 pub fn label(self) -> &'static str {
37 match self {
38 ThemeChoice::Dark => "Dark",
39 ThemeChoice::Light => "Light",
40 ThemeChoice::System => "Follow system",
41 }
42 }
43
44 pub fn preference(self) -> egui::ThemePreference {
45 match self {
46 ThemeChoice::Dark => egui::ThemePreference::Dark,
47 ThemeChoice::Light => egui::ThemePreference::Light,
48 ThemeChoice::System => egui::ThemePreference::System,
49 }
50 }
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum Tone {
56 Neutral,
58 Good,
60 Busy,
62 Bad,
64 Info,
66}
67
68impl Tone {
69 pub const ALL: [Tone; 5] = [Tone::Neutral, Tone::Good, Tone::Busy, Tone::Bad, Tone::Info];
70}
71
72#[derive(Debug, Clone, Copy, PartialEq)]
74pub struct Palette {
75 pub dark: bool,
76 pub page: Color32,
78 pub chrome: Color32,
80 pub card: Color32,
81 pub card_hover: Color32,
82 pub inset: Color32,
84 pub line: Color32,
86 pub control_line: Color32,
88 pub text: Color32,
89 pub muted: Color32,
91 pub accent: Color32,
93 pub on_accent: Color32,
95 pub accent_soft: Color32,
97 pub good: Color32,
98 pub bad: Color32,
99 pub info: Color32,
100 pub neutral_soft: Color32,
101 pub good_soft: Color32,
102 pub busy_soft: Color32,
103 pub bad_soft: Color32,
104 pub info_soft: Color32,
105}
106
107const fn hex(rgb: u32) -> Color32 {
108 Color32::from_rgb((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8)
109}
110
111impl Palette {
112 pub const DARK: Palette = Palette {
113 dark: true,
114 page: hex(0x16171A),
115 chrome: hex(0x101113),
116 card: hex(0x1E1F23),
117 card_hover: hex(0x25272C),
118 inset: hex(0x0C0D0F),
119 line: hex(0x2C2E34),
120 control_line: hex(0x75716A),
121 text: hex(0xEDE9E3),
122 muted: hex(0xA9A49C),
123 accent: hex(0xE8B660),
124 on_accent: hex(0x1A1408),
125 accent_soft: hex(0x33291A),
126 good: hex(0x86CFA0),
127 bad: hex(0xF4897C),
128 info: hex(0x93BBF0),
129 neutral_soft: hex(0x2B2D33),
130 good_soft: hex(0x1C3226),
131 busy_soft: hex(0x3A2F1A),
132 bad_soft: hex(0x3E2321),
133 info_soft: hex(0x1C2A3E),
134 };
135
136 pub const LIGHT: Palette = Palette {
137 dark: false,
138 page: hex(0xF3F1ED),
139 chrome: hex(0xE9E6E0),
140 card: hex(0xFFFFFF),
141 card_hover: hex(0xF8F6F2),
142 inset: hex(0xF0EDE7),
143 line: hex(0xD6D0C6),
144 control_line: hex(0x857F75),
145 text: hex(0x1C1B19),
146 muted: hex(0x57524B),
147 accent: hex(0x8A5700),
148 on_accent: hex(0xFFFFFF),
149 accent_soft: hex(0xF3E4C6),
150 good: hex(0x1B6A3F),
151 bad: hex(0xB3261E),
152 info: hex(0x1D58A3),
153 neutral_soft: hex(0xECE9E3),
154 good_soft: hex(0xE2F0E7),
155 busy_soft: hex(0xF5E8CF),
156 bad_soft: hex(0xFAE5E2),
157 info_soft: hex(0xE0EAF6),
158 };
159
160 pub fn of(dark_mode: bool) -> &'static Palette {
161 if dark_mode {
162 &Self::DARK
163 } else {
164 &Self::LIGHT
165 }
166 }
167
168 pub fn of_ui(ui: &egui::Ui) -> &'static Palette {
170 Self::of(ui.visuals().dark_mode)
171 }
172
173 pub fn tone(&self, tone: Tone) -> Color32 {
175 match tone {
176 Tone::Neutral => self.muted,
177 Tone::Good => self.good,
178 Tone::Busy => self.accent,
179 Tone::Bad => self.bad,
180 Tone::Info => self.info,
181 }
182 }
183
184 pub fn tone_soft(&self, tone: Tone) -> Color32 {
186 match tone {
187 Tone::Neutral => self.neutral_soft,
188 Tone::Good => self.good_soft,
189 Tone::Busy => self.busy_soft,
190 Tone::Bad => self.bad_soft,
191 Tone::Info => self.info_soft,
192 }
193 }
194
195 pub fn surfaces(&self) -> [Color32; 5] {
197 [
198 self.page,
199 self.chrome,
200 self.card,
201 self.card_hover,
202 self.inset,
203 ]
204 }
205}
206
207fn linear(channel: u8) -> f32 {
208 let c = channel as f32 / 255.0;
209 if c <= 0.040_45 {
210 c / 12.92
211 } else {
212 ((c + 0.055) / 1.055).powf(2.4)
213 }
214}
215
216pub fn luminance(c: Color32) -> f32 {
218 0.2126 * linear(c.r()) + 0.7152 * linear(c.g()) + 0.0722 * linear(c.b())
219}
220
221pub fn contrast_ratio(a: Color32, b: Color32) -> f32 {
223 let (la, lb) = (luminance(a), luminance(b));
224 let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
225 (hi + 0.05) / (lo + 0.05)
226}
227
228pub fn breath(t: f64, reduce_motion: bool) -> f32 {
232 if reduce_motion {
233 return 1.0;
234 }
235 let phase = (t / BREATH_PERIOD_SECS * TAU).sin() * 0.5 + 0.5;
236 (0.35 + 0.65 * phase) as f32
237}
238
239pub fn with_alpha(colour: Color32, alpha: f32) -> Color32 {
241 let a = (alpha.clamp(0.0, 1.0) * 255.0).round() as u8;
242 Color32::from_rgba_unmultiplied(colour.r(), colour.g(), colour.b(), a)
243}
244
245pub fn stroke(width: f32, colour: Color32) -> Stroke {
247 Stroke::new(width, colour)
248}
249
250pub fn visuals(p: &Palette) -> egui::Visuals {
252 let mut v = if p.dark {
253 egui::Visuals::dark()
254 } else {
255 egui::Visuals::light()
256 };
257 let control = CornerRadius::same(CONTROL_RADIUS);
258 v.panel_fill = p.page;
259 v.window_fill = p.card;
260 v.window_stroke = stroke(1.0, p.line);
261 v.window_corner_radius = CornerRadius::same(CARD_RADIUS);
262 v.extreme_bg_color = p.inset;
263 v.text_edit_bg_color = Some(p.inset);
264 v.faint_bg_color = p.card_hover;
265 v.code_bg_color = p.inset;
266 v.hyperlink_color = p.info;
267 v.warn_fg_color = p.accent;
268 v.error_fg_color = p.bad;
269 v.weak_text_color = Some(p.muted);
270 v.selection.bg_fill = with_alpha(p.accent, 0.35);
271 v.selection.stroke = stroke(1.0, p.text);
272 v.text_cursor.stroke = stroke(2.0, p.accent);
273
274 let w = &mut v.widgets;
275 w.noninteractive.bg_fill = p.card;
276 w.noninteractive.weak_bg_fill = p.card;
277 w.noninteractive.bg_stroke = stroke(1.0, p.line);
278 w.noninteractive.fg_stroke = stroke(1.0, p.text);
279 w.noninteractive.corner_radius = control;
280
281 w.inactive.bg_fill = p.inset;
282 w.inactive.weak_bg_fill = p.neutral_soft;
283 w.inactive.bg_stroke = stroke(1.0, p.control_line);
284 w.inactive.fg_stroke = stroke(1.0, p.text);
285 w.inactive.corner_radius = control;
286
287 w.hovered.bg_fill = p.card_hover;
288 w.hovered.weak_bg_fill = p.card_hover;
289 w.hovered.bg_stroke = stroke(1.0, p.text);
290 w.hovered.fg_stroke = stroke(1.5, p.text);
291 w.hovered.corner_radius = control;
292 w.hovered.expansion = 0.0;
293
294 w.active.bg_fill = p.card_hover;
296 w.active.weak_bg_fill = p.card_hover;
297 w.active.bg_stroke = stroke(2.0, p.accent);
298 w.active.fg_stroke = stroke(2.0, p.text);
299 w.active.corner_radius = control;
300 w.active.expansion = 0.0;
301
302 w.open = w.active;
303 v
304}
305
306pub fn style_tweaks(style: &mut egui::Style) {
308 use FontFamily::{Monospace, Proportional};
309 style.text_styles = [
310 (TextStyle::Heading, FontId::new(22.0, Proportional)),
311 (TextStyle::Body, FontId::new(14.0, Proportional)),
312 (TextStyle::Button, FontId::new(14.0, Proportional)),
313 (TextStyle::Small, FontId::new(12.0, Proportional)),
314 (TextStyle::Monospace, FontId::new(13.0, Monospace)),
315 ]
316 .into();
317 let s = &mut style.spacing;
318 s.item_spacing = egui::vec2(8.0, 6.0);
319 s.button_padding = egui::vec2(12.0, 5.0);
320 s.interact_size.y = 28.0;
322 s.window_margin = egui::Margin::same(16);
323}
324
325pub fn apply(ctx: &egui::Context, choice: ThemeChoice) {
327 ctx.set_visuals_of(egui::Theme::Dark, visuals(&Palette::DARK));
328 ctx.set_visuals_of(egui::Theme::Light, visuals(&Palette::LIGHT));
329 ctx.all_styles_mut(style_tweaks);
330 ctx.set_theme(choice.preference());
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 const PALETTES: [&Palette; 2] = [&Palette::DARK, &Palette::LIGHT];
338
339 fn assert_contrast(what: &str, fg: Color32, bg: Color32, min: f32) {
340 let ratio = contrast_ratio(fg, bg);
341 assert!(
342 ratio >= min,
343 "{what}: {fg:?} on {bg:?} is {ratio:.2}:1, below {min}:1"
344 );
345 }
346
347 #[test]
348 fn contrast_follows_the_wcag_formula() {
349 assert!((contrast_ratio(Color32::BLACK, Color32::WHITE) - 21.0).abs() < 0.01);
350 assert!((contrast_ratio(Color32::WHITE, Color32::WHITE) - 1.0).abs() < 0.001);
351 assert!((contrast_ratio(hex(0x767676), Color32::WHITE) - 4.54).abs() < 0.01);
353 }
354
355 #[test]
356 fn text_and_state_colours_meet_aa_on_every_surface() {
357 for p in PALETTES {
358 for bg in p.surfaces() {
359 assert_contrast("text", p.text, bg, TEXT_CONTRAST_MIN);
360 assert_contrast("muted", p.muted, bg, TEXT_CONTRAST_MIN);
361 for tone in Tone::ALL {
362 assert_contrast(&format!("{tone:?}"), p.tone(tone), bg, TEXT_CONTRAST_MIN);
363 }
364 }
365 }
366 }
367
368 #[test]
369 fn pills_and_tinted_boxes_keep_their_text_readable() {
370 for p in PALETTES {
371 for tone in Tone::ALL {
372 let bg = p.tone_soft(tone);
373 assert_contrast(
374 &format!("{tone:?} on its pill"),
375 p.tone(tone),
376 bg,
377 TEXT_CONTRAST_MIN,
378 );
379 assert_contrast("text on a pill", p.text, bg, TEXT_CONTRAST_MIN);
380 }
381 assert_contrast("selected rail", p.text, p.accent_soft, TEXT_CONTRAST_MIN);
382 assert_contrast("selected rail", p.accent, p.accent_soft, TEXT_CONTRAST_MIN);
383 assert_contrast("primary button", p.on_accent, p.accent, TEXT_CONTRAST_MIN);
384 }
385 }
386
387 #[test]
388 fn the_focus_ring_and_indicators_stand_out() {
389 for p in PALETTES {
390 for bg in p.surfaces() {
391 assert_contrast("focus ring", p.accent, bg, NON_TEXT_CONTRAST_MIN);
392 assert_contrast("control border", p.control_line, bg, NON_TEXT_CONTRAST_MIN);
393 }
394 }
395 }
396
397 #[test]
398 fn the_visuals_carry_the_palette() {
399 for p in PALETTES {
400 let v = visuals(p);
401 assert_eq!(v.dark_mode, p.dark);
402 assert_eq!(v.panel_fill, p.page);
403 assert_eq!(v.widgets.noninteractive.fg_stroke.color, p.text);
404 assert_eq!(v.widgets.active.bg_stroke.color, p.accent, "focus ring");
405 assert_eq!(v.error_fg_color, p.bad);
406 }
407 assert_eq!(Palette::of(true), &Palette::DARK);
408 assert_eq!(Palette::of(false), &Palette::LIGHT);
409 }
410
411 #[test]
412 fn the_glow_breathes_between_bounds_and_holds_still_on_request() {
413 let samples: Vec<f32> = (0..48).map(|i| breath(i as f64 * 0.05, false)).collect();
414 let (lo, hi) = samples
415 .iter()
416 .fold((f32::MAX, f32::MIN), |(lo, hi), &s| (lo.min(s), hi.max(s)));
417 assert!(lo >= 0.35 - 1e-4 && hi <= 1.0 + 1e-4, "{lo}..{hi}");
418 assert!(hi - lo > 0.5, "it visibly breathes");
419 assert!((0..48).all(|i| breath(i as f64 * 0.05, true) == 1.0));
420 }
421
422 #[test]
423 fn a_theme_choice_maps_to_egui_and_has_a_label() {
424 assert_eq!(
425 ThemeChoice::default().preference(),
426 egui::ThemePreference::Dark
427 );
428 assert_eq!(
429 ThemeChoice::Light.preference(),
430 egui::ThemePreference::Light
431 );
432 assert_eq!(
433 ThemeChoice::System.preference(),
434 egui::ThemePreference::System
435 );
436 assert_eq!(ThemeChoice::System.label(), "Follow system");
437 }
438
439 #[test]
440 fn with_alpha_keeps_the_hue() {
441 let c = with_alpha(hex(0x102030), 0.5);
442 assert_eq!(c.a(), 128);
443 assert_eq!(with_alpha(Color32::WHITE, 2.0).a(), 255);
444 }
445
446 #[test]
447 fn applying_a_theme_sets_both_palettes_and_the_type_scale() {
448 let ctx = egui::Context::default();
449 apply(&ctx, ThemeChoice::Light);
450 assert_eq!(
451 ctx.options(|o| o.theme_preference),
452 egui::ThemePreference::Light
453 );
454 ctx.style_mut_of(egui::Theme::Dark, |s| {
455 assert_eq!(s.visuals.panel_fill, Palette::DARK.page);
456 assert_eq!(s.text_styles[&TextStyle::Body].size, 14.0);
457 });
458 }
459}