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    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/// Classic light theme (MATLAB-style)
340#[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), // Blue
360                Vec4::new(1.0, 0.5, 0.0, 1.0), // Orange
361                Vec4::new(0.5, 0.8, 0.2, 1.0), // Green
362                Vec4::new(0.8, 0.2, 0.8, 1.0), // Magenta
363                Vec4::new(1.0, 0.8, 0.0, 1.0), // Yellow
364                Vec4::new(0.2, 0.8, 0.8, 1.0), // Cyan
365                Vec4::new(0.8, 0.2, 0.2, 1.0), // Red
366            ],
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/// High contrast theme for accessibility
396#[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), // Yellow
416                Vec4::new(0.0, 1.0, 1.0, 1.0), // Cyan
417                Vec4::new(1.0, 0.0, 1.0, 1.0), // Magenta
418                Vec4::new(1.0, 1.0, 1.0, 1.0), // White
419                Vec4::new(1.0, 0.5, 0.0, 1.0), // Orange
420                Vec4::new(0.5, 1.0, 0.5, 1.0), // Light green
421            ],
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/// Custom theme from user configuration
455#[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    /// Create a custom theme from configuration
467    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) // Default gray
504        } 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
527/// Convert hex color string to Vec4
528fn 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
541/// Validate theme configuration
542pub fn validate_theme_config(config: &PlotThemeConfig) -> Result<(), String> {
543    // Validate font sizes
544    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    // Validate layout values
552    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    // Validate custom colors if present
560    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    // Validate animation settings
573    if config.interaction.animation_duration_ms > 5000 {
574        return Err("Animation duration too long (max 5000ms)".to_string());
575    }
576
577    // Validate performance settings
578    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        // Should create a valid theme
617        let bg_color = theme.get_background_color();
618        assert!(bg_color.w > 0.0); // Alpha should be positive
619    }
620
621    #[test]
622    fn test_custom_theme_validation() {
623        let mut config = PlotThemeConfig {
624            variant: ThemeVariant::Custom,
625            ..Default::default()
626        };
627
628        // Should fail without custom colors
629        assert!(config.validate().is_err());
630
631        // Should pass with valid custom colors
632        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        // Test negative font size
641        config.typography.title_font_size = -1.0;
642        assert!(config.validate().is_err());
643
644        // Test excessive animation duration
645        config.typography.title_font_size = 18.0; // Reset
646        config.interaction.animation_duration_ms = 10000;
647        assert!(config.validate().is_err());
648
649        // Test invalid FPS
650        config.interaction.animation_duration_ms = 300; // Reset
651        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        // Should cycle back to first color
670        assert_eq!(color1, color2);
671    }
672}