material_dioxus/
theming.rs

1use std::fmt;
2
3use ::palette::{blend::Blend, color_difference::Wcag21RelativeContrast, Mix, Srgb, Srgba};
4use dioxus::prelude::*;
5
6use crate::palette::{self, Color};
7
8#[derive(Debug, PartialEq)]
9pub struct Colors {
10    pub primary: Color,
11    pub secondary: Option<Color>,
12    pub surface: Option<Color>,
13    pub inverse_surface: Color,
14    pub background: Color,
15    pub error: Color,
16
17    pub on_primary: Option<Color>,
18    pub on_secondary: Option<Color>,
19    pub on_surface: Option<Color>,
20    pub on_inverse_surface: Option<Color>,
21    pub on_error: Option<Color>,
22
23    pub four_color_progress: [Color; 4],
24    pub switch_use_secondary: bool,
25}
26
27impl Colors {
28    pub const DEFAULT_LIGHT: Self = Self {
29        primary: palette::PURPLE_500,
30        secondary: None,
31        surface: None,
32        inverse_surface: palette::from_u32(0x121212, 1.),
33        background: palette::from_u32(0xffffff, 1.),
34        error: palette::from_u32(0xb00020, 1.),
35
36        on_primary: None,
37        on_secondary: None,
38        on_surface: None,
39        on_inverse_surface: None,
40        on_error: None,
41
42        four_color_progress: [
43            palette::GREEN_500,
44            palette::YELLOW_500,
45            palette::RED_500,
46            palette::BLUE_500,
47        ],
48        switch_use_secondary: false,
49    };
50    pub const DEFAULT_DARK: Self = Self {
51        primary: palette::PURPLE_200,
52        inverse_surface: palette::from_u32(0xffffff, 1.),
53        background: palette::from_u32(0x121212, 1.),
54        error: palette::from_u32(0xcf6679, 1.),
55        ..Self::DEFAULT_LIGHT
56    };
57}
58
59#[derive(Props, PartialEq)]
60pub struct ThemeProps {
61    #[props(default = Colors::DEFAULT_LIGHT)]
62    theme: Colors,
63    #[props(!optional, default = Some(Colors::DEFAULT_DARK))]
64    dark_theme: Option<Colors>,
65}
66
67#[derive(Clone, Copy)]
68struct ColorDisplay(Color);
69impl fmt::Display for ColorDisplay {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        let (r, g, b, a) = self.0.into_components();
72        write!(f, "rgba({r}, {g}, {b}, {a})")
73    }
74}
75
76fn overlay(base: Color, overlay: Color, alpha: f32) -> Color {
77    let base: Srgba = base.into_format();
78    base.overlay(palette::with_alpha(overlay, alpha).into_format())
79        .into_format()
80}
81
82pub fn contrast_text(bg: Color) -> Color {
83    let bg = Srgba::<f32>::from_format(bg);
84    let light_contrast = bg.relative_contrast(Srgb::new(1., 1., 1.));
85    let dark_contrast = bg.relative_contrast(Srgb::new(0., 0., 0.));
86    const MIN_CONTRAST: f32 = 3.1;
87    if light_contrast < MIN_CONTRAST && dark_contrast > light_contrast {
88        palette::from_u32(0x000000, 0.87)
89    } else {
90        palette::from_u32(0xffffff, 1.)
91    }
92}
93
94fn mix(col1: Color, col2: Color, factor: f32) -> Color {
95    let col1 = Srgba::<f32>::from_format(col1);
96    let col2 = Srgba::<f32>::from_format(col2);
97    col2.mix(col1, factor).into_format()
98}
99
100#[rustfmt::skip]
101fn define_vars(colors: &Colors) -> String {
102    let primary = ColorDisplay(colors.primary);
103    let secondary = ColorDisplay(colors.secondary.unwrap_or(colors.primary));
104    let surface = ColorDisplay(colors.surface.unwrap_or(colors.background));
105    let background = ColorDisplay(colors.background);
106    let error = ColorDisplay(colors.error);
107
108    let on_primary = ColorDisplay(colors.on_primary.unwrap_or(contrast_text(primary.0)));
109    let on_secondary = ColorDisplay(colors.on_secondary.unwrap_or(contrast_text(secondary.0)));
110    let on_surface = ColorDisplay(colors.on_surface.unwrap_or(contrast_text(surface.0)));
111    let on_inverse_surface = ColorDisplay(colors.on_inverse_surface.unwrap_or(contrast_text(colors.inverse_surface)));
112    let on_error = ColorDisplay(colors.on_error.unwrap_or(contrast_text(colors.error)));
113
114    let surface_alpha = |alpha: f32| palette::with_alpha(on_surface.0, alpha);
115    let inverse_surface_alpha = |alpha: f32| palette::with_alpha(on_inverse_surface.0, alpha);
116
117    let text_primary_on_bg = ColorDisplay(surface_alpha(0.87));
118    let text_secondary_on_bg = ColorDisplay(surface_alpha(0.6));
119    let text_hint_on_bg = ColorDisplay(surface_alpha(0.6));
120    let text_disabled_on_bg = ColorDisplay(surface_alpha(0.38));
121    let text_icon_on_bg = ColorDisplay(surface_alpha(0.6));
122    let text_primary_on_inverse = ColorDisplay(inverse_surface_alpha(0.87));
123    let text_secondary_on_inverse = ColorDisplay(inverse_surface_alpha(0.6));
124    let text_hint_on_inverse = ColorDisplay(inverse_surface_alpha(0.6));
125    let text_disabled_on_inverse = ColorDisplay(inverse_surface_alpha(0.38));
126    let text_icon_on_inverse = ColorDisplay(inverse_surface_alpha(0.6));
127
128    let button_outline = ColorDisplay(surface_alpha(0.12));
129    let button_disabled_fill = ColorDisplay(surface_alpha(0.12));
130    let button_disabled_ink = ColorDisplay(surface_alpha(0.37));
131    let button_disabled_outline = ColorDisplay(surface_alpha(0.12));
132
133    let four_color_progress_1 = ColorDisplay(colors.four_color_progress[0]);
134    let four_color_progress_2 = ColorDisplay(colors.four_color_progress[1]);
135    let four_color_progress_3 = ColorDisplay(colors.four_color_progress[2]);
136    let four_color_progress_4 = ColorDisplay(colors.four_color_progress[3]);
137
138    let checkbox_ink = ColorDisplay(on_secondary.0);
139    let checkbox_unchecked = ColorDisplay(surface_alpha(0.54));
140    let checkbox_disabled = ColorDisplay(surface_alpha(0.38));
141
142    // TODO: disabled switch on dark is barely visible
143    let switch_primary = ColorDisplay(if colors.switch_use_secondary { secondary.0 } else { colors.primary });
144    let switch_on_surface = ColorDisplay(palette::GREY_800);
145    let switch_on_surface_state_content = ColorDisplay(palette::GREY_700);
146    let switch_hairline = ColorDisplay(palette::GREY_400);
147    let switch_primary_state_content = switch_primary;
148    let switch_inverse_primary = ColorDisplay(overlay(switch_hairline.0, switch_primary.0, 0.25));
149
150    let textfield_idle_line = ColorDisplay(surface_alpha(0.42));
151    let textfield_hover_line = ColorDisplay(surface_alpha(0.87));
152    let textfield_disabled_line = ColorDisplay(surface_alpha(0.06));
153    let textfield_idle_border = ColorDisplay(surface_alpha(0.38));
154    let textfield_hover_border = ColorDisplay(surface_alpha(0.87));
155    let textfield_disabled_border = ColorDisplay(surface_alpha(0.06));
156    let textfield_fill = ColorDisplay(mix(on_surface.0, surface.0, 0.04));
157    let textfield_disabled_fill = ColorDisplay(mix(on_surface.0, surface.0, 0.02));
158    let textfield_ink = ColorDisplay(surface_alpha(0.87));
159    let textfield_label_ink = ColorDisplay(surface_alpha(0.6));
160    let textfield_disabled_ink = ColorDisplay(surface_alpha(0.37));
161    let textfield_icon = ColorDisplay(surface_alpha(0.54));
162    let textfield_disabled_icon = ColorDisplay(surface_alpha(0.3));
163
164    let dialog_scrim = ColorDisplay(surface_alpha(0.32));
165    let dialog_heading = ColorDisplay(surface_alpha(0.87));
166    let dialog_content = ColorDisplay(surface_alpha(0.6));
167    let dialog_divider = ColorDisplay(surface_alpha(0.12));
168
169    let textarea_idle_border = ColorDisplay(surface_alpha(0.38));
170    let textarea_hover_border = ColorDisplay(surface_alpha(0.87));
171    let textarea_disabled_border = ColorDisplay(surface_alpha(0.06));
172
173    let list_ripple = ColorDisplay(surface_alpha(1.));
174    let list_divider = ColorDisplay(surface_alpha(0.12));
175
176    format!(
177        "
178:root {{
179    --mdc-theme-primary: {primary};
180    --mdc-theme-secondary: {secondary};
181    --mdc-theme-surface: {surface};
182    --mdc-theme-background: {background};
183    --mdc-theme-error: {error};
184
185    --mdc-theme-on-primary: {on_primary};
186    --mdc-theme-on-secondary: {on_secondary};
187    --mdc-theme-on-surface: {on_surface};
188    --mdc-theme-on-error: {on_error};
189
190    --mdc-theme-text-primary-on-background: {text_primary_on_bg};
191    --mdc-theme-text-secondary-on-background: {text_secondary_on_bg};
192    --mdc-theme-text-hint-on-background: {text_hint_on_bg};
193    --mdc-theme-text-disabled-on-background: {text_disabled_on_bg};
194    --mdc-theme-text-icon-on-background: {text_icon_on_bg};
195    --mdc-theme-text-primary-on-light: {text_primary_on_bg};
196    --mdc-theme-text-secondary-on-light: {text_secondary_on_bg};
197    --mdc-theme-text-hint-on-light: {text_hint_on_bg};
198    --mdc-theme-text-disabled-on-light: {text_disabled_on_bg};
199    --mdc-theme-text-icon-on-light: {text_icon_on_bg};
200    --mdc-theme-text-primary-on-dark: {text_primary_on_inverse};
201    --mdc-theme-text-secondary-on-dark: {text_secondary_on_inverse};
202    --mdc-theme-text-hint-on-dark: {text_hint_on_inverse};
203    --mdc-theme-text-disabled-on-dark: {text_disabled_on_inverse};
204    --mdc-theme-text-icon-on-dark: {text_icon_on_inverse};
205
206    --mdc-button-outline-color: {button_outline};
207    --mdc-button-disabled-fill-color: {button_disabled_fill};
208    --mdc-button-disabled-ink-color: {button_disabled_ink};
209    --mdc-button-disabled-outline-color: {button_disabled_outline};
210
211    --mdc-circular-progress-bar-color-1: {four_color_progress_1};
212    --mdc-circular-progress-bar-color-2: {four_color_progress_2};
213    --mdc-circular-progress-bar-color-3: {four_color_progress_3};
214    --mdc-circular-progress-bar-color-4: {four_color_progress_4};
215
216    --mdc-checkbox-ink-color: {checkbox_ink};
217    --mdc-checkbox-unchecked-color: {checkbox_unchecked};
218    --mdc-checkbox-disabled-color: {checkbox_disabled};
219
220    --mdc-radio-unchecked-color: {checkbox_unchecked};
221    --mdc-radio-disabled-color: {checkbox_disabled};
222
223    --mdc-switch-disabled-selected-handle-color: {switch_on_surface};
224    --mdc-switch-disabled-selected-track-color: {switch_on_surface};
225    --mdc-switch-disabled-unselected-handle-color: {switch_on_surface};
226    --mdc-switch-disabled-unselected-track-color: {switch_on_surface};
227    --mdc-switch-selected-focus-handle-color: {switch_primary_state_content};
228    --mdc-switch-selected-focus-track-color: {switch_inverse_primary};
229    --mdc-switch-selected-hover-handle-color: {switch_primary_state_content};
230    --mdc-switch-selected-hover-track-color: {switch_inverse_primary};
231    --mdc-switch-selected-pressed-handle-color: {switch_primary_state_content};
232    --mdc-switch-selected-pressed-track-color: {switch_inverse_primary};
233    --mdc-switch-selected-track-color: {switch_inverse_primary};
234    --mdc-switch-unselected-focus-handle-color: {switch_on_surface_state_content};
235    --mdc-switch-unselected-focus-state-layer-color: {switch_on_surface};
236    --mdc-switch-unselected-focus-track-color: {switch_hairline};
237    --mdc-switch-unselected-handle-color: {switch_on_surface_state_content};
238    --mdc-switch-unselected-hover-handle-color: {switch_on_surface_state_content};
239    --mdc-switch-unselected-hover-state-layer-color: {switch_on_surface};
240    --mdc-switch-unselected-hover-track-color: {switch_hairline};
241    --mdc-switch-unselected-pressed-handle-color: {switch_on_surface_state_content};
242    --mdc-switch-unselected-pressed-state-layer-color: {switch_on_surface};
243    --mdc-switch-unselected-pressed-track-color: {switch_hairline};
244    --mdc-switch-unselected-track-color: {switch_hairline};
245
246    --mdc-text-field-idle-line-color: {textfield_idle_line};
247    --mdc-text-field-hover-line-color: {textfield_hover_line};
248    --mdc-text-field-disabled-line-color: {textfield_disabled_line};
249    --mdc-text-field-outlined-idle-border-color: {textfield_idle_border};
250    --mdc-text-field-outlined-hover-border-color: {textfield_hover_border};
251    --mdc-text-field-outlined-disabled-border-color: {textfield_disabled_border};
252    --mdc-text-field-fill-color: {textfield_fill};
253    --mdc-text-field-disabled-fill-color: {textfield_disabled_fill};
254    --mdc-text-field-ink-color: {textfield_ink};
255    --mdc-text-field-label-ink-color: {textfield_label_ink};
256    --mdc-text-field-disabled-ink-color: {textfield_disabled_ink};
257    --mdc-text-field-icon-color: {textfield_icon};
258    --mdc-text-field-disabled-icon-color: {textfield_disabled_icon};
259
260    --mdc-dialog-scrim-color: {dialog_scrim};
261    --mdc-dialog-heading-ink-color: {dialog_heading};
262    --mdc-dialog-content-ink-color: {dialog_content};
263    --mdc-dialog-scroll-divider-color: {dialog_divider};
264
265    --mdc-text-area-outlined-idle-border-color: {textarea_idle_border};
266    --mdc-text-area-outlined-hover-border-color: {textarea_hover_border};
267    --mdc-text-area-outlined-disabled-border-color: {textarea_disabled_border};
268
269    --mdc-deprecated-list-divider-color: {list_divider};
270}}
271
272mwc-switch {{
273    --mdc-theme-primary: {switch_primary};
274}}
275
276mwc-list {{
277    --mdc-ripple-color: {list_ripple};
278}}
279"
280    )
281}
282
283#[allow(non_snake_case)]
284pub fn MatTheme(cx: Scope<ThemeProps>) -> Element {
285    let light_theme = define_vars(&cx.props.theme);
286    let dark_theme = if let Some(colors) = &cx.props.dark_theme {
287        format!(
288            "@media screen and (prefers-color-scheme: dark) {{{}}}",
289            define_vars(colors)
290        )
291    } else {
292        String::new()
293    };
294
295    render! {
296        style { dangerous_inner_html: "{light_theme}{dark_theme}" }
297    }
298}