1use dioxus::prelude::*;
2
3#[derive(Clone, Debug, PartialEq, Default)]
5pub struct BootstrapTheme {
6 pub colors: ThemeColors,
7 pub surfaces: SurfaceColors,
8 pub dark: Option<ThemeModeTokens>,
9}
10
11#[derive(Clone, Debug, PartialEq, Default)]
13pub struct ThemeModeTokens {
14 pub colors: ThemeColors,
15 pub surfaces: SurfaceColors,
16}
17
18#[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#[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#[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#[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}