Skip to main content

egui_charts/theme/
context.rs

1//! Reading the theme last applied to an egui context.
2
3use egui::{Context, Ui};
4
5use super::Theme;
6
7/// Read the theme last passed to [`apply_to_egui`](super::apply_to_egui).
8///
9/// Widgets call it once per frame and read the colours egui does not model from it;
10/// the rest come from `ui.visuals()`, which `apply_to_egui` sets from the same theme.
11///
12/// ```
13/// use egui_charts::theme::{Theme, ThemeExt, apply_to_egui};
14///
15/// # egui::__run_test_ui(|ui| {
16/// apply_to_egui(ui.ctx(), &Theme::light());
17/// let theme = ui.active_theme();
18/// assert_eq!(theme, Theme::light());
19/// let _on_accent = theme.ui.accent_text;
20/// # });
21/// ```
22pub trait ThemeExt {
23    /// The theme last applied, or [`Theme::dark`] if none has been.
24    fn active_theme(&self) -> Theme;
25}
26
27impl ThemeExt for Context {
28    fn active_theme(&self) -> Theme {
29        applied_theme(self).unwrap_or_default()
30    }
31}
32
33impl ThemeExt for Ui {
34    fn active_theme(&self) -> Theme {
35        self.ctx().active_theme()
36    }
37}
38
39fn active_theme_id() -> egui::Id {
40    egui::Id::NULL.with("active_theme")
41}
42
43/// The theme last applied to `ctx`, or `None` if none has been.
44pub(crate) fn applied_theme(ctx: &Context) -> Option<Theme> {
45    ctx.data(|d| d.get_temp::<Theme>(active_theme_id()))
46}
47
48/// Store the theme [`ThemeExt::active_theme`] returns.
49pub(crate) fn set_active_theme(ctx: &Context, theme: Theme) {
50    ctx.data_mut(|d| d.insert_temp(active_theme_id(), theme));
51}
52
53/// Restores the active palette after painting one chart, including during unwinding.
54pub(crate) struct ThemeScope {
55    ctx: Context,
56    previous: Option<Theme>,
57}
58
59impl ThemeScope {
60    pub(crate) fn new(ctx: &Context, theme: &Theme) -> Self {
61        let previous = applied_theme(ctx);
62        set_active_theme(ctx, theme.clone());
63        Self {
64            ctx: ctx.clone(),
65            previous,
66        }
67    }
68}
69
70impl Drop for ThemeScope {
71    fn drop(&mut self) {
72        if let Some(theme) = self.previous.take() {
73            set_active_theme(&self.ctx, theme);
74        } else {
75            self.ctx
76                .data_mut(|data| data.remove::<Theme>(active_theme_id()));
77        }
78    }
79}
80
81/// A component config derived from a theme, whose fields can follow a new theme one by one.
82#[cfg(feature = "ui")]
83pub(crate) trait ThemeDerived {
84    /// Move every field still equal to its value in `old` to its value in `new`,
85    /// keeping the fields the caller has changed.
86    fn follow(&mut self, old: &Self, new: &Self);
87}
88
89/// Implement [`ThemeDerived`] for a config struct, listing all of its fields.
90#[cfg(feature = "ui")]
91macro_rules! theme_derived {
92    ($config:ty { $($field:ident),+ $(,)? }) => {
93        impl $crate::theme::ThemeDerived for $config {
94            fn follow(&mut self, old: &Self, new: &Self) {
95                // Exhaustive, so a field added to the struct must be listed here.
96                let Self { $($field),+ } = self;
97                $(if *$field == old.$field {
98                    *$field = new.$field.clone();
99                })+
100            }
101        }
102    };
103}
104#[cfg(feature = "ui")]
105pub(crate) use theme_derived;
106
107/// Rederive a theme-derived component config when the active theme changes.
108///
109/// `derived` holds the theme and config that `config` was last derived from, or
110/// `None` for a config the caller supplied, which is never replaced. Fields the
111/// caller has changed since are kept; the others follow the new theme.
112#[cfg(feature = "ui")]
113pub(crate) fn follow_active_theme<C: ThemeDerived>(
114    ctx: &Context,
115    derived: &mut Option<(Theme, C)>,
116    config: &mut C,
117    from_theme: impl FnOnce(&Theme) -> C,
118) {
119    if let Some(theme) = applied_theme(ctx)
120        && let Some((derived_from, old)) = derived
121        && *derived_from != theme
122    {
123        let new = from_theme(&theme);
124        config.follow(old, &new);
125        *derived_from = theme;
126        *old = new;
127    }
128}
129
130#[cfg(all(test, feature = "ui"))]
131mod tests {
132    use super::*;
133    use crate::theme::apply_to_egui;
134    use crate::ui::timeframe_toolbar::{TimeframeToolbar, TimeframeToolbarConfig};
135    use egui::Color32;
136
137    #[test]
138    fn theme_derived_config_follows_the_active_theme_and_keeps_caller_edits() {
139        let ctx = Context::default();
140        let mut themed = TimeframeToolbar::new();
141        themed.config.panel_height = 40.0;
142        themed.config.text_color = Color32::RED;
143        let mut custom =
144            TimeframeToolbar::with_config(TimeframeToolbarConfig::from_theme(&Theme::dark()));
145
146        // Light differs from the Dark config the toolbar is built with; Dark is a later change,
147        // and an edited Dark theme must propagate too.
148        let mut edited_dark = Theme::dark();
149        edited_dark.ui.border = Color32::GREEN;
150        edited_dark.text.body = 17.0;
151        edited_dark.text.bottom_bar = 19.0;
152        edited_dark.spacing.md = 9.0;
153        edited_dark.radius.sm = 7.0;
154        for (name, theme) in [
155            ("light", Theme::light()),
156            ("dark", Theme::dark()),
157            ("edited dark", edited_dark),
158        ] {
159            ctx.run_ui(Default::default(), |ui| {
160                apply_to_egui(ui.ctx(), &theme);
161                egui::CentralPanel::default().show(ui, |ui| {
162                    themed.show(ui);
163                    custom.show(ui);
164                });
165            })
166            .drop_without_applying_deltas();
167            assert_eq!(
168                themed.config.panel_height, 40.0,
169                "{name}: edited height replaced"
170            );
171            assert_eq!(
172                themed.config.text_color,
173                Color32::RED,
174                "{name}: edited colour replaced"
175            );
176            assert_eq!(
177                themed.config.border_color, theme.ui.border,
178                "{name}: untouched colour stale"
179            );
180            assert_eq!(
181                themed.config.font_size, theme.text.bottom_bar,
182                "{name}: font stale"
183            );
184            assert_eq!(
185                themed.config.btn_padding_h, theme.spacing.md,
186                "{name}: spacing stale"
187            );
188            assert_eq!(
189                ctx.global_style().text_styles[&egui::TextStyle::Body].size,
190                theme.text.body
191            );
192            assert_eq!(
193                ctx.global_style().visuals.widgets.inactive.corner_radius,
194                egui::CornerRadius::from(theme.radius.sm)
195            );
196            assert_eq!(custom.config.font_size, Theme::dark().text.bottom_bar);
197            assert_eq!(
198                custom.config.border_color,
199                Theme::dark().ui.border,
200                "{name}: with_config replaced"
201            );
202        }
203    }
204}