Skip to main content

egui_charts/theme/
style.rs

1//! Mapping of a [`Theme`] onto egui's styles.
2
3use super::{Theme, context};
4use crate::tokens::DESIGN_TOKENS;
5use egui::Color32;
6
7/// WCAG 2 contrast ratio of `fg` painted over the opaque `bg`.
8pub(crate) fn contrast(fg: Color32, bg: Color32) -> f32 {
9    let luminance = |c: Color32| {
10        let c = egui::Rgba::from(c);
11        0.2126 * c.r() + 0.7152 * c.g() + 0.0722 * c.b()
12    };
13    let (a, b) = (luminance(bg.blend(fg)), luminance(bg));
14    (a.max(b) + 0.05) / (a.min(b) + 0.05)
15}
16
17/// Move `color` towards `text` only as far as needed to read on every background.
18pub(crate) fn readable_color(
19    color: Color32,
20    text: Color32,
21    backgrounds: &[Color32],
22    min: f32,
23) -> Color32 {
24    (0..=255)
25        .map(|step| color.lerp_to_gamma(text, step as f32 / 255.0))
26        .find(|&candidate| backgrounds.iter().all(|&bg| contrast(candidate, bg) >= min))
27        .unwrap_or(text)
28}
29
30/// Applies the same palette and spacing to a local UI or to the global styles.
31pub(crate) fn apply_to_style(style: &mut egui::Style, theme: &Theme) {
32    let mut visuals = if theme.dark {
33        egui::Visuals::dark()
34    } else {
35        egui::Visuals::light()
36    };
37
38    let ui = &theme.ui;
39
40    // Window/panel backgrounds
41    visuals.panel_fill = ui.panel_bg;
42    // Windows, popups, menus and tooltips sit one elevation step above panels.
43    visuals.window_fill = ui.panel_bg_floating;
44    visuals.extreme_bg_color = ui.panel_bg;
45    // faint_bg_color must match panel_fill to avoid thin line at window top
46    // Previously used a different color for "visible gaps" but this caused artifacts
47    visuals.faint_bg_color = ui.panel_bg;
48
49    // Widget backgrounds
50    visuals.widgets.noninteractive.bg_fill = ui.panel_bg;
51    visuals.widgets.inactive.bg_fill = ui.panel_bg;
52    visuals.widgets.hovered.bg_fill = ui.btn_bg_hover;
53    visuals.widgets.active.bg_fill = ui.btn_bg_active;
54    visuals.widgets.open.bg_fill = ui.btn_bg_active;
55    // Buttons, combo boxes and headers paint weak fills rather than bg_fill.
56    let widgets = &mut visuals.widgets;
57    for state in [
58        &mut widgets.noninteractive,
59        &mut widgets.inactive,
60        &mut widgets.hovered,
61        &mut widgets.active,
62        &mut widgets.open,
63    ] {
64        state.weak_bg_fill = state.bg_fill;
65    }
66
67    // Widget borders
68    visuals.widgets.noninteractive.bg_stroke =
69        egui::Stroke::new(DESIGN_TOKENS.stroke.hairline, ui.border);
70    // egui outlines checkboxes, radios, text inputs and combo boxes with these strokes.
71    visuals.widgets.inactive.bg_stroke =
72        egui::Stroke::new(DESIGN_TOKENS.stroke.hairline, ui.border_control);
73    visuals.widgets.hovered.bg_stroke =
74        egui::Stroke::new(DESIGN_TOKENS.stroke.hairline, ui.border_control);
75    visuals.widgets.active.bg_stroke = egui::Stroke::new(DESIGN_TOKENS.stroke.hairline, ui.accent);
76
77    visuals.widgets.open.bg_stroke =
78        egui::Stroke::new(DESIGN_TOKENS.stroke.hairline, ui.border_control);
79
80    // Text/foreground colors
81    visuals.widgets.noninteractive.fg_stroke =
82        egui::Stroke::new(DESIGN_TOKENS.stroke.hairline, ui.text);
83    visuals.widgets.inactive.fg_stroke = egui::Stroke::new(DESIGN_TOKENS.stroke.hairline, ui.text);
84    visuals.widgets.hovered.fg_stroke = egui::Stroke::new(DESIGN_TOKENS.stroke.hairline, ui.text);
85    visuals.widgets.active.fg_stroke = egui::Stroke::new(DESIGN_TOKENS.stroke.hairline, ui.text);
86
87    visuals.widgets.open.fg_stroke = egui::Stroke::new(DESIGN_TOKENS.stroke.hairline, ui.text);
88
89    // Selection: egui paints selected labels in `selection.stroke`, on top of `bg_fill`, and
90    // outlines a focused text input with `selection.stroke`. Dark themes use white on the accent,
91    // light themes accent text on a pale accent fill.
92    visuals.text_cursor.stroke.color = ui.text;
93    visuals.selection.bg_fill = ui.selection_bg;
94    visuals.selection.stroke = egui::Stroke::new(DESIGN_TOKENS.stroke.hairline, ui.selection_text);
95
96    // egui fades weak text by alpha by default, which drops it below a readable contrast.
97    visuals.weak_text_color = Some(ui.text_muted);
98    visuals.error_fg_color = ui.error;
99    visuals.warn_fg_color = ui.warning;
100
101    // Hyperlinks
102    visuals.hyperlink_color = readable_color(
103        ui.accent,
104        ui.text,
105        &[
106            ui.panel_bg,
107            ui.panel_bg_floating,
108            ui.panel_bg_secondary,
109            ui.btn_bg_hover,
110        ],
111        7.0,
112    );
113
114    // Floating chrome: hairline border, small radius and a tight shadow that stays close to
115    // the surface instead of smearing over neighbouring toolbars.
116    let shadow_alpha = if theme.dark { 96 } else { 32 };
117    visuals.window_stroke = egui::Stroke::new(DESIGN_TOKENS.stroke.hairline, ui.border);
118    visuals.window_corner_radius = theme.radius.md.into();
119    visuals.menu_corner_radius = theme.radius.sm.into();
120    visuals.window_shadow = egui::epaint::Shadow {
121        offset: [0, 4],
122        blur: 16,
123        spread: 0,
124        color: Color32::from_black_alpha(shadow_alpha),
125    };
126    visuals.popup_shadow = egui::epaint::Shadow {
127        offset: [0, 2],
128        blur: 6,
129        spread: 0,
130        color: Color32::from_black_alpha(shadow_alpha / 2),
131    };
132
133    // egui's built-in widgets keep the theme's compact look: small corners here, and tight
134    // padding and control height on the style below.
135    let widgets = &mut visuals.widgets;
136    for state in [
137        &mut widgets.noninteractive,
138        &mut widgets.inactive,
139        &mut widgets.hovered,
140        &mut widgets.active,
141        &mut widgets.open,
142    ] {
143        state.corner_radius = theme.radius.sm.into();
144    }
145
146    let spacing = &theme.spacing;
147    style.visuals = visuals;
148    for (role, size) in [
149        (egui::TextStyle::Small, theme.text.small),
150        (egui::TextStyle::Body, theme.text.body),
151        (egui::TextStyle::Button, theme.text.body),
152        (egui::TextStyle::Heading, theme.text.heading),
153        (egui::TextStyle::Monospace, theme.text.body),
154    ] {
155        if let Some(font) = style.text_styles.get_mut(&role) {
156            font.size = size;
157        }
158    }
159    style.spacing.window_margin = egui::Margin::same(spacing.xl as i8);
160    style.spacing.menu_margin = egui::Margin::symmetric(spacing.lg as i8, spacing.sm as i8);
161    style.spacing.item_spacing = egui::vec2(spacing.lg, spacing.sm);
162    style.spacing.button_padding = egui::vec2(spacing.sm, DESIGN_TOKENS.spacing.hairline);
163    style.spacing.interact_size.y = DESIGN_TOKENS.sizing.control_height;
164}
165
166/// Apply `theme` to egui's visuals and store it for [`ThemeExt::active_theme`](super::ThemeExt::active_theme).
167/// Also install Roboto ahead of the application's proportional fonts.
168///
169/// Call it once at startup and again after the theme changes. Inside `eframe::App::ui` the root
170/// `Ui` already exists, so follow the call with `ui.set_style(ui.ctx().global_style())` for the
171/// panels shown in that `Ui` to use the theme in the same frame.
172pub fn apply_to_egui(ctx: &egui::Context, theme: &Theme) {
173    // Style both appearances so system changes keep the applied theme.
174    ctx.all_styles_mut(|style| apply_to_style(style, theme));
175
176    // Interface text: Roboto, whose default digits are tabular, so prices keep their width.
177    ctx.add_font(egui::FontInsert::new(
178        "Roboto",
179        egui::FontData::from_static(include_bytes!(concat!(
180            env!("CARGO_MANIFEST_DIR"),
181            "/assets/fonts/Roboto-Latin.ttf"
182        ))),
183        vec![
184            egui::InsertFontFamily {
185                family: egui::FontFamily::Proportional,
186                priority: egui::FontPriority::Highest,
187            },
188            egui::InsertFontFamily {
189                family: egui::FontFamily::Monospace,
190                priority: egui::FontPriority::Lowest,
191            },
192        ],
193    ));
194
195    // Store the theme so components resolve their colours from it while painting.
196    context::set_active_theme(ctx, theme.clone());
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use crate::theme::{ThemeExt, presets};
203
204    #[test]
205    fn chart_theme_changes_keep_data_and_viewport_and_refresh_overlays() {
206        use crate::{
207            chart::selection::SeriesId,
208            model::{Bar, BarData},
209            widget::Chart,
210        };
211        let bars = vec![Bar::new(
212            chrono::DateTime::UNIX_EPOCH,
213            1.0,
214            2.0,
215            1.0,
216            2.0,
217            10.0,
218        )];
219        let mut chart = Chart::new(BarData::from_bars(bars.clone()));
220        let ctx = egui::Context::default();
221        let offset = chart.state().time_scale().right_offset();
222        for (_, theme) in presets() {
223            chart.set_theme(&ctx, &theme);
224            assert_eq!(chart.bars(SeriesId::MAIN), bars);
225            assert_eq!(chart.state().time_scale().right_offset(), offset);
226            assert_eq!(chart.config().background_color, theme.chart.bg);
227            let crosshair = chart.chart_options().crosshair.resolved(&ctx);
228            assert_eq!(
229                crosshair.vert_label_background_color,
230                theme.chart.crosshair_label_bg
231            );
232            assert_eq!(
233                crosshair.horz_label_background_color,
234                theme.chart.crosshair_label_bg
235            );
236            assert_eq!(crosshair.label_text_color, theme.chart.crosshair_label_text);
237            let tooltip = chart.chart_options().tooltip.resolved(&ctx);
238            assert_eq!(tooltip.background_color, theme.chart.bg_tooltip);
239        }
240        // A builder theme must reach text and overlays locally, leaving the host palette and
241        // egui styles intact for the next chart in the same frame.
242        let global = Theme::dark();
243        apply_to_egui(&ctx, &global);
244        let original_style = ctx.global_style();
245        let mut local = Theme::light();
246        local.text.label = 19.0;
247        let mut chart = crate::widget::ChartBuilder::new()
248            .with_symbol("Scoped")
249            .with_theme(local.clone())
250            .build();
251        chart.set_data(SeriesId::MAIN, bars.clone());
252        let mut chart_id = None;
253        for clear_globally in [false, true] {
254            if clear_globally {
255                chart.set_theme_override(Some(local.clone()));
256            }
257            for clear in [false, true] {
258                if clear {
259                    if clear_globally {
260                        chart.set_theme(&ctx, &global);
261                    } else {
262                        chart.set_theme_override(None);
263                    }
264                }
265                let mut output = ctx.run_ui(
266                    egui::RawInput {
267                        screen_rect: Some(egui::Rect::from_min_size(
268                            egui::Pos2::ZERO,
269                            egui::vec2(800.0, 600.0),
270                        )),
271                        ..Default::default()
272                    },
273                    |ui| {
274                        egui::CentralPanel::default().show(ui, |ui| {
275                            let response = chart.show(ui);
276                            if let Some(id) = chart_id {
277                                assert_eq!(
278                                    response.id, id,
279                                    "theme changes must retain chart UI state"
280                                );
281                                assert!(response.has_focus());
282                            } else {
283                                chart_id = Some(response.id);
284                                response.request_focus();
285                            }
286                        });
287                    },
288                );
289                output.textures_delta.clear();
290                let sizes: Vec<_> = output
291                    .shapes
292                    .iter()
293                    .filter_map(|shape| match &shape.shape {
294                        egui::Shape::Text(text) if text.galley.text().parse::<f64>().is_ok() => {
295                            Some(text.galley.job.sections[0].format.font_id.size)
296                        }
297                        _ => None,
298                    })
299                    .collect();
300                let title = output
301                    .shapes
302                    .iter()
303                    .find_map(|shape| match &shape.shape {
304                        egui::Shape::Text(text) if text.galley.text().starts_with("Scoped") => {
305                            Some(text)
306                        }
307                        _ => None,
308                    })
309                    .expect("the series title is visible");
310                assert_eq!(
311                    title.galley.job.sections[0].format.color,
312                    if clear { global.ui.text } else { local.ui.text },
313                    "series titles use UI ink even when axis ink differs"
314                );
315                assert!(!sizes.is_empty());
316                assert_eq!(sizes.contains(&local.text.label), !clear, "{sizes:?}");
317                if clear {
318                    assert!(sizes.contains(&global.text.label), "{sizes:?}");
319                }
320                assert_eq!(ctx.active_theme(), global);
321                assert_eq!(ctx.global_style().visuals, original_style.visuals);
322                assert_eq!(ctx.global_style().spacing, original_style.spacing);
323                output.drop_without_applying_deltas();
324            }
325        }
326    }
327
328    #[test]
329    fn bundled_font_keeps_prices_tabular() {
330        let ctx = egui::Context::default();
331        for theme in [Theme::dark(), Theme::light(), Theme::dark()] {
332            apply_to_egui(&ctx, &theme);
333            ctx.run_ui(Default::default(), |ui| {
334                ctx.fonts(|fonts| {
335                    assert_eq!(
336                        fonts.definitions().families[&egui::FontFamily::Proportional][0],
337                        "Roboto",
338                        "the bundled face must be the first proportional font"
339                    );
340                });
341                for (size, weight) in [(12.0, None), (13.0, None), (13.0, Some(600.0))] {
342                    let mut format = egui::text::TextFormat::simple(
343                        egui::FontId::proportional(size),
344                        Color32::WHITE,
345                    );
346                    if let Some(weight) = weight {
347                        format.coords.push("wght", weight);
348                    }
349                    let widths: Vec<_> = ('0'..='9')
350                        .map(|digit| {
351                            ui.painter()
352                                .layout_job(egui::text::LayoutJob::single_section(
353                                    digit.to_string(),
354                                    format.clone(),
355                                ))
356                                .size()
357                                .x
358                        })
359                        .collect();
360                    assert!(
361                        widths.iter().all(|width| *width == widths[0]),
362                        "digits must keep their width at size {size}, weight {weight:?}: {widths:?}"
363                    );
364                }
365            })
366            .drop_without_applying_deltas();
367        }
368    }
369
370    #[test]
371    fn text_and_control_outlines_are_readable_in_every_preset() {
372        let dark = Theme::dark();
373        for (role, actual, expected) in [
374            ("panel", dark.ui.panel_bg, [0, 0, 0]),
375            ("plot", dark.chart.bg, [0, 0, 0]),
376            ("axis", dark.chart.bg_axis, [0, 0, 0]),
377            ("separator", dark.ui.border, [46, 46, 46]),
378            ("text", dark.ui.text, [219, 219, 219]),
379            ("secondary text", dark.ui.text_secondary, [184, 184, 184]),
380            ("disabled text", dark.ui.text_disabled, [87, 87, 87]),
381            ("price text", dark.chart.axis_text, [255, 255, 255]),
382            (
383                "secondary axis text",
384                dark.chart.axis_text_secondary,
385                [184, 184, 184],
386            ),
387            ("grid", dark.chart.grid_line, [34, 34, 34]),
388            ("up candle", dark.chart.candle_up, [8, 153, 129]),
389            ("down candle", dark.chart.candle_down, [242, 54, 69]),
390            ("active toggle", dark.ui.accent, [41, 98, 255]),
391        ] {
392            assert_eq!(
393                actual,
394                Color32::from_rgb(expected[0], expected[1], expected[2]),
395                "dark {role}"
396            );
397        }
398        let light = Theme::light();
399        for (role, actual, expected) in [
400            ("text", light.ui.text, 0),
401            ("secondary text", light.ui.text_secondary, 0),
402            ("axis text", light.chart.axis_text, 0),
403            ("secondary axis text", light.chart.axis_text_secondary, 0),
404            ("disabled text", light.ui.text_disabled, 126),
405            ("grid", light.chart.grid_line, 229),
406            ("major grid", light.chart.grid_line_major, 229),
407        ] {
408            assert_eq!(actual, Color32::from_gray(expected), "light {role}");
409        }
410        let orange = Color32::from_rgb(255, 120, 0);
411        let purple = Color32::from_rgb(110, 60, 200);
412        let mut themes = presets()
413            .map(|(name, theme)| (name.to_owned(), theme))
414            .to_vec();
415        themes.extend([
416            (
417                "dark, orange accent".into(),
418                Theme::dark().with_accent(orange),
419            ),
420            (
421                "light, orange accent".into(),
422                Theme::light().with_accent(orange),
423            ),
424            (
425                "light, purple accent".into(),
426                Theme::light().with_accent(purple),
427            ),
428        ]);
429        for (name, base) in presets() {
430            for (accent_name, accent) in [
431                ("white", Color32::WHITE),
432                ("black", Color32::BLACK),
433                ("yellow", Color32::YELLOW),
434                ("blue", Color32::BLUE),
435                ("navy", Color32::from_rgb(10, 10, 30)),
436            ] {
437                themes.push((
438                    format!("{name}, {accent_name} accent"),
439                    base.clone().with_accent(accent),
440                ));
441            }
442        }
443        let focus_ring_of = |ctx: &egui::Context| {
444            let mut focus_ring = Color32::TRANSPARENT;
445            ctx.run_ui(Default::default(), |ui| {
446                egui::CentralPanel::default().show(ui, |ui| {
447                    focus_ring = crate::styles::focus::focus_ring_color(ui);
448                });
449            })
450            .drop_without_applying_deltas();
451            focus_ring
452        };
453        let mut failures = Vec::new();
454        for (name, theme) in themes {
455            let ctx = egui::Context::default();
456            apply_to_egui(&ctx, &theme);
457            let visuals = ctx.global_style().visuals.clone();
458            let ui = &theme.ui;
459            if visuals.text_cursor.stroke.color != ui.text {
460                failures.push(format!("{name}: caret does not use the theme text colour"));
461            }
462            for state in [
463                &visuals.widgets.inactive,
464                &visuals.widgets.hovered,
465                &visuals.widgets.active,
466                &visuals.widgets.open,
467            ] {
468                if state.corner_radius != egui::CornerRadius::from(theme.radius.sm)
469                    || DESIGN_TOKENS.rounding.button != theme.radius.sm
470                {
471                    failures.push(format!("{name}: interactive radii differ"));
472                }
473            }
474            for (role, fg) in [
475                ("text", ui.text),
476                ("secondary text", ui.text_secondary),
477                ("price text", theme.chart.axis_text),
478                ("secondary axis text", theme.chart.axis_text_secondary),
479            ] {
480                assert!(contrast(fg, theme.chart.bg) >= 4.5, "{name}: {role}");
481            }
482            assert_eq!(visuals.widgets.noninteractive.fg_stroke.color, ui.text);
483            for (state, fill) in [
484                (&visuals.widgets.noninteractive, ui.panel_bg),
485                (&visuals.widgets.inactive, ui.panel_bg),
486                (&visuals.widgets.hovered, ui.btn_bg_hover),
487                (&visuals.widgets.active, ui.btn_bg_active),
488                (&visuals.widgets.open, ui.btn_bg_active),
489            ] {
490                assert_eq!(state.weak_bg_fill, fill, "{name}: widget fill");
491            }
492            assert_eq!(visuals.widgets.open.fg_stroke.color, ui.text);
493            #[cfg(feature = "ui")]
494            assert_eq!(
495                crate::ui::timeframe_toolbar::TimeframeBarConfig::from_theme(&theme)
496                    .active_text_color,
497                ui.text
498            );
499            let focus_ring = focus_ring_of(&ctx);
500            // High contrast holds every text level to 7:1 and outlines to 4.5:1 (WCAG AAA).
501            let (text_min, outline_min) = if name == "high-contrast" {
502                (7.0, 4.5)
503            } else {
504                (4.5, 3.0)
505            };
506            let mut check = |role: &str, fg: Color32, surface: &str, bg: Color32, min: f32| {
507                let ratio = contrast(fg, bg);
508                if ratio < min {
509                    failures.push(format!(
510                        "{name}: {role} on {surface} is {ratio:.2}:1, below {min}:1"
511                    ));
512                }
513            };
514            check(
515                "selected text",
516                visuals.selection.stroke.color,
517                "selection",
518                visuals.selection.bg_fill,
519                text_min,
520            );
521            for (fill, bg) in [
522                ("accent", ui.accent),
523                ("hovered accent", ui.accent_hover),
524                ("pressed accent", ui.accent_active),
525            ] {
526                check("on-accent text", ui.accent_text, fill, bg, text_min);
527            }
528            for (fill, bg) in [
529                ("danger", ui.danger_bg),
530                ("hovered danger", ui.danger_bg_hover),
531                ("pressed danger", ui.danger_bg_active),
532            ] {
533                check("on-danger text", ui.danger_text, fill, bg, text_min);
534            }
535            // Hover must read on a panel, and pressed must sit further from it than hover.
536            let (panel, hovered) = (visuals.panel_fill, visuals.widgets.hovered.weak_bg_fill);
537            check("hover fill", hovered, "panels", panel, 1.2);
538            let pressed_min = contrast(hovered, panel) + 0.05;
539            check(
540                "pressed fill",
541                visuals.widgets.active.weak_bg_fill,
542                "panels",
543                panel,
544                pressed_min,
545            );
546            check(
547                "pressed fill",
548                visuals.widgets.active.weak_bg_fill,
549                "hover fill",
550                hovered,
551                1.2,
552            );
553            check(
554                "pressed text",
555                ui.text,
556                "pressed fill",
557                ui.btn_bg_active,
558                text_min,
559            );
560            check(
561                "open outline",
562                visuals.widgets.open.bg_stroke.color,
563                "panels",
564                panel,
565                outline_min,
566            );
567            if name.contains("accent") {
568                check("selection fill", ui.selection_bg, "panels", panel, 1.3);
569                check("accent hover", ui.accent_hover, "accent", ui.accent, 1.2);
570                check(
571                    "accent pressed",
572                    ui.accent_active,
573                    "accent hover",
574                    ui.accent_hover,
575                    1.2,
576                );
577                if theme.dark {
578                    assert_ne!(ui.selection_text, ui.text, "{name}: selected glyphs");
579                }
580            }
581            // egui outlines a focused text input with the selection stroke.
582            check(
583                "focused input outline",
584                visuals.selection.stroke.color,
585                "inputs",
586                visuals.text_edit_bg_color(),
587                outline_min,
588            );
589            for (surface, bg) in [
590                ("panels", visuals.panel_fill),
591                ("windows", visuals.window_fill),
592                ("secondary panels", ui.panel_bg_secondary),
593                ("hover fill", ui.btn_bg_hover),
594            ] {
595                check("links", visuals.hyperlink_color, surface, bg, 7.0);
596                check("text", ui.text, surface, bg, text_min);
597                check("label text", visuals.text_color(), surface, bg, text_min);
598                check("muted text", ui.text_muted, surface, bg, text_min);
599                check(
600                    "weak text",
601                    visuals.weak_text_color(),
602                    surface,
603                    bg,
604                    text_min,
605                );
606                check("success text", ui.success, surface, bg, text_min);
607                check("error text", visuals.error_fg_color, surface, bg, text_min);
608                check("warning text", visuals.warn_fg_color, surface, bg, text_min);
609                check(
610                    "control outline",
611                    visuals.widgets.inactive.bg_stroke.color,
612                    surface,
613                    bg,
614                    outline_min,
615                );
616                check("keyboard focus ring", focus_ring, surface, bg, outline_min);
617                check(
618                    "text cursor",
619                    visuals.text_cursor.stroke.color,
620                    surface,
621                    bg,
622                    3.0,
623                );
624            }
625        }
626        // An app that styles egui itself and never applies a theme keeps a visible ring.
627        let ctx = egui::Context::default();
628        ctx.set_theme(egui::Theme::Light);
629        let (focus_ring, panel) = (focus_ring_of(&ctx), ctx.global_style().visuals.panel_fill);
630        let ratio = contrast(focus_ring, panel);
631        if ratio < 3.0 {
632            failures.push(format!(
633                "no theme, light visuals: keyboard focus ring on panels is {ratio:.2}:1, below 3:1"
634            ));
635        }
636        assert!(failures.is_empty(), "{}", failures.join("\n"));
637    }
638
639    /// egui follows the system appearance by default and keeps a style for each, so an app that
640    /// applies its theme once must keep it when the system switches between light and dark.
641    #[test]
642    fn the_theme_outlasts_a_switch_of_the_system_appearance() {
643        let ctx = egui::Context::default();
644        let frame = |system: egui::Theme, apply: bool| {
645            let input = egui::RawInput {
646                system_theme: Some(system),
647                ..Default::default()
648            };
649            ctx.run_ui(input, |ui| {
650                if apply {
651                    apply_to_egui(ui.ctx(), &Theme::high_contrast());
652                }
653            })
654            .drop_without_applying_deltas();
655        };
656        frame(egui::Theme::Dark, true);
657        let applied = ctx.global_style();
658        frame(egui::Theme::Light, false);
659        let style = ctx.global_style();
660        assert!(
661            style.visuals == applied.visuals && style.spacing == applied.spacing,
662            "switching the system to light dropped the applied theme"
663        );
664    }
665}