1use super::theme::{Layout, ModernDarkTheme, Typography};
8use glam::Vec4;
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum ThemeVariant {
15 ModernDark,
17 ClassicLight,
19 HighContrast,
21 Custom,
23}
24
25impl Default for ThemeVariant {
26 fn default() -> Self {
27 Self::ModernDark
28 }
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, Default)]
33pub struct PlotThemeConfig {
34 pub variant: ThemeVariant,
36
37 pub typography: TypographyConfig,
39
40 pub layout: LayoutConfig,
42
43 pub custom_colors: Option<CustomColorConfig>,
45
46 pub grid: GridConfig,
48
49 pub interaction: InteractionConfig,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct TypographyConfig {
56 pub title_font_size: f32,
58 pub subtitle_font_size: f32,
59 pub axis_label_font_size: f32,
60 pub tick_label_font_size: f32,
61 pub legend_font_size: f32,
62
63 pub title_font_family: String,
65 pub body_font_family: String,
66 pub monospace_font_family: String,
67
68 pub enable_antialiasing: bool,
70 pub enable_subpixel_rendering: bool,
71}
72
73impl Default for TypographyConfig {
74 fn default() -> Self {
75 let typography = Typography::default();
76 Self {
77 title_font_size: typography.title_font_size,
78 subtitle_font_size: typography.subtitle_font_size,
79 axis_label_font_size: typography.axis_label_font_size,
80 tick_label_font_size: typography.tick_label_font_size,
81 legend_font_size: typography.legend_font_size,
82 title_font_family: typography.title_font_family,
83 body_font_family: typography.body_font_family,
84 monospace_font_family: typography.monospace_font_family,
85 enable_antialiasing: true,
86 enable_subpixel_rendering: true,
87 }
88 }
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct LayoutConfig {
94 pub plot_padding: f32,
96 pub title_margin: f32,
97 pub axis_margin: f32,
98 pub legend_margin: f32,
99
100 pub grid_line_width: f32,
102 pub axis_line_width: f32,
103 pub data_line_width: f32,
104
105 pub point_size: f32,
107 pub marker_size: f32,
108
109 pub auto_adjust_margins: bool,
111 pub maintain_aspect_ratio: bool,
112}
113
114impl Default for LayoutConfig {
115 fn default() -> Self {
116 let layout = Layout::default();
117 Self {
118 plot_padding: layout.plot_padding,
119 title_margin: layout.title_margin,
120 axis_margin: layout.axis_margin,
121 legend_margin: layout.legend_margin,
122 grid_line_width: layout.grid_line_width,
123 axis_line_width: layout.axis_line_width,
124 data_line_width: layout.data_line_width,
125 point_size: layout.point_size,
126 marker_size: 6.0,
127 auto_adjust_margins: true,
128 maintain_aspect_ratio: false,
129 }
130 }
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct CustomColorConfig {
136 pub background_primary: String,
138 pub background_secondary: String,
139 pub plot_background: String,
140
141 pub text_primary: String,
143 pub text_secondary: String,
144
145 pub accent_primary: String,
147 pub accent_secondary: String,
148
149 pub grid_major: String,
151 pub grid_minor: String,
152 pub axis_color: String,
153
154 pub data_colors: Vec<String>,
156}
157
158impl Default for CustomColorConfig {
159 fn default() -> Self {
160 Self {
161 background_primary: "#141619".to_string(),
162 background_secondary: "#1f2329".to_string(),
163 plot_background: "#1a1d21".to_string(),
164 text_primary: "#f2f4f7".to_string(),
165 text_secondary: "#bfc7d1".to_string(),
166 accent_primary: "#59c878".to_string(),
167 accent_secondary: "#47a661".to_string(),
168 grid_major: "#404449".to_string(),
169 grid_minor: "#33373c".to_string(),
170 axis_color: "#a6adb7".to_string(),
171 data_colors: vec![
172 "#59c878".to_string(), "#40a5d6".to_string(), "#f28c40".to_string(), "#bf59d6".to_string(), "#f2c040".to_string(), "#d95973".to_string(), "#40d6bf".to_string(), "#a6bf59".to_string(), ],
181 }
182 }
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct GridConfig {
188 pub show_major_grid: bool,
190 pub show_minor_grid: bool,
191
192 pub major_grid_alpha: f32,
194 pub minor_grid_alpha: f32,
195
196 pub auto_grid_spacing: bool,
198 pub major_grid_divisions: u32,
199 pub minor_grid_subdivisions: u32,
200}
201
202impl Default for GridConfig {
203 fn default() -> Self {
204 Self {
205 show_major_grid: true,
206 show_minor_grid: true,
207 major_grid_alpha: 0.6,
208 minor_grid_alpha: 0.3,
209 auto_grid_spacing: true,
210 major_grid_divisions: 5,
211 minor_grid_subdivisions: 5,
212 }
213 }
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct InteractionConfig {
219 pub enable_animations: bool,
221 pub animation_duration_ms: u32,
222 pub animation_easing: String,
223
224 pub enable_zoom: bool,
226 pub enable_pan: bool,
227 pub enable_selection: bool,
228 pub enable_tooltips: bool,
229
230 pub max_fps: u32,
232 pub enable_vsync: bool,
233 pub enable_gpu_acceleration: bool,
234}
235
236impl Default for InteractionConfig {
237 fn default() -> Self {
238 Self {
239 enable_animations: true,
240 animation_duration_ms: 300,
241 animation_easing: "ease_out".to_string(),
242 enable_zoom: true,
243 enable_pan: true,
244 enable_selection: true,
245 enable_tooltips: true,
246 max_fps: 60,
247 enable_vsync: true,
248 enable_gpu_acceleration: true,
249 }
250 }
251}
252
253impl PlotThemeConfig {
254 pub fn build_theme(&self) -> Box<dyn PlotTheme> {
256 match self.variant {
257 ThemeVariant::ModernDark => Box::new(ModernDarkTheme::default()),
258 ThemeVariant::ClassicLight => Box::new(ClassicLightTheme::default()),
259 ThemeVariant::HighContrast => Box::new(HighContrastTheme::default()),
260 ThemeVariant::Custom => {
261 if let Some(custom) = &self.custom_colors {
262 Box::new(CustomTheme::from_config(custom))
263 } else {
264 Box::new(ModernDarkTheme::default())
265 }
266 }
267 }
268 }
269
270 pub fn validate(&self) -> Result<(), String> {
272 validate_theme_config(self)
273 }
274
275 pub fn get_typography(&self) -> Typography {
277 Typography {
278 title_font_size: self.typography.title_font_size,
279 subtitle_font_size: self.typography.subtitle_font_size,
280 axis_label_font_size: self.typography.axis_label_font_size,
281 tick_label_font_size: self.typography.tick_label_font_size,
282 legend_font_size: self.typography.legend_font_size,
283 title_font_family: self.typography.title_font_family.clone(),
284 body_font_family: self.typography.body_font_family.clone(),
285 monospace_font_family: self.typography.monospace_font_family.clone(),
286 }
287 }
288
289 pub fn get_layout(&self) -> Layout {
291 Layout {
292 plot_padding: self.layout.plot_padding,
293 title_margin: self.layout.title_margin,
294 axis_margin: self.layout.axis_margin,
295 legend_margin: self.layout.legend_margin,
296 grid_line_width: self.layout.grid_line_width,
297 axis_line_width: self.layout.axis_line_width,
298 data_line_width: self.layout.data_line_width,
299 point_size: self.layout.point_size,
300 }
301 }
302}
303
304pub trait PlotTheme {
306 fn get_background_color(&self) -> Vec4;
307 fn get_text_color(&self) -> Vec4;
308 fn get_accent_color(&self) -> Vec4;
309 fn get_grid_color(&self) -> Vec4;
310 fn get_axis_color(&self) -> Vec4;
311 fn get_data_color(&self, index: usize) -> Vec4;
312 fn apply_to_egui(&self, ctx: &egui::Context);
313}
314
315impl PlotTheme for ModernDarkTheme {
316 fn get_background_color(&self) -> Vec4 {
317 self.background_primary
318 }
319 fn get_text_color(&self) -> Vec4 {
320 self.text_primary
321 }
322 fn get_accent_color(&self) -> Vec4 {
323 self.accent_primary
324 }
325 fn get_grid_color(&self) -> Vec4 {
326 self.grid_major
327 }
328 fn get_axis_color(&self) -> Vec4 {
329 self.axis_color
330 }
331 fn get_data_color(&self, index: usize) -> Vec4 {
332 self.get_data_color(index)
333 }
334 fn apply_to_egui(&self, ctx: &egui::Context) {
335 self.apply_to_egui(ctx)
336 }
337}
338
339#[derive(Debug, Clone)]
341pub struct ClassicLightTheme {
342 pub background_color: Vec4,
343 pub text_color: Vec4,
344 pub accent_color: Vec4,
345 pub grid_color: Vec4,
346 pub axis_color: Vec4,
347 pub data_colors: Vec<Vec4>,
348}
349
350impl Default for ClassicLightTheme {
351 fn default() -> Self {
352 Self {
353 background_color: Vec4::new(1.0, 1.0, 1.0, 1.0),
354 text_color: Vec4::new(0.0, 0.0, 0.0, 1.0),
355 accent_color: Vec4::new(0.0, 0.5, 1.0, 1.0),
356 grid_color: Vec4::new(0.8, 0.8, 0.8, 0.8),
357 axis_color: Vec4::new(0.3, 0.3, 0.3, 1.0),
358 data_colors: vec![
359 Vec4::new(0.0, 0.5, 1.0, 1.0), Vec4::new(1.0, 0.5, 0.0, 1.0), Vec4::new(0.5, 0.8, 0.2, 1.0), Vec4::new(0.8, 0.2, 0.8, 1.0), Vec4::new(1.0, 0.8, 0.0, 1.0), Vec4::new(0.2, 0.8, 0.8, 1.0), Vec4::new(0.8, 0.2, 0.2, 1.0), ],
367 }
368 }
369}
370
371impl PlotTheme for ClassicLightTheme {
372 fn get_background_color(&self) -> Vec4 {
373 self.background_color
374 }
375 fn get_text_color(&self) -> Vec4 {
376 self.text_color
377 }
378 fn get_accent_color(&self) -> Vec4 {
379 self.accent_color
380 }
381 fn get_grid_color(&self) -> Vec4 {
382 self.grid_color
383 }
384 fn get_axis_color(&self) -> Vec4 {
385 self.axis_color
386 }
387 fn get_data_color(&self, index: usize) -> Vec4 {
388 self.data_colors[index % self.data_colors.len()]
389 }
390 fn apply_to_egui(&self, ctx: &egui::Context) {
391 ctx.set_visuals(egui::Visuals::light());
392 }
393}
394
395#[derive(Debug, Clone)]
397pub struct HighContrastTheme {
398 pub background_color: Vec4,
399 pub text_color: Vec4,
400 pub accent_color: Vec4,
401 pub grid_color: Vec4,
402 pub axis_color: Vec4,
403 pub data_colors: Vec<Vec4>,
404}
405
406impl Default for HighContrastTheme {
407 fn default() -> Self {
408 Self {
409 background_color: Vec4::new(0.0, 0.0, 0.0, 1.0),
410 text_color: Vec4::new(1.0, 1.0, 1.0, 1.0),
411 accent_color: Vec4::new(1.0, 1.0, 0.0, 1.0),
412 grid_color: Vec4::new(0.5, 0.5, 0.5, 1.0),
413 axis_color: Vec4::new(1.0, 1.0, 1.0, 1.0),
414 data_colors: vec![
415 Vec4::new(1.0, 1.0, 0.0, 1.0), Vec4::new(0.0, 1.0, 1.0, 1.0), Vec4::new(1.0, 0.0, 1.0, 1.0), Vec4::new(1.0, 1.0, 1.0, 1.0), Vec4::new(1.0, 0.5, 0.0, 1.0), Vec4::new(0.5, 1.0, 0.5, 1.0), ],
422 }
423 }
424}
425
426impl PlotTheme for HighContrastTheme {
427 fn get_background_color(&self) -> Vec4 {
428 self.background_color
429 }
430 fn get_text_color(&self) -> Vec4 {
431 self.text_color
432 }
433 fn get_accent_color(&self) -> Vec4 {
434 self.accent_color
435 }
436 fn get_grid_color(&self) -> Vec4 {
437 self.grid_color
438 }
439 fn get_axis_color(&self) -> Vec4 {
440 self.axis_color
441 }
442 fn get_data_color(&self, index: usize) -> Vec4 {
443 self.data_colors[index % self.data_colors.len()]
444 }
445 fn apply_to_egui(&self, ctx: &egui::Context) {
446 let mut visuals = egui::Visuals::dark();
447 visuals.extreme_bg_color = egui::Color32::BLACK;
448 visuals.widgets.noninteractive.bg_fill = egui::Color32::BLACK;
449 visuals.widgets.noninteractive.fg_stroke.color = egui::Color32::WHITE;
450 ctx.set_visuals(visuals);
451 }
452}
453
454#[derive(Debug, Clone)]
456pub struct CustomTheme {
457 pub background_color: Vec4,
458 pub text_color: Vec4,
459 pub accent_color: Vec4,
460 pub grid_color: Vec4,
461 pub axis_color: Vec4,
462 pub data_colors: Vec<Vec4>,
463}
464
465impl CustomTheme {
466 pub fn from_config(config: &CustomColorConfig) -> Self {
468 Self {
469 background_color: hex_to_vec4(&config.background_primary)
470 .unwrap_or(Vec4::new(0.1, 0.1, 0.1, 1.0)),
471 text_color: hex_to_vec4(&config.text_primary).unwrap_or(Vec4::new(1.0, 1.0, 1.0, 1.0)),
472 accent_color: hex_to_vec4(&config.accent_primary)
473 .unwrap_or(Vec4::new(0.0, 0.8, 0.4, 1.0)),
474 grid_color: hex_to_vec4(&config.grid_major).unwrap_or(Vec4::new(0.3, 0.3, 0.3, 0.6)),
475 axis_color: hex_to_vec4(&config.axis_color).unwrap_or(Vec4::new(0.7, 0.7, 0.7, 1.0)),
476 data_colors: config
477 .data_colors
478 .iter()
479 .filter_map(|hex| hex_to_vec4(hex))
480 .collect(),
481 }
482 }
483}
484
485impl PlotTheme for CustomTheme {
486 fn get_background_color(&self) -> Vec4 {
487 self.background_color
488 }
489 fn get_text_color(&self) -> Vec4 {
490 self.text_color
491 }
492 fn get_accent_color(&self) -> Vec4 {
493 self.accent_color
494 }
495 fn get_grid_color(&self) -> Vec4 {
496 self.grid_color
497 }
498 fn get_axis_color(&self) -> Vec4 {
499 self.axis_color
500 }
501 fn get_data_color(&self, index: usize) -> Vec4 {
502 if self.data_colors.is_empty() {
503 Vec4::new(0.5, 0.5, 0.5, 1.0) } else {
505 self.data_colors[index % self.data_colors.len()]
506 }
507 }
508 fn apply_to_egui(&self, ctx: &egui::Context) {
509 let mut visuals =
510 if self.background_color.x + self.background_color.y + self.background_color.z < 1.5 {
511 egui::Visuals::dark()
512 } else {
513 egui::Visuals::light()
514 };
515
516 visuals.panel_fill = egui::Color32::from_rgba_unmultiplied(
517 (self.background_color.x * 255.0) as u8,
518 (self.background_color.y * 255.0) as u8,
519 (self.background_color.z * 255.0) as u8,
520 255,
521 );
522
523 ctx.set_visuals(visuals);
524 }
525}
526
527fn hex_to_vec4(hex: &str) -> Option<Vec4> {
529 let hex = hex.trim_start_matches('#');
530 if hex.len() != 6 {
531 return None;
532 }
533
534 let r = u8::from_str_radix(&hex[0..2], 16).ok()? as f32 / 255.0;
535 let g = u8::from_str_radix(&hex[2..4], 16).ok()? as f32 / 255.0;
536 let b = u8::from_str_radix(&hex[4..6], 16).ok()? as f32 / 255.0;
537
538 Some(Vec4::new(r, g, b, 1.0))
539}
540
541pub fn validate_theme_config(config: &PlotThemeConfig) -> Result<(), String> {
543 if config.typography.title_font_size <= 0.0 {
545 return Err("Title font size must be positive".to_string());
546 }
547 if config.typography.axis_label_font_size <= 0.0 {
548 return Err("Axis label font size must be positive".to_string());
549 }
550
551 if config.layout.plot_padding < 0.0 {
553 return Err("Plot padding must be non-negative".to_string());
554 }
555 if config.layout.data_line_width <= 0.0 {
556 return Err("Data line width must be positive".to_string());
557 }
558
559 if config.variant == ThemeVariant::Custom {
561 if let Some(custom) = &config.custom_colors {
562 for color in &custom.data_colors {
563 if hex_to_vec4(color).is_none() {
564 return Err(format!("Invalid hex color: {color}"));
565 }
566 }
567 } else {
568 return Err("Custom theme variant requires custom_colors configuration".to_string());
569 }
570 }
571
572 if config.interaction.animation_duration_ms > 5000 {
574 return Err("Animation duration too long (max 5000ms)".to_string());
575 }
576
577 if config.interaction.max_fps == 0 || config.interaction.max_fps > 240 {
579 return Err("Max FPS must be between 1 and 240".to_string());
580 }
581
582 Ok(())
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588
589 #[test]
590 fn test_default_config_is_valid() {
591 let config = PlotThemeConfig::default();
592 assert!(config.validate().is_ok());
593 }
594
595 #[test]
596 fn test_hex_to_vec4_conversion() {
597 let color = hex_to_vec4("#ff0000").unwrap();
598 assert!((color.x - 1.0).abs() < 0.01);
599 assert!(color.y.abs() < 0.01);
600 assert!(color.z.abs() < 0.01);
601 assert!((color.w - 1.0).abs() < 0.01);
602 }
603
604 #[test]
605 fn test_invalid_hex_colors() {
606 assert!(hex_to_vec4("invalid").is_none());
607 assert!(hex_to_vec4("#gg0000").is_none());
608 assert!(hex_to_vec4("#ff00").is_none());
609 }
610
611 #[test]
612 fn test_theme_variants() {
613 let config = PlotThemeConfig::default();
614 let theme = config.build_theme();
615
616 let bg_color = theme.get_background_color();
618 assert!(bg_color.w > 0.0); }
620
621 #[test]
622 fn test_custom_theme_validation() {
623 let mut config = PlotThemeConfig {
624 variant: ThemeVariant::Custom,
625 ..Default::default()
626 };
627
628 assert!(config.validate().is_err());
630
631 config.custom_colors = Some(CustomColorConfig::default());
633 assert!(config.validate().is_ok());
634 }
635
636 #[test]
637 fn test_config_validation_bounds() {
638 let mut config = PlotThemeConfig::default();
639
640 config.typography.title_font_size = -1.0;
642 assert!(config.validate().is_err());
643
644 config.typography.title_font_size = 18.0; config.interaction.animation_duration_ms = 10000;
647 assert!(config.validate().is_err());
648
649 config.interaction.animation_duration_ms = 300; config.interaction.max_fps = 0;
652 assert!(config.validate().is_err());
653 }
654
655 #[test]
656 fn test_typography_defaults() {
657 let typography = TypographyConfig::default();
658 assert!(typography.title_font_size > typography.subtitle_font_size);
659 assert!(typography.subtitle_font_size > typography.axis_label_font_size);
660 assert!(typography.enable_antialiasing);
661 }
662
663 #[test]
664 fn test_data_color_cycling() {
665 let theme = ModernDarkTheme::default();
666 let color1 = theme.get_data_color(0);
667 let color2 = theme.get_data_color(theme.data_colors.len());
668
669 assert_eq!(color1, color2);
671 }
672}