1use egui::{Color32, FontId, Id, TextStyle};
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
39#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
40pub enum ThemeVariant {
41 #[default]
42 Light,
43 Dark,
44}
45
46pub trait ThemeProvider {
48 fn to_ds_theme(&self) -> Theme;
49}
50
51#[derive(Debug, Clone)]
53pub struct Theme {
54 pub variant: ThemeVariant,
55
56 pub primary: Color32,
58 pub primary_hover: Color32,
59 pub primary_text: Color32,
60
61 pub secondary: Color32,
63 pub secondary_hover: Color32,
64 pub secondary_text: Color32,
65
66 pub bg_primary: Color32,
68 pub bg_secondary: Color32,
69 pub bg_tertiary: Color32,
70
71 pub text_primary: Color32,
73 pub text_secondary: Color32,
74 pub text_muted: Color32,
75
76 pub state_success: Color32,
78 pub state_warning: Color32,
79 pub state_danger: Color32,
80 pub state_info: Color32,
81
82 pub state_success_text: Color32,
84 pub state_warning_text: Color32,
85 pub state_danger_text: Color32,
86 pub state_info_text: Color32,
87
88 pub state_success_hover: Color32,
90 pub state_warning_hover: Color32,
91 pub state_danger_hover: Color32,
92 pub state_info_hover: Color32,
93
94 pub log_debug: Color32,
96 pub log_info: Color32,
97 pub log_warn: Color32,
98 pub log_error: Color32,
99 pub log_critical: Color32,
100
101 pub border: Color32,
103 pub border_focus: Color32,
104
105 pub spacing_xs: f32,
107 pub spacing_sm: f32,
108 pub spacing_md: f32,
109 pub spacing_lg: f32,
110 pub spacing_xl: f32,
111
112 pub radius_sm: f32,
114 pub radius_md: f32,
115 pub radius_lg: f32,
116
117 pub border_width: f32,
119 pub stroke_width: f32,
120
121 pub font_size_xs: f32,
123 pub font_size_sm: f32,
124 pub font_size_md: f32,
125 pub font_size_lg: f32,
126 pub font_size_xl: f32,
127 pub font_size_2xl: f32,
128 pub font_size_3xl: f32,
129
130 pub line_height: f32,
132
133 pub overlay_dim: f32,
136 pub surface_alpha: f32,
138 pub shadow_blur: Option<f32>,
140
141 pub glass_opacity: f32,
144 pub glass_blur_radius: f32,
146 pub glass_tint: Option<Color32>,
148 pub glass_border: bool,
150 pub titlebar_height: f32,
152}
153
154impl Default for Theme {
155 fn default() -> Self {
156 Self::light()
157 }
158}
159
160impl Theme {
161 pub fn light() -> Self {
163 Self {
164 variant: ThemeVariant::Light,
165
166 primary: Color32::from_rgb(59, 130, 246),
168 primary_hover: Color32::from_rgb(37, 99, 235),
169 primary_text: Color32::WHITE,
170
171 secondary: Color32::from_rgb(107, 114, 128),
173 secondary_hover: Color32::from_rgb(75, 85, 99),
174 secondary_text: Color32::WHITE,
175
176 bg_primary: Color32::WHITE,
178 bg_secondary: Color32::from_rgb(249, 250, 251),
179 bg_tertiary: Color32::from_rgb(243, 244, 246),
180
181 text_primary: Color32::from_rgb(17, 24, 39),
183 text_secondary: Color32::from_rgb(75, 85, 99),
184 text_muted: Color32::from_rgb(156, 163, 175),
185
186 state_success: Color32::from_rgb(34, 197, 94), state_warning: Color32::from_rgb(245, 158, 11), state_danger: Color32::from_rgb(239, 68, 68), state_info: Color32::from_rgb(14, 165, 233), state_success_text: Color32::WHITE,
194 state_warning_text: Color32::WHITE,
195 state_danger_text: Color32::WHITE,
196 state_info_text: Color32::WHITE,
197
198 state_success_hover: Color32::from_rgb(22, 163, 74), state_warning_hover: Color32::from_rgb(217, 119, 6), state_danger_hover: Color32::from_rgb(220, 38, 38), state_info_hover: Color32::from_rgb(2, 132, 199), log_debug: Color32::from_rgb(156, 163, 175), log_info: Color32::from_rgb(59, 130, 246), log_warn: Color32::from_rgb(245, 158, 11), log_error: Color32::from_rgb(239, 68, 68), log_critical: Color32::from_rgb(190, 24, 93), border: Color32::from_rgb(229, 231, 235),
213 border_focus: Color32::from_rgb(59, 130, 246),
214
215 spacing_xs: 6.0,
217 spacing_sm: 12.0,
218 spacing_md: 20.0,
219 spacing_lg: 32.0,
220 spacing_xl: 48.0,
221
222 radius_sm: 4.0,
224 radius_md: 8.0,
225 radius_lg: 12.0,
226
227 border_width: 1.0,
229 stroke_width: 1.0,
230
231 font_size_xs: 10.0,
233 font_size_sm: 12.0,
234 font_size_md: 14.0,
235 font_size_lg: 16.0,
236 font_size_xl: 20.0,
237 font_size_2xl: 24.0,
238 font_size_3xl: 30.0,
239 line_height: 1.4,
240
241 overlay_dim: 0.5,
243 surface_alpha: 1.0,
244 shadow_blur: None, glass_opacity: 0.6,
248 glass_blur_radius: 8.0,
249 glass_tint: None, glass_border: true,
251 titlebar_height: 32.0,
252 }
253 }
254
255 pub fn dark() -> Self {
257 Self {
258 variant: ThemeVariant::Dark,
259
260 primary: Color32::from_rgb(96, 165, 250),
262 primary_hover: Color32::from_rgb(59, 130, 246),
263 primary_text: Color32::from_rgb(17, 24, 39),
264
265 secondary: Color32::from_rgb(156, 163, 175),
267 secondary_hover: Color32::from_rgb(107, 114, 128),
268 secondary_text: Color32::from_rgb(17, 24, 39),
269
270 bg_primary: Color32::from_rgb(17, 24, 39),
272 bg_secondary: Color32::from_rgb(31, 41, 55),
273 bg_tertiary: Color32::from_rgb(55, 65, 81),
274
275 text_primary: Color32::from_rgb(249, 250, 251),
277 text_secondary: Color32::from_rgb(209, 213, 219),
278 text_muted: Color32::from_rgb(156, 163, 175),
279
280 state_success: Color32::from_rgb(74, 222, 128), state_warning: Color32::from_rgb(251, 191, 36), state_danger: Color32::from_rgb(248, 113, 113), state_info: Color32::from_rgb(56, 189, 248), state_success_text: Color32::from_rgb(17, 24, 39),
288 state_warning_text: Color32::from_rgb(17, 24, 39),
289 state_danger_text: Color32::from_rgb(17, 24, 39),
290 state_info_text: Color32::from_rgb(17, 24, 39),
291
292 state_success_hover: Color32::from_rgb(34, 197, 94), state_warning_hover: Color32::from_rgb(245, 158, 11), state_danger_hover: Color32::from_rgb(239, 68, 68), state_info_hover: Color32::from_rgb(14, 165, 233), log_debug: Color32::from_rgb(209, 213, 219), log_info: Color32::from_rgb(96, 165, 250), log_warn: Color32::from_rgb(251, 191, 36), log_error: Color32::from_rgb(248, 113, 113), log_critical: Color32::from_rgb(244, 114, 182), border: Color32::from_rgb(55, 65, 81),
307 border_focus: Color32::from_rgb(96, 165, 250),
308
309 spacing_xs: 6.0,
311 spacing_sm: 12.0,
312 spacing_md: 20.0,
313 spacing_lg: 32.0,
314 spacing_xl: 48.0,
315
316 radius_sm: 4.0,
318 radius_md: 8.0,
319 radius_lg: 12.0,
320
321 border_width: 1.0,
323 stroke_width: 1.0,
324
325 font_size_xs: 10.0,
327 font_size_sm: 12.0,
328 font_size_md: 14.0,
329 font_size_lg: 16.0,
330 font_size_xl: 20.0,
331 font_size_2xl: 24.0,
332 font_size_3xl: 30.0,
333 line_height: 1.4,
334
335 overlay_dim: 0.7,
337 surface_alpha: 1.0,
338 shadow_blur: None, glass_opacity: 0.7,
342 glass_blur_radius: 8.0,
343 glass_tint: None, glass_border: true,
345 titlebar_height: 32.0,
346 }
347 }
348
349 const STORAGE_ID: &'static str = "egui_cha_ds_theme";
351
352 pub fn current(ctx: &egui::Context) -> Self {
354 ctx.data(|d| d.get_temp::<Theme>(Id::new(Self::STORAGE_ID)))
355 .unwrap_or_default()
356 }
357
358 pub fn from_provider(provider: impl ThemeProvider) -> Self {
360 provider.to_ds_theme()
361 }
362
363 pub fn apply_colors_only(&self, ctx: &egui::Context) {
368 ctx.data_mut(|d| d.insert_temp(Id::new(Self::STORAGE_ID), self.clone()));
370
371 let mut style = (*ctx.style()).clone();
372 let visuals = &mut style.visuals;
373
374 visuals.dark_mode = self.variant == ThemeVariant::Dark;
376
377 visuals.panel_fill = self.bg_primary;
379 visuals.window_fill = self.bg_primary;
380 visuals.extreme_bg_color = self.bg_secondary;
381 visuals.faint_bg_color = self.bg_secondary;
382 visuals.code_bg_color = self.bg_tertiary;
383
384 visuals.override_text_color = Some(self.text_primary);
386 visuals.hyperlink_color = self.primary;
387 visuals.warn_fg_color = self.state_warning;
388 visuals.error_fg_color = self.state_danger;
389
390 visuals.widgets.noninteractive.bg_fill = self.bg_secondary;
392 visuals.widgets.noninteractive.weak_bg_fill = self.bg_secondary;
393 visuals.widgets.noninteractive.bg_stroke.color = self.border;
394 visuals.widgets.noninteractive.fg_stroke.color = self.text_primary;
395
396 visuals.widgets.inactive.bg_fill = self.bg_tertiary;
398 visuals.widgets.inactive.weak_bg_fill = self.bg_tertiary;
399 visuals.widgets.inactive.bg_stroke.color = self.border;
400 visuals.widgets.inactive.fg_stroke.color = self.text_primary;
401
402 visuals.widgets.hovered.bg_fill = self.primary_hover;
404 visuals.widgets.hovered.weak_bg_fill = self.primary_hover;
405 visuals.widgets.hovered.bg_stroke.color = self.primary;
406 visuals.widgets.hovered.fg_stroke.color = self.primary_text;
407
408 visuals.widgets.active.bg_fill = self.primary;
410 visuals.widgets.active.weak_bg_fill = self.primary;
411 visuals.widgets.active.bg_stroke.color = self.primary;
412 visuals.widgets.active.fg_stroke.color = self.primary_text;
413
414 visuals.widgets.open.bg_fill = self.bg_tertiary;
416 visuals.widgets.open.weak_bg_fill = self.bg_tertiary;
417 visuals.widgets.open.bg_stroke.color = self.primary;
418 visuals.widgets.open.fg_stroke.color = self.text_primary;
419
420 visuals.selection.bg_fill = self.primary.linear_multiply(0.3);
422 visuals.selection.stroke.color = self.primary;
423
424 visuals.window_stroke.color = self.border;
426
427 ctx.set_style(style);
428 }
429
430 pub fn apply(&self, ctx: &egui::Context) {
432 ctx.data_mut(|d| d.insert_temp(Id::new(Self::STORAGE_ID), self.clone()));
434
435 let mut style = (*ctx.style()).clone();
436 let visuals = &mut style.visuals;
437
438 visuals.dark_mode = self.variant == ThemeVariant::Dark;
440
441 visuals.panel_fill = self.bg_primary;
443 visuals.window_fill = self.bg_primary;
444 visuals.extreme_bg_color = self.bg_secondary;
445 visuals.faint_bg_color = self.bg_secondary;
446 visuals.code_bg_color = self.bg_tertiary;
447
448 visuals.override_text_color = Some(self.text_primary);
450 visuals.hyperlink_color = self.primary;
451 visuals.warn_fg_color = self.state_warning;
452 visuals.error_fg_color = self.state_danger;
453
454 visuals.widgets.noninteractive.bg_fill = self.bg_secondary;
456 visuals.widgets.noninteractive.weak_bg_fill = self.bg_secondary;
457 visuals.widgets.noninteractive.bg_stroke.color = self.border;
458 visuals.widgets.noninteractive.fg_stroke.color = self.text_primary;
459
460 visuals.widgets.inactive.bg_fill = self.bg_tertiary;
462 visuals.widgets.inactive.weak_bg_fill = self.bg_tertiary;
463 visuals.widgets.inactive.bg_stroke.color = self.border;
464 visuals.widgets.inactive.fg_stroke.color = self.text_primary;
465
466 visuals.widgets.hovered.bg_fill = self.primary_hover;
468 visuals.widgets.hovered.weak_bg_fill = self.primary_hover;
469 visuals.widgets.hovered.bg_stroke.color = self.primary;
470 visuals.widgets.hovered.fg_stroke.color = self.primary_text;
471
472 visuals.widgets.active.bg_fill = self.primary;
474 visuals.widgets.active.weak_bg_fill = self.primary;
475 visuals.widgets.active.bg_stroke.color = self.primary;
476 visuals.widgets.active.fg_stroke.color = self.primary_text;
477
478 visuals.widgets.open.bg_fill = self.bg_tertiary;
480 visuals.widgets.open.weak_bg_fill = self.bg_tertiary;
481 visuals.widgets.open.bg_stroke.color = self.primary;
482 visuals.widgets.open.fg_stroke.color = self.text_primary;
483
484 visuals.selection.bg_fill = self.primary.linear_multiply(0.3);
486 visuals.selection.stroke.color = self.primary;
487
488 visuals.widgets.noninteractive.bg_stroke.width = self.border_width;
490 visuals.widgets.noninteractive.fg_stroke.width = self.stroke_width;
491 visuals.widgets.inactive.bg_stroke.width = self.border_width;
492 visuals.widgets.inactive.fg_stroke.width = self.stroke_width;
493 visuals.widgets.hovered.bg_stroke.width = self.border_width;
494 visuals.widgets.hovered.fg_stroke.width = self.stroke_width;
495 visuals.widgets.active.bg_stroke.width = self.border_width;
496 visuals.widgets.active.fg_stroke.width = self.stroke_width;
497 visuals.widgets.open.bg_stroke.width = self.border_width;
498 visuals.widgets.open.fg_stroke.width = self.stroke_width;
499 visuals.selection.stroke.width = self.stroke_width;
500
501 visuals.window_stroke.color = self.border;
503 visuals.window_stroke.width = self.border_width;
504
505 match self.shadow_blur {
507 None => {
508 visuals.window_shadow = egui::Shadow::NONE;
510 visuals.popup_shadow = egui::Shadow::NONE;
511 }
512 Some(blur) => {
513 let alpha = if self.variant == ThemeVariant::Dark {
515 60
516 } else {
517 30
518 };
519 visuals.window_shadow = egui::Shadow {
520 offset: [0, 2],
521 blur: blur as u8,
522 spread: 0,
523 color: Color32::from_black_alpha(alpha),
524 };
525 visuals.popup_shadow = visuals.window_shadow;
526 }
527 }
528
529 style
531 .text_styles
532 .insert(TextStyle::Small, FontId::proportional(self.font_size_sm));
533 style
534 .text_styles
535 .insert(TextStyle::Body, FontId::proportional(self.font_size_md));
536 style
537 .text_styles
538 .insert(TextStyle::Button, FontId::proportional(self.font_size_md));
539 style
540 .text_styles
541 .insert(TextStyle::Heading, FontId::proportional(self.font_size_xl));
542 style
543 .text_styles
544 .insert(TextStyle::Monospace, FontId::monospace(self.font_size_md));
545
546 style.spacing.item_spacing = egui::vec2(self.spacing_sm, self.spacing_sm);
548 style.spacing.window_margin = egui::Margin::same(self.spacing_md as i8);
549 style.spacing.button_padding = egui::vec2(self.spacing_sm, self.spacing_xs);
550 style.spacing.menu_margin = egui::Margin::same(self.spacing_sm as i8);
551 style.spacing.indent = self.spacing_md;
552 style.spacing.icon_spacing = self.spacing_xs;
553 style.spacing.icon_width = self.spacing_md;
554
555 ctx.set_style(style);
556 }
557
558 pub fn with_scale(mut self, scale: f32) -> Self {
587 self.spacing_xs *= scale;
588 self.spacing_sm *= scale;
589 self.spacing_md *= scale;
590 self.spacing_lg *= scale;
591 self.spacing_xl *= scale;
592 self
593 }
594
595 pub fn with_spacing_scale(mut self, scale: f32) -> Self {
599 self.spacing_xs *= scale;
600 self.spacing_sm *= scale;
601 self.spacing_md *= scale;
602 self.spacing_lg *= scale;
603 self.spacing_xl *= scale;
604 self
605 }
606
607 pub fn with_radius_scale(mut self, scale: f32) -> Self {
622 self.radius_sm *= scale;
623 self.radius_md *= scale;
624 self.radius_lg *= scale;
625 self
626 }
627
628 pub fn with_font_scale(mut self, scale: f32) -> Self {
644 self.font_size_xs *= scale;
645 self.font_size_sm *= scale;
646 self.font_size_md *= scale;
647 self.font_size_lg *= scale;
648 self.font_size_xl *= scale;
649 self.font_size_2xl *= scale;
650 self.font_size_3xl *= scale;
651 self
652 }
653
654 pub fn with_stroke_scale(mut self, scale: f32) -> Self {
666 self.border_width *= scale;
667 self.stroke_width *= scale;
668 self
669 }
670
671 pub fn with_shadow(self) -> Self {
684 self.with_shadow_blur(4.0)
685 }
686
687 pub fn with_shadow_blur(mut self, blur: f32) -> Self {
689 self.shadow_blur = Some(blur);
690 self
691 }
692
693 pub fn pastel() -> Self {
695 Self {
696 variant: ThemeVariant::Light,
697
698 primary: Color32::from_rgb(167, 139, 250), primary_hover: Color32::from_rgb(139, 92, 246), primary_text: Color32::WHITE,
702
703 secondary: Color32::from_rgb(244, 114, 182), secondary_hover: Color32::from_rgb(236, 72, 153), secondary_text: Color32::WHITE,
707
708 bg_primary: Color32::from_rgb(255, 251, 245), bg_secondary: Color32::from_rgb(254, 243, 235), bg_tertiary: Color32::from_rgb(253, 235, 223), text_primary: Color32::from_rgb(64, 57, 72), text_secondary: Color32::from_rgb(107, 98, 116), text_muted: Color32::from_rgb(156, 148, 163), state_success: Color32::from_rgb(134, 239, 172), state_warning: Color32::from_rgb(253, 224, 71), state_danger: Color32::from_rgb(253, 164, 175), state_info: Color32::from_rgb(147, 197, 253), state_success_text: Color32::from_rgb(22, 101, 52), state_warning_text: Color32::from_rgb(133, 77, 14), state_danger_text: Color32::from_rgb(159, 18, 57), state_info_text: Color32::from_rgb(30, 64, 175), state_success_hover: Color32::from_rgb(74, 222, 128), state_warning_hover: Color32::from_rgb(250, 204, 21), state_danger_hover: Color32::from_rgb(251, 113, 133), state_info_hover: Color32::from_rgb(96, 165, 250), log_debug: Color32::from_rgb(156, 148, 163), log_info: Color32::from_rgb(96, 165, 250), log_warn: Color32::from_rgb(250, 204, 21), log_error: Color32::from_rgb(251, 113, 133), log_critical: Color32::from_rgb(236, 72, 153), border: Color32::from_rgb(233, 213, 202), border_focus: Color32::from_rgb(167, 139, 250), spacing_xs: 6.0,
749 spacing_sm: 12.0,
750 spacing_md: 20.0,
751 spacing_lg: 32.0,
752 spacing_xl: 48.0,
753
754 radius_sm: 6.0,
756 radius_md: 12.0,
757 radius_lg: 16.0,
758
759 border_width: 1.0,
761 stroke_width: 1.0,
762
763 font_size_xs: 10.0,
765 font_size_sm: 12.0,
766 font_size_md: 14.0,
767 font_size_lg: 16.0,
768 font_size_xl: 20.0,
769 font_size_2xl: 24.0,
770 font_size_3xl: 30.0,
771 line_height: 1.4,
772
773 overlay_dim: 0.4,
775 surface_alpha: 1.0,
776 shadow_blur: None, glass_opacity: 0.55,
780 glass_blur_radius: 10.0,
781 glass_tint: None,
782 glass_border: true,
783 titlebar_height: 32.0,
784 }
785 }
786
787 pub fn pastel_dark() -> Self {
789 Self {
790 variant: ThemeVariant::Dark,
791
792 primary: Color32::from_rgb(196, 181, 253), primary_hover: Color32::from_rgb(167, 139, 250), primary_text: Color32::from_rgb(30, 27, 38), secondary: Color32::from_rgb(249, 168, 212), secondary_hover: Color32::from_rgb(244, 114, 182), secondary_text: Color32::from_rgb(30, 27, 38),
801
802 bg_primary: Color32::from_rgb(24, 22, 32), bg_secondary: Color32::from_rgb(32, 29, 43), bg_tertiary: Color32::from_rgb(45, 41, 58), text_primary: Color32::from_rgb(243, 237, 255), text_secondary: Color32::from_rgb(196, 189, 210),
810 text_muted: Color32::from_rgb(140, 133, 156),
811
812 state_success: Color32::from_rgb(74, 222, 128), state_warning: Color32::from_rgb(250, 204, 21), state_danger: Color32::from_rgb(251, 113, 133), state_info: Color32::from_rgb(96, 165, 250), state_success_text: Color32::from_rgb(20, 30, 25),
820 state_warning_text: Color32::from_rgb(35, 30, 15),
821 state_danger_text: Color32::from_rgb(35, 20, 25),
822 state_info_text: Color32::from_rgb(20, 25, 35),
823
824 state_success_hover: Color32::from_rgb(134, 239, 172),
826 state_warning_hover: Color32::from_rgb(253, 224, 71),
827 state_danger_hover: Color32::from_rgb(253, 164, 175),
828 state_info_hover: Color32::from_rgb(147, 197, 253),
829
830 log_debug: Color32::from_rgb(140, 133, 156), log_info: Color32::from_rgb(147, 197, 253), log_warn: Color32::from_rgb(253, 224, 71), log_error: Color32::from_rgb(253, 164, 175), log_critical: Color32::from_rgb(249, 168, 212), border: Color32::from_rgb(55, 50, 70),
839 border_focus: Color32::from_rgb(196, 181, 253),
840
841 spacing_xs: 6.0,
843 spacing_sm: 12.0,
844 spacing_md: 20.0,
845 spacing_lg: 32.0,
846 spacing_xl: 48.0,
847
848 radius_sm: 6.0,
850 radius_md: 12.0,
851 radius_lg: 16.0,
852
853 border_width: 1.0,
855 stroke_width: 1.0,
856
857 font_size_xs: 10.0,
859 font_size_sm: 12.0,
860 font_size_md: 14.0,
861 font_size_lg: 16.0,
862 font_size_xl: 20.0,
863 font_size_2xl: 24.0,
864 font_size_3xl: 30.0,
865 line_height: 1.4,
866
867 overlay_dim: 0.6,
869 surface_alpha: 1.0,
870 shadow_blur: None, glass_opacity: 0.65,
874 glass_blur_radius: 10.0,
875 glass_tint: None,
876 glass_border: true,
877 titlebar_height: 32.0,
878 }
879 }
880}
881
882#[cfg(feature = "serde")]
907#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
908#[serde(default)]
909pub struct ThemeConfig {
910 pub base: Option<String>,
912
913 pub primary: Option<String>,
915 pub primary_hover: Option<String>,
916 pub primary_text: Option<String>,
917
918 pub secondary: Option<String>,
920 pub secondary_hover: Option<String>,
921 pub secondary_text: Option<String>,
922
923 pub bg_primary: Option<String>,
925 pub bg_secondary: Option<String>,
926 pub bg_tertiary: Option<String>,
927
928 pub text_primary: Option<String>,
930 pub text_secondary: Option<String>,
931 pub text_muted: Option<String>,
932
933 pub state_success: Option<String>,
935 pub state_warning: Option<String>,
936 pub state_danger: Option<String>,
937 pub state_info: Option<String>,
938
939 pub border: Option<String>,
941 pub border_focus: Option<String>,
942
943 pub spacing_scale: Option<f32>,
945 pub font_scale: Option<f32>,
946 pub radius_scale: Option<f32>,
947 pub stroke_scale: Option<f32>,
948
949 pub shadow_blur: Option<f32>,
951 pub overlay_dim: Option<f32>,
952 pub surface_alpha: Option<f32>,
953
954 pub glass_opacity: Option<f32>,
956 pub glass_blur_radius: Option<f32>,
957 pub glass_tint: Option<String>,
958 pub glass_border: Option<bool>,
959 pub titlebar_height: Option<f32>,
960}
961
962#[cfg(feature = "serde")]
963impl ThemeConfig {
964 pub fn parse_color(s: &str) -> Option<Color32> {
972 let s = s.trim();
973
974 if let Some(hex) = s.strip_prefix('#') {
976 return match hex.len() {
977 6 => {
978 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
979 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
980 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
981 Some(Color32::from_rgb(r, g, b))
982 }
983 8 => {
984 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
985 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
986 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
987 let a = u8::from_str_radix(&hex[6..8], 16).ok()?;
988 Some(Color32::from_rgba_unmultiplied(r, g, b, a))
989 }
990 _ => None,
991 };
992 }
993
994 if let Some(inner) = s.strip_prefix("rgb(").and_then(|s| s.strip_suffix(')')) {
996 let parts: Vec<&str> = inner.split(',').collect();
997 if parts.len() == 3 {
998 let r: u8 = parts[0].trim().parse().ok()?;
999 let g: u8 = parts[1].trim().parse().ok()?;
1000 let b: u8 = parts[2].trim().parse().ok()?;
1001 return Some(Color32::from_rgb(r, g, b));
1002 }
1003 }
1004
1005 if let Some(inner) = s.strip_prefix("rgba(").and_then(|s| s.strip_suffix(')')) {
1007 let parts: Vec<&str> = inner.split(',').collect();
1008 if parts.len() == 4 {
1009 let r: u8 = parts[0].trim().parse().ok()?;
1010 let g: u8 = parts[1].trim().parse().ok()?;
1011 let b: u8 = parts[2].trim().parse().ok()?;
1012 let a_str = parts[3].trim();
1014 let a: u8 = if a_str.contains('.') {
1015 let f: f32 = a_str.parse().ok()?;
1016 (f * 255.0) as u8
1017 } else {
1018 a_str.parse().ok()?
1019 };
1020 return Some(Color32::from_rgba_unmultiplied(r, g, b, a));
1021 }
1022 }
1023
1024 None
1025 }
1026
1027 pub fn color_to_hex(color: Color32) -> String {
1029 let [r, g, b, a] = color.to_srgba_unmultiplied();
1030 if a == 255 {
1031 format!("#{:02X}{:02X}{:02X}", r, g, b)
1032 } else {
1033 format!("#{:02X}{:02X}{:02X}{:02X}", r, g, b, a)
1034 }
1035 }
1036}
1037
1038#[cfg(feature = "serde")]
1039impl Theme {
1040 pub fn from_config(config: &ThemeConfig) -> Self {
1044 let mut theme = match config.base.as_deref() {
1046 Some("dark") => Self::dark(),
1047 Some("pastel") => Self::pastel(),
1048 Some("pastel_dark") => Self::pastel_dark(),
1049 _ => Self::light(), };
1051
1052 macro_rules! apply_color {
1054 ($field:ident) => {
1055 if let Some(ref s) = config.$field {
1056 if let Some(c) = ThemeConfig::parse_color(s) {
1057 theme.$field = c;
1058 }
1059 }
1060 };
1061 }
1062
1063 apply_color!(primary);
1064 apply_color!(primary_hover);
1065 apply_color!(primary_text);
1066 apply_color!(secondary);
1067 apply_color!(secondary_hover);
1068 apply_color!(secondary_text);
1069 apply_color!(bg_primary);
1070 apply_color!(bg_secondary);
1071 apply_color!(bg_tertiary);
1072 apply_color!(text_primary);
1073 apply_color!(text_secondary);
1074 apply_color!(text_muted);
1075 apply_color!(state_success);
1076 apply_color!(state_warning);
1077 apply_color!(state_danger);
1078 apply_color!(state_info);
1079 apply_color!(border);
1080 apply_color!(border_focus);
1081
1082 if let Some(scale) = config.spacing_scale {
1084 theme = theme.with_spacing_scale(scale);
1085 }
1086 if let Some(scale) = config.font_scale {
1087 theme = theme.with_font_scale(scale);
1088 }
1089 if let Some(scale) = config.radius_scale {
1090 theme = theme.with_radius_scale(scale);
1091 }
1092 if let Some(scale) = config.stroke_scale {
1093 theme = theme.with_stroke_scale(scale);
1094 }
1095
1096 if let Some(blur) = config.shadow_blur {
1098 theme.shadow_blur = Some(blur);
1099 }
1100 if let Some(dim) = config.overlay_dim {
1101 theme.overlay_dim = dim;
1102 }
1103 if let Some(alpha) = config.surface_alpha {
1104 theme.surface_alpha = alpha;
1105 }
1106
1107 if let Some(opacity) = config.glass_opacity {
1109 theme.glass_opacity = opacity.clamp(0.0, 1.0);
1110 }
1111 if let Some(blur) = config.glass_blur_radius {
1112 theme.glass_blur_radius = blur.max(0.0);
1113 }
1114 if let Some(ref tint) = config.glass_tint {
1115 theme.glass_tint = ThemeConfig::parse_color(tint);
1116 }
1117 if let Some(border) = config.glass_border {
1118 theme.glass_border = border;
1119 }
1120 if let Some(height) = config.titlebar_height {
1121 theme.titlebar_height = height.max(0.0);
1122 }
1123
1124 theme
1125 }
1126
1127 pub fn to_config(&self) -> ThemeConfig {
1131 ThemeConfig {
1132 base: Some(match self.variant {
1133 ThemeVariant::Light => "light".into(),
1134 ThemeVariant::Dark => "dark".into(),
1135 }),
1136 primary: Some(ThemeConfig::color_to_hex(self.primary)),
1137 primary_hover: Some(ThemeConfig::color_to_hex(self.primary_hover)),
1138 primary_text: Some(ThemeConfig::color_to_hex(self.primary_text)),
1139 secondary: Some(ThemeConfig::color_to_hex(self.secondary)),
1140 secondary_hover: Some(ThemeConfig::color_to_hex(self.secondary_hover)),
1141 secondary_text: Some(ThemeConfig::color_to_hex(self.secondary_text)),
1142 bg_primary: Some(ThemeConfig::color_to_hex(self.bg_primary)),
1143 bg_secondary: Some(ThemeConfig::color_to_hex(self.bg_secondary)),
1144 bg_tertiary: Some(ThemeConfig::color_to_hex(self.bg_tertiary)),
1145 text_primary: Some(ThemeConfig::color_to_hex(self.text_primary)),
1146 text_secondary: Some(ThemeConfig::color_to_hex(self.text_secondary)),
1147 text_muted: Some(ThemeConfig::color_to_hex(self.text_muted)),
1148 state_success: Some(ThemeConfig::color_to_hex(self.state_success)),
1149 state_warning: Some(ThemeConfig::color_to_hex(self.state_warning)),
1150 state_danger: Some(ThemeConfig::color_to_hex(self.state_danger)),
1151 state_info: Some(ThemeConfig::color_to_hex(self.state_info)),
1152 border: Some(ThemeConfig::color_to_hex(self.border)),
1153 border_focus: Some(ThemeConfig::color_to_hex(self.border_focus)),
1154 spacing_scale: None, font_scale: None,
1156 radius_scale: None,
1157 stroke_scale: None,
1158 shadow_blur: self.shadow_blur,
1159 overlay_dim: Some(self.overlay_dim),
1160 surface_alpha: Some(self.surface_alpha),
1161 glass_opacity: Some(self.glass_opacity),
1162 glass_blur_radius: Some(self.glass_blur_radius),
1163 glass_tint: self.glass_tint.map(ThemeConfig::color_to_hex),
1164 glass_border: Some(self.glass_border),
1165 titlebar_height: Some(self.titlebar_height),
1166 }
1167 }
1168
1169 pub fn from_toml(toml_str: &str) -> Result<Self, toml::de::Error> {
1171 let config: ThemeConfig = toml::from_str(toml_str)?;
1172 Ok(Self::from_config(&config))
1173 }
1174
1175 pub fn to_toml(&self) -> Result<String, toml::ser::Error> {
1177 toml::to_string_pretty(&self.to_config())
1178 }
1179
1180 pub fn load_toml(path: impl AsRef<std::path::Path>) -> Result<Self, ThemeLoadError> {
1182 let content = std::fs::read_to_string(path)?;
1183 let theme = Self::from_toml(&content)?;
1184 Ok(theme)
1185 }
1186
1187 pub fn save_toml(&self, path: impl AsRef<std::path::Path>) -> Result<(), ThemeSaveError> {
1189 let toml_str = self.to_toml()?;
1190 std::fs::write(path, toml_str)?;
1191 Ok(())
1192 }
1193}
1194
1195#[cfg(feature = "serde")]
1197#[derive(Debug)]
1198pub enum ThemeLoadError {
1199 Io(std::io::Error),
1200 Parse(toml::de::Error),
1201}
1202
1203#[cfg(feature = "serde")]
1204impl std::fmt::Display for ThemeLoadError {
1205 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1206 match self {
1207 Self::Io(e) => write!(f, "IO error: {}", e),
1208 Self::Parse(e) => write!(f, "Parse error: {}", e),
1209 }
1210 }
1211}
1212
1213#[cfg(feature = "serde")]
1214impl std::error::Error for ThemeLoadError {}
1215
1216#[cfg(feature = "serde")]
1217impl From<std::io::Error> for ThemeLoadError {
1218 fn from(e: std::io::Error) -> Self {
1219 Self::Io(e)
1220 }
1221}
1222
1223#[cfg(feature = "serde")]
1224impl From<toml::de::Error> for ThemeLoadError {
1225 fn from(e: toml::de::Error) -> Self {
1226 Self::Parse(e)
1227 }
1228}
1229
1230#[cfg(feature = "serde")]
1232#[derive(Debug)]
1233pub enum ThemeSaveError {
1234 Io(std::io::Error),
1235 Serialize(toml::ser::Error),
1236}
1237
1238#[cfg(feature = "serde")]
1239impl std::fmt::Display for ThemeSaveError {
1240 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1241 match self {
1242 Self::Io(e) => write!(f, "IO error: {}", e),
1243 Self::Serialize(e) => write!(f, "Serialize error: {}", e),
1244 }
1245 }
1246}
1247
1248#[cfg(feature = "serde")]
1249impl std::error::Error for ThemeSaveError {}
1250
1251#[cfg(feature = "serde")]
1252impl From<std::io::Error> for ThemeSaveError {
1253 fn from(e: std::io::Error) -> Self {
1254 Self::Io(e)
1255 }
1256}
1257
1258#[cfg(feature = "serde")]
1259impl From<toml::ser::Error> for ThemeSaveError {
1260 fn from(e: toml::ser::Error) -> Self {
1261 Self::Serialize(e)
1262 }
1263}
1264
1265pub trait LightweightTheme {
1297 fn primary(&self) -> Color32;
1299
1300 fn background(&self) -> Color32;
1302
1303 fn text(&self) -> Color32;
1305
1306 fn to_theme(&self) -> Theme {
1314 let primary = self.primary();
1315 let bg = self.background();
1316 let text = self.text();
1317
1318 let [r, g, b, _] = bg.to_array();
1320 let luminance = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32;
1321 let is_dark = luminance < 128.0;
1322
1323 let mut theme = if is_dark {
1325 Theme::dark()
1326 } else {
1327 Theme::light()
1328 };
1329
1330 theme.primary = primary;
1332 theme.primary_hover = if is_dark {
1333 lighten(primary, 0.15)
1334 } else {
1335 darken(primary, 0.15)
1336 };
1337 theme.primary_text = contrast_text(primary);
1338 theme.border_focus = primary;
1339
1340 theme.bg_primary = bg;
1342 theme.bg_secondary = if is_dark {
1343 lighten(bg, 0.05)
1344 } else {
1345 darken(bg, 0.02)
1346 };
1347 theme.bg_tertiary = if is_dark {
1348 lighten(bg, 0.10)
1349 } else {
1350 darken(bg, 0.05)
1351 };
1352
1353 theme.text_primary = text;
1355 theme.text_secondary = with_alpha(text, 0.7);
1356 theme.text_muted = with_alpha(text, 0.5);
1357
1358 theme.border = if is_dark {
1360 lighten(bg, 0.15)
1361 } else {
1362 darken(bg, 0.10)
1363 };
1364
1365 theme
1366 }
1367}
1368
1369fn lighten(color: Color32, amount: f32) -> Color32 {
1372 let [r, g, b, a] = color.to_array();
1373 let f = 1.0 + amount;
1374 Color32::from_rgba_unmultiplied(
1375 ((r as f32 * f).min(255.0)) as u8,
1376 ((g as f32 * f).min(255.0)) as u8,
1377 ((b as f32 * f).min(255.0)) as u8,
1378 a,
1379 )
1380}
1381
1382fn darken(color: Color32, amount: f32) -> Color32 {
1383 let [r, g, b, a] = color.to_array();
1384 let f = 1.0 - amount;
1385 Color32::from_rgba_unmultiplied(
1386 (r as f32 * f) as u8,
1387 (g as f32 * f) as u8,
1388 (b as f32 * f) as u8,
1389 a,
1390 )
1391}
1392
1393fn with_alpha(color: Color32, alpha: f32) -> Color32 {
1394 let [r, g, b, _] = color.to_array();
1395 Color32::from_rgba_unmultiplied(r, g, b, (alpha * 255.0) as u8)
1396}
1397
1398fn contrast_text(bg: Color32) -> Color32 {
1399 let [r, g, b, _] = bg.to_array();
1400 let luminance = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32;
1401 if luminance > 128.0 {
1402 Color32::from_rgb(17, 24, 39) } else {
1404 Color32::WHITE }
1406}
1407
1408#[cfg(all(test, feature = "serde"))]
1413mod tests {
1414 use super::*;
1415
1416 #[test]
1417 fn test_parse_hex_color() {
1418 assert_eq!(
1419 ThemeConfig::parse_color("#FF0000"),
1420 Some(Color32::from_rgb(255, 0, 0))
1421 );
1422 assert_eq!(
1423 ThemeConfig::parse_color("#00FF00"),
1424 Some(Color32::from_rgb(0, 255, 0))
1425 );
1426 assert_eq!(
1427 ThemeConfig::parse_color("#0000FF"),
1428 Some(Color32::from_rgb(0, 0, 255))
1429 );
1430 assert_eq!(
1431 ThemeConfig::parse_color("#FF000080"),
1432 Some(Color32::from_rgba_unmultiplied(255, 0, 0, 128))
1433 );
1434 }
1435
1436 #[test]
1437 fn test_parse_rgb_color() {
1438 assert_eq!(
1439 ThemeConfig::parse_color("rgb(255, 0, 0)"),
1440 Some(Color32::from_rgb(255, 0, 0))
1441 );
1442 assert_eq!(
1443 ThemeConfig::parse_color("rgba(255, 0, 0, 128)"),
1444 Some(Color32::from_rgba_unmultiplied(255, 0, 0, 128))
1445 );
1446 assert_eq!(
1447 ThemeConfig::parse_color("rgba(255, 0, 0, 0.5)"),
1448 Some(Color32::from_rgba_unmultiplied(255, 0, 0, 127))
1449 );
1450 }
1451
1452 #[test]
1453 fn test_color_to_hex() {
1454 assert_eq!(
1455 ThemeConfig::color_to_hex(Color32::from_rgb(255, 0, 0)),
1456 "#FF0000"
1457 );
1458 assert_eq!(
1459 ThemeConfig::color_to_hex(Color32::from_rgba_unmultiplied(255, 0, 0, 128)),
1460 "#FF000080"
1461 );
1462 }
1463
1464 #[test]
1465 fn test_theme_from_toml() {
1466 let toml = r##"
1467 base = "dark"
1468 primary = "#8B5CF6"
1469 spacing_scale = 0.85
1470 "##;
1471
1472 let theme = Theme::from_toml(toml).unwrap();
1473 assert_eq!(theme.variant, ThemeVariant::Dark);
1474 assert_eq!(theme.primary, Color32::from_rgb(139, 92, 246));
1475 }
1476
1477 #[test]
1478 fn test_theme_roundtrip() {
1479 let original = Theme::dark();
1480 let toml = original.to_toml().unwrap();
1481 let restored = Theme::from_toml(&toml).unwrap();
1482
1483 assert_eq!(original.variant, restored.variant);
1484 assert_eq!(original.primary, restored.primary);
1485 assert_eq!(original.bg_primary, restored.bg_primary);
1486 }
1487
1488 #[test]
1489 fn test_lightweight_theme() {
1490 struct TestTheme;
1491 impl LightweightTheme for TestTheme {
1492 fn primary(&self) -> Color32 {
1493 Color32::from_rgb(139, 92, 246)
1494 }
1495 fn background(&self) -> Color32 {
1496 Color32::from_rgb(15, 15, 25)
1497 }
1498 fn text(&self) -> Color32 {
1499 Color32::WHITE
1500 }
1501 }
1502
1503 let theme = TestTheme.to_theme();
1504 assert_eq!(theme.primary, Color32::from_rgb(139, 92, 246));
1505 assert_eq!(theme.bg_primary, Color32::from_rgb(15, 15, 25));
1506 assert_eq!(theme.text_primary, Color32::WHITE);
1507 assert_eq!(theme.variant, ThemeVariant::Dark); }
1509}