Skip to main content

runmat_plot/styling/
config.rs

1//! Theme configuration system that integrates with RunMat's config
2//!
3//! This module provides the configuration structures that RunMat can import
4//! and use in its main configuration system, while keeping the plotting
5//! library in control of its own theming.
6
7use super::theme::{Layout, ModernDarkTheme, Typography};
8use glam::Vec4;
9use serde::{Deserialize, Serialize};
10
11/// Theme variants available in the plotting system
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum ThemeVariant {
15    /// Modern dark theme with green accents (default)
16    ModernDark,
17    /// Light theme
18    ClassicLight,
19    /// High contrast theme for accessibility
20    HighContrast,
21    /// Custom theme (loads from user config)
22    Custom,
23}
24
25impl Default for ThemeVariant {
26    fn default() -> Self {
27        Self::ModernDark
28    }
29}
30
31/// Complete plotting theme configuration
32#[derive(Debug, Clone, Serialize, Deserialize, Default)]
33pub struct PlotThemeConfig {
34    /// Theme variant to use
35    pub variant: ThemeVariant,
36
37    /// Typography settings
38    pub typography: TypographyConfig,
39
40    /// Layout and spacing settings
41    pub layout: LayoutConfig,
42
43    /// Custom color overrides (when variant is Custom)
44    pub custom_colors: Option<CustomColorConfig>,
45
46    /// Grid settings
47    pub grid: GridConfig,
48
49    /// Animation and interaction settings
50    pub interaction: InteractionConfig,
51}
52
53/// Typography configuration
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct TypographyConfig {
56    /// Font sizes
57    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    /// Font families
64    pub title_font_family: String,
65    pub body_font_family: String,
66    pub monospace_font_family: String,
67
68    /// Typography features
69    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/// Layout and spacing configuration
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct LayoutConfig {
94    /// Margins and padding
95    pub plot_padding: f32,
96    pub title_margin: f32,
97    pub axis_margin: f32,
98    pub legend_margin: f32,
99
100    /// Line widths
101    pub grid_line_width: f32,
102    pub axis_line_width: f32,
103    pub data_line_width: f32,
104
105    /// Point and marker sizes
106    pub point_size: f32,
107    pub marker_size: f32,
108
109    /// Layout features
110    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/// Custom color configuration (used when variant is Custom)
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct CustomColorConfig {
136    /// Background colors (as hex strings for easy configuration)
137    pub background_primary: String,
138    pub background_secondary: String,
139    pub plot_background: String,
140
141    /// Text colors
142    pub text_primary: String,
143    pub text_secondary: String,
144
145    /// Accent colors
146    pub accent_primary: String,
147    pub accent_secondary: String,
148
149    /// Grid and axis colors
150    pub grid_major: String,
151    pub grid_minor: String,
152    pub axis_color: String,
153
154    /// Data series colors
155    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(), // Green
173                "#40a5d6".to_string(), // Blue
174                "#f28c40".to_string(), // Orange
175                "#bf59d6".to_string(), // Purple
176                "#f2c040".to_string(), // Yellow
177                "#d95973".to_string(), // Pink
178                "#40d6bf".to_string(), // Turquoise
179                "#a6bf59".to_string(), // Lime
180            ],
181        }
182    }
183}
184
185/// Grid display configuration
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct GridConfig {
188    /// Grid visibility
189    pub show_major_grid: bool,
190    pub show_minor_grid: bool,
191
192    /// Grid styling
193    pub major_grid_alpha: f32,
194    pub minor_grid_alpha: f32,
195
196    /// Grid spacing
197    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/// Interaction and animation configuration
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct InteractionConfig {
219    /// Animation settings
220    pub enable_animations: bool,
221    pub animation_duration_ms: u32,
222    pub animation_easing: String,
223
224    /// Interaction settings
225    pub enable_zoom: bool,
226    pub enable_pan: bool,
227    pub enable_selection: bool,
228    pub enable_tooltips: bool,
229
230    /// Performance settings
231    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    /// Create a theme instance from this configuration
255    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    /// Validate this configuration
271    pub fn validate(&self) -> Result<(), String> {
272        validate_theme_config(self)
273    }
274
275    /// Get the active typography settings
276    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    /// Get the active layout settings
290    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
304/// Trait for theme implementations
305pub 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/// Classic light theme (MATLAB-style)
342#[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), // Cobalt
362                Vec4::new(0.88, 0.38, 0.12, 1.0), // Vermilion
363                Vec4::new(0.10, 0.58, 0.45, 1.0), // Teal
364                Vec4::new(0.53, 0.29, 0.78, 1.0), // Violet
365                Vec4::new(0.76, 0.58, 0.08, 1.0), // Ochre
366                Vec4::new(0.13, 0.60, 0.72, 1.0), // Cerulean
367                Vec4::new(0.74, 0.24, 0.27, 1.0), // Brick
368            ],
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/// High contrast theme for accessibility
399#[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), // Yellow
419                Vec4::new(0.0, 1.0, 1.0, 1.0), // Cyan
420                Vec4::new(1.0, 0.0, 1.0, 1.0), // Magenta
421                Vec4::new(1.0, 1.0, 1.0, 1.0), // White
422                Vec4::new(1.0, 0.5, 0.0, 1.0), // Orange
423                Vec4::new(0.5, 1.0, 0.5, 1.0), // Light green
424            ],
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/// Custom theme from user configuration
459#[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    /// Create a custom theme from configuration
471    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) // Default gray
508        } 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
532/// Convert hex color string to Vec4
533fn 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
546/// Validate theme configuration
547pub fn validate_theme_config(config: &PlotThemeConfig) -> Result<(), String> {
548    // Validate font sizes
549    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    // Validate layout values
557    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    // Validate custom colors if present
565    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    // Validate animation settings
578    if config.interaction.animation_duration_ms > 5000 {
579        return Err("Animation duration too long (max 5000ms)".to_string());
580    }
581
582    // Validate performance settings
583    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        // Should create a valid theme
622        let bg_color = theme.get_background_color();
623        assert!(bg_color.w > 0.0); // Alpha should be positive
624    }
625
626    #[test]
627    fn test_custom_theme_validation() {
628        let mut config = PlotThemeConfig {
629            variant: ThemeVariant::Custom,
630            ..Default::default()
631        };
632
633        // Should fail without custom colors
634        assert!(config.validate().is_err());
635
636        // Should pass with valid custom colors
637        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        // Test negative font size
646        config.typography.title_font_size = -1.0;
647        assert!(config.validate().is_err());
648
649        // Test excessive animation duration
650        config.typography.title_font_size = 18.0; // Reset
651        config.interaction.animation_duration_ms = 10000;
652        assert!(config.validate().is_err());
653
654        // Test invalid FPS
655        config.interaction.animation_duration_ms = 300; // Reset
656        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        // Should cycle back to first color
675        assert_eq!(color1, color2);
676    }
677}