Skip to main content

dioxus_bootstrap_css/
theme_vars.rs

1use dioxus::prelude::*;
2
3/// Runtime Bootstrap CSS variable theme overrides.
4#[derive(Clone, Debug, PartialEq, Default)]
5pub struct BootstrapTheme {
6    pub colors: ThemeColors,
7    pub surfaces: SurfaceColors,
8    pub dark: Option<ThemeModeTokens>,
9}
10
11/// Token overrides for a specific theme mode.
12#[derive(Clone, Debug, PartialEq, Default)]
13pub struct ThemeModeTokens {
14    pub colors: ThemeColors,
15    pub surfaces: SurfaceColors,
16}
17
18/// Semantic Bootstrap color slots.
19#[derive(Clone, Debug, PartialEq, Default)]
20pub struct ThemeColors {
21    pub primary: Option<SemanticColorScale>,
22    pub secondary: Option<SemanticColorScale>,
23    pub success: Option<SemanticColorScale>,
24    pub info: Option<SemanticColorScale>,
25    pub warning: Option<SemanticColorScale>,
26    pub danger: Option<SemanticColorScale>,
27    pub light: Option<SemanticColorScale>,
28    pub dark: Option<SemanticColorScale>,
29}
30
31/// Bootstrap surface and utility color variables.
32#[derive(Clone, Debug, PartialEq, Default)]
33pub struct SurfaceColors {
34    pub body_bg: Option<String>,
35    pub body_color: Option<String>,
36    pub secondary_bg: Option<String>,
37    pub secondary_color: Option<String>,
38    pub tertiary_bg: Option<String>,
39    pub tertiary_color: Option<String>,
40    pub border_color: Option<String>,
41    pub link_color: Option<String>,
42    pub link_hover_color: Option<String>,
43}
44
45/// Semantic Bootstrap color variables for one slot.
46#[derive(Clone, Debug, PartialEq)]
47pub struct SemanticColorScale {
48    pub base: String,
49    pub rgb: Option<(u8, u8, u8)>,
50    pub text_emphasis: Option<String>,
51    pub bg_subtle: Option<String>,
52    pub border_subtle: Option<String>,
53}
54
55impl SemanticColorScale {
56    pub fn new(base: impl Into<String>) -> Self {
57        Self {
58            base: base.into(),
59            rgb: None,
60            text_emphasis: None,
61            bg_subtle: None,
62            border_subtle: None,
63        }
64    }
65}
66
67impl From<&str> for SemanticColorScale {
68    fn from(value: &str) -> Self {
69        Self::new(value)
70    }
71}
72
73#[derive(Clone, Copy)]
74enum ThemeVariant {
75    Light,
76    Dark,
77}
78
79/// Injects Bootstrap 5.3 CSS variables as a runtime `<style>` block.
80#[derive(Clone, PartialEq, Props)]
81pub struct BootstrapThemeProviderProps {
82    pub theme: BootstrapTheme,
83}
84
85#[component]
86pub fn BootstrapThemeProvider(props: BootstrapThemeProviderProps) -> Element {
87    let css = build_theme_css(&props.theme);
88
89    if css.is_empty() {
90        return rsx! {};
91    }
92
93    rsx! {
94        style { "{css}" }
95    }
96}
97
98fn build_theme_css(theme: &BootstrapTheme) -> String {
99    let mut out = String::new();
100
101    push_mode_css(
102        &mut out,
103        ":root",
104        &theme.colors,
105        &theme.surfaces,
106        ThemeVariant::Light,
107    );
108
109    if let Some(dark) = &theme.dark {
110        push_mode_css(
111            &mut out,
112            r#"[data-bs-theme="dark"]"#,
113            &dark.colors,
114            &dark.surfaces,
115            ThemeVariant::Dark,
116        );
117    }
118
119    out
120}
121
122fn push_mode_css(
123    out: &mut String,
124    selector: &str,
125    colors: &ThemeColors,
126    surfaces: &SurfaceColors,
127    variant: ThemeVariant,
128) {
129    let mut block = String::new();
130
131    for (name, scale) in [
132        ("primary", colors.primary.as_ref()),
133        ("secondary", colors.secondary.as_ref()),
134        ("success", colors.success.as_ref()),
135        ("info", colors.info.as_ref()),
136        ("warning", colors.warning.as_ref()),
137        ("danger", colors.danger.as_ref()),
138        ("light", colors.light.as_ref()),
139        ("dark", colors.dark.as_ref()),
140    ] {
141        push_color_scale_css(&mut block, name, scale, variant);
142    }
143
144    for (name, value) in [
145        ("--bs-body-bg", surfaces.body_bg.as_deref()),
146        ("--bs-body-color", surfaces.body_color.as_deref()),
147        ("--bs-secondary-bg", surfaces.secondary_bg.as_deref()),
148        ("--bs-secondary-color", surfaces.secondary_color.as_deref()),
149        ("--bs-tertiary-bg", surfaces.tertiary_bg.as_deref()),
150        ("--bs-tertiary-color", surfaces.tertiary_color.as_deref()),
151        ("--bs-border-color", surfaces.border_color.as_deref()),
152        ("--bs-link-color", surfaces.link_color.as_deref()),
153        (
154            "--bs-link-hover-color",
155            surfaces.link_hover_color.as_deref(),
156        ),
157    ] {
158        push_var(&mut block, name, value);
159    }
160
161    if block.is_empty() {
162        return;
163    }
164
165    out.push_str(selector);
166    out.push_str(" {\n");
167    out.push_str(&block);
168    out.push_str("}\n");
169}
170
171fn push_color_scale_css(
172    out: &mut String,
173    name: &str,
174    scale: Option<&SemanticColorScale>,
175    variant: ThemeVariant,
176) {
177    let Some(scale) = scale else {
178        return;
179    };
180
181    push_var(out, &format!("--bs-{name}"), Some(scale.base.as_str()));
182
183    let parsed_rgb = parse_hex_color(&scale.base);
184    let rgb = scale.rgb.or(parsed_rgb);
185    let derived = parsed_rgb.map(|rgb| derive_scale(rgb, variant));
186
187    if let Some((red, green, blue)) = rgb {
188        let rgb_value = format!("{red}, {green}, {blue}");
189        push_var(out, &format!("--bs-{name}-rgb"), Some(&rgb_value));
190    }
191
192    let text_emphasis = scale.text_emphasis.as_deref().or_else(|| {
193        derived
194            .as_ref()
195            .map(|derived| derived.text_emphasis.as_str())
196    });
197    let bg_subtle = scale
198        .bg_subtle
199        .as_deref()
200        .or_else(|| derived.as_ref().map(|derived| derived.bg_subtle.as_str()));
201    let border_subtle = scale.border_subtle.as_deref().or_else(|| {
202        derived
203            .as_ref()
204            .map(|derived| derived.border_subtle.as_str())
205    });
206
207    push_var(out, &format!("--bs-{name}-text-emphasis"), text_emphasis);
208    push_var(out, &format!("--bs-{name}-bg-subtle"), bg_subtle);
209    push_var(out, &format!("--bs-{name}-border-subtle"), border_subtle);
210}
211
212fn push_var(out: &mut String, name: &str, value: Option<&str>) {
213    let Some(value) = value else {
214        return;
215    };
216
217    out.push_str("  ");
218    out.push_str(name);
219    out.push_str(": ");
220    out.push_str(value);
221    out.push_str(";\n");
222}
223
224#[derive(Clone)]
225struct DerivedScale {
226    text_emphasis: String,
227    bg_subtle: String,
228    border_subtle: String,
229}
230
231fn derive_scale((red, green, blue): (u8, u8, u8), variant: ThemeVariant) -> DerivedScale {
232    let base = (red, green, blue);
233
234    let (text_emphasis, bg_subtle, border_subtle) = match variant {
235        ThemeVariant::Light => (
236            mix_rgb(base, (0, 0, 0), 0.35),
237            mix_rgb(base, (255, 255, 255), 0.85),
238            mix_rgb(base, (255, 255, 255), 0.65),
239        ),
240        ThemeVariant::Dark => {
241            let subtle = mix_rgb(base, (0, 0, 0), 0.75);
242            (
243                mix_rgb(base, (255, 255, 255), 0.55),
244                subtle,
245                mix_rgb(subtle, (255, 255, 255), 0.08),
246            )
247        }
248    };
249
250    DerivedScale {
251        text_emphasis: rgb_to_hex(text_emphasis),
252        bg_subtle: rgb_to_hex(bg_subtle),
253        border_subtle: rgb_to_hex(border_subtle),
254    }
255}
256
257fn parse_hex_color(value: &str) -> Option<(u8, u8, u8)> {
258    let hex = value.strip_prefix('#')?;
259
260    match hex.len() {
261        3 => {
262            let red = parse_hex_byte(&hex[0..1].repeat(2))?;
263            let green = parse_hex_byte(&hex[1..2].repeat(2))?;
264            let blue = parse_hex_byte(&hex[2..3].repeat(2))?;
265            Some((red, green, blue))
266        }
267        6 => Some((
268            parse_hex_byte(&hex[0..2])?,
269            parse_hex_byte(&hex[2..4])?,
270            parse_hex_byte(&hex[4..6])?,
271        )),
272        _ => None,
273    }
274}
275
276fn parse_hex_byte(value: &str) -> Option<u8> {
277    u8::from_str_radix(value, 16).ok()
278}
279
280fn mix_rgb(from: (u8, u8, u8), to: (u8, u8, u8), amount: f32) -> (u8, u8, u8) {
281    (
282        mix_channel(from.0, to.0, amount),
283        mix_channel(from.1, to.1, amount),
284        mix_channel(from.2, to.2, amount),
285    )
286}
287
288fn mix_channel(from: u8, to: u8, amount: f32) -> u8 {
289    let blended = (from as f32 * (1.0 - amount)) + (to as f32 * amount);
290    blended.round().clamp(0.0, 255.0) as u8
291}
292
293fn rgb_to_hex((red, green, blue): (u8, u8, u8)) -> String {
294    format!("#{red:02x}{green:02x}{blue:02x}")
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn parses_short_and_long_hex_colors() {
303        assert_eq!(parse_hex_color("#165317"), Some((22, 83, 23)));
304        assert_eq!(parse_hex_color("#abc"), Some((170, 187, 204)));
305        assert_eq!(parse_hex_color("165317"), None);
306        assert_eq!(parse_hex_color("#abcd"), None);
307    }
308
309    #[test]
310    fn generates_light_and_dark_theme_css() {
311        let theme = BootstrapTheme {
312            colors: ThemeColors {
313                primary: Some(SemanticColorScale::new("#165317")),
314                dark: Some(SemanticColorScale::new("#092817")),
315                ..ThemeColors::default()
316            },
317            surfaces: SurfaceColors {
318                body_bg: Some("#f7fbf7".into()),
319                body_color: Some("#1d2b1f".into()),
320                ..SurfaceColors::default()
321            },
322            dark: Some(ThemeModeTokens {
323                colors: ThemeColors {
324                    primary: Some(SemanticColorScale::new("#4e9f53")),
325                    ..ThemeColors::default()
326                },
327                surfaces: SurfaceColors {
328                    body_bg: Some("#0b120c".into()),
329                    body_color: Some("#e6efe7".into()),
330                    ..SurfaceColors::default()
331                },
332            }),
333        };
334
335        let css = build_theme_css(&theme);
336
337        assert!(css.contains(":root {\n"));
338        assert!(css.contains(r#"[data-bs-theme="dark"] {"#));
339        assert!(css.contains("  --bs-primary: #165317;"));
340        assert!(css.contains("  --bs-primary-rgb: 22, 83, 23;"));
341        assert!(css.contains("  --bs-dark: #092817;"));
342        assert!(css.contains("  --bs-body-bg: #f7fbf7;"));
343        assert!(css.contains("  --bs-body-color: #1d2b1f;"));
344        assert!(css.contains("  --bs-primary: #4e9f53;"));
345        assert!(css.contains("  --bs-primary-rgb: 78, 159, 83;"));
346        assert!(css.contains("  --bs-body-bg: #0b120c;"));
347        assert!(css.contains("  --bs-body-color: #e6efe7;"));
348    }
349
350    #[test]
351    fn preserves_raw_values_and_explicit_overrides() {
352        let theme = BootstrapTheme {
353            colors: ThemeColors {
354                primary: Some(SemanticColorScale {
355                    base: "var(--brand-primary)".into(),
356                    rgb: None,
357                    text_emphasis: Some("#112233".into()),
358                    bg_subtle: None,
359                    border_subtle: Some("#ddeeff".into()),
360                }),
361                ..ThemeColors::default()
362            },
363            ..BootstrapTheme::default()
364        };
365
366        let css = build_theme_css(&theme);
367
368        assert!(css.contains("  --bs-primary: var(--brand-primary);"));
369        assert!(css.contains("  --bs-primary-text-emphasis: #112233;"));
370        assert!(css.contains("  --bs-primary-border-subtle: #ddeeff;"));
371        assert!(!css.contains("--bs-primary-rgb"));
372        assert!(!css.contains("--bs-primary-bg-subtle"));
373    }
374}