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 #[cfg(feature = "gui")]
313 fn apply_to_egui(&self, ctx: &egui::Context);
314}
315
316impl PlotTheme for ModernDarkTheme {
317 fn get_background_color(&self) -> Vec4 {
318 self.background_primary
319 }
320 fn get_text_color(&self) -> Vec4 {
321 self.text_primary
322 }
323 fn get_accent_color(&self) -> Vec4 {
324 self.accent_primary
325 }
326 fn get_grid_color(&self) -> Vec4 {
327 self.grid_major
328 }
329 fn get_axis_color(&self) -> Vec4 {
330 self.axis_color
331 }
332 fn get_data_color(&self, index: usize) -> Vec4 {
333 self.get_data_color(index)
334 }
335 #[cfg(feature = "gui")]
336 fn apply_to_egui(&self, ctx: &egui::Context) {
337 self.apply_to_egui(ctx)
338 }
339}
340
341#[derive(Debug, Clone)]
343pub struct ClassicLightTheme {
344 pub background_color: Vec4,
345 pub text_color: Vec4,
346 pub accent_color: Vec4,
347 pub grid_color: Vec4,
348 pub axis_color: Vec4,
349 pub data_colors: Vec<Vec4>,
350}
351
352impl Default for ClassicLightTheme {
353 fn default() -> Self {
354 Self {
355 background_color: Vec4::new(0.98, 0.985, 0.995, 1.0),
356 text_color: Vec4::new(0.12, 0.16, 0.22, 1.0),
357 accent_color: Vec4::new(0.05, 0.44, 0.86, 1.0),
358 grid_color: Vec4::new(0.28, 0.34, 0.44, 0.42),
359 axis_color: Vec4::new(0.18, 0.24, 0.33, 1.0),
360 data_colors: vec![
361 Vec4::new(0.07, 0.40, 0.80, 1.0), Vec4::new(0.88, 0.38, 0.12, 1.0), Vec4::new(0.10, 0.58, 0.45, 1.0), Vec4::new(0.53, 0.29, 0.78, 1.0), Vec4::new(0.76, 0.58, 0.08, 1.0), Vec4::new(0.13, 0.60, 0.72, 1.0), Vec4::new(0.74, 0.24, 0.27, 1.0), ],
369 }
370 }
371}
372
373impl PlotTheme for ClassicLightTheme {
374 fn get_background_color(&self) -> Vec4 {
375 self.background_color
376 }
377 fn get_text_color(&self) -> Vec4 {
378 self.text_color
379 }
380 fn get_accent_color(&self) -> Vec4 {
381 self.accent_color
382 }
383 fn get_grid_color(&self) -> Vec4 {
384 self.grid_color
385 }
386 fn get_axis_color(&self) -> Vec4 {
387 self.axis_color
388 }
389 fn get_data_color(&self, index: usize) -> Vec4 {
390 self.data_colors[index % self.data_colors.len()]
391 }
392 #[cfg(feature = "gui")]
393 fn apply_to_egui(&self, ctx: &egui::Context) {
394 ctx.set_visuals(egui::Visuals::light());
395 }
396}
397
398#[derive(Debug, Clone)]
400pub struct HighContrastTheme {
401 pub background_color: Vec4,
402 pub text_color: Vec4,
403 pub accent_color: Vec4,
404 pub grid_color: Vec4,
405 pub axis_color: Vec4,
406 pub data_colors: Vec<Vec4>,
407}
408
409impl Default for HighContrastTheme {
410 fn default() -> Self {
411 Self {
412 background_color: Vec4::new(0.0, 0.0, 0.0, 1.0),
413 text_color: Vec4::new(1.0, 1.0, 1.0, 1.0),
414 accent_color: Vec4::new(1.0, 1.0, 0.0, 1.0),
415 grid_color: Vec4::new(0.5, 0.5, 0.5, 1.0),
416 axis_color: Vec4::new(1.0, 1.0, 1.0, 1.0),
417 data_colors: vec![
418 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), ],
425 }
426 }
427}
428
429impl PlotTheme for HighContrastTheme {
430 fn get_background_color(&self) -> Vec4 {
431 self.background_color
432 }
433 fn get_text_color(&self) -> Vec4 {
434 self.text_color
435 }
436 fn get_accent_color(&self) -> Vec4 {
437 self.accent_color
438 }
439 fn get_grid_color(&self) -> Vec4 {
440 self.grid_color
441 }
442 fn get_axis_color(&self) -> Vec4 {
443 self.axis_color
444 }
445 fn get_data_color(&self, index: usize) -> Vec4 {
446 self.data_colors[index % self.data_colors.len()]
447 }
448 #[cfg(feature = "gui")]
449 fn apply_to_egui(&self, ctx: &egui::Context) {
450 let mut visuals = egui::Visuals::dark();
451 visuals.extreme_bg_color = egui::Color32::BLACK;
452 visuals.widgets.noninteractive.bg_fill = egui::Color32::BLACK;
453 visuals.widgets.noninteractive.fg_stroke.color = egui::Color32::WHITE;
454 ctx.set_visuals(visuals);
455 }
456}
457
458#[derive(Debug, Clone)]
460pub struct CustomTheme {
461 pub background_color: Vec4,
462 pub text_color: Vec4,
463 pub accent_color: Vec4,
464 pub grid_color: Vec4,
465 pub axis_color: Vec4,
466 pub data_colors: Vec<Vec4>,
467}
468
469impl CustomTheme {
470 pub fn from_config(config: &CustomColorConfig) -> Self {
472 Self {
473 background_color: hex_to_vec4(&config.background_primary)
474 .unwrap_or(Vec4::new(0.1, 0.1, 0.1, 1.0)),
475 text_color: hex_to_vec4(&config.text_primary).unwrap_or(Vec4::new(1.0, 1.0, 1.0, 1.0)),
476 accent_color: hex_to_vec4(&config.accent_primary)
477 .unwrap_or(Vec4::new(0.0, 0.8, 0.4, 1.0)),
478 grid_color: hex_to_vec4(&config.grid_major).unwrap_or(Vec4::new(0.3, 0.3, 0.3, 0.6)),
479 axis_color: hex_to_vec4(&config.axis_color).unwrap_or(Vec4::new(0.7, 0.7, 0.7, 1.0)),
480 data_colors: config
481 .data_colors
482 .iter()
483 .filter_map(|hex| hex_to_vec4(hex))
484 .collect(),
485 }
486 }
487}
488
489impl PlotTheme for CustomTheme {
490 fn get_background_color(&self) -> Vec4 {
491 self.background_color
492 }
493 fn get_text_color(&self) -> Vec4 {
494 self.text_color
495 }
496 fn get_accent_color(&self) -> Vec4 {
497 self.accent_color
498 }
499 fn get_grid_color(&self) -> Vec4 {
500 self.grid_color
501 }
502 fn get_axis_color(&self) -> Vec4 {
503 self.axis_color
504 }
505 fn get_data_color(&self, index: usize) -> Vec4 {
506 if self.data_colors.is_empty() {
507 Vec4::new(0.5, 0.5, 0.5, 1.0) } else {
509 self.data_colors[index % self.data_colors.len()]
510 }
511 }
512 #[cfg(feature = "gui")]
513 fn apply_to_egui(&self, ctx: &egui::Context) {
514 let mut visuals =
515 if self.background_color.x + self.background_color.y + self.background_color.z < 1.5 {
516 egui::Visuals::dark()
517 } else {
518 egui::Visuals::light()
519 };
520
521 visuals.panel_fill = egui::Color32::from_rgba_unmultiplied(
522 (self.background_color.x * 255.0) as u8,
523 (self.background_color.y * 255.0) as u8,
524 (self.background_color.z * 255.0) as u8,
525 255,
526 );
527
528 ctx.set_visuals(visuals);
529 }
530}
531
532fn hex_to_vec4(hex: &str) -> Option<Vec4> {
534 let hex = hex.trim_start_matches('#');
535 if hex.len() != 6 {
536 return None;
537 }
538
539 let r = u8::from_str_radix(&hex[0..2], 16).ok()? as f32 / 255.0;
540 let g = u8::from_str_radix(&hex[2..4], 16).ok()? as f32 / 255.0;
541 let b = u8::from_str_radix(&hex[4..6], 16).ok()? as f32 / 255.0;
542
543 Some(Vec4::new(r, g, b, 1.0))
544}
545
546pub fn validate_theme_config(config: &PlotThemeConfig) -> Result<(), String> {
548 if config.typography.title_font_size <= 0.0 {
550 return Err("Title font size must be positive".to_string());
551 }
552 if config.typography.axis_label_font_size <= 0.0 {
553 return Err("Axis label font size must be positive".to_string());
554 }
555
556 if config.layout.plot_padding < 0.0 {
558 return Err("Plot padding must be non-negative".to_string());
559 }
560 if config.layout.data_line_width <= 0.0 {
561 return Err("Data line width must be positive".to_string());
562 }
563
564 if config.variant == ThemeVariant::Custom {
566 if let Some(custom) = &config.custom_colors {
567 for color in &custom.data_colors {
568 if hex_to_vec4(color).is_none() {
569 return Err(format!("Invalid hex color: {color}"));
570 }
571 }
572 } else {
573 return Err("Custom theme variant requires custom_colors configuration".to_string());
574 }
575 }
576
577 if config.interaction.animation_duration_ms > 5000 {
579 return Err("Animation duration too long (max 5000ms)".to_string());
580 }
581
582 if config.interaction.max_fps == 0 || config.interaction.max_fps > 240 {
584 return Err("Max FPS must be between 1 and 240".to_string());
585 }
586
587 Ok(())
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593
594 #[test]
595 fn test_default_config_is_valid() {
596 let config = PlotThemeConfig::default();
597 assert!(config.validate().is_ok());
598 }
599
600 #[test]
601 fn test_hex_to_vec4_conversion() {
602 let color = hex_to_vec4("#ff0000").unwrap();
603 assert!((color.x - 1.0).abs() < 0.01);
604 assert!(color.y.abs() < 0.01);
605 assert!(color.z.abs() < 0.01);
606 assert!((color.w - 1.0).abs() < 0.01);
607 }
608
609 #[test]
610 fn test_invalid_hex_colors() {
611 assert!(hex_to_vec4("invalid").is_none());
612 assert!(hex_to_vec4("#gg0000").is_none());
613 assert!(hex_to_vec4("#ff00").is_none());
614 }
615
616 #[test]
617 fn test_theme_variants() {
618 let config = PlotThemeConfig::default();
619 let theme = config.build_theme();
620
621 let bg_color = theme.get_background_color();
623 assert!(bg_color.w > 0.0); }
625
626 #[test]
627 fn test_custom_theme_validation() {
628 let mut config = PlotThemeConfig {
629 variant: ThemeVariant::Custom,
630 ..Default::default()
631 };
632
633 assert!(config.validate().is_err());
635
636 config.custom_colors = Some(CustomColorConfig::default());
638 assert!(config.validate().is_ok());
639 }
640
641 #[test]
642 fn test_config_validation_bounds() {
643 let mut config = PlotThemeConfig::default();
644
645 config.typography.title_font_size = -1.0;
647 assert!(config.validate().is_err());
648
649 config.typography.title_font_size = 18.0; config.interaction.animation_duration_ms = 10000;
652 assert!(config.validate().is_err());
653
654 config.interaction.animation_duration_ms = 300; config.interaction.max_fps = 0;
657 assert!(config.validate().is_err());
658 }
659
660 #[test]
661 fn test_typography_defaults() {
662 let typography = TypographyConfig::default();
663 assert!(typography.title_font_size > typography.subtitle_font_size);
664 assert!(typography.subtitle_font_size > typography.axis_label_font_size);
665 assert!(typography.enable_antialiasing);
666 }
667
668 #[test]
669 fn test_data_color_cycling() {
670 let theme = ModernDarkTheme::default();
671 let color1 = theme.get_data_color(0);
672 let color2 = theme.get_data_color(theme.data_colors.len());
673
674 assert_eq!(color1, color2);
676 }
677}