open_sesame/ui/
theme.rs

1//! Theme definitions for UI rendering
2//!
3//! Integrates with COSMIC desktop theme when available, falling back to
4//! user config or sensible defaults.
5
6use crate::config::Config;
7use crate::platform::CosmicTheme;
8use crate::render::Color;
9
10/// Theme for overlay rendering
11#[derive(Debug, Clone)]
12pub struct Theme {
13    /// Background overlay color (semi-transparent)
14    pub background: Color,
15    /// Card background color
16    pub card_background: Color,
17    /// Card border color
18    pub card_border: Color,
19    /// Primary text color
20    pub text_primary: Color,
21    /// Secondary text color (for titles)
22    pub text_secondary: Color,
23    /// Hint badge background
24    pub badge_background: Color,
25    /// Hint badge text color
26    pub badge_text: Color,
27    /// Matched hint badge background
28    pub badge_matched_background: Color,
29    /// Matched hint badge text color
30    pub badge_matched_text: Color,
31    /// Border width
32    pub border_width: f32,
33    /// Corner radius
34    pub corner_radius: f32,
35}
36
37impl Theme {
38    /// Create a theme from COSMIC desktop configuration
39    ///
40    /// This provides native integration with COSMIC's theming system,
41    /// automatically matching dark/light mode and accent colors.
42    ///
43    /// COSMIC uses a layered color system with guaranteed contrast:
44    /// - Container level (base/on): For surfaces - `on` contrasts with `base`
45    /// - Component level (component.base/on): For buttons/inputs inside containers
46    ///
47    /// The overlay popup implements a "primary" container pattern:
48    /// - primary.base for the card background
49    /// - primary.on for text (designed for contrast on primary.base)
50    /// - primary.component.* for badge elements (interactive-like)
51    /// - accent.* for highlights and matched states
52    pub fn from_cosmic() -> Option<Self> {
53        let cosmic = CosmicTheme::load()?;
54
55        // Container-level colors for the card surface
56        // These are the main surface colors with guaranteed text contrast
57        let bg = cosmic.background.base.to_rgba();
58        let primary_base = cosmic.primary.base.to_rgba();
59        let primary_on = cosmic.primary.on.to_rgba();
60
61        // Secondary component colors for badge elements
62        // Using secondary.component (not primary.component) for better contrast
63        // against the primary.base card background
64        let badge_base = cosmic.secondary.component.base.to_rgba();
65        let badge_on = cosmic.secondary.component.on.to_rgba();
66
67        // Accent colors for highlights and selection states
68        let accent_base = cosmic.accent.base.to_rgba();
69        let accent_on = cosmic.accent.on.to_rgba();
70
71        // Use COSMIC's corner radii (radius_m is typical for popups)
72        let corner_radius = cosmic.corner_radii.radius_m[0];
73
74        tracing::info!(
75            "Loaded COSMIC {} theme",
76            if cosmic.is_dark { "dark" } else { "light" }
77        );
78
79        Some(Self {
80            // Semi-transparent background using COSMIC's background color
81            background: Color::rgba(bg.0, bg.1, bg.2, 200),
82            // Card uses primary container base (surface color)
83            card_background: Color::rgba(primary_base.0, primary_base.1, primary_base.2, 245),
84            // Border uses accent color for visual pop
85            card_border: Color::rgba(accent_base.0, accent_base.1, accent_base.2, 255),
86            // Text uses primary.on (designed for contrast on primary.base)
87            text_primary: Color::rgba(primary_on.0, primary_on.1, primary_on.2, primary_on.3),
88            // Secondary text slightly dimmed but still readable
89            text_secondary: Color::rgba(
90                primary_on.0,
91                primary_on.1,
92                primary_on.2,
93                (primary_on.3 as f32 * 0.7) as u8,
94            ),
95            // Badge uses secondary.component colors for contrast against primary.base
96            badge_background: Color::rgba(badge_base.0, badge_base.1, badge_base.2, 255),
97            badge_text: Color::rgba(badge_on.0, badge_on.1, badge_on.2, badge_on.3),
98            // Matched badge uses accent for visual emphasis
99            badge_matched_background: Color::rgba(accent_base.0, accent_base.1, accent_base.2, 255),
100            badge_matched_text: Color::rgba(accent_on.0, accent_on.1, accent_on.2, accent_on.3),
101            border_width: 2.0,
102            corner_radius,
103        })
104    }
105
106    /// Create a theme from user configuration
107    ///
108    /// This is used when COSMIC theme is not available or when user
109    /// has explicit color overrides in their config.
110    pub fn from_config(config: &Config) -> Self {
111        // Try COSMIC theme first, then fall back to config
112        if let Some(cosmic_theme) = Self::from_cosmic() {
113            // Apply any user overrides from config
114            return Self::apply_config_overrides(cosmic_theme, config);
115        }
116
117        // Fall back to config-based theme
118        Self::from_config_only(config)
119    }
120
121    /// Apply user config overrides to a COSMIC-derived theme
122    fn apply_config_overrides(mut theme: Theme, config: &Config) -> Theme {
123        let settings = &config.settings;
124        let defaults = Config::default().settings;
125
126        // Only override if user explicitly set a non-default value
127        if settings.background_color != defaults.background_color {
128            theme.background = Color::rgba(
129                settings.background_color.r,
130                settings.background_color.g,
131                settings.background_color.b,
132                settings.background_color.a,
133            );
134        }
135
136        if settings.card_color != defaults.card_color {
137            theme.card_background = Color::rgba(
138                settings.card_color.r,
139                settings.card_color.g,
140                settings.card_color.b,
141                settings.card_color.a,
142            );
143        }
144
145        if settings.border_color != defaults.border_color {
146            theme.card_border = Color::rgba(
147                settings.border_color.r,
148                settings.border_color.g,
149                settings.border_color.b,
150                settings.border_color.a,
151            );
152        }
153
154        if settings.text_color != defaults.text_color {
155            theme.text_primary = Color::rgba(
156                settings.text_color.r,
157                settings.text_color.g,
158                settings.text_color.b,
159                settings.text_color.a,
160            );
161            theme.text_secondary = Color::rgba(
162                settings.text_color.r,
163                settings.text_color.g,
164                settings.text_color.b,
165                (settings.text_color.a as f32 * 0.7) as u8,
166            );
167        }
168
169        if settings.hint_color != defaults.hint_color {
170            theme.badge_background = Color::rgba(
171                settings.hint_color.r,
172                settings.hint_color.g,
173                settings.hint_color.b,
174                settings.hint_color.a,
175            );
176        }
177
178        if settings.hint_matched_color != defaults.hint_matched_color {
179            theme.badge_matched_background = Color::rgba(
180                settings.hint_matched_color.r,
181                settings.hint_matched_color.g,
182                settings.hint_matched_color.b,
183                settings.hint_matched_color.a,
184            );
185        }
186
187        if settings.border_width != defaults.border_width {
188            theme.border_width = settings.border_width;
189        }
190
191        theme
192    }
193
194    /// Create theme from config only (no COSMIC integration)
195    fn from_config_only(config: &Config) -> Self {
196        let settings = &config.settings;
197
198        Self {
199            background: Color::rgba(
200                settings.background_color.r,
201                settings.background_color.g,
202                settings.background_color.b,
203                settings.background_color.a,
204            ),
205            card_background: Color::rgba(
206                settings.card_color.r,
207                settings.card_color.g,
208                settings.card_color.b,
209                settings.card_color.a,
210            ),
211            card_border: Color::rgba(
212                settings.border_color.r,
213                settings.border_color.g,
214                settings.border_color.b,
215                settings.border_color.a,
216            ),
217            text_primary: Color::rgba(
218                settings.text_color.r,
219                settings.text_color.g,
220                settings.text_color.b,
221                settings.text_color.a,
222            ),
223            text_secondary: Color::rgba(
224                settings.text_color.r,
225                settings.text_color.g,
226                settings.text_color.b,
227                (settings.text_color.a as f32 * 0.7) as u8,
228            ),
229            badge_background: Color::rgba(
230                settings.hint_color.r,
231                settings.hint_color.g,
232                settings.hint_color.b,
233                settings.hint_color.a,
234            ),
235            badge_text: Color::rgb(255, 255, 255),
236            badge_matched_background: Color::rgba(
237                settings.hint_matched_color.r,
238                settings.hint_matched_color.g,
239                settings.hint_matched_color.b,
240                settings.hint_matched_color.a,
241            ),
242            badge_matched_text: Color::rgb(255, 255, 255),
243            border_width: settings.border_width,
244            corner_radius: 8.0,
245        }
246    }
247}
248
249impl Default for Theme {
250    fn default() -> Self {
251        // Try COSMIC theme first
252        if let Some(cosmic_theme) = Self::from_cosmic() {
253            return cosmic_theme;
254        }
255
256        // Fall back to hardcoded defaults
257        Self {
258            background: Color::rgba(0, 0, 0, 200),
259            card_background: Color::rgba(30, 30, 30, 240),
260            card_border: Color::rgba(80, 80, 80, 255),
261            text_primary: Color::rgb(255, 255, 255),
262            text_secondary: Color::rgba(255, 255, 255, 180),
263            badge_background: Color::rgba(100, 100, 100, 255),
264            badge_text: Color::rgb(255, 255, 255),
265            badge_matched_background: Color::rgba(76, 175, 80, 255),
266            badge_matched_text: Color::rgb(255, 255, 255),
267            border_width: 2.0,
268            corner_radius: 16.0,
269        }
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn test_default_theme() {
279        let theme = Theme::default();
280        assert!(theme.border_width > 0.0);
281        assert!(theme.corner_radius > 0.0);
282    }
283
284    #[test]
285    fn test_cosmic_theme_loading() {
286        // This will work on COSMIC desktop, gracefully fail elsewhere
287        let theme = Theme::from_cosmic();
288        if let Some(t) = theme {
289            println!("Loaded COSMIC theme with corner_radius={}", t.corner_radius);
290        } else {
291            println!("COSMIC theme not available (expected on non-COSMIC systems)");
292        }
293    }
294}