Skip to main content

studio_worker/ui/
theme.rs

1//! The tray UI's look: two palettes (dark, light) held to WCAG 2.2 AA
2//! contrast by tests, the egui visuals and style built from them, and the
3//! soft "breathing" glow of running work.
4
5use std::f64::consts::TAU;
6
7use eframe::egui::{self, Color32, CornerRadius, FontFamily, FontId, Stroke, TextStyle};
8use serde::{Deserialize, Serialize};
9
10/// WCAG 2.2 AA minimum contrast for text (1.4.3).
11pub const TEXT_CONTRAST_MIN: f32 = 4.5;
12/// WCAG 2.2 AA minimum contrast for indicators and focus rings (1.4.11).
13pub const NON_TEXT_CONTRAST_MIN: f32 = 3.0;
14
15/// One breath of the running glow, in seconds: slow enough to read as calm.
16pub const BREATH_PERIOD_SECS: f64 = 2.4;
17
18/// Corner radius of cards and panels, in points.
19pub const CARD_RADIUS: u8 = 10;
20/// Corner radius of buttons, inputs and pills, in points.
21pub const CONTROL_RADIUS: u8 = 5;
22
23/// The operator's theme choice (Config → This window).
24#[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/// What a colour means.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum Tone {
56    /// Nothing to report.
57    Neutral,
58    /// Healthy, done, loaded.
59    Good,
60    /// Running, in transition, or asking for attention.
61    Busy,
62    /// Failed, refused, unreachable.
63    Bad,
64    /// Informational: sources, residency.
65    Info,
66}
67
68impl Tone {
69    pub const ALL: [Tone; 5] = [Tone::Neutral, Tone::Good, Tone::Busy, Tone::Bad, Tone::Info];
70}
71
72/// Every colour the UI paints with.
73#[derive(Debug, Clone, Copy, PartialEq)]
74pub struct Palette {
75    pub dark: bool,
76    /// Behind the page.
77    pub page: Color32,
78    /// Behind the rail, the header and the status bar.
79    pub chrome: Color32,
80    pub card: Color32,
81    pub card_hover: Color32,
82    /// Sunken areas: logs, inputs.
83    pub inset: Color32,
84    /// Hairlines between areas.
85    pub line: Color32,
86    /// Borders of inputs and checkboxes: 3:1 against every surface.
87    pub control_line: Color32,
88    pub text: Color32,
89    /// Secondary text; still AA on every background.
90    pub muted: Color32,
91    /// Brass: selection, focus, running work.
92    pub accent: Color32,
93    /// Text on an accent-filled button.
94    pub on_accent: Color32,
95    /// The selected rail item's background.
96    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    /// The palette of the theme `ui` paints in.
169    pub fn of_ui(ui: &egui::Ui) -> &'static Palette {
170        Self::of(ui.visuals().dark_mode)
171    }
172
173    /// The colour of text or an indicator that means `tone`.
174    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    /// The background of a pill or box that means `tone`.
185    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    /// The surfaces text is drawn on.
196    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
216/// WCAG relative luminance of an opaque colour.
217pub fn luminance(c: Color32) -> f32 {
218    0.2126 * linear(c.r()) + 0.7152 * linear(c.g()) + 0.0722 * linear(c.b())
219}
220
221/// WCAG contrast ratio between two opaque colours, 1.0 to 21.0.
222pub 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
228/// How strongly running work glows at time `t` (seconds), 0.35 to 1.0:
229/// one slow breath per [`BREATH_PERIOD_SECS`].  Reduce motion holds it
230/// steady at full strength.
231pub 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
239/// `colour` at `alpha` (0..=1), for glows over any background.
240pub 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
245/// A stroke of `width` points (typed, so float literals never fall back).
246pub fn stroke(width: f32, colour: Color32) -> Stroke {
247    Stroke::new(width, colour)
248}
249
250/// The egui visuals of `palette`.
251pub 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    // Pressed and keyboard-focused: the brass focus ring.
295    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
306/// Type scale and spacing, the same in both themes.
307pub 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    // WCAG 2.5.8: targets of at least 24 × 24.
321    s.interact_size.y = 28.0;
322    s.window_margin = egui::Margin::same(16);
323}
324
325/// Apply both palettes and the operator's theme choice to `ctx`.
326pub 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        // #767676 on white is the classic 4.54:1.
352        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}