1use super::{Theme, context};
4use crate::tokens::DESIGN_TOKENS;
5use egui::Color32;
6
7pub(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
17pub(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
30pub(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 visuals.panel_fill = ui.panel_bg;
42 visuals.window_fill = ui.panel_bg_floating;
44 visuals.extreme_bg_color = ui.panel_bg;
45 visuals.faint_bg_color = ui.panel_bg;
48
49 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 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 visuals.widgets.noninteractive.bg_stroke =
69 egui::Stroke::new(DESIGN_TOKENS.stroke.hairline, ui.border);
70 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 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 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 visuals.weak_text_color = Some(ui.text_muted);
98 visuals.error_fg_color = ui.error;
99 visuals.warn_fg_color = ui.warning;
100
101 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 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 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
166pub fn apply_to_egui(ctx: &egui::Context, theme: &Theme) {
173 ctx.all_styles_mut(|style| apply_to_style(style, theme));
175
176 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 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 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 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 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 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 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 #[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}