Skip to main content

egui_charts/theme/
mod.rs

1//! Theme: the colours and scales of the UI and the chart.
2//!
3//! A [`Theme`] is a plain value with public fields. Start from a preset
4//! ([`Theme::dark`], the default, [`Theme::light`] or [`Theme::high_contrast`]),
5//! change the fields you need, and pass it to [`apply_to_egui`], once at startup
6//! and again after every change.
7//!
8//! ```
9//! use egui::Color32;
10//! use egui_charts::theme::{Theme, apply_to_egui};
11//!
12//! # let ctx = &egui::Context::default();
13//! let mut theme = Theme::dark().with_accent(Color32::from_rgb(255, 120, 0));
14//! theme.chart.candle_up = Color32::from_rgb(0, 160, 120);
15//! apply_to_egui(ctx, &theme);
16//! ```
17
18mod context;
19mod presets;
20mod style;
21
22pub use context::ThemeExt;
23#[cfg(feature = "ui")]
24pub(crate) use context::{ThemeDerived, follow_active_theme, theme_derived};
25pub(crate) use context::{ThemeScope, applied_theme};
26pub use presets::presets;
27pub use style::apply_to_egui;
28pub(crate) use style::{apply_to_style, contrast, readable_color};
29
30use crate::tokens::DESIGN_TOKENS;
31use egui::Color32;
32
33/// The colours and scales of the UI and the chart.
34///
35/// With the `serde` feature, a theme serialises with any serde format. The `dark`
36/// flag and both complete colour palettes are required; omitted text, spacing and
37/// radius scales use their defaults. Colours are egui's premultiplied `[r, g, b, a]`
38/// arrays.
39#[derive(Clone, Debug, PartialEq)]
40#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
41pub struct Theme {
42    /// Whether the UI is dark: selects egui's base visuals and the shadow strength.
43    pub dark: bool,
44    /// Colours of the UI chrome: panels, text, controls and status.
45    pub ui: UiColors,
46    /// Colours of the chart surface.
47    pub chart: ChartColors,
48    /// Type scale and chart text roles, also applied to egui's built-in text styles.
49    #[cfg_attr(feature = "serde", serde(default))]
50    pub text: TextSizes,
51    /// Spacing scale.
52    #[cfg_attr(feature = "serde", serde(default))]
53    pub spacing: Spacing,
54    /// Corner radius scale.
55    #[cfg_attr(feature = "serde", serde(default))]
56    pub radius: Radii,
57}
58
59impl Default for Theme {
60    fn default() -> Self {
61        Self::dark()
62    }
63}
64
65/// Colours of the UI chrome.
66#[derive(Clone, Copy, Debug, PartialEq)]
67#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
68pub struct UiColors {
69    /// Panels, toolbars and inputs.
70    pub panel_bg: Color32,
71    /// Sidebars, cards and the gaps between panels.
72    pub panel_bg_secondary: Color32,
73    /// Windows, menus, popups and tooltips.
74    pub panel_bg_floating: Color32,
75    /// Scrim behind modal dialogs; transparent when the border alone separates them.
76    pub modal_overlay: Color32,
77    /// Primary text.
78    pub text: Color32,
79    /// Secondary labels.
80    pub text_secondary: Color32,
81    /// Hints, shortcuts and other weak text.
82    pub text_muted: Color32,
83    /// Text of disabled controls.
84    pub text_disabled: Color32,
85    /// Separators and window outlines.
86    pub border: Color32,
87    /// Resting outline of checkboxes, radios, inputs and combo boxes.
88    pub border_control: Color32,
89    /// Fill of a hovered button.
90    pub btn_bg_hover: Color32,
91    /// Fill of a pressed or open button.
92    pub btn_bg_active: Color32,
93    /// Fill of selected text and selected items.
94    pub selection_bg: Color32,
95    /// Text on `selection_bg`; egui also outlines a focused text input with it.
96    pub selection_text: Color32,
97    /// Keyboard focus ring.
98    pub focus: Color32,
99    /// Primary action fill and active icon colour; links derive a readable shade.
100    pub accent: Color32,
101    /// `accent` when hovered.
102    pub accent_hover: Color32,
103    /// `accent` when pressed.
104    pub accent_active: Color32,
105    /// Text and icons on the accent fills.
106    pub accent_text: Color32,
107    /// Fill of destructive actions.
108    pub danger_bg: Color32,
109    /// `danger_bg` when hovered.
110    pub danger_bg_hover: Color32,
111    /// `danger_bg` when pressed.
112    pub danger_bg_active: Color32,
113    /// Text and icons on the danger fills.
114    pub danger_text: Color32,
115    /// Success text and icons.
116    pub success: Color32,
117    /// Take-profit lines, labels and controls.
118    #[cfg_attr(feature = "serde", serde(default = "default_take_profit"))]
119    pub take_profit: Color32,
120    /// Stop-loss lines, labels and controls.
121    #[cfg_attr(feature = "serde", serde(default = "default_stop_loss"))]
122    pub stop_loss: Color32,
123    /// Warning text and icons.
124    pub warning: Color32,
125    /// Error text and icons.
126    pub error: Color32,
127    /// Informational text and icons.
128    pub info: Color32,
129}
130
131#[cfg(feature = "serde")]
132fn default_take_profit() -> Color32 {
133    Color32::from_rgb(0, 166, 147)
134}
135#[cfg(feature = "serde")]
136fn default_stop_loss() -> Color32 {
137    Color32::from_rgb(255, 166, 0)
138}
139/// The light theme's, which also reads at 3:1 or more on black, for a theme saved without it.
140#[cfg(feature = "serde")]
141fn default_order_notable() -> Color32 {
142    Theme::light().chart.order_notable
143}
144#[cfg(feature = "serde")]
145fn default_order_large() -> Color32 {
146    Theme::light().chart.order_large
147}
148
149impl Default for UiColors {
150    fn default() -> Self {
151        Theme::dark().ui
152    }
153}
154
155/// Colours of the chart surface.
156#[derive(Clone, Copy, Debug, PartialEq)]
157#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
158pub struct ChartColors {
159    /// Plot background.
160    pub bg: Color32,
161    /// Price and time axis background.
162    pub bg_axis: Color32,
163    /// Tooltip background.
164    pub bg_tooltip: Color32,
165    /// Minor grid lines.
166    pub grid_line: Color32,
167    /// Major grid lines.
168    pub grid_line_major: Color32,
169    /// Axis labels.
170    pub axis_text: Color32,
171    /// Secondary axis labels.
172    pub axis_text_secondary: Color32,
173    /// Crosshair lines.
174    pub crosshair_line: Color32,
175    /// Background of the crosshair value labels.
176    pub crosshair_label_bg: Color32,
177    /// Text of the crosshair value labels.
178    pub crosshair_label_text: Color32,
179    /// Rising candles and bars: body, border and wick.
180    pub candle_up: Color32,
181    /// Falling candles and bars: body, border and wick.
182    pub candle_down: Color32,
183    /// Resting orders that reach the notable size tier of an
184    /// [`OrderBookView`](crate::chart::overlays::OrderBookView): heatmap cells, book profile bars
185    /// and depth ladder cells.
186    #[cfg_attr(feature = "serde", serde(default = "default_order_notable"))]
187    pub order_notable: Color32,
188    /// Resting orders that reach the large size tier.
189    #[cfg_attr(feature = "serde", serde(default = "default_order_large"))]
190    pub order_large: Color32,
191    /// Opacity of the volume colours returned by [`Self::volume_up`] and [`Self::volume_down`].
192    pub volume_alpha: u8,
193}
194
195impl Default for ChartColors {
196    fn default() -> Self {
197        Theme::dark().chart
198    }
199}
200
201impl ChartColors {
202    /// Rising volume bars: teal at `volume_alpha`.
203    pub fn volume_up(&self) -> Color32 {
204        Color32::from_rgb(38, 166, 154).gamma_multiply_u8(self.volume_alpha)
205    }
206
207    /// Falling volume bars: red at `volume_alpha`.
208    pub fn volume_down(&self) -> Color32 {
209        Color32::from_rgb(239, 83, 80).gamma_multiply_u8(self.volume_alpha)
210    }
211}
212
213/// Font sizes, in points.
214///
215/// Components choose a role; the built-in egui text styles use the same scale.
216/// Use `..Default::default()` in struct literals to keep defaults for the other roles.
217#[derive(Clone, Copy, Debug, PartialEq)]
218#[cfg_attr(
219    feature = "serde",
220    derive(serde::Serialize, serde::Deserialize),
221    serde(default)
222)]
223pub struct TextSizes {
224    /// Small badges.
225    pub micro: f32,
226    /// Chart markers and compact annotations.
227    pub marker: f32,
228    /// Badges and footnotes.
229    pub tiny: f32,
230    /// Secondary labels.
231    pub small: f32,
232    /// Control labels and axis text.
233    pub label: f32,
234    /// Body text and buttons.
235    pub body: f32,
236    /// Panel and dialog titles.
237    pub title: f32,
238    /// Section headings.
239    pub heading: f32,
240    /// Modal dialog titles.
241    pub dialog_title: f32,
242    /// In-pane chart title, independently of its OHLC values.
243    pub chart_title: f32,
244    /// Toolbar labels, including Save.
245    pub toolbar: f32,
246    /// OHLC values and supplementary legend rows.
247    pub ohlc: f32,
248    /// Price and time axis labels.
249    pub axis: f32,
250    /// Price tags on the axis.
251    pub price_tag: f32,
252    /// Bottom range and status bar labels.
253    pub bottom_bar: f32,
254}
255
256impl Default for TextSizes {
257    fn default() -> Self {
258        Self {
259            micro: 8.0,
260            marker: 9.0,
261            dialog_title: 20.0,
262            tiny: 10.0,
263            small: 11.0,
264            label: 12.0,
265            body: 13.0,
266            title: 14.0,
267            heading: 16.0,
268            chart_title: 13.0,
269            toolbar: 14.0,
270            ohlc: 13.0,
271            axis: 12.0,
272            price_tag: 12.0,
273            bottom_bar: 12.0,
274        }
275    }
276}
277
278impl TextSizes {
279    /// Every size multiplied by `factor`.
280    pub fn scaled(self, factor: f32) -> Self {
281        Self {
282            micro: self.micro * factor,
283            marker: self.marker * factor,
284            tiny: self.tiny * factor,
285            small: self.small * factor,
286            label: self.label * factor,
287            body: self.body * factor,
288            title: self.title * factor,
289            heading: self.heading * factor,
290            dialog_title: self.dialog_title * factor,
291            chart_title: self.chart_title * factor,
292            toolbar: self.toolbar * factor,
293            ohlc: self.ohlc * factor,
294            axis: self.axis * factor,
295            price_tag: self.price_tag * factor,
296            bottom_bar: self.bottom_bar * factor,
297        }
298    }
299}
300
301/// Spacing scale, in points.
302#[derive(Clone, Copy, Debug, PartialEq)]
303#[cfg_attr(
304    feature = "serde",
305    derive(serde::Serialize, serde::Deserialize),
306    serde(default)
307)]
308pub struct Spacing {
309    /// Extra small, in egui logical points.
310    pub xs: f32,
311    /// Small, in egui logical points.
312    pub sm: f32,
313    /// Medium, in egui logical points.
314    pub md: f32,
315    /// Large, in egui logical points.
316    pub lg: f32,
317    /// Extra large, in egui logical points.
318    pub xl: f32,
319    /// Double extra large, in egui logical points.
320    pub xxl: f32,
321    /// Triple extra large, in egui logical points.
322    pub xxxl: f32,
323}
324
325impl Default for Spacing {
326    fn default() -> Self {
327        Self {
328            xs: DESIGN_TOKENS.spacing.xs,
329            sm: DESIGN_TOKENS.spacing.sm,
330            md: DESIGN_TOKENS.spacing.md,
331            lg: DESIGN_TOKENS.spacing.lg,
332            xl: DESIGN_TOKENS.spacing.xl,
333            xxl: DESIGN_TOKENS.spacing.xxl,
334            xxxl: DESIGN_TOKENS.spacing.xxxl,
335        }
336    }
337}
338
339/// Corner radius scale, in points.
340#[derive(Clone, Copy, Debug, PartialEq)]
341#[cfg_attr(
342    feature = "serde",
343    derive(serde::Serialize, serde::Deserialize),
344    serde(default)
345)]
346pub struct Radii {
347    /// Extra small, in egui logical points.
348    pub xs: f32,
349    /// Small, in egui logical points.
350    pub sm: f32,
351    /// Medium, in egui logical points.
352    pub md: f32,
353    /// Large, in egui logical points.
354    pub lg: f32,
355    /// Extra large, in egui logical points.
356    pub xl: f32,
357}
358
359impl Default for Radii {
360    fn default() -> Self {
361        Self {
362            xs: DESIGN_TOKENS.rounding.xs,
363            sm: DESIGN_TOKENS.rounding.sm,
364            md: DESIGN_TOKENS.rounding.md,
365            lg: DESIGN_TOKENS.rounding.lg,
366            xl: DESIGN_TOKENS.rounding.xl,
367        }
368    }
369}
370
371/// The shared buy and sell colours of trading controls and execution marks.
372pub(crate) fn trading_side_color(theme: &Theme, side: crate::model::OrderSide) -> Color32 {
373    match side {
374        crate::model::OrderSide::Buy => theme.ui.accent,
375        crate::model::OrderSide::Sell => theme.chart.candle_down,
376    }
377}
378
379impl Theme {
380    /// This theme with `accent` as its accent colour.
381    ///
382    /// Also derives opaque hover and pressed shades at least 1.2:1 apart, black or
383    /// white text reading at 4.5:1 on every shade, a selection at least 1.3:1 from
384    /// the panel, and a light theme's focus ring. These overrides promise 4.5:1
385    /// text contrast rather than the high-contrast preset's 7:1.
386    pub fn with_accent(mut self, accent: Color32) -> Self {
387        let ui = &mut self.ui;
388        let accent = ui.panel_bg.blend(accent);
389        let (on_accent, away) =
390            if contrast(Color32::WHITE, accent) >= contrast(Color32::BLACK, accent) {
391                (Color32::WHITE, Color32::BLACK)
392            } else {
393                (Color32::BLACK, Color32::WHITE)
394            };
395        // Near black or white, move towards the text instead so both steps have room.
396        let towards = if contrast(accent, away) >= 1.5 {
397            away
398        } else {
399            on_accent
400        };
401        let shade = |fill: Color32| {
402            (1..=255)
403                .map(|step| fill.lerp_to_gamma(towards, step as f32 / 255.0))
404                .find(|&next| contrast(next, fill) >= 1.2 && contrast(on_accent, next) >= 4.5)
405                .unwrap_or(fill)
406        };
407        ui.accent = accent;
408        ui.accent_hover = shade(accent);
409        ui.accent_active = shade(ui.accent_hover);
410        ui.accent_text = on_accent;
411        ui.selection_text = if self.dark && ui.text == Color32::WHITE {
412            ui.text_secondary
413        } else if self.dark {
414            Color32::WHITE
415        } else {
416            Color32::BLACK
417        };
418        let selection = if self.dark {
419            accent
420        } else {
421            ui.panel_bg.lerp_to_gamma(accent, 1.0 / 6.0)
422        };
423        let selection = readable_color(selection, ui.text, &[ui.panel_bg], 1.3);
424        ui.selection_bg = readable_color(selection, ui.panel_bg, &[ui.selection_text], 4.5);
425        if !self.dark {
426            ui.focus = readable_color(
427                accent,
428                ui.text,
429                &[
430                    ui.panel_bg,
431                    ui.panel_bg_secondary,
432                    ui.panel_bg_floating,
433                    ui.btn_bg_hover,
434                ],
435                3.0,
436            );
437        }
438        self
439    }
440}
441
442#[cfg(all(test, feature = "serde"))]
443mod tests {
444    use super::*;
445
446    #[test]
447    fn partial_light_palettes_are_rejected_instead_of_mixing_presets() {
448        let partial = serde_json::json!({"dark": false, "ui": {
449            "panel_bg": [255, 255, 255, 255],
450            "panel_bg_floating": [255, 255, 255, 255],
451            "text": [19, 23, 34, 255]
452        }});
453        assert!(serde_json::from_value::<Theme>(partial).is_err());
454        for (_, theme) in presets() {
455            let mut saved = serde_json::to_value(&theme).unwrap();
456            for scale in ["text", "spacing", "radius"] {
457                saved.as_object_mut().unwrap().remove(scale);
458            }
459            assert_eq!(serde_json::from_value::<Theme>(saved).unwrap(), theme);
460        }
461    }
462
463    #[test]
464    fn saved_text_roles_default_when_missing_and_keep_overrides_when_scaled() {
465        for (role, default) in [
466            ("micro", 8.0),
467            ("marker", 9.0),
468            ("dialog_title", 20.0),
469            ("chart_title", 13.0),
470            ("toolbar", 14.0),
471            ("ohlc", 13.0),
472            ("axis", 12.0),
473            ("price_tag", 12.0),
474            ("bottom_bar", 12.0),
475        ] {
476            for override_size in [None, Some(19.0)] {
477                let mut saved = serde_json::to_value(Theme::dark()).unwrap();
478                saved["text"] = serde_json::json!({"body": 17.0});
479                if let Some(size) = override_size {
480                    saved["text"][role] = serde_json::json!(size);
481                }
482                let mut theme: Theme = serde_json::from_value(saved).unwrap();
483                theme.text = theme.text.scaled(1.5);
484                let saved = serde_json::to_value(&theme).unwrap();
485                assert_eq!(
486                    saved["text"][role],
487                    serde_json::json!(override_size.unwrap_or(default) * 1.5),
488                    "{role}: {override_size:?}"
489                );
490                assert_eq!(saved["text"]["body"], serde_json::json!(25.5));
491                assert_eq!(serde_json::from_value::<Theme>(saved).unwrap(), theme);
492            }
493        }
494    }
495}