Skip to main content

egui_cha_ds/
theme.rs

1//! Theme system for consistent styling
2//!
3//! Provides a centralized theme system with:
4//! - Design tokens (colors, spacing, radii)
5//! - `Theme::current()` for component access
6//! - `ThemeProvider` trait for external theme integration
7//! - TOML-based theme configuration (with `serde` feature)
8//!
9//! # TOML Configuration Example
10//!
11//! ```toml
12//! base = "dark"
13//! primary = "#8B5CF6"
14//! spacing_scale = 0.85
15//! shadow_blur = 4.0
16//! ```
17//!
18//! # Usage
19//!
20//! ```ignore
21//! // Load theme from TOML file
22//! let theme = Theme::load_toml("my-theme.toml")?;
23//! theme.apply(&ctx);
24//!
25//! // Or create from ThemeConfig
26//! let config = ThemeConfig {
27//!     base: Some("dark".into()),
28//!     primary: Some("#8B5CF6".into()),
29//!     ..Default::default()
30//! };
31//! let theme = Theme::from_config(&config);
32//! ```
33
34use egui::{Color32, FontId, Id, TextStyle};
35
36/// Theme variant
37#[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
46/// Trait for converting external theme systems to DS Theme
47pub trait ThemeProvider {
48    fn to_ds_theme(&self) -> Theme;
49}
50
51/// Design system theme containing all style tokens
52#[derive(Debug, Clone)]
53pub struct Theme {
54    pub variant: ThemeVariant,
55
56    // Colors - Primary
57    pub primary: Color32,
58    pub primary_hover: Color32,
59    pub primary_text: Color32,
60
61    // Colors - Secondary
62    pub secondary: Color32,
63    pub secondary_hover: Color32,
64    pub secondary_text: Color32,
65
66    // Colors - Background
67    pub bg_primary: Color32,
68    pub bg_secondary: Color32,
69    pub bg_tertiary: Color32,
70
71    // Colors - Text
72    pub text_primary: Color32,
73    pub text_secondary: Color32,
74    pub text_muted: Color32,
75
76    // Colors - UI State (for buttons, badges, alerts)
77    pub state_success: Color32,
78    pub state_warning: Color32,
79    pub state_danger: Color32,
80    pub state_info: Color32,
81
82    // Colors - UI State (text on state background)
83    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    // Colors - UI State (hover states)
89    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    // Colors - Log Severity (for log viewers, console output)
95    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    // Colors - Border
102    pub border: Color32,
103    pub border_focus: Color32,
104
105    // Spacing
106    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    // Border radius
113    pub radius_sm: f32,
114    pub radius_md: f32,
115    pub radius_lg: f32,
116
117    // Stroke / Border width
118    pub border_width: f32,
119    pub stroke_width: f32,
120
121    // Typography - Font sizes
122    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    // Typography - Line height multiplier
131    pub line_height: f32,
132
133    // Overlay / Surface
134    /// Dim amount for modal backdrop (0.0 = transparent, 1.0 = opaque black)
135    pub overlay_dim: f32,
136    /// Alpha for floating surfaces like dropdowns (0.0 = transparent, 1.0 = opaque)
137    pub surface_alpha: f32,
138    /// Shadow blur radius. None = no shadow (lightweight), Some(4.0) = subtle shadow
139    pub shadow_blur: Option<f32>,
140
141    // Glass / Transparency (for vibrancy windows)
142    /// Glass frame opacity (0.0 = fully transparent, 1.0 = opaque). Default: 0.6
143    pub glass_opacity: f32,
144    /// Glass frame blur radius (visual hint, actual blur requires vibrancy). Default: 8.0
145    pub glass_blur_radius: f32,
146    /// Glass frame tint color. None = use bg_primary
147    pub glass_tint: Option<Color32>,
148    /// Show border on glass frames. Default: true
149    pub glass_border: bool,
150    /// Custom titlebar height. Default: 32.0
151    pub titlebar_height: f32,
152}
153
154impl Default for Theme {
155    fn default() -> Self {
156        Self::light()
157    }
158}
159
160impl Theme {
161    /// Light theme
162    pub fn light() -> Self {
163        Self {
164            variant: ThemeVariant::Light,
165
166            // Primary - Blue
167            primary: Color32::from_rgb(59, 130, 246),
168            primary_hover: Color32::from_rgb(37, 99, 235),
169            primary_text: Color32::WHITE,
170
171            // Secondary - Gray
172            secondary: Color32::from_rgb(107, 114, 128),
173            secondary_hover: Color32::from_rgb(75, 85, 99),
174            secondary_text: Color32::WHITE,
175
176            // Background
177            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
182            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            // UI State (for buttons, badges, alerts)
187            state_success: Color32::from_rgb(34, 197, 94), // green-500
188            state_warning: Color32::from_rgb(245, 158, 11), // amber-500
189            state_danger: Color32::from_rgb(239, 68, 68),  // red-500
190            state_info: Color32::from_rgb(14, 165, 233),   // sky-500
191
192            // UI State text (on state background)
193            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            // UI State hover
199            state_success_hover: Color32::from_rgb(22, 163, 74), // green-600
200            state_warning_hover: Color32::from_rgb(217, 119, 6), // amber-600
201            state_danger_hover: Color32::from_rgb(220, 38, 38),  // red-600
202            state_info_hover: Color32::from_rgb(2, 132, 199),    // sky-600
203
204            // Log Severity (for log viewers, console output)
205            log_debug: Color32::from_rgb(156, 163, 175), // gray-400
206            log_info: Color32::from_rgb(59, 130, 246),   // blue-500
207            log_warn: Color32::from_rgb(245, 158, 11),   // amber-500
208            log_error: Color32::from_rgb(239, 68, 68),   // red-500
209            log_critical: Color32::from_rgb(190, 24, 93), // pink-700
210
211            // Border
212            border: Color32::from_rgb(229, 231, 235),
213            border_focus: Color32::from_rgb(59, 130, 246),
214
215            // Spacing (modern, spacious)
216            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
223            radius_sm: 4.0,
224            radius_md: 8.0,
225            radius_lg: 12.0,
226
227            // Stroke / Border width
228            border_width: 1.0,
229            stroke_width: 1.0,
230
231            // Typography
232            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 / Surface
242            overlay_dim: 0.5,
243            surface_alpha: 1.0,
244            shadow_blur: None, // Lightweight: no shadow
245
246            // Glass / Transparency
247            glass_opacity: 0.6,
248            glass_blur_radius: 8.0,
249            glass_tint: None, // Use bg_primary
250            glass_border: true,
251            titlebar_height: 32.0,
252        }
253    }
254
255    /// Dark theme
256    pub fn dark() -> Self {
257        Self {
258            variant: ThemeVariant::Dark,
259
260            // Primary - Blue
261            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 - Gray
266            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            // Background
271            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
276            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            // UI State (for buttons, badges, alerts)
281            state_success: Color32::from_rgb(74, 222, 128), // green-400
282            state_warning: Color32::from_rgb(251, 191, 36), // amber-400
283            state_danger: Color32::from_rgb(248, 113, 113), // red-400
284            state_info: Color32::from_rgb(56, 189, 248),    // sky-400
285
286            // UI State text (on state background) - dark text for light bg
287            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            // UI State hover
293            state_success_hover: Color32::from_rgb(34, 197, 94), // green-500
294            state_warning_hover: Color32::from_rgb(245, 158, 11), // amber-500
295            state_danger_hover: Color32::from_rgb(239, 68, 68),  // red-500
296            state_info_hover: Color32::from_rgb(14, 165, 233),   // sky-500
297
298            // Log Severity (for log viewers, console output)
299            log_debug: Color32::from_rgb(209, 213, 219), // gray-300
300            log_info: Color32::from_rgb(96, 165, 250),   // blue-400
301            log_warn: Color32::from_rgb(251, 191, 36),   // amber-400
302            log_error: Color32::from_rgb(248, 113, 113), // red-400
303            log_critical: Color32::from_rgb(244, 114, 182), // pink-400
304
305            // Border
306            border: Color32::from_rgb(55, 65, 81),
307            border_focus: Color32::from_rgb(96, 165, 250),
308
309            // Spacing (modern, spacious - same as light)
310            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 (same as light)
317            radius_sm: 4.0,
318            radius_md: 8.0,
319            radius_lg: 12.0,
320
321            // Stroke / Border width (same as light)
322            border_width: 1.0,
323            stroke_width: 1.0,
324
325            // Typography (same as light)
326            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 / Surface (darker for dark theme)
336            overlay_dim: 0.7,
337            surface_alpha: 1.0,
338            shadow_blur: None, // Lightweight: no shadow
339
340            // Glass / Transparency (slightly more opaque for dark theme)
341            glass_opacity: 0.7,
342            glass_blur_radius: 8.0,
343            glass_tint: None, // Use bg_primary
344            glass_border: true,
345            titlebar_height: 32.0,
346        }
347    }
348
349    /// ID used for storing theme in egui context
350    const STORAGE_ID: &'static str = "egui_cha_ds_theme";
351
352    /// Get current theme from egui context (fallback to default if not set)
353    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    /// Create theme from external provider
359    pub fn from_provider(provider: impl ThemeProvider) -> Self {
360        provider.to_ds_theme()
361    }
362
363    /// Apply only color-related settings without affecting layout/spacing
364    ///
365    /// Use this for theme switching (dark/light toggle) to avoid layout changes.
366    /// Unlike `apply()`, this only updates Visuals colors, not typography or spacing.
367    pub fn apply_colors_only(&self, ctx: &egui::Context) {
368        // Store theme for component access via Theme::current()
369        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        // Dark mode flag
375        visuals.dark_mode = self.variant == ThemeVariant::Dark;
376
377        // Background colors
378        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        // Text colors
385        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        // Widget styles - noninteractive (labels, separators)
391        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        // Widget styles - inactive (buttons at rest)
397        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        // Widget styles - hovered
403        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        // Widget styles - active (being clicked)
409        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        // Widget styles - open (dropdown open, etc)
415        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        // Selection
421        visuals.selection.bg_fill = self.primary.linear_multiply(0.3);
422        visuals.selection.stroke.color = self.primary;
423
424        // Window stroke color (not width)
425        visuals.window_stroke.color = self.border;
426
427        ctx.set_style(style);
428    }
429
430    /// Apply theme to egui context and store for component access
431    pub fn apply(&self, ctx: &egui::Context) {
432        // Store theme for component access via Theme::current()
433        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        // Dark mode flag
439        visuals.dark_mode = self.variant == ThemeVariant::Dark;
440
441        // Background colors
442        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        // Text colors
449        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        // Widget styles - noninteractive (labels, separators)
455        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        // Widget styles - inactive (buttons at rest)
461        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        // Widget styles - hovered
467        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        // Widget styles - active (being clicked)
473        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        // Widget styles - open (dropdown open, etc)
479        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        // Selection
485        visuals.selection.bg_fill = self.primary.linear_multiply(0.3);
486        visuals.selection.stroke.color = self.primary;
487
488        // Stroke widths - Apply to all widget states
489        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        // Window
502        visuals.window_stroke.color = self.border;
503        visuals.window_stroke.width = self.border_width;
504
505        // Shadow - configurable via shadow_blur
506        match self.shadow_blur {
507            None => {
508                // Lightweight: no shadow
509                visuals.window_shadow = egui::Shadow::NONE;
510                visuals.popup_shadow = egui::Shadow::NONE;
511            }
512            Some(blur) => {
513                // Subtle fixed shadow
514                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        // Typography - Configure text styles
530        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        // Spacing - Apply theme spacing to egui
547        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    /// Apply a scale factor to all spacing values
559    ///
560    /// This scales `spacing_xs`, `spacing_sm`, `spacing_md`, `spacing_lg`, and `spacing_xl`.
561    /// Components using these spacing values will automatically respect the scale.
562    ///
563    /// # Scale Guidelines
564    /// - `0.75` - Compact UI, dense layouts
565    /// - `1.0` - Default (no scaling)
566    /// - `1.25` - Spacious UI, touch-friendly
567    /// - `1.5` - Large UI, accessibility
568    ///
569    /// # Example
570    /// ```
571    /// use egui_cha_ds::Theme;
572    ///
573    /// // Compact theme (75% spacing)
574    /// let compact = Theme::dark().with_scale(0.75);
575    ///
576    /// // Spacious theme (125% spacing)
577    /// let spacious = Theme::light().with_scale(1.25);
578    /// ```
579    ///
580    /// # Affected Components
581    /// All DS components use theme spacing values, including:
582    /// - `ListItem` height (Compact/Medium/Large)
583    /// - `Button` padding
584    /// - `Card` margins
585    /// - `Menu` item spacing
586    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    /// Apply a scale factor to spacing values only
596    ///
597    /// Same as [`with_scale`](Self::with_scale), provided for explicit naming.
598    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    /// Apply a scale factor to border radius values
608    ///
609    /// Scales `radius_sm`, `radius_md`, `radius_lg` for rounded corners.
610    ///
611    /// # Example
612    /// ```
613    /// use egui_cha_ds::Theme;
614    ///
615    /// // Sharper corners
616    /// let sharp = Theme::light().with_radius_scale(0.5);
617    ///
618    /// // More rounded
619    /// let rounded = Theme::dark().with_radius_scale(2.0);
620    /// ```
621    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    /// Apply a scale factor to font sizes
629    ///
630    /// Scales all font size tokens from `font_size_xs` to `font_size_3xl`.
631    /// Useful for accessibility or density preferences.
632    ///
633    /// # Example
634    /// ```
635    /// use egui_cha_ds::Theme;
636    ///
637    /// // Larger text for accessibility
638    /// let accessible = Theme::light().with_font_scale(1.2);
639    ///
640    /// // Smaller text for dense displays
641    /// let dense = Theme::dark().with_font_scale(0.9);
642    /// ```
643    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    /// Apply a scale factor to stroke and border widths
655    ///
656    /// Scales `border_width` and `stroke_width` for thicker/thinner lines.
657    ///
658    /// # Example
659    /// ```
660    /// use egui_cha_ds::Theme;
661    ///
662    /// // Bolder borders
663    /// let bold = Theme::light().with_stroke_scale(2.0);
664    /// ```
665    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    /// Enable subtle shadow (default: 4.0 blur)
672    ///
673    /// # Example
674    /// ```
675    /// use egui_cha_ds::Theme;
676    ///
677    /// // Enable default subtle shadow
678    /// let with_shadow = Theme::light().with_shadow();
679    ///
680    /// // Custom blur radius
681    /// let soft_shadow = Theme::dark().with_shadow_blur(8.0);
682    /// ```
683    pub fn with_shadow(self) -> Self {
684        self.with_shadow_blur(4.0)
685    }
686
687    /// Enable shadow with custom blur radius
688    pub fn with_shadow_blur(mut self, blur: f32) -> Self {
689        self.shadow_blur = Some(blur);
690        self
691    }
692
693    /// Pastel theme - soft, modern colors
694    pub fn pastel() -> Self {
695        Self {
696            variant: ThemeVariant::Light,
697
698            // Primary - Soft lavender
699            primary: Color32::from_rgb(167, 139, 250), // violet-400
700            primary_hover: Color32::from_rgb(139, 92, 246), // violet-500
701            primary_text: Color32::WHITE,
702
703            // Secondary - Soft pink
704            secondary: Color32::from_rgb(244, 114, 182), // pink-400
705            secondary_hover: Color32::from_rgb(236, 72, 153), // pink-500
706            secondary_text: Color32::WHITE,
707
708            // Background - Cream/off-white
709            bg_primary: Color32::from_rgb(255, 251, 245), // warm white
710            bg_secondary: Color32::from_rgb(254, 243, 235), // peach-50
711            bg_tertiary: Color32::from_rgb(253, 235, 223), // peach-100
712
713            // Text - Soft dark
714            text_primary: Color32::from_rgb(64, 57, 72), // muted purple-gray
715            text_secondary: Color32::from_rgb(107, 98, 116), // lighter
716            text_muted: Color32::from_rgb(156, 148, 163), // even lighter
717
718            // UI State (for buttons, badges, alerts) - Pastel versions
719            state_success: Color32::from_rgb(134, 239, 172), // green-300
720            state_warning: Color32::from_rgb(253, 224, 71),  // yellow-300
721            state_danger: Color32::from_rgb(253, 164, 175),  // rose-300
722            state_info: Color32::from_rgb(147, 197, 253),    // blue-300
723
724            // UI State text
725            state_success_text: Color32::from_rgb(22, 101, 52), // green-800
726            state_warning_text: Color32::from_rgb(133, 77, 14), // amber-800
727            state_danger_text: Color32::from_rgb(159, 18, 57),  // rose-800
728            state_info_text: Color32::from_rgb(30, 64, 175),    // blue-800
729
730            // UI State hover
731            state_success_hover: Color32::from_rgb(74, 222, 128), // green-400
732            state_warning_hover: Color32::from_rgb(250, 204, 21), // yellow-400
733            state_danger_hover: Color32::from_rgb(251, 113, 133), // rose-400
734            state_info_hover: Color32::from_rgb(96, 165, 250),    // blue-400
735
736            // Log Severity (for log viewers, console output)
737            log_debug: Color32::from_rgb(156, 148, 163), // muted purple-gray
738            log_info: Color32::from_rgb(96, 165, 250),   // blue-400
739            log_warn: Color32::from_rgb(250, 204, 21),   // yellow-400
740            log_error: Color32::from_rgb(251, 113, 133), // rose-400
741            log_critical: Color32::from_rgb(236, 72, 153), // pink-500
742
743            // Border - Soft
744            border: Color32::from_rgb(233, 213, 202), // warm gray
745            border_focus: Color32::from_rgb(167, 139, 250), // violet-400
746
747            // Spacing (modern, spacious)
748            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 - More rounded for soft look
755            radius_sm: 6.0,
756            radius_md: 12.0,
757            radius_lg: 16.0,
758
759            // Stroke / Border width
760            border_width: 1.0,
761            stroke_width: 1.0,
762
763            // Typography (same as light)
764            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 / Surface (softer for pastel)
774            overlay_dim: 0.4,
775            surface_alpha: 1.0,
776            shadow_blur: None, // Lightweight: no shadow
777
778            // Glass / Transparency (soft for pastel)
779            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    /// Pastel dark theme - soft colors on dark background
788    pub fn pastel_dark() -> Self {
789        Self {
790            variant: ThemeVariant::Dark,
791
792            // Primary - Soft lavender
793            primary: Color32::from_rgb(196, 181, 253), // violet-300
794            primary_hover: Color32::from_rgb(167, 139, 250), // violet-400
795            primary_text: Color32::from_rgb(30, 27, 38), // dark purple
796
797            // Secondary - Soft pink
798            secondary: Color32::from_rgb(249, 168, 212), // pink-300
799            secondary_hover: Color32::from_rgb(244, 114, 182), // pink-400
800            secondary_text: Color32::from_rgb(30, 27, 38),
801
802            // Background - Deep purple-gray
803            bg_primary: Color32::from_rgb(24, 22, 32), // deep purple
804            bg_secondary: Color32::from_rgb(32, 29, 43), // slightly lighter
805            bg_tertiary: Color32::from_rgb(45, 41, 58), // even lighter
806
807            // Text - Soft light
808            text_primary: Color32::from_rgb(243, 237, 255), // soft white
809            text_secondary: Color32::from_rgb(196, 189, 210),
810            text_muted: Color32::from_rgb(140, 133, 156),
811
812            // UI State (for buttons, badges, alerts) - Muted pastel on dark
813            state_success: Color32::from_rgb(74, 222, 128), // green-400
814            state_warning: Color32::from_rgb(250, 204, 21), // yellow-400
815            state_danger: Color32::from_rgb(251, 113, 133), // rose-400
816            state_info: Color32::from_rgb(96, 165, 250),    // blue-400
817
818            // UI State text (dark on light bg)
819            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            // UI State hover
825            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 Severity (for log viewers, console output)
831            log_debug: Color32::from_rgb(140, 133, 156), // muted
832            log_info: Color32::from_rgb(147, 197, 253),  // blue-300
833            log_warn: Color32::from_rgb(253, 224, 71),   // yellow-300
834            log_error: Color32::from_rgb(253, 164, 175), // rose-300
835            log_critical: Color32::from_rgb(249, 168, 212), // pink-300
836
837            // Border
838            border: Color32::from_rgb(55, 50, 70),
839            border_focus: Color32::from_rgb(196, 181, 253),
840
841            // Spacing (modern, spacious)
842            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 - More rounded
849            radius_sm: 6.0,
850            radius_md: 12.0,
851            radius_lg: 16.0,
852
853            // Stroke / Border width
854            border_width: 1.0,
855            stroke_width: 1.0,
856
857            // Typography (same as light)
858            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 / Surface (softer for pastel dark)
868            overlay_dim: 0.6,
869            surface_alpha: 1.0,
870            shadow_blur: None, // Lightweight: no shadow
871
872            // Glass / Transparency (slightly more opaque for pastel dark)
873            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// ============================================================================
883// TOML Configuration Support (feature = "serde")
884// ============================================================================
885
886/// TOML-friendly theme configuration with human-readable color format.
887///
888/// All fields are optional - unspecified fields inherit from base theme.
889///
890/// # Color Format
891///
892/// Colors can be specified as:
893/// - Hex: `"#RRGGBB"` or `"#RRGGBBAA"`
894/// - RGB: `"rgb(r, g, b)"` or `"rgba(r, g, b, a)"`
895///
896/// # Example TOML
897///
898/// ```toml
899/// base = "dark"
900/// primary = "#8B5CF6"
901/// bg_primary = "#1a1a2e"
902/// spacing_scale = 0.85
903/// font_scale = 1.1
904/// shadow_blur = 4.0
905/// ```
906#[cfg(feature = "serde")]
907#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
908#[serde(default)]
909pub struct ThemeConfig {
910    /// Base theme: "light", "dark", "pastel", "pastel_dark"
911    pub base: Option<String>,
912
913    // Colors - Primary
914    pub primary: Option<String>,
915    pub primary_hover: Option<String>,
916    pub primary_text: Option<String>,
917
918    // Colors - Secondary
919    pub secondary: Option<String>,
920    pub secondary_hover: Option<String>,
921    pub secondary_text: Option<String>,
922
923    // Colors - Background
924    pub bg_primary: Option<String>,
925    pub bg_secondary: Option<String>,
926    pub bg_tertiary: Option<String>,
927
928    // Colors - Text
929    pub text_primary: Option<String>,
930    pub text_secondary: Option<String>,
931    pub text_muted: Option<String>,
932
933    // Colors - UI State
934    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    // Colors - Border
940    pub border: Option<String>,
941    pub border_focus: Option<String>,
942
943    // Scale factors (1.0 = default)
944    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    // Effects
950    pub shadow_blur: Option<f32>,
951    pub overlay_dim: Option<f32>,
952    pub surface_alpha: Option<f32>,
953
954    // Glass / Transparency
955    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    /// Parse a color string to Color32.
965    ///
966    /// Supports:
967    /// - `#RRGGBB`
968    /// - `#RRGGBBAA`
969    /// - `rgb(r, g, b)`
970    /// - `rgba(r, g, b, a)`
971    pub fn parse_color(s: &str) -> Option<Color32> {
972        let s = s.trim();
973
974        // Hex format: #RRGGBB or #RRGGBBAA
975        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        // RGB format: rgb(r, g, b)
995        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        // RGBA format: rgba(r, g, b, a)
1006        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                // Alpha can be 0-255 or 0.0-1.0
1013                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    /// Convert Color32 to hex string (#RRGGBB or #RRGGBBAA)
1028    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    /// Create a Theme from ThemeConfig.
1041    ///
1042    /// Starts from base theme and applies overrides.
1043    pub fn from_config(config: &ThemeConfig) -> Self {
1044        // Start with base theme
1045        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(), // default
1050        };
1051
1052        // Apply color overrides
1053        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        // Apply scale factors
1083        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        // Apply effects
1097        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        // Apply glass / transparency settings
1108        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    /// Convert Theme to ThemeConfig for serialization.
1128    ///
1129    /// Exports all color values as hex strings.
1130    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, // Not stored, applied at creation
1155            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    /// Load theme from TOML string.
1170    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    /// Serialize theme to TOML string.
1176    pub fn to_toml(&self) -> Result<String, toml::ser::Error> {
1177        toml::to_string_pretty(&self.to_config())
1178    }
1179
1180    /// Load theme from TOML file.
1181    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    /// Save theme to TOML file.
1188    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/// Error loading theme from file
1196#[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/// Error saving theme to file
1231#[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
1265// ============================================================================
1266// Lightweight Theme Trait
1267// ============================================================================
1268
1269/// Minimal theme trait for quick theme creation.
1270///
1271/// Implement only 3 methods to create a basic theme.
1272/// All other values inherit from the base theme.
1273///
1274/// # Example
1275///
1276/// ```
1277/// use egui::Color32;
1278/// use egui_cha_ds::{LightweightTheme, Theme};
1279///
1280/// struct MyBrandTheme;
1281///
1282/// impl LightweightTheme for MyBrandTheme {
1283///     fn primary(&self) -> Color32 {
1284///         Color32::from_rgb(139, 92, 246)  // Violet
1285///     }
1286///     fn background(&self) -> Color32 {
1287///         Color32::from_rgb(15, 15, 25)  // Dark
1288///     }
1289///     fn text(&self) -> Color32 {
1290///         Color32::from_rgb(240, 240, 250)  // Light
1291///     }
1292/// }
1293///
1294/// let theme = MyBrandTheme.to_theme();
1295/// ```
1296pub trait LightweightTheme {
1297    /// Primary accent color (buttons, links, highlights)
1298    fn primary(&self) -> Color32;
1299
1300    /// Main background color
1301    fn background(&self) -> Color32;
1302
1303    /// Primary text color
1304    fn text(&self) -> Color32;
1305
1306    /// Convert to full Theme with sensible defaults.
1307    ///
1308    /// Automatically derives:
1309    /// - Hover states (slightly darker/lighter)
1310    /// - Secondary colors (muted primary)
1311    /// - Border colors (based on background)
1312    /// - Text on primary (contrast with primary)
1313    fn to_theme(&self) -> Theme {
1314        let primary = self.primary();
1315        let bg = self.background();
1316        let text = self.text();
1317
1318        // Detect if dark or light theme based on background luminance
1319        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        // Start with base theme
1324        let mut theme = if is_dark {
1325            Theme::dark()
1326        } else {
1327            Theme::light()
1328        };
1329
1330        // Apply primary color
1331        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        // Apply background
1341        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        // Apply text
1354        theme.text_primary = text;
1355        theme.text_secondary = with_alpha(text, 0.7);
1356        theme.text_muted = with_alpha(text, 0.5);
1357
1358        // Border based on background
1359        theme.border = if is_dark {
1360            lighten(bg, 0.15)
1361        } else {
1362            darken(bg, 0.10)
1363        };
1364
1365        theme
1366    }
1367}
1368
1369// Helper functions for color manipulation
1370
1371fn 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) // Dark text
1403    } else {
1404        Color32::WHITE // Light text
1405    }
1406}
1407
1408// ============================================================================
1409// Tests
1410// ============================================================================
1411
1412#[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); // Detected from bg
1508    }
1509}